「前端面試題系列7」Javascript 中的事件機制(從原生到框架)

圖片描述

前言

這是前端面試題系列的第 7 篇,你可能錯過了前面的篇章,可以在這裏找到:

最近,小夥伴L 在溫習 《JavaScript高級程序設計》中的 事件 這一章節時,產生了困惑。

他問了我這樣幾個問題:

  • 瞭解事件流的順序,對日常的工作有什麼幫助麼?
  • 在 vue 的文檔中,有一個修飾符 native ,把它用 . 的形式 連結在事件之後,就可以監聽原生事件了。它的背後有什麼原理?
  • 事件的 event 對象中,有好多的屬性和方法,該如何使用?

瀏覽器中的事件機制,也經常在面試中被提及。所以這回,我們共同探討了這些問題,並最終整理成文,希望幫到有需要的同學。

事件流的概念

先從概念說起,DOM 事件流分爲三個階段:捕獲階段目標階段冒泡階段。先調用捕獲階段的處理函數,其次調用目標階段的處理函數,最後調用冒泡階段的處理函數。

網景公司提出了 事件捕獲 的事件流。這就好比採礦的小遊戲,每次都會從地面開始一路往下,拋出抓鬥,捕獲礦石。在上圖中就是,某個 div 元素觸發了某個事件,最先得到通知的是 window,然後是 document,依次往下,直到真正觸發事件的那個目標元素 div 爲止。

事件冒泡 則是由微軟提出的,與之順序相反。還是剛纔的採礦小遊戲,命中目標後,抓鬥再沿路收回,直到冒出地面。在上圖中就是,事件會從目標元素 div 開始依次往上,直到 window 對象爲止。

w3c 爲了制定統一的標準,採取了折中的方式:先捕獲在冒泡。同一個 DOM 元素可以註冊多個同類型的事件,通過 addEventListener 和 removeEventListener 進行管理。addEventListener 的第三個參數,就是爲了捕獲和冒泡準備的。

註冊事件(addEventListener) 有三個參數,分別爲:"事件名稱", "事件回調", "捕獲/冒泡"(布爾型,true代表捕獲事件,false代表冒泡事件)。

target.addEventListener(type, listener[, useCapture]);
  • type 表示事件類型的字符串。
  • listener 是一個實現了 EventListener 接口的對象,或者是一個函數。當所監聽的事件類型觸發時,會接收到一個事件通知對象(實現了 Event 接口的對象)。
  • capture 表示 listener 會在該類型的事件捕獲階段,傳播到該 EventTarget 時觸發,它是一個 Boolean 值。

解除事件(removeEventListener) 也有三個參數,分別爲:"事件名稱", "事件回調", "捕獲/冒泡"(Boolean 值,這個必須和註冊事件時的類型一致)。

target.removeEventListener(type, listener[, useCapture]);

要想註冊過的事件能夠被解除,必須將回調函數保存起來,否則無法解除。例如這樣:

const btn = document.getElementById("test");

//將回調存儲在變量中
const fn = function(e){
    alert("ok");
};

//綁定
btn.addEventListener("click", fn, false);

//解除
btn.removeEventListener("click", fn, false);

事件捕獲和冒泡的5個注意點

當有多層交互嵌套時,事件捕獲和冒泡的先後順序,似乎不是那麼好理解。接下來,將分 5 種情況討論它們的順序,以及如何規避意外情況的發生。

1.在外層 div 註冊事件,點擊內層 div 來觸發事件時,捕獲事件總是要比冒泡事件先觸發(與代碼順序無關)

假設,有這樣的 html 結構:

<div id="test" class="test">
   <div id="testInner" class="test-inner"></div>
</div>

然後,我們在外層 div 上註冊兩個 click 事件,分別是捕獲事件和冒泡事件,代碼如下:

const btn = document.getElementById("test");
 
//捕獲事件
btn.addEventListener("click", function(e){
    alert("capture is ok");
}, true);
 
//冒泡事件
btn.addEventListener("click", function(e){
    alert("bubble is ok");
}, false);

點擊內層的 div,先彈出 capture is ok,後彈出 bubble is ok。只有當真正觸發事件的 DOM 元素是內層的時候,外層 DOM 元素纔有機會模擬捕獲事件和冒泡事件。

2.當在觸發事件的 DOM 元素上註冊事件時,哪個先註冊,就先執行哪個

html 結構同上,js 代碼如下:

const btnInner = document.getElementById("testInner");

//冒泡事件
btnInner.addEventListener("click", function(e){
    alert("bubble is ok");
}, false);
 
//捕獲事件
btnInner.addEventListener("click", function(e){
    alert("capture is ok");
}, true);

本例中,冒泡事件先註冊,所以先執行。所以,點擊內層 div,先彈出 bubble is ok,再彈出 capture is ok

3.當外層 div 和內層 div 同時註冊了捕獲事件時,點擊內層 div 時,外層 div 的事件一定會先觸發

js 代碼如下:

const btn = document.getElementById("test");
const btnInner = document.getElementById("testInner");

btnInner.addEventListener("click", function(e){
    alert("inner capture is ok");
}, true);

btn.addEventListener("click", function(e){
    alert("outer capture is ok");
}, true);

雖然外層 div 的事件註冊在後面,但會先觸發。所以,結果是先彈出 outer capture is ok,再彈出 inner capture is ok

4.同理,當外層 div 和內層 div 都同時註冊了冒泡事件,點擊內層 div 時,一定是內層 div 事件先觸發。

const btn = document.getElementById("test");
const btnInner = document.getElementById("testInner");

btn.addEventListener("click", function(e){
    alert("outer bubble is ok");
}, false);

btnInner.addEventListener("click", function(e){
    alert("inner bubble is ok");
}, false);

先彈出 inner bubble is ok,再彈出 outer bubble is ok

5.阻止事件的派發

通常情況下,我們都希望點擊某個 div 時,就只觸發自己的事件回調。比如,明明點擊的是內層 div,但是外層 div 的事件也觸發了,這是就不是我們想要的了。這時,就需要阻止事件的派發。

事件觸發時,會默認傳入一個 event 對象,這個 event 對象上有一個方法:stopPropagation。MDN 上的解釋是:阻止 捕獲 和 冒泡 階段中,當前事件的進一步傳播。所以,通過此方法,讓外層 div 接收不到事件,自然也就不會觸發了。

btnInner.addEventListener("click", function(e){
    //阻止冒泡
    e.stopPropagation();
    alert("inner bubble is ok");
}, false);

事件代理

我們經常會遇到,要監聽列表中多項 li 的情況,假設我們有一個列表如下:

<ul id="list">
    <li id="item1">item1</li>
    <li id="item2">item2</li>
    <li id="item3">item3</li>
    <li id="item4">item4</li>
</ul>

如果我們要實現以下功能:當鼠標點擊某一 li 時,輸出該 li 的內容,我們通常的寫法是這樣的:

window.onload=function(){
    const ulNode = document.getElementById("list");
    const liNodes = ulNode.children;
    for(var i=0; i<liNodes.length; i++){
        liNodes[i].addEventListener('click',function(e){
            console.log(e.target.innerHTML);
        }, false);
    }
}

在傳統的事件處理中,我們可能會按照需要,爲每一個元素添加或者刪除事件處理器。然而,事件處理器將有可能導致內存泄露,或者性能下降,用得越多這種風險就越大。JavaScript 的事件代理,則是一種簡單的技巧。

用法及原理

事件代理,用到了在 JavaSciprt 事件中的兩個特性:事件冒泡 和 目標元素。使用事件代理,我們可以把事件處理器添加到一個元素上,等待一個事件從它的子級元素裏冒泡上來,並且可以得知這個事件是從哪個元素開始的。

改進後的 js 代碼如下:

window.onload=function(){
    const ulNode=document.getElementById("list");
    ulNode.addEventListener('click', function(e) {
        /*判斷目標事件是否爲li*/
        if(e.target && e.target.nodeName.toUpperCase()=="LI"){
            console.log(e.target.innerHTML);
        }
    }, false);
};

一些常用技巧

回到文章開頭的問題:瞭解事件流的順序,對日常的工作有什麼幫助呢?我總結了以下幾個注意點。

1. 阻止默認事件

比如 href 的鏈接跳轉,submit 的表單提交等。可以在方法的最後,加上一行 return false;。它會阻止通過 on 的方式綁定的事件的默認事件。

ele.onclick = function() {
    ……
    // 通過返回 false 值,阻止默認事件行爲
    return false;
}

另外,重寫 onclick 會覆蓋之前的屬性,所以解綁事件可以這麼寫:

// 解綁事件,將 onlick 屬性設爲 null 即可
ele.onclick = null;

2. stopPropagation 和 stopImmediatePropagation

前面說過 stopPropagation 的定義是:終止事件在傳播過程的捕獲、目標處理或起泡階段進一步傳播。事件不再被分派到其他節點上。

// 事件捕獲到 ele 元素後,就不再向下傳播了
ele.addEventListener('click', function (event) {
  event.stopPropagation();
}, true);

// 事件冒泡到 ele 元素後,就不再向上傳播了
ele.addEventListener('click', function (event) {
  event.stopPropagation();
}, false);

但是,stopPropagation 只會阻止當前元素 同類型的 事件冒泡或捕獲的傳播,並不會阻止該元素上 其他類型 事件的監聽。以 click 事件爲例:

ele.addEventListener('click', function (event) {
  event.stopPropagation();
  console.log(1);
});

ele.addEventListener('click', function(event) {
  // 仍然可以觸發
  console.log(2);
});

如果想禁用之後所有的 click 事件,就要用到 stopImmediatePropagation 了。但是,需要注意的是,stopImmediatePropagation 只會禁用之後註冊的同類型的監聽事件。就比如阻止了之後的 click 事件監聽函數,但別的事件類型如 mousedown、dblclick 之類,還是可以監聽到的。

ele.addEventListener('click', function (event) {
    event.stopImmediatePropagation();
    console.log(1);
});

ele.addEventListener('click', function(event) {
    // 不會觸發
    console.log(2);
});

ele.addEventListener('mousedown', function(event) {
    // 會觸發
    console.log(3);
});

3. jquery 中的 return false;

jquery 中的 on 是事件冒泡。當用 return false; 阻止瀏覽器的默認行爲時,會做下面這 3 件事:

  • event.preventDefault();
  • event.stopPropagation();
  • 停止回調函數執行並立即返回。

這 3 件事中,只有 preventDefault 是用來阻止默認行爲的。除非你還想阻止事件冒泡,否則直接用 return false; 會埋下隱患。

4. angular 中的 $event

angular 是個包羅萬象的框架,似乎學完它的一整套之後,就能玩轉世界了。它加工封裝了許多原生的東西,其中就包括了 event,只是前面需要加一個 $,表示這是 angular 中的特有對象。

// template
<div>
    <button (click)="doSomething($event)">Click me</button>
</div>

// js
doSomething($event: Event) {
    $event.stopPropagation();
    ...
}

$event 在這裏作爲一個變量,顯式地 傳入回調函數,之後就可以將 $event 當做原生的事件對象來用了。

5. vue 中的 native 修飾符

在 vue 的自定義組件中綁定原生事件,需要用到修飾符 native。

那是因爲,我們的自定義組件,最終會渲染成原生的 html 標籤,而非類似於 這樣的自定義組件。如果想讓一個普通的 html 標籤觸發事件,那就需要對它做事件監聽(addEventListener)。修飾符 native 的作用就在這裏,它可以在背後幫我們綁定了原生事件,進行監聽。

一個常用的場景是,配合 element-ui 做登錄界面時,輸完賬號密碼,想按一下回車就能登錄。就可以像下面這樣用修飾符:

<el-input
    class="input"
    v-model="password" type="password"
    @keyup.enter.native="handleSubmit">
</el-input>

el-input 就是自定義組件,而 keyup 就是原生事件,需要用 native 修飾符進行綁定才能監聽到。

6. react 中的合成事件

想要在 react 的事件回調中使用 event 對象,會產生困擾,會發現不少原生的屬性都是 null。

那是因爲在 react 中的事件,其實是合成事件(SyntheticEvent),並不是瀏覽器的原生事件,但它也符合 w3c 規範。

舉一個簡單的例子,我們要實現一個組件,它有一個按鈕,點擊按鈕後會顯示一張圖片,點擊這張圖片之外的任意區域,可以隱藏這張圖片,但是點擊該圖片本身時,不會隱藏。代碼如下:

class ShowImg extends Component {
    constructor(props) {
        super(props);
        this.state = {
          active: false
        };
    }
  
    componentDidMount() {
        document.addEventListener('click', this.hideImg.bind(this));
    }

    componentWillUnmount() {
        document.removeEventListener('click', this.hideImg);
    }
    
    hideImg () {
        this.setState({ active: false });
    }
    
    handleClickBtn() {
        this.setState({ active: !this.state.active });
    }
  
    handleClickImg (e) {
        e.stopPropagation();
    }

    render() {
        return (
            <div className="img-wrapper">
                <button
                    className="showImgBtn"
                    onClick={this.handleClickBtn.bind(this)}>
                    顯示圖片
                </button>
                <div
                    className="img"
                    style={{ display: this.state.active ? 'block' : 'none' }}
                    onClick={this.handleClickImg.bind(this)}>
                    <img src="@/assets/avatar.jpg" >
                </div>
            </div>
        );
    }
}

按照之前說的原生事件機制,我們會錯誤地認爲通過:

handleClickImg (e) {
    e.stopPropagation();
}

就可以阻止事件的派發了,但其實沒法這麼做。想要解決這個問題,當然也不復雜,就把 react 的事件和原生事件分開即可。

componentDidMount() {
    document.addEventListener('click', this.hideImg.bind(this));
    
    document.addEventListener('click', this.imgStopPropagation.bind(this));
}

componentWillUnmount() {
    document.removeEventListener('click', this.hideImg);
    
    document.removeEventListener('click', this.imgStopPropagation);
}

hideImg () {
    this.setState({ active: false });
}

imgStopPropagation (e) {
    e.stopPropagation();
}

7. 事件對象 event

當對一個元素進行事件監聽的時候,它的回調函數裏就會默認傳遞一個參數 event,它是一個對象,包含了許多屬性。我列出了一些比較常用的屬性:

  • event.target:指的是觸發事件的那個節點,也就是事件最初發生的節點。
  • event.target.matches:可以對關鍵節點進行匹配,來執行相應操作。
  • event.currentTarget:指的是正在執行的監聽函數的那個節點。
  • event.isTrusted:表示事件是否是真實用戶觸發。
  • event.preventDefault():取消事件的默認行爲。
  • event.stopPropagation():阻止事件的派發(包括了捕獲和冒泡)。
  • event.stopImmediatePropagation():阻止同一個事件的其他監聽函數被調用。

總結

事件機制在瀏覽器中非常有用,所有用戶的交互型操作,都依賴於它。現代 JavaScript 框架應用中,我們也都離不開與原生事件的交互。

所以,在理解了事件流的概念,清楚了事件捕獲與冒泡的順序,掌握了一些原生事件的技巧之後,相信下次再遇到坑的時候,可以少走一些彎路了。

PS:歡迎關注我的公衆號 “超哥前端小棧”,交流更多的想法與技術。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章