JS事件那些事兒 一次整明白

初代發表於2018-12-06

DOM 事件流

事件流包括三個階段。簡而言之:事件一開始從文件的根節點流向目標物件(捕獲階段),然後在目標物件上被觸發(目標階段),之後再回溯到文件的根節點(冒泡階段)。

DOM 事件流

事件捕獲階段(Capture Phase)

事件的第一個階段是捕獲階段。事件從文件的根節點出發,隨著 DOM 樹的結構向事件的目標節點流去。途中經過各個層次的 DOM 節點,並在各節點上觸發捕獲事件,直到到達事件的目標節點。捕獲階段的主要任務是建立傳播路徑,在冒泡階段,事件會通過這個路徑回溯到文件跟節點。

目標階段(Target Phase)

當事件到達目標節點的,事件就進入了目標階段。事件在目標節點上被觸發,然後會逆向迴流,直到傳播至最外層的文件節點。

冒泡階段(Bubble Phase)

事件在目標元素上觸發後,並不在這個元素上終止。它會隨著 DOM 樹一層層向上冒泡,直到到達最外層的根節點。也就是說,同一個事件會依次在目標節點的父節點,父節點的父節點...直到最外層的節點上被觸發。

冒泡過程非常有用。它將我們從對特定元素的事件監聽中釋放出來,相反,我們可以監聽 DOM 樹上更上層的元素,等待事件冒泡的到達。如果沒有事件冒泡,在某些情況下,我們需要監聽很多不同的元素來確保捕獲到想要的事件。

所有的事件都要經過捕捉階段和目標階段,但是有些事件會跳過冒泡階段。例如,讓元素獲得輸入焦點的 focus 事件以及失去輸入焦點的 blur 事件就都不會冒泡。

事件處理程式

HTML 事件處理程式

<!-- 輸出 click -->
<input type="button" value="Click Me" onclick="console.log(event.type)">

<!-- 輸出 Click Me this 值等於事件的目標元素 -->
<input type="button" value="Click Me" onclick="console.log(this.value)">
複製程式碼

當然在 HTML 中定義的事件處理程式也可以呼叫其它地方定義的指令碼:

<!-- Chrome 輸出 click -->
<script>
    function showMessage(event) {
        console.log(event.type);
    }
</script>
<input type="button" value="Click Me" onclick="showMessage(event)">
複製程式碼

通過 HTML 指定的事件處理程式都需要HTML的參與,即結構和行為相耦合,不易維護。

DOM0 級事件處理程式

<input type="button" value="Click Me" id="btn">
<script>
    var btn=document.getElementById("btn");
    btn.onclick=function(){
        console.log(this.id); // 輸出 btn
    }
</script>
複製程式碼

這裡是將一個函式賦值給一個事件處理程式的屬性,以這種方式新增的事件處理程式會在事件流的冒泡階段被處理。要刪除事件將 btn.onclick 設定為 null 即可。

DOM2 級事件處理程式

DOM2 級事件定義了addEventListener()removeEventListener()兩個方法,用於處理和刪除事件處理程式的操作。

所有 DOM 節點都包含這兩個方法,它們接受3個引數:要處理的事件名作為事件處理程式的函式一個布林值。最後的布林值引數是 true 表示在捕獲階段呼叫事件處理程式,如果是 false(預設) 表示在冒泡階段呼叫事件處理程式。

<input type="button" value="Click Me" id="btn">
<script>
    var btn=document.getElementById("btn");
    btn.addEventListener("click",function(){
        console.log(this.id);
    },false);
    btn.addEventListener("click",function(){
        console.log('Hello word!');
    },false);
</script>
複製程式碼

上面程式碼兩個事件處理程式會按照它們的新增順序觸發,先輸出 btn 再輸出 Hello word!

通過 addEventListener()新增的事件處理程式只能使用 removeEventListener()來移除,移除時傳入的引數與新增時使用的引數相同,即匿名函式無法被移除。

<input type="button" value="Click Me" id="btn">
<script>
    var btn=document.getElementById("btn");
    var handler = function(){
        console.log(this.id);
    }
    btn.addEventListener("click", handler, false);
    btn.removeEventListener("click",handler, false);
</script>
複製程式碼

IE 事件處理程式

IE通常都是特立獨行的,它新增和刪除事件處理程式的方法分別是:attachEvent()detachEvent()

同樣接受事件處理程式名稱與事件處理程式函式兩個引數,但跟addEventListener()的區別是:

  • 事件名稱需要加“on”,比如“onclick”;
  • 沒了第三個布林值,IE8及更早版本只支援事件冒泡;
  • 仍可新增多個處理程式,但觸發順序是反著來的。

還有一點需要注意,DOM0 和 DOM2 級的方法,其作用域都是在其所依附的元素當中,attachEvent()則是全域性,即如果像之前一樣使用this.id,訪問到的就不是 button 元素,而是 window,就得不到正確的結果。

跨瀏覽器事件處理程式

直接上碼:

var EventUtil={
    addHandler:function(element,type,handler){
        if(element.addEventListener){
            element.addEventListener(type,handler,false);
        } else if(element.attachEvent){
            element.attachEvent(“on”+ type,handler);
        } else {
            element[“on” + type]=handler;
        }
    },
    removeHandler:function(element,type,handler){
        if(element.removeEventListener){
            element.removeEventListener(type,handler,false);
        } else if(element.detachEvent){
            element.detachEvent(“on”+ type,handler);
        } else {
            element[“on” + type]=null;
        }
    }
}
複製程式碼

事件物件

在觸發 DOM 上的某個事件時,會產生一個事件物件 event,這個物件中包含著所有與事件有關的資訊。所有的瀏覽器都支援 event 物件,但支援方式不同。

DOM 中的事件物件

相容 DOM 的瀏覽器會將一個 event 物件傳入到事件處理程式中。event 物件包含與建立它的特定事件有關的屬性和方法。觸發的事件型別不一樣,可用的屬性和方法也不一樣。

不過,所有事件都會有下面列出的成員。

  • bubbles (boolean) — 表明事件是否冒泡

  • cancelable (boolean) — 這個變數指明這個事件的預設行為是否可以通過呼叫 event.preventDefault 來阻止。也就是說,只有 cancelable 為 true 的時候,呼叫 event.preventDefault 才能生效。

  • currentTarget(element) — 當事件遍歷DOM時,標識事件的當前目標。它總是引用事件處理程式附加到的元素,而不是event.target,event.target標識事件發生的元素。

  • defaultPrevented (boolean) — 這個狀態變數表明當前事件物件的 preventDefault 方法是否被呼叫過

  • eventPhase (number) — 這個數字變數表示當前這個事件所處的階段(phase):none(0),capture(1),target(2),bubbling(3)。

  • preventDefault(function) — 這個方法將阻止瀏覽器中使用者代理對當前事件的相關預設行為被觸發。比如阻止<a>元素的 click 事件載入一個新的頁面

  • stopImmediatePropagation (function) — 這個方法將阻止當前事件鏈上所有的回撥函式被觸發,也包括當前節點上針對此事件已繫結的其他回撥函式。

  • stopPropagation (function) — 阻止捕獲和冒泡階段中當前事件的進一步傳播。

  • target (element) — 事件起源的 DOM 節點(獲取標籤名:ev.target.nodeName)

  • type (String) — 事件的名稱

  • isTrusted (boolean) — 如果一個事件是由裝置本身(如瀏覽器)觸發的,而不是通過 JavaScript 模擬合成的,那個這個事件被稱為可信任的(trusted)

  • timestamp (number) — 事件發生的時間

在事件處理程式內部,物件 this 始終等於 currentTarget 的值,而 target 則只包含事件的實際目標。如果直接將事件處理程式指定給了目標元素, 則 this、currentTarget、target 包含相同的值。

<input type="button" value="Click Me" id="btn">
<script>
    var btn=document.getElementById("btn");
    btn.onclick = function (event) {
        console.log(event.currentTarget === this); // true
        console.log(event.target === this); // true
    }
</script>
複製程式碼

如果事件處理程式存在於按鈕的父節點,那麼這些值是不同的。

<input type="button" value="Click Me" id="btn">
<script>
    var btn=document.getElementById("btn");
    document.body.onclick = function (event) {
        console.log(event.currentTarget === document.body); // true
        console.log(this === document.body); // true
        console.log(event.target === btn); // true
    }
</script>
複製程式碼

在需要通過一個函式處理多個事件時,可以使用 type 屬性。

<input type="button" value="Click Me" id="btn">
<script>
    var btn=document.getElementById("btn");
    var handler = function(event) {
        switch (event.type) {
            case "click":
                console.log("clicked");
                break;
            case "mouseover":
                event.target.style.backgroundColor = "red";
                break;
            case "mouseout":
                event.target.style.backgroundColor = "";
                break;
        }
    }
    btn.onclick = handler;
    btn.onmouseover = handler;
    btn.onmouseout = handler;
</script>
複製程式碼

跨瀏覽器的事件物件

萬惡的 IE!直接上碼:

EventUtil = {
    addHandler: function(element,type,handler){
        // 省略程式碼
    },
    removeHandler: function(element,type,handler){
        // 省略程式碼
    },
    getEvent: function(event){
        return event?event:window.event;
    },
    getTarget: function(event){
        return event.target || event.srcElement;
    },
    preventDefault: function(event){
        if(event.preventDefault){
            event.preventDefault();
        }else{
            event.returnValue = false;
        }
    },
    stopProgagation: function(event){
        if(event.stopProgagation){
            event.stopProgagation();
        }else{
            event.cancelBubble = true;
        }
    }
};
複製程式碼

更多詳情參見:跨瀏覽器的事件物件

阻止事件冒泡/停止傳播(Stopping Propagation)

可以通過呼叫事件物件的 stopPropagation 方法,在任何階段(捕獲階段或者冒泡階段)中斷事件的傳播。此後,事件不會在後面傳播過程中的經過的節點上呼叫任何的監聽函式。

<input type="button" value="Click Me" id="btn">
<script>
    var btn=document.getElementById("btn");
    btn.onclick = function (event) {
        console.log("Clicked"); // 觸發
        event.stopPropagation();
    }
    document.body.onclick = function (event) {
        console.log("Body clicked"); // 傳播阻斷 不觸發
    }
</script>
複製程式碼

呼叫 event.stopPropagation()不會阻止當前節點上此事件其他的監聽函式被呼叫。如果你希望阻止當前節點上的其他回撥函式被呼叫的話,你可以使用更激進的 event.stopImmediatePropagation()方法。

阻止瀏覽器預設行為

當特定事件發生的時候,瀏覽器會有一些預設的行為作為反應。最常見的事件不過於 link 被點選。當一個 click 事件在一個<a>元素上被觸發時,它會向上冒泡直到 DOM 結構的最外層 document,瀏覽器會解釋 href 屬性,並且在視窗中載入新地址的內容。

在 web 應用中,開發人員經常希望能夠自行管理導航(navigation)資訊,而不是通過重新整理頁面。為了實現這個目的,我們需要阻止瀏覽器針對點選事件的預設行為,而使用我們自己的處理方式。這時,我們就需要呼叫 event.preventDefault().

我們可以阻止瀏覽器的很多其他預設行為。比如,我們可以在 HTML5 遊戲中阻止敲擊空格時的頁面滾動行為,或者阻止文字選擇框的點選行為。

呼叫 event.stopPropagation()只會阻止傳播鏈中後續的回撥函式被觸發。它不會阻止瀏覽器的自身的行為。

事件型別

事件的種類可謂相當繁多,不同的事件型別具有不同的資訊,常用的大致可分為如下幾類:

  • UI:load、 error(錯誤觸發)、select、resize、scroll等
  • 焦點:blur、focus、change(當使用者提交對元素值的更改時觸發)等
  • 滑鼠:click、dblclick、mousedown/up、mouseenter/leave、mousemove、mouseover等

一些注意點:1、除 mouseenter/leave 所有滑鼠事件都會冒泡; 2、只有在同一個元素上相繼觸發 mousedown/up 事件,才會觸發 click 事件,只有觸發兩次 click 事件才會觸發一次 dblclick 事件,4個事件的觸發順序為 mousedown → mouseup → click → mousedown → mouseup → click → dblclick (ie8及之前雙擊事件會跳過第二個 mousedown 和 click 事件);

  • 鍵盤:keydown/up、keypress
  • 觸控:touchstart(即使已經有一個手指放在螢幕上也會觸發)、touchend、touchmove等

在觸控螢幕上的元素時事件的發生順序為:touchstart → mouseover → mousemove(一次) → mousedown → mouseup → click → touchend;

  • 手勢:gesturestart、gestureend、gesturechange等

1、gesturestart:當一個手指已經按在螢幕上,而另一個手指又觸控在螢幕時觸發。 2、gesturechange:當觸控螢幕的任何一個手指的位置發生變化時觸發。3、gestureend:當任何一個手指從螢幕上面移開時觸發。注意:只有兩個手指都觸控到事件的接收容器時才觸發這些手勢事件。

  • 裝置:orientationchange(檢測裝置螢幕旋轉)、deviceorientation(檢測裝置方向變化)等

上面這些,大都是人為操作,還有些事件是網頁狀態帶來的,比如:網頁載入完成、提交表單、網頁出錯等。

除此之外,還有變動事件,複合事件,HTML5新加入的一些事件,不再一一列出。完整列表可在這裡檢視 Web Events

記憶體和效能

事件委託/代理事件監聽

事件委託利用了事件冒泡,只指定一個事件處理程式,就可以管理某一型別的所有事件。例如,click 事件會一直冒泡到 document 層次,也就是說,我們可以為整個頁面指定一個 onclick 事件處理程式,而不必為每個可點選的元素分別新增事件處理程式。

<ul id="myLinks">
    <li id="goSomewhere">Go somewhere</li>
    <li id="doSomething">Do something</li>
    <li id="sayHi">Say hi</li>
</ul>
<script>
    var list = document.getElementById("myLinks");
    EVentUtil.addHandler (list, "click", function (event) {
        event = EVentUtil.getEvent(event);
        var target = EVentUtil.gitTarget(event);

        switch(target.id) {
            case "doSomething":
                document.title = "I changed the document's title";
                break;
            case "goSomewhere":
                location.href = "https://heycoder.cn/";
                break;
            case "sayHi":
                console.log("hi");
                break;
        }
    })
</script>
複製程式碼

移除事件處理程式

大多是 IE 的鍋!

記憶體中留有那些過時不用的“空事件處理程式”,也是造成 web 應用程式記憶體與效能問題的主要原因。

在兩種情況下,可能會造成上述問題:

第一種情況就是從文件中移除帶有事件處理程式的元素時。這可能是通過純粹的DOM操作,例如使用removeChild()replaceChild()方法,但更多地是發生在使用 innerHTML 替換頁面中某一部分的時候。如果帶有事件處理程式的元素被 innerHTML 刪除了,那麼原來新增到元素中的事件處理程式極有可能被當作垃圾回收。來看下面的例子:

<div id="myDiv">
    <input type="button" value="ClickMe" id="myBtn">
</div>
<script>
    var btn = document.getElementById("myBtn");
    btn.onclick=function(){
        document.getElementById("myDiv").innerHTML="Processing…";
    }
</script>
複製程式碼

這裡,有一個按鈕被包含在<div>元素中,為避免雙擊,單擊這個按鈕時就將按鈕移除並替換成一條訊息;這是網站設計中非常流行的一種做法。但問題在於,當按鈕被從頁面中移除時,它還帶著一個事件處理程式呢,在<div>元素中設定 innerHTML 可以把按鈕移走,但事件處理各種仍然與按鈕保持著引用聯絡。有的瀏覽器(尤其是IE)在這種情況下不會作出恰當的處理,它們很有可能會將對元素和事件處理程式的引用都儲存在記憶體中。如果你想知道某個元即將被移除,那麼最好手工移除事件處理程式。如下面的例子所示:

<div id="myDiv">
    <input type="button" value="ClickMe" id="myBtn">
</div>
<script>
    var btn = document.getElementById("myBtn");
    btn.onclick=function(){
        btn.onclick=null;
        document.getElementById("myDiv").innerHTML="Processing…";
    }
</script>
複製程式碼

在此,我們設定<div>的innerHTML屬性之前,先移除了按鈕的事件處理程式。這樣就確保了記憶體可以被再次利用,而從DOM中移除按鈕也做到了乾淨利索。

注意,在事件處理程式中刪除按鈕也能阻止事件冒泡。目標元素在文件中是事件冒泡的前提。

導致“空事件處理程式”的另一情況,就是解除安裝頁面中的時候。毫不奇怪,IE在這種情況下依然是問題最多的瀏覽器,儘管其他瀏覽器或多或少也有類似的問題。如果在頁面被解除安裝之前沒有清理乾淨事件處理程式。那它們就會滯留在記憶體中。每次載入完頁面再解除安裝頁面時(可能是在兩個頁面間來加切換,也可以是單擊了“重新整理”按鈕),記憶體中滯留的物件數目就會增加,因為事件處理程式佔用的記憶體並沒有被釋放。

一般來說,最好的做法是在頁面解除安裝之前 ,先通過 onunload 事件處理程式移除所有事件處理程式。在此,事件委託技術再次表現出它的優勢——需要跟蹤的事件程式越少,移除它們就越容易,對這種類似的操作,我們可把它想象成:只要是通過 onload 事件處理程式新增的東西,最後都要通過 onunload 事件處理程式將它們移除。

注:不要忘了,使用 onunload 事件處理程式意味著頁面不會被快取在 bfcachek 中,如果你在意這個問題,那麼就只能在 IE 中通過onunload 來移除事件處理程式了。

一些常用的操作

load 事件:

load 事件可以在任何資源(包括被依賴的資源)被載入完成時被觸發,這些資源可以是圖片,css,指令碼,視訊,音訊等檔案,也可以是 document 或者 window。

Image 元素 load:

// window onload
EVentUtil.addHandler (window, "load", function () {
    var image = new Image();
    // 要在指定 src 屬性之前先指定事件
    EVentUtil.addHandler (image, "load", function () {
        console.log("Image loaded!");
    });
    image.src = "smile.gif";
})
複製程式碼

注意:新影像元素不一定要新增到文件後才開始下載,只要設定了 src 屬性就會開始下載。

script 元素 load:

// window onload
EVentUtil.addHandler (window, "load", function () {
    var script = document.createElement("script");
    EVentUtil.addHandler (script, "load", function (event) {
        console.log("loaded!");
    });
    script.src = "example.js";
    document.body.appendChild(script);
})
複製程式碼

與影像不同,只有設定了 script 元素的 src 屬性並將元素新增到文件後,才會開始下載 js 檔案,對於 script 元素而言指定 src 屬性和指定事件處理程式的先後順序就不重要了。

onbeforeunload 事件(HTML5事件):

window.onbeforeunload 讓開發人員可以在想使用者離開一個頁面的時候進行確認。這個在有些應用中非常有用,比如使用者不小心關閉瀏覽器的 tab,我們可以要求使用者儲存他的修改和資料,否則將會丟失他這次的操作。

EVentUtil.addHandler (window, "onbeforeunload", function (event) {
    if (textarea.value != textarea.defaultValue) {
        return 'Do you want to leave the page and discard changes?';
    }
});
複製程式碼

需要注意的是,對頁面新增 onbeforeunload 處理會導致瀏覽器不對頁面進行快取,這樣會影響頁面的訪問響應時間。 同時,onbeforeunload 的處理函式必須是同步的(synchronous)。

resize 事件:

在一些複雜的響應式佈局中,對 window 物件監聽 resize 事件是非常常用的一個技巧。僅僅通過 css 來達到想要的佈局效果比較困難。很多時候,我們需要使用 JavaScript 來計算並設定一個元素的大小。

EVentUtil.addHandler (window, "resize", function (event) {
    // update the layout
});
複製程式碼

error 事件:

當我們的應用在載入資源的時候發生了錯誤,我們很多時候需要去做點什麼,尤其當使用者處於一個不穩定的網路情況下。Financial Times 中,我們使用 error 事件來監測文章中的某些圖片載入失敗,從而立刻隱藏它。由於“DOM Leven 3 Event”規定重新定義了 error 事件不再冒泡,我們可以使用如下的兩種方式來處理這個事件。

imageNode.addEventListener('error', function(event) {
    image.style.display = 'none';
});
複製程式碼

不幸的是,addEventListener 並不能處理所有的情況。而確保圖片載入錯誤回撥函式被執行的唯一方式是使用讓人詬病內聯事件處理函式(inline event handlers)。

<img src="http://example.com/image.jpg" onerror="this.style.display='none';" />
複製程式碼

原因是你不能確定繫結 error 事件處理函式的程式碼會在 error 事件發生之前被執行。而使用內聯處理函式意味著在標籤被解析並且請求圖片的時候,error監聽器也將並繫結。

獲取滑鼠在網頁中的座標:

From:js滑鼠事件引數,獲取滑鼠在網頁中的座標

直接上碼:

// 滑鼠事件引數 相容性封裝 Test Already.
var EventUtil = {
    getEvent : function(e){
        return e || window.event;
    },

    getTarget : function(e){
        return this.getEvent(e).target || this.getEvent(e).srcElement;
    },

    getClientX : function(e){
        return this.getEvent(e).clientX;
    },

    getClientY : function(e){
        return this.getEvent(e).clientY;
    },

    // 水平滾動條偏移
    getScrollLeft : function(){
        return  document.documentElement.scrollLeft ||    // 火狐 IE9及以下滾動條是HTML的
                window.pageXOffset ||                     // IE10及以上 window.pageXOffset
                document.body.scrollLeft;                 // chrome 滾動條是body的
    },

    // 垂直滾動條偏移
    getScrollTop : function(){
        return  document.documentElement.scrollTop ||    // 火狐 IE9 及以下滾動條是 HTML 的
                window.pageYOffset ||                    // IE10 及以上 window.pageXOffset
                document.body.scrollTop;                 // chrome 滾動條是body的,不過目前新版的滾動條也放到 document上去了
    },

    getPageX : function(e){
        return (this.getEvent(e).pageX)?( this.getEvent(e).pageX ):( this.getClientX(e)+this.getScrollLeft() );
    },

    getPageY : function(e){
        return (this.getEvent(e).pageY)?( this.getEvent(e).pageY ):( this.getClientY(e)+this.getScrollTop() );
    }
};
複製程式碼

附加

關於Chrome瀏覽器 document.body.scrollTop 一直為0的問題可參考這裡

寫在後面的話:對於 js 事件總會有一種朦朧美的感覺,最近也算沒太忙,索性再翻看下 js 高程,本篇文章算是高程事件章節的讀書筆記,也會有所擴充套件。

相關文章