DOM事件簡介

Click、touch、load、drag、change、input、error、risize — 這些都是冗長的DOM(文檔對象模型)事件列表的一部分。事件可以在文檔(Document)結構的任何部分被觸發,觸發者可以是用戶操作,也可以是瀏覽器本身。事件並不是只是在一處被觸發和終止;他們在整個document中流動,擁有它們自己的生命週期。而這個生命週期讓DOM事件有更多的用途和可擴展性。

作爲一個開發人員,我們必須要理解DOM事件是如何工作的,然後才能更好的駕馭它,利用它們潛在的優勢,開發出更高交互性的參與體驗(engaging experiences)。

反觀我做前端開發的這麼長時間裏,我覺得我從來沒有看到過一個關於DOM事件是如何工作的較爲直接準確的解釋。今天我的目標就是在這個課題上給大家一個清晰的介紹,讓大家能夠更快速的瞭解它。 我首先會介紹DOM事件的基本使用方式,然後會深入挖掘事件內部的工作機制,解釋我們如何使用這些機制來解決一些常見的問題。

 

監聽事件

在過去,主流瀏覽器之間對於如何給DOM節點添加事件監聽有着很大的不一致性。jQuery這樣的前端庫爲我們封裝和抽象了這些差異行爲,爲事件處理帶來了極大的便利。

如今,我們正一步步走向一個標準化的瀏覽器時代,我們可以更加安全地使用官方規範的接口。爲了簡單起見,這篇文章將主要介紹在現代瀏覽器中如何管理事件。如果你在爲IE8或者更低版本寫JavaScript,我會推薦你使用polyfill或者一些框架(如jQuery)來管理事件監聽。

在JavaScript中,我們使用如下的方式爲元素添加事件監聽:

1
element.addEventListener(<event-name>, <callback>, <use-capture>);
  • event-name(string)
    這是你想監聽的事件的名稱或類型。它可以是任何的標準DOM事件(click, mousedown, touchstart, transitionEnd,等等),當然也可以是你自己定義的事件名稱(我們會在後面介紹自定義事件相關內容)。
  • callback(function)(回調函數)
    這個函數會在事件觸發的時候被調用。相應的事件(event)對象,以及事件的數據,會被作爲第一個參數傳入這個函數。
  • use-capture(boolean)
    這個參數決定了回調函數(callback)是否在“捕獲(capture)”階段被觸發。不用擔心,我們稍後會對此做詳細的解釋。
1
2
3
4
5
6
7
8
var element = document.getElementById('element');
 
function callback() {
  alert('Hello');
}
 
// Add listener
element.addEventListener('click', callback);

Demo: addEventListener

 

移除監聽

移除不再使用的事件監聽是一個最佳實踐(尤其對於長時間運行的Web應用)。我們使用element.removeEventListener()方法來移除事件監聽:

1
element.removeEventListener(<event-name>, <callback>, <use-capture>);

但是removeElementListener有一點需要注意的是:你必須要有這個被綁定的回調函數的引用。簡單地調用element.removeEventListener('click');是不能達到想要的效果的。

本質上來講,如果我們考慮要移除事件監聽(我們在長時間運行(long-lived)的應用中需要用到),那麼我們就需要保留回調函數的句柄。意思就是說,我們不能使用匿名函數作爲回調函數。

1
2
3
4
5
6
7
8
9
var element = document.getElementById('element');
 
function callback() {
  alert('Hello once');
  element.removeEventListener('click', callback);
}
 
// Add listener
element.addEventListener('click', callback);

Demo: removeEventListener

 

維護回調函數上下文

一個很容易遇到的問題就是回調函數沒有在預想的運行上下文被調用。讓我們看一個簡單的例子來解釋一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
var element = document.getElementById('element');
 
var user = {
 firstname: 'Wilson',
 greeting: function(){
   alert('My name is ' + this.firstname);
 }
};
 
// Attach user.greeting as a callback
element.addEventListener('click', user.greeting);
 
// alert => 'My name is undefined'

Demo: Incorrect callback context

 

使用匿名函數(Anonymous Functions)

我們希望回調函數中能夠正確的輸出”My name is Wilson”。事實上,結果確是”My name is undefined”。爲了使得 this.firstName 能夠返回”Wilson”,user.greeting必須在user對象的上下文環境(context)中被執行(這裏的運行上下文指的是.號左邊的對象)。

當我們將greeting函數傳給addEventListener方法的時候,我們傳遞的是一個函數的引用;user相應的上下文並沒有傳遞過去。運行的時候,這個回調函數實際上是在element的上下文中被執行了,也就是說,在運行的時候,this指向的是element,而不是user。所以this.firstName是undefined。

有兩種方式可以避免這種上下文錯誤的問題。第一種方法,我們可以在一個匿名函數內部調用user.greeting()方法,從而獲得正確的函數執行上下文(user)。

1
2
3
4
element.addEventListener('click', function() {
  user.greeting();
  // alert => 'My name is Wilson'
});

Demo:Anonymouse functions

使用Function.prototype.bind

上一種方式並不是非常好,因爲我們不能獲得回調函數的句柄以便後面通過.removeEventListener()移除事件監聽。另外,這種方式也比較醜陋。。我更喜歡使用.bind()方法(做爲ECMAScript 5的標準內建在所有的函數對象中)來生成一個新的函數(被綁定過的函數),這個函數會在指定的上下文中被執行。然後我們將這個被綁定過的函數作爲參數傳給.addEventListener()的回調函數。

1
2
3
4
5
6
// Overwrite the original function with
// one bound to the context of 'user'
user.greeting = user.greeting.bind(user);
 
// Attach the bound user.greeting as a callback
button.addEventListener('click', user.greeting);

與此同時,我們獲得了回調函數的句柄,從而可以隨時從元素上移除相應的事件監聽。

1
button.removeEventListener('click', user.greeting);

DEMO:Function.ptototype.bind

想獲取Function.prototype.bind的更多信息,請點擊的瀏覽器支持頁面,以及polyfill的介紹。

 

Event對象

Event對象在event第一次觸發的時候被創建出來,並且一直伴隨着事件在DOM結構中流轉的整個生命週期。event對象會被作爲第一個參數傳遞給事件監聽的回調函數。我們可以通過這個event對象來獲取到大量當前事件相關的信息:

  • type (String) — 事件的名稱
  • target (node) — 事件起源的DOM節點
  • currentTarget?(node) — 當前回調函數被觸發的DOM節點(後面會做比較詳細的介紹)
  • bubbles (boolean) — 指明這個事件是否是一個冒泡事件(接下來會做解釋)
  • preventDefault(function) — 這個方法將阻止瀏覽器中用戶代理對當前事件的相關默認行爲被觸發。比如阻止<a>元素的click事件加載一個新的頁面
  • stopPropagation (function) — 這個方法將阻止當前事件鏈上後面的元素的回調函數被觸發,當前節點上針對此事件的其他回調函數依然會被觸發。(我們稍後會詳細介紹。)
  • stopImmediatePropagation (function) — 這個方法將阻止當前事件鏈上所有的回調函數被觸發,也包括當前節點上針對此事件已綁定的其他回調函數。
  • cancelable (boolean) — 這個變量指明這個事件的默認行爲是否可以通過調用event.preventDefault來阻止。也就是說,只有cancelable爲true的時候,調用event.preventDefault才能生效。
  • defaultPrevented (boolean) — 這個狀態變量表明當前事件對象的preventDefault方法是否被調用過
  • isTrusted (boolean) — 如果一個事件是由設備本身(如瀏覽器)觸發的,而不是通過JavaScript模擬合成的,那個這個事件被稱爲可信任的(trusted)
  • eventPhase (number) — 這個數字變量表示當前這個事件所處的階段(phase):none(0), capture(1),target(2),bubbling(3)。我們會在下一個部分介紹事件的各個階段
  • timestamp (number) — 事件發生的時間

此外事件對象還可能擁有很多其他的屬性,但是他們都是針對特定的event的。比如,鼠標事件包含clientX和clientY屬性來表明鼠標在當前視窗的位置。

我們可以使用熟悉的瀏覽器的調試工具或者通過console.log在控制檯輸出來更具體地查看事件對象以及它的屬性。

 

事件階段(Event Phases)

當一個DOM事件被觸發的時候,它並不只是在它的起源對象上觸發一次,而是會經歷三個不同的階段。簡而言之:事件一開始從文檔的根節點流向目標對象(捕獲階段),然後在目標對向上被觸發(目標階段),之後再回溯到文檔的根節點(冒泡階段)。

eventflow

(圖片來源:W3C)

Demo: Slow motion event path

 

事件捕獲階段(Capture Phase)

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

正如文章一開始的地方提到,我們可以通過將addEventListener的第三個參數設置成true來爲事件的捕獲階段添加監聽回調函數。在實際應用中,我們並沒有太多使用捕獲階段監聽的用例,但是通過在捕獲階段對事件的處理,我們可以阻止類似clicks事件在某個特定元素上被觸發。

1
2
3
4
5
var form = document.querySelector('form');
 
form.addEventListener('click', function(event) {
  event.stopPropagation();
}, true); // Note: 'true'

如果你對這種用法不是很瞭解的話,最好還是將useCapture設置爲false或者undefined,從而在冒泡階段對事件進行監聽。

 

目標階段(Target Phase)

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

對於多層嵌套的節點,鼠標和指針事件經常會被定位到最裏層的元素上。假設,你在一個<div>元素上設置了click事件的監聽函數,而用戶點擊在了這個<div>元素內部的<p>元素上,那麼<p>元素就是這個事件的目標元素。事件冒泡讓我們可以在這個<div>(或者更上層的)元素上監聽click事件,並且事件傳播過程中觸發回調函數。

 

冒泡階段(Bubble Phase)

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

將DOM結構想象成一個洋蔥,事件目標是這個洋蔥的中心。在捕獲階段,事件從最外層鑽入洋蔥,穿過途徑的每一層。在到達中心後,事件被觸發(目標階段)。然後事件開始回溯,再次經過每一層返回(冒泡階段)。當到達洋蔥表面的時候,這次旅程就結束了。

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

Demo: Identifying event phases

絕大多數事件會冒泡,但並非所有的。當你發現有些事件不冒泡的時候,它肯定是有原因的。不相信?你可以查看一下相應的規範說明

 

停止傳播(Stopping Propagation)

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

1
2
3
4
5
6
7
8
child.addEventListener('click', function(event) {
 event.stopPropagation();
});
 
parent.addEventListener('click', function(event) {
 // If the child element is clicked
 // this callback will not fire
});

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

1
2
3
4
5
6
7
8
child.addEventListener('click', function(event) {
 event.stopImmediatePropagation();
});
 
child.addEventListener('click', function(event) {
 // If the child element is clicked
 // this callback will not fire
});

Demo:Stopping propagation

 

阻止瀏覽器默認行爲

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

在web應用中,開發人員經常希望能夠自行管理導航(navigation)信息,而不是通過刷新頁面。爲了實現這個目的,我們需要阻止瀏覽器針對點擊事件的默認行爲,而使用我們自己的處理方式。這時,我們就需要調用event.preventDefault().

1
2
3
4
anchor.addEventListener('click', function(event) {
  event.preventDefault();
  // Do our own thing
});

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

調用event.stopPropagation()只會阻止傳播鏈中後續的回調函數被觸發。它不會阻止瀏覽器的自身的行爲。

Demo:Preventing default vehaviour

 

自定義事件

瀏覽器並不是唯一能觸發DOM事件的載體。我們可以創建自定義的事件並把它們分派給你文檔中的任意節點。這些自定義的事件和通常的DOM事件有相同的行爲。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var myEvent = new CustomEvent("myevent", {
  detail: {
    name: "Wilson"
  },
  bubbles: true,
  cancelable: false
});
 
// Listen for 'myevent' on an element
myElement.addEventListener('myevent', function(event) {
  alert('Hello ' + event.detail.name);
});
 
// Trigger the 'myevent'
myElement.dispatchEvent(myEvent);

在元素上合成不可信任的(untrusted)DOM事件(如click)來模擬用戶操作也是可行的。這個在對DOM相關的代碼庫進行測試的時候特別有用。如果你對此感興趣的話,在Mozilla Developer Network上有一篇相關的文章

幾個注意點:

  • CustomEvent接口在IE 8以及IE更低版本不可用
  • 來自Twitter的Flight框架使用了自定義事件進行模塊間通信。它強調了一種高度解耦的模塊化架構。

Demo:Custom events

代理事件監聽

代理事件監聽可以讓你使用一個事件監聽器去監聽大量的DOM節點的事件,在這種情況下,它是一種更加方便並且高性能的事件監聽方法。舉例來說,如果有一個列表<ul>包含了100個子元素<li>,它們都需要對click事件做出相似的響應,那麼我們可能需要查詢這100個子元素,並分別爲他們添加上事件監聽器。這樣的話,我們就會產生100個獨立的事件監聽器。如果有一個新的元素被添加進去,我們也需要爲它添加同樣的監聽器。這種方式不但代價比較大,維護起來也比較麻煩。

代理事件監聽可以讓我們更簡單的處理這種情況。我們不去監聽所有的子元素的click事件,相反,我們監聽他們的父元素<ul>。當一個<li>元素被點擊的時候,這個事件會向上冒泡至<ul>,觸發回調函數。我們可以通過檢查事件的event.target屬性來判斷具體是哪一個<li>被點擊了。下面我們舉個簡單的例子來說明:

1
2
3
4
5
6
7
8
9
10
11
12
var list = document.querySelector('ul');
 
list.addEventListener('click', function(event) {
  var target = event.target;
 
  while (target.tagName !== 'LI') {
    target = target.parentNode;
    if (target === list) return;
  }
 
  // Do stuff here
});

這樣就好多了,我們僅僅使用了一個上層的事件監聽器,並且我們不需要在爲添加元素而考慮它的事件監聽問題。這個概念很簡單,但是非常有用。

但是我並不建議你在你的項目中使用上面的這個粗糙的實現。相反,使用一個事件代理的JavaScript庫是更好的選擇,比如 FT Lab的ftdomdelegate。如果你在使用jQuery,你可以在調用.on()方法的時候,將一個選擇器作爲第二個參數的方式來輕鬆的實現事件代理。

1
2
3
4
5
// Not using event delegation
$('li').on('click', function(){});
 
// Using event delegation
$('ul').on('click', 'li', function(){});

Demo: Delegate event listeners

 

一些有用的事件

load

load事件可以在任何資源(包括被依賴的資源)被加載完成時被觸發,這些資源可以是圖片,css,腳本,視頻,音頻等文件,也可以是document或者window。

1
2
3
image.addEventListener('load', function(event) {
  image.classList.add('has-loaded');
});

Demo:Image load event

onbeforeunload

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

1
2
3
4
5
window.onbeforeunload = function() {
  if (textarea.value != textarea.defaultValue) {
    return 'Do you want to leave the page and discard changes?';
  }
};

需要注意的是,對頁面添加onbeforeunload處理會導致瀏覽器不對頁面進行緩存?,這樣會影響頁面的訪問響應時間。 同時,onbeforeunload的處理函數必須是同步的(synchronous)。

Demo: onbeforeunload

 

在手機Safari上阻止窗口抖動

在Financial Times中,我們使用了一個簡單的event.preventDefault相關的技巧防止了Safari在滾動的時候出現的抖動。(手機端開發接觸的不多,所以可能有所誤解,如果錯誤,請了解的同學提點一下。)

1
2
3
document.body.addEventListener('touchmove', function(event) {
 event.preventDefault();
});

需要提醒的是這個操作同時也會阻礙正常的原生滾動條的功能(比如使用overflow:scroll)。爲了使得內部的子元素在需要的時候能夠使用滾動條的功能,我們在支持滾動的元素上監聽這個事件,並且在事件對象上設置一個標識屬性。在回調函數中,在document這一層,我們通過對這個擴展的isScrollable標識屬性來判斷是否對觸摸事件阻止默認的滾動行爲。

1
2
3
4
5
6
7
8
9
10
// Lower down in the DOM we set a flag
scrollableElement.addEventListener('touchmove', function(event) {
 event.isScrollable = true;
});
 
// Higher up the DOM we check for this flag to decide
// whether to let the browser handle the scroll
document.addEventListener('touchmove', function(event) {
 if (!event.isScrollable) event.preventDefault();
});

在IE8即一下的版本中,我們是不能操作事件對象的。作爲一個變通方案,我們將一些擴展的屬性設置在event.target節點對向上。

resize

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

1
2
3
window.addEventListener('resize', function() {
  // update the layout
});

我推薦使用防抖動的回調函數來統一調整回調的頻率,從而防止佈局上極端抖動的情況出現。

Demo: Window resizing

 

transitionend

現在在項目中,我們經常使用CSS來執行一些轉換和動畫的效果。有些時候,我們還是需要知道一個特定動畫的結束時間。

1
2
3
el.addEventListener('transitionEnd', function() {
 // Do stuff
});

一些注意點:

  • 如果你使用@keyframe動畫,那麼使用animationEnd事件,而不是transitionEnd。
  • 跟很多事件一樣,transitionEnd也向上冒泡。記得在子節點上調用event.stopPropagation()或者檢查event.target來防止回調函數在不該被調用的時候被調用。
  • 事件名目前還是被各種供應商添加了不同的前綴(比如webkitTransitionEnd, msTransitionEnd等等)。使用類似於Modernizr的庫來獲取正確的事件前綴。

Demo:Transition end

 

animtioniteration

animationiteration事件會在當前的動畫元素完成一個動畫迭代的時候被觸發。這個事件非常有用,特別是當我們想在某個迭代完成後停止一個動畫,但又不是在動畫過程中打斷它。

1
2
3
4
5
6
7
8
9
10
11
12
function start() {
  div.classList.add('spin');
}
 
function stop() {
  div.addEventListener('animationiteration', callback);
 
  function callback() {
    div.classList.remove('spin');
    div.removeEventListener('animationiteration', callback);
  }
}

如果你感興趣的話,我在博客中有另一篇關於animationiteration事件的文章

Demo:Animation iteration

 

error

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

1
2
3
imageNode.addEventListener('error', function(event) {
  image.style.display = 'none';
});

不幸的是,addEventListener並不能處理所有的情況。我的同事Kornel給了我一個很好的例子,說明確保圖片加載錯誤回調函數被執行的唯一方式是使用讓人詬病內聯事件處理函數(inline event handlers)。

1
<img src="http://example.com/image.jpg" onerror="this.style.display='none';" />

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

Demo:Image error

 

從事件模型中學到

從事件模型的成功上,我們可以學到很多。我們可以在我們的項目中使用類似的解耦的概念。應用中的模塊可以有很高的很複雜度,只要它的複雜度被封裝隱藏在一套簡單的接口背後。很多前端框架(比如Backbone.js)都是重度基於事件的,使用發佈-訂閱(publish and subscribe)的方式來處理跨模塊間的通信,這點跟DOM非常相似。

基於事件的架構是極好的。它提供給我們一套非常簡單通用的接口,通過針對這套接口的開發,我們能完成適應成千上萬不同設備的應用。通過事件,設備們能準確地告訴我們正在發生的事情以及發生的時間,讓我們隨心所欲地做出響應。我們不再顧慮場景背後具體發生的事情,而是通過一個更高層次的抽象來寫出更加令人驚豔的應用。

 

進一步閱讀

特別感謝Kornel對這篇文章做出的精彩的技術審查。

原文地址:http://blog.jobbole.com/52430/
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章