JS事件詳解
前言
JS和HTML之間的互動是通過事件來實現的,在我們的頁面載入完畢,所有的 html,css,js
檔案都已經load的情況下,我們如何在文件或瀏覽器視窗發生變化時通過js來進行互動,這就是事件產生的原因。我們在js中預定一個事件的處理程式,然後瀏覽器的監聽程式監聽各種事件的發生,當事件發生的時候呼叫預定的事件處理程式來執行。這種觀察員模式的模型讓我們實現了 js
和 html,css
之間的鬆散耦合。
事件流
由於DOM結構是層層巢狀的,所以監聽程式會遇到一個問題,就是當我們點選一個位置的時候,這個位置有多個DOM節點巢狀在一起,那麼我們到底點選的誰呢? Javascript高階程式設計
給出了一個更形象的比喻,在紙上畫一組同心圓,然後把手指指向圓心,此時你指向的不僅僅是一個圓,而是一串同心圓。同理當你點選一個DOM元素,你不僅點選了這個元素,也點選了所有父元素,包括頁面本身。由於這個問題,才產生了事件流,也就是一個事件發生了,這個事件會在巢狀的DOM結構裡面傳播,而事件流就描述了這個傳播的具體規則,巢狀的DOM元素是按照什麼順序接收事件的。
即使你沒深入學習過事件,也應該聽過事件捕獲和事件冒泡,它們也只不過是瀏覽器大戰中IE和網景對事件流的不同理解而產生的不同實現,IE選擇了事件冒泡流,而網景選擇了事件捕獲流。
事件冒泡流
先來說一說IE的事件冒泡流,事件冒泡流的設計思路是事件最開始是由事件的目標事件(當前點選的位置所巢狀的DOM結構的巢狀最深的那個節點)接收,然後沿著 DOM
樹逐級向上傳播一直到根節點也就是 document
節點。一下面的 HTML
文件為例:
<!DOCTYPE html> <html lang="en"> <head> <title>Test</title> </head> <body> <div class="btn">Click me!</div> </body> </html>
如果我們點選了Click這個按鈕以後按事件冒泡流的規則,事件會按照如下順序傳播
1.<div>
2.
<body>
3.
<html>
4. document
也就是click事件首先發生在 div
上,也就是我們單擊的元素,然後 click
事件沿著 DOM
樹向上傳播,在每一級的DOM節點上都會發生,直到傳播到 DOM
樹的根節點 document
。
所有現代瀏覽器都支援事件冒泡,但在具體實現上還是有一些差別。IE5.5 及更早版本中的事件冒 泡會跳過 <html>
元素(從 <body>
直接跳到 document
)。IE9、Firefox、Chrome 和 Safari 則將事件一直 冒泡到 window 物件。
目標事件,也就是我們後面提到的 DOM2
裡面 event
物件的 target
屬性,我看網上的很多解釋都不清晰,其實直白的說就是事件觸發的座標所在位置 DOM
結構巢狀最深的那個節點,也即是在 DOM
樹上最深的節點,這個節點也就是所謂的 target
。
事件捕獲
網景對於事件流的實現和IE則是截然相反的,也就是目標節點最後接收事件,DOM樹上的上層節點則更早地接收事件。還以上面的 HTML
程式碼作為例子,在事件捕獲流中,事件的傳播順序如下:
document <html> <body> <div>
在事件捕獲流中, document
物件首先接收到事件,然後沿著 DOM
樹逐級向下傳播,一直傳播到目標節點 div
元素,如下圖:
DOM事件流
事件捕獲只能在事件傳播到目標元素之前截獲,而事件冒泡只能在事件已經傳播到目標元素之後進行截獲,所以 DOM二級事件
把兩者結合了起來, DOM二級事件
規定了事件傳播的三個階段:
1. 事件捕獲階段
2. 處於目標事件階段
3. 事件冒泡階段
還用上面的 HTML
例子來解釋,在 DOM
事件流中,實際的目標( <div>
元素)在捕獲階段不會接收到事件。這意味著在捕獲階段,事件從 document
到 <html>
再到 <body>
後就停止了。下一個階段是“處於目標”階段,於是事件在 <div>
上發生,並在事件處理(後面將會討論這個概念)中被看成冒泡階段的一部分。然後,冒泡階段發生, 事件又傳播迴文檔。傳播的順序如下圖:
在 DOM Level 3 Events draft 中有一個更清晰的圖表示三個階段:
事件處理程式
也可以叫做事件偵聽器,事件就是使用者或瀏覽器自身執行的某種動作。諸如 click、load 和 mouseover
,都是事件的名字。 而響應某個事件的函式就叫做事件處理程式(或事件偵聽器)。事件處理程式的名字以 on
開頭,因此 click
事件的事件處理程式就是 onclick
,load 事件的事件處理程式就是 onload
。為事件指定處理 程式的方式有好幾種。
HTML事件處理程式
當一個HTML元素支援某種事件,我們可以通過該元素的屬性來指定事件處理程式,比如 click
事件就可以用 onclick
屬性來指定事件處理程式,屬性值應該是可執行的 javascript
程式碼,比如點選按鈕彈出警告框:
<input type="button" value="Click Me" onclick="alert('Clicked')" />
在 html
中定義的事件處理程式也可以呼叫別的地方定義的指令碼,比如呼叫你在別的地方定義的函式,如下:
<script type="text/javascript"> function showMessage() { alert("Hello world!"); } </script> <input type="button" value="Click Me" onclick="showMessage()" />
呼叫的函式可以是在當前html檔案中的script標籤中,也可以是在頁面引用的其他js檔案中,事件處理程式中的程式碼在執行時,有權訪問全域性作用域中的任何程式碼。
HTML事件處理程式的特點:
1. 建立了一個封裝著元素屬性值的函式,通過函式中的區域性變數event直接訪問事件物件(後面會介紹),不需要定義這個引數,也不需要從引數列表中讀取,可以直接使用。
<script type="text/javascript"> function showMessage() { console.log(event.type); console.log(this.value); } </script> <input type="button" value="Click Me" onclick="showMessage()" /> /* 輸出 click*/
- this的指向:如果是直接在
onclick
屬性中執行的javascript
程式碼,那麼this指向當前的元素;如果是引用自其他標籤或檔案中的函式,那麼this指向window
物件。
<script type="text/javascript"> function showMessage() { console.log(event.type); console.log(this); } </script> <!-- 指向當前元素 --> <input type="button" value="Click Me" onclick="console.log(this)" /> <!-- 指向window物件 --> <input type="button" value="Click Me" onclick="showMessage()" />
- 在函式內部可以像訪問區域性變數一樣訪問document以及該元素本身的成員,需要注意的是引用的函式同樣不可以(函式的作用域鏈取決於函式定義的位置,而不是執行的位置),函式的內部實現類似如下:
function(){ with(document){ with(this){ //元素屬性值 } } }
如果當前元素是一個表單輸入元素,則作用域中還會包含訪問表單元素(父元素)的入口,這樣擴充套件作用域的方式,無非就是想讓事件處理程式無需引用表單元素就能訪問其他表單 欄位。
<form method="post"> <input type="text" name="username" value=""> <!-- 可以直接訪問username的value --> <input type="button" value="Echo Username" onclick="alert(username.value)"> </form>
- 如果屬性值採取的引用函式的方式,當元素已經渲染好,而js還沒有載入完成,可能會造成觸發事件而事件處理程式並沒有執行,這樣會報錯,防止報錯可以使用
try-catch
:
<input type="button" value="Click Me" onclick="try{showMessage();}catch(ex){}">
- 用HTML指定的事件處理程式造成html和javascript耦合,我們也無法同時給多個元素繫結事件,也無法給同一個事件繫結多個函式等等,由於這種方式的缺點非常明顯,所以幾乎已經消失了。
由於屬性值是javascript程式碼,因此不能在語句中使用未經轉義的HTML語法字元,比如和號 &
,雙引號 "
,大於號 >
,小於號 <
等,並且在HTML中轉義不能使用反斜槓 \
,而要使用html實體(entity),比如雙引號是 "
,如果你要查詢某個字元的實體,在 w3.org 查詢。
DOM0級事件處理程式
頁面上的每一個元素都有一個事件處理程式屬性(包括window和document物件),這些屬性都是小寫,比如代表click事件的 onclick
,將需要監聽事件的元素的該屬性的值設定為一個函式,就可以指定事件處理程式,當事件在元素上觸發的時候會呼叫事件處理程式。
var btn = document.getElementById("myBtn"); btn.onclick = function(){ alert("Clicked"); };
DOM0級事件處理程式中的 this
指向繫結事件的元素,在事件處理程式中可以訪問元素的所有屬性和方法。這種方法繫結的事件處理程式會在事件流的冒泡階段被執行。
想要刪除繫結的DOM0級事件處理程式,只要將元素的 onclick
屬性設定為 null
即可,在HTML標籤中繫結的事件處理函式也可以用這個方法來刪除繫結的事件處理程式,需要注意的是刪除繫結的程式碼要在需要刪除的標籤之後。
DOM2級事件處理程式
DOM2級事件處理程式是我們目前最多使用的繫結事件處理程式的方法,包含了兩個主要的方法用來繫結和刪除事件處理函式 addEventListener()
和 removeEventListener()
。所有DOM節點都包含這兩個方法。這兩個方法都接受三個引數,第一個引數是要處理的事件型別(和DOM0級事件中的物件屬性不同,這裡的事件型別不需要加 on
),第二個引數是事件處理程式對應的函式,第三個引數是一個布林值,如果是 false
表示在捕獲階段呼叫事件處理程式,如果是 true
表示在冒泡階段呼叫事件處理程式,預設為 false
。具體細節可以看 MDN 。如果我們要在一個元素上新增click事件的事件處理程式,就可以使用如下程式碼:
var btn = document.querySelector(".btn"); btn.addEventListener("click", function () { console.log(this); }, false);
其中的 click
就是要處理的事件型別,匿名函式就是我們指定的事件處理函式,最後的 false
就是指定事件觸發是在冒泡階段。當事件監聽程式監聽到符合要求的事件發生時,就會呼叫事件處理程式來執行。
DOM2級事件處理程式的特點:
1. 可以為同一個元素的同一型別的事件繫結多個事件處理程式,他們會按照新增順序執行。如:
var btn = document.getElementById("myBtn"); btn.addEventListener("click", function(){ console.log(this.id); }, false); btn.addEventListener("click", function(){ console.log("Hello world!"); }, false);
這段程式碼為btn的click型別的事件指定了兩個事件處理程式,當我們觸發btn的click事件的時候,這兩個事件處理程式會按順序執行,也就是先輸出 this.id
然後輸出 Hello world!
。
- 由於可以指定事件觸發的階段以及event物件的存在我們可以用DOM2級事件進行事件委託,達到對效能的提升和同型別元素繫結事件的簡化,後面會詳細討論。
-
因為可以為同一元素和同一型別的事件繫結多個事件處理程式,所以要刪除這些事件只能通過
removeEventListener()
來刪除,並且移除時傳入的引數必須與繫結時傳入的引數相同,如果在繫結的時候如果用的是匿名函式,那麼這個事件處理程式將無法刪除,因為兩個不同的匿名函式指向的是不同的空間,如:
var btn = document.getElementById("myBtn"); btn.addEventListener("click", function(){ alert(this.id); }, false); btn.removeEventListener("click", function(){ //無效 alert(this.id); }, false);
如果要實現事件處理函式的刪除需要將第二個引數換成函式的引用:
var btn = document.getElementById("myBtn"); var handler = function(){ alert(this.id); }; btn.addEventListener("click", handler, false); //這裡省略了其他程式碼 btn.removeEventListener("click", handler, false); //有效!
IE9、Firefox、Safari、Chrome 和 Opera 支援 DOM2 級事件處理程式。如果不是特別的事件如 mouseenter
不支援事件冒泡,一般就預設在冒泡階段觸發即可,事件捕獲可以當我們需要在事件傳播到目標之前截獲的時候使用。
IE事件處理程式
在IE9之前的版本使用的是IE獨特的事件處理程式,它只支援冒泡,有兩個方法 attachEvent()
和 detachEvent()
兩個方法,接收兩個引數:事件型別(和DOM0一樣需要加上 on
)和事件處理程式函式,由於現在很少需要相容IE9之前的版本,就不過多討論了,放上一個跨瀏覽器的事件處理程式:
/* 若支援DOM2級事件處理程式則用DOM2級,若支援IE的事件處理程式則用IE,否則用DOM0級事件處理程式 */ 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; } } };
事件物件
在上面的事件處理程式中多次提到了 event
物件,我們在上面的程式碼中輸出過 event
物件的 type
屬性,這個屬性表示當前指定的事件處理成熟的事件型別。事實上,每當某個DOM元素觸發了某個事件,都會產生一個 event
事件物件,這個物件中包含著所有與事件有關的資訊。包括觸發事件的元素、事件的型別以及其他與特定事件相關的資訊。例如,滑鼠操作導致的事件 物件中,會包含滑鼠位置的資訊,而鍵盤操作導致的事件物件中,會包含與按下的鍵有關的資訊。所有 瀏覽器都支援 event
物件,但支援方式不同。
在DOM0級和DOM2級事件處理程式中,瀏覽器會將一個 event
物件傳入我們定義的事件處理程式的函式中,即使我們沒有在函式的引數列表中加入 event
形參,我們也可以在函式內部使用,應該是瀏覽器替我加上了引數 event
:
<button id="myBtn">btn</button> <script type="text/javascript"> var btn = document.getElementById("myBtn"); btn.onclick = function () { console.log(event.type);//可以輸出 } </script>
一般為了程式碼便於理解,我們在事件處理程式的回撥函式中給出引數 event
.
即使我們通過HTML內聯的方式執行事件處理程式,在其中我們也可以使用一個指向 event
物件的變數 event
:
<input type="button" value="Click Me" onclick="alert(event.type)"/>
event 物件包含與建立它的特定事件有關的屬性和方法。觸發的事件型別不一樣,可用的屬性和方法也不一樣。不過,所有事件都會有下表列出的成員。
屬性/方法 | 型別 | 讀/寫 | 說明 |
---|---|---|---|
bubbles | Boolean | 只讀 | 事件是否冒泡 |
cancelable | Boolean | 只讀 | 是否可以取消事件的預設行為 |
currentTarget | Element | 只讀 | 事件處理程式當前處理元素 |
defaultPrevented | Boolean | 只讀 | 為 true表示已經呼叫了preventDefault()(DOM3級事件中新增) |
detail | Integer | 只讀 | 與事件相關細節資訊 |
eventPhase | Integer | 只讀 | 事件處理程式階段:1 捕獲階段,2 處於目標階段,3 冒泡階段 |
preventDefault() | Function | 只讀 | 取消事件預設行為 |
stopPropagation() | Function | 只讀 | 取消事件進一步捕獲或冒泡 |
target | Element | 只讀 | 事件的目標元素 |
trusted | Boolean | 只讀 | 為true表示事件是瀏覽器生成的。為false表 示事件是由開發人員通過JavaScript建立的(DOM3級事件中新增) |
type | String | 只讀 | 被觸發的事件型別 |
view | AbstractView | 只讀 | 與事件關聯的抽象檢視,等同於發生事件的window物件 |
關於 currentTarget
和 target
只要記住一點,不管事件傳播處於什麼階段, target
都是不變的,指向目標元素,所以我們在實現事件委託的時候會用到 target
。而 currentTarget
則是隨著事件傳播處於不同的階段而指向不同的元素。具體細節點選實現頁面,開啟控制檯點選不同的元素檢視細節。
在事件處理程式內部,物件 this 始終等於 currentTarget 的值,而 target 則只包含事件的實際目標。如果直接將事件處理程式指定給了目標元素,則 this、currentTarget 和 target 包含相同 的值。來看下面的例子。
我們可以利用 event
物件的 type
屬性來用一個函式處理多種事件:
var handler = function(event){ switch(event.type){ case "click": alert("Clicked"); break; case "mouseover": event.target.style.backgroundColor = "red"; break; case "mouseout": event.target.style.backgroundColor = ""; break; } };
有時我們會需要阻止某些事件的預設行為,比如點選表單的 submit
跳轉,以及點選 a標籤
的跳轉,如果我們不希望這些行為發生,那麼我們可以利用 event物件
的 preventDefault()
方法,只有 cancelable 屬性設定為 true 的事件,才可以使用 preventDefault()來取消其預設行為。
event物件
還有一個重要的方法是 stopPropagation()
,這個方法用來停止事件在 DOM
樹上的傳播,不管在哪個傳播階段,都會停止事件的傳播。比如我們在按鈕和body上都註冊了一個事件,當用戶點選按鈕,我們不希望註冊在body上的事件被觸發,此時我們就需要用到這個方法:
var btn = document.getElementById("myBtn"); btn.onclick = function(event){ alert("Clicked"); event.stopPropagation(); }; document.body.onclick = function(event){ alert("Body clicked"); };
事件物件的 eventPhase
屬性,可以用來確定事件當前正位於事件流的哪個階段。如果是在捕獲階 段呼叫的事件處理程式,那麼 eventPhase
等於 1
;如果事件處理程式處於目標物件上,則 event- Phase
等於 2
;如果是在冒泡階段呼叫的事件處理程式, eventPhase
等於 3
。這裡要注意的是,儘管“處於目標”發生在冒泡階段,但 eventPhase 仍然一直等於 2。來看下面的例子。
var btn = document.getElementById("myBtn"); btn.onclick = function(event){ alert(event.eventPhase); //2 }; document.body.addEventListener("click", function(event){ alert(event.eventPhase); //1 }, true); document.body.onclick = function(event){ alert(event.eventPhase); //3 };
還有一個要注意的點是,很多同學學習事件傳播順序的知識會容易混淆到單個元素的事件處理程式的執行順序上,對於單個元素,無論你是繫結在捕獲階段還是冒泡階段,都是先繫結的事件處理程式先執行,事件傳播只在巢狀中的不同DOM元素之間有效,比如如下程式碼,就是先執行冒泡在執行捕獲。
<div id="el">element</div> <script type="text/javascript"> var el = document.getElementById('el'); //冒泡 el.addEventListener('click',function () { console.log("el冒泡"); },false); //捕獲 el.addEventListener('click',function () { console.log("el捕獲"); },true); </script>
event物件
在事件觸發的時候生成,當事件處理程式執行結束後, event物件
即被銷燬,也就是說只有在事件處理程式執行期間, event物件
才會存在。
事件型別
瀏覽器中發生的事件型別很多,詳情查詢 MDN
記憶體和效能
在JS中,新增到頁面上的事件處理程式的數量會影響的頁面的整體效能,因為每一個事件處理函式也都是物件,都儲存在記憶體中,事件處理程式多了自然對記憶體的開銷會增大。其次,在JS的渲染過程中,指定事件處理程式需要訪問DOM,沒繫結一個事件處理程式都需要訪問一次DOM,如果事件處理程式過多,會影響頁面渲染完成的時間。如果我們能夠更好地處理事件,對提升頁面的效能是有一定的幫助的。
事件委託
事件委託其實很好理解,利用事件流傳播的特性,利用事件冒泡,我們可以對多個需要繫結事件的同類型元素的上級DOM節點繫結一個事件處理程式,用這個上層節點的事件處理程式來同一管理那些同一型別的事件。舉個例子:
<ul id="myLinks"> <li id="goSomewhere">Go somewhere</li> <li id="doSomething">Do something</li> <li id="sayHi">Say hi</li> </ul>
如果我們要實現點選每個 li
都輸出其中的文字,那麼按照傳統的做法,我們會為每個 li
繫結一個事件,如果同樣型別的元素特別多,那麼我們一個一個繫結事件顯然是不現實的,而且這樣頁面的效能也不佳。如果我們事件委託,我們就可以把事件處理程式繫結到 ul
上,利用事件冒泡的特性來統一管理點選 li
的事件。
var list = document.querySelector("myLinks"); ul.addEventListener("click", function (e) { if (e.target.tagName.toLowerCase() === "li") { console.log(e.target.innerText); } })
當我們點選 li
的時候,由於事件冒泡傳播,所以當事件傳播到 ul
的時候,會被我們繫結在 ul
上的事件處理程式捕獲,然後在函式內部我們利用 event.target
會指向目標元素的特點來判斷使用者點選的是否是 li
,然後在執行對應的邏輯需求。
使用事件委託還有一個優點就是當我們動態向我們綁定了事件處理程式的上冊元素中新增新的元素時,事件處理程式對這個新的元素也會生效,而傳統的繫結事件方法則不行。
最適合採用事件委託技術的事件包括 click、mousedown、mouseup、keydown、keyup 和 keypress。 雖然 mouseover 和 mouseout 事件也冒泡,但要適當處理它們並不容易,而且經常需要計算元素的位置。(因為當滑鼠從一個元素移到其子節點時,或者當滑鼠移出該元素時,都會觸發 mouseout 事件。)
移除事件處理程式
移除事件處理程式更像一個程式設計師來維護的垃圾回收方式。每當將事件處理程式指定給元素時,執行中的瀏覽器程式碼與支援頁面互動的 JavaScript 程式碼之間就 會建立一個連線。這種連線越多,頁面執行起來就越慢。採用事件委託的方式能有效的減少連線的數量,但是一些殘留在記憶體中的未被回收的空事件處理程式也是影響頁面效能的一個原因。
如果我們綁定了事件處理程式的元素被 removeChild(), replaceChild()或者innerHTML
方法刪除或替換的時候,原來新增到元素中的事件處理程式很可能沒有被當作垃圾回收,這時候用 removeEventListener()
或者 element.onclick = null
來手動移除事件處理程式是個不錯的選擇。
在事件處理程式中刪除按鈕也能阻止事件冒泡。目標元素在文件中是事件冒泡的前提。
自定義事件
自定義事件與瀏覽器定義的事件並沒有什麼不同,一樣能夠傳播,能夠指定事件處理程式。有了自定義事件以後,我們可以在任意時刻觸發特定的事件。對於複雜頁面不同功能模組之間的解耦有很大的幫助。同時自定義事件能夠實現對全域性的廣播,這在複雜的應用中有很大的作用。
建立自定義事件
Events 可以使用 Event 建構函式建立如下:
var event = new Event('build'); // Listen for the event. elem.addEventListener('build', function (e) { ... }, false); // Dispatch the event. elem.dispatchEvent(event);
新增自定義資料
要向事件物件新增更多資料,可以使用 CustomEvent,detail 屬性可用於傳遞自定義資料
CustomEvent 介面可以為 event 物件新增更多的資料。例如,event 可以建立如下:
var event = new CustomEvent('build', { 'detail': elem.dataset.time });
訪問自定義資料,在事件處理程式中的回撥函式中使用:
function eventHandler(e) { log('The time is: ' + e.detail); }
元素可以偵聽尚未建立的事件:
<form> <textarea></textarea> </form> <script> const form = document.querySelector('form'); const textarea = document.querySelector('textarea'); form.addEventListener('awesome', e => console.log(e.detail.text())); textarea.addEventListener('input', function() { // Create and dispatch/trigger an event on the fly // Note: Optionally, we've also leveraged the "function expression" (instead of the "arrow function expression") so "this" will represent the element this.dispatchEvent(new CustomEvent('awesome', { bubbles: true, detail: { text: () => textarea.value } })) }); </script>
javascript高階程式設計中的模擬事件章節中的方法都已經廢棄,想要了解自定義事件的檢視 MDN