DOM事件簡介

jobbole發表於2013-12-04

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

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

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

  監聽事件

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

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

  在JavaScript中,我們使用如下的方式為元素新增事件監聽:

element.addEventListener(<event-name>, <callback>, <use-capture>);
  • event-name(string)
    這是你想監聽的事件的名稱或型別。它可以是任何的標準DOM事件(click, mousedown, touchstart, transitionEnd,等等),當然也可以是你自己定義的事件名稱(我們會在後面介紹自定義事件相關內容)。
  • callback(function)(回撥函式)
    這個函式會在事件觸發的時候被呼叫。相應的事件(event)物件,以及事件的資料,會被作為第一個引數傳入這個函式。
  • use-capture(boolean)
    這個引數決定了回撥函式(callback)是否在“捕獲(capture)”階段被觸發。不用擔心,我們稍後會對此做詳細的解釋。
var element = document.getElementById('element');

function callback() {
  alert('Hello');
}

// Add listener
element.addEventListener('click', callback);

  Demo: addEventListener

  移除監聽

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

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

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

  本質上來講,如果我們考慮要移除事件監聽(我們在長時間執行(long-lived)的應用中需要用到),那麼我們就需要保留回撥函式的控制程式碼。意思就是說,我們不能使用匿名函式作為回撥函式。

var element = document.getElementById('element');

function callback() {
  alert('Hello once');
  element.removeEventListener('click', callback);
}

// Add listener
element.addEventListener('click', callback);

  Demo: removeEventListener

  維護回撥函式上下文

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

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)。

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

  Demo:Anonymouse functions

  使用Function.prototype.bind

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

// 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);

  與此同時,我們獲得了回撥函式的控制程式碼,從而可以隨時從元素上移除相應的事件監聽。

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事件在某個特定元素上被觸發。

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方法,在任何階段(捕獲階段或者冒泡階段)中斷事件的傳播。此後,事件不會在後面傳播過程中的經過的節點上呼叫任何的監聽函式。

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()方法。

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().

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

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

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

  Demo:Preventing default vehaviour

  自定義事件

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

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>被點選了。下面我們舉個簡單的例子來說明:

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()方法的時候,將一個選擇器作為第二個引數的方式來輕鬆的實現事件代理。

// 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。

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

  Demo:Image load event

  onbeforeunload

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

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在滾動的時候出現的抖動。(手機端開發接觸的不多,所以可能有所誤解,如果錯誤,請了解的同學提點一下。)

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

  需要提醒的是這個操作同時也會阻礙正常的原生滾動條的功能(比如使用overflow:scroll)。為了使得內部的子元素在需要的時候能夠使用滾動條的功能,我們在支援滾動的元素上監聽這個事件,並且在事件物件上設定一個標識屬性。在回撥函式中,在document這一層,我們通過對這個擴充套件的isScrollable標識屬性來判斷是否對觸控事件阻止預設的滾動行為。

// 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來計算並設定一個元素的大小。

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

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

  Demo: Window resizing

  transitionend

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

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

  一些注意點:

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

  Demo:Transition end

  animtioniteration

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

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事件不再冒泡,我們可以使用如下的兩種方式來處理這個事件。

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

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

<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對這篇文章做出的精彩的技術審查。

  原文連結: smashingmagazine   翻譯: 伯樂線上 - Owen Chen

相關文章