最詳細的JavaScript和事件解讀

於江水發表於2015-03-20

與瀏覽器進行互動的時候瀏覽器就會觸發各種事件。比如當我們開啟某一個網頁的時候,瀏覽器載入完成了這個網頁,就會觸發一個 load 事件;當我們點選頁面中的某一個“地方”,瀏覽器就會在那個“地方”觸發一個 click 事件。

這樣,我們就可以編寫 JavaScript,通過監聽某一個事件,來實現某些功能擴充套件。例如監聽 load 事件,顯示歡迎資訊,那麼當瀏覽器載入完一個網頁之後,就會顯示歡迎資訊。

下面就來介紹一下事件。

基礎事件操作

監聽事件

瀏覽器會根據某些操作觸發對應事件,如果我們需要針對某種事件進行處理,則需要監聽這個事件。監聽事件的方法主要有以下幾種:

HTML 內聯屬性(避免使用)

HTML 元素裡面直接填寫事件有關屬性,屬性值為 JavaScript 程式碼,即可在觸發該事件的時候,執行屬性值的內容。

例如:

<button onclick="alert('你點選了這個按鈕');">點選這個按鈕</button>

onclick 屬性表示觸發 click,屬性值的內容(JavaScript 程式碼)會在單擊該 HTML 節點時執行。

顯而易見,使用這種方法,JavaScript 程式碼與 HTML 程式碼耦合在了一起,不便於維護和開發。所以除非在必須使用的情況(例如統計連結點選資料)下,儘量避免使用這種方法。

DOM 屬性繫結

也可以直接設定 DOM 屬性來指定某個事件對應的處理函式,這個方法比較簡單:

element.onclick = function(event){
    alert('你點選了這個按鈕');
};

上面程式碼就是監聽 element 節點的 click 事件。它比較簡單易懂,而且有較好的相容性。但是也有缺陷,因為直接賦值給對應屬性,如果你在後面程式碼中再次為 element 繫結一個回撥函式,會覆蓋掉之前回撥函式的內容。

雖然也可以用一些方法實現多個繫結,但還是推薦下面的標準事件監聽函式。

使用事件監聽函式

標準的事件監聽函式如下:

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

表示在 element 這個物件上面新增一個事件監聽器,當監聽到有 <event-name> 事件發生的時候,呼叫 <callback> 這個回撥函式。至於 <use-capture> 這個引數,表示該事件監聽是在“捕獲”階段中監聽(設定為 true)還是在“冒泡”階段中監聽(設定為 false)。關於捕獲和冒泡,我們會在下面講解。

用標準事件監聽函式改寫上面的例子:

var btn = document.getElementsByTagName('button');
btn[0].addEventListener('click', function() {
    alert('你點選了這個按鈕');
}, false);

這裡最好是為 HTML 結構定義個 id 或者 class 屬性,方便選擇,在這裡只作為演示使用。

Demo:

移除事件監聽

當我們為某個元素繫結了一個事件,每次觸發這個事件的時候,都會執行事件繫結的回撥函式。如果我們想解除繫結,需要使用 removeEventListener 方法:

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

需要注意的是,繫結事件時的回撥函式不能是匿名函式,必須是一個宣告的函式,因為解除事件繫結時需要傳遞這個回撥函式的引用,才可以斷開繫結。例如:

var fun = function() {
    // function logic
};

element.addEventListener('click', fun, false);
element.removeEventListener('click', fun, false);

Demo:

事件觸發過程

在上面大體瞭解了事件是什麼、如何監聽並執行某些操作,但我們對事件觸發整個過程還不夠了解。

下圖就是事件的觸發過程,借用了 W3C 的圖片

捕獲階段(Capture Phase)

當我們在 DOM 樹的某個節點發生了一些操作(例如單擊、滑鼠移動上去),就會有一個事件發射過去。這個事件從 Window 發出,不斷經過下級節點直到目標節點。在到達目標節點之前的過程,就是捕獲階段(Capture Phase)。

所有經過的節點,都會觸發這個事件。捕獲階段的任務就是建立這個事件傳遞路線,以便後面冒泡階段順著這條路線返回 Window。

監聽某個在捕獲階段觸發的事件,需要在事件監聽函式傳遞第三個引數 true

element.addEventListener(<event-name>, <callback>, true);

但一般使用時我們往往傳遞 false,會在後面說明原因。

目標階段(Target Phase)

當事件跑啊跑,跑到了事件觸發目標節點那裡,最終在目標節點上觸發這個事件,就是目標階段。

需要注意的時,事件觸發的目標總是最底層的節點。比如你點選一段文字,你以為你的事件目標節點在 div 上,但實際上觸發在 <p><span> 等子節點上。例如:

在 Demo 中,我監聽單擊事件,將目標節點的 tag name 彈出。當你點選加粗字型時,事件的目標節點就為最底層的<strong> 節點。

冒泡階段(Bubbling Phase)

當事件達到目標節點之後,就會沿著原路返回,由於這個過程類似水泡從底部浮到頂部,所以稱作冒泡階段。

在實際使用中,你並不需要把事件監聽函式準確繫結到最底層的節點也可以正常工作。比如在上例,你想為這個<div> 繫結單擊時的回撥函式,你無須為這個 <div> 下面的所有子節點全部繫結單擊事件,只需要為 <div>這一個節點繫結即可。因為發生它子節點的單擊事件,都會冒泡上去,發生在 <div> 上面。

針對這三個階段,wilsonpage 做了一個非常棒的 Demo,可以看下:

為什麼不用第三個引數 true

介紹完上面三個事件觸發階段,我們來看下這個問題。

所有介紹事件的文章都會說,在使用 addEventListener 函式來監聽事件時,第三個引數設定為 false,這樣監聽事件時只會監聽冒泡階段發生的事件。

這是因為 IE 瀏覽器不支援在捕獲階段監聽事件,為了統一而設定的,畢竟 IE 瀏覽器的份額是不可忽略的。

IE 瀏覽器在事件這方面與標準還有一些其他的差異,我們會在後面集中介紹。

使用事件代理(Event Delegate)提升效能

因為事件有冒泡機制,所有子節點的事件都會順著父級節點跑回去,所以我們可以通過監聽父級節點來實現監聽子節點的功能,這就是事件代理。

使用事件代理主要有兩個優勢:

  1. 減少事件繫結,提升效能。之前你需要繫結一堆子節點,而現在你只需要繫結一個父節點即可。減少了繫結事件監聽函式的數量。
  2. 動態變化的 DOM 結構,仍然可以監聽。當一個 DOM 動態建立之後,不會帶有任何事件監聽,除非你重新執行事件監聽函式,而使用事件監聽無須擔憂這個問題。

看一個例子:

上面例子中,為了簡便,我使用 jQuery 來實現普通事件繫結和事件代理。我的目標是監聽所有 a 連結的單擊事件,.ul1 是常規的事件繫結方法,jQuery 會迴圈每一個 .ul > a 結構並繫結事件監聽函式。.ul2 則是事件監聽的方法,jQuery 只為 .ul2 結構繫結事件監聽函式,因為 .ul2 下面可能會有很多無關節點也會觸發click 事件,所以我在 on 函式裡傳遞了第二個引數,表示只監聽 a 子節點的事件。

它們都可以正常工作,但是當我動態建立新 DOM 結構的時候,第一個 ul 問題就出現了,新建立結構雖然還是.ul1 > a,但是沒有繫結事件,所以無法執行回撥函式。而第二個 ul 工作的很好,因為點選新建立的 DOM ,它的事件會冒泡到父級節點進行處理。

如果使用原生的方式實現事件代理,需要注意過濾非目標節點,可以通過 id、class 或者 tagname 等等,例如:

element.addEventListener('click', function(event) {
    // 判斷是否是 a 節點
    if ( event.target.tagName == 'A' ) {
        // a 的一些互動操作
    }
}, false);

停止事件冒泡(stopPropagation)

所有的事情都會有對立面,事件的冒泡階段雖然看起來很好,也會有不適合的場所。比較複雜的應用,由於事件監聽比較複雜,可能會希望只監聽發生在具體節點的事件。這個時候就需要停止事件冒泡。

停止事件冒泡需要使用事件物件的 stopPropagation 方法,具體程式碼如下:

element.addEventListener('click', function(event) {
    event.stopPropagation();
}, false);

在事件監聽的回撥函式裡,會傳遞一個引數,這就是 Event 物件,在這個物件上呼叫 stopPropagation 方法即可停止事件冒泡。舉個停止事件冒泡的應用例項:

JS Bin

在上面例子中,有一個彈出層,我們可以在彈出層上做任何操作,例如 click 等。當我們想關掉這個彈出層,在彈出層外面的任意結構中點選即可關掉。它首先對 document 節點進行 click 事件監聽,所有的 click 事件,都會讓彈出層隱藏掉。同樣的,我們在彈出層上面的單擊操作也會導致彈出層隱藏。之後我們對彈出層使用停止事件冒泡,掐斷了單擊事件返回 document 的冒泡路線,這樣在彈出層的操作就不會被 document 的事件處理函式監聽到。

更多關於 Event 物件的事情,我們會在下面介紹。

事件的 Event 物件

當一個事件被觸發的時候,會建立一個事件物件(Event Object),這個物件裡面包含了一些有用的屬性或者方法。事件物件會作為第一個引數,傳遞給我們的毀掉函式。我們可以使用下面程式碼,在瀏覽器中列印出這個事件物件:

<button>列印 Event Object</button>

<script>
    var btn = document.getElementsByTagName('button');
    btn[0].addEventListener('click', function(event) {
        console.log(event);
    }, false);
</script>

就可以看到一堆屬性列表:

事件屬性列表

事件物件包括很多有用的資訊,比如事件觸發時,滑鼠在螢幕上的座標、被觸發的 DOM 詳細資訊、以及上圖最下面繼承過來的停止冒泡方法(stopPropagation)。下面介紹一下比較常用的幾個屬性和方法:

type(string)

事件的名稱,比如 “click”。

target(node)

事件要觸發的目標節點。

bubbles (boolean)

表明該事件是否是在冒泡階段觸發的。

preventDefault (function)

這個方法可以禁止一切預設的行為,例如點選 a 標籤時,會開啟一個新頁面,如果為 a 標籤監聽事件 click同時呼叫該方法,則不會開啟新頁面。

stopPropagation (function)

停止冒泡,上面有提到,不再贅述。

stopImmediatePropagation (function)

與 stopPropagation 類似,就是阻止觸發其他監聽函式。但是與 stopPropagation 不同的是,它更加 “強力”,阻止除了目標之外的事件觸發,甚至阻止針對同一個目標節點的相同事件,Demo:http://jsfiddle.net/yujiangshui/ju2ujmzp/2/。

cancelable (boolean)

這個屬性表明該事件是否可以通過呼叫 event.preventDefault 方法來禁用預設行為。

eventPhase (number)

這個屬性的數字表示當前事件觸發在什麼階段。none:0;捕獲:1;目標:2;冒泡:3。

pageX 和 pageY (number)

這兩個屬性表示觸發事件時,滑鼠相對於頁面的座標。Demo:http://api.jquery.com/event.pagex/。

isTrusted (boolean)

表明該事件是瀏覽器觸發(使用者真實操作觸發),還是 JavaScript 程式碼觸發的。

jQuery 中的事件

如果你在寫文章或者 Demo,為了簡單,你當然可以用上面的事件監聽函式,以及那些事件物件提供的方法等。但在實際中,有一些方法和屬性是有相容性問題的,所以我們會使用 jQuery 來消除相容性問題。

下面簡單的來說一下 jQuery 中事件的基礎操作。

繫結事件和事件代理

在 jQuery 中,提供了諸如 click() 這樣的語法糖來繫結對應事件,但是這裡推薦統一使用 on() 來繫結事件。語法:

.on( events [, selector ] [, data ], handler )

events 即為事件的名稱,你可以傳遞第二個引數來實現事件代理,具體文件.on() 這裡不再贅述。

處理過相容性的事件物件(Event Object)

事件物件有些方法等也有相容性差異,jQuery 將其封裝處理,並提供跟標準一直的命名。

如果你想在 jQuery 事件回撥函式中訪問原來的事件物件,需要使用 event.originalEvent,它指向原生的事件物件。

觸發事件 trigger 方法

點選某個繫結了 click 事件的節點,自然會觸發該節點的 click 事件,從而執行對應回撥函式。

trigger 方法可以模擬觸發事件,我們單擊另一個節點 elementB,可以使用:

$(elementB).on('click', function(){
    $(elementA).trigger( "click" );
});

來觸發 elementA 節點的單擊監聽回撥函式。詳情請看文件 .trigger()

事件進階話題

IE 瀏覽器的差異和相容性問題

IE 瀏覽器就是特立獨行,它對於事件的操作與標準有一些差異。不過 IE 瀏覽器現在也開始慢慢努力改造,讓瀏覽器變得更加標準。

IE 下繫結事件

在 IE 下面繫結一個事件監聽,在 IE9- 無法使用標準的 addEventListener 函式,而是使用自家的attachEvent,具體用法:

element.attachEvent(<event-name>, <callback>);

其中 <event-name> 引數需要注意,它需要為事件名稱新增 on 字首,比如有個事件叫 click,標準事件監聽函式監聽 click,IE 這裡需要監聽 onclick

另一個,它沒有第三個引數,也就是說它只支援監聽在冒泡階段觸發的事件,所以為了統一,在使用標準事件監聽函式的時候,第三引數傳遞 false。

當然,這個方法在 IE9 已經被拋棄,在 IE11 已經被移除了,IE 也在慢慢變好。

IE 中 Event 物件需要注意的地方

IE 中往回撥函式中傳遞的事件物件與標準也有一些差異,你需要使用 window.event 來獲取事件物件。所以你通常會寫出下面程式碼來獲取事件物件:

event = event || window.event

此外還有一些事件屬性有差別,比如比較常用的 event.target 屬性,IE 中沒有,而是使用 event.srcElement來代替。如果你的回撥函式需要處理觸發事件的節點,那麼需要寫:

node = event.srcElement || event.target;

常見的就是這點,更細節的不再多說。在概念學習中,我們沒必要為不標準的東西支付學習成本;在實際應用中,類庫已經幫我們封裝好這些相容性問題。可喜的是 IE 瀏覽器現在也開始不斷向標準進步。

事件回撥函式的作用域問題

與事件繫結在一起的回撥函式作用域會有問題,我們來看個例子:

Events in JavaScript: Removing event listeners

回撥函式呼叫的 user.greeting 函式作用域應該是在 user 下的,本期望輸出 My name is Bob 結果卻輸出了My name is undefined。這是因為事件繫結函式時,該函式會以當前元素為作用域執行。為了證明這一點,我們可以為當前 element 新增屬性:

element.firstname = 'jiangshui';

再次點選,可以正確彈出 My name is jiangshui。那麼我們來解決一下這個問題。

使用匿名函式

我們為回撥函式包裹一層匿名函式。

Events in JavaScript: Removing event listeners

包裹之後,雖然匿名函式的作用域被指向事件觸發元素,但執行的內容就像直接呼叫一樣,不會影響其作用域。

使用 bind 方法

使用匿名函式是有缺陷的,每次呼叫都包裹進匿名函式裡面,增加了冗餘程式碼等,此外如果想使用removeEventListener 解除繫結,還需要再建立一個函式引用。Function 型別提供了 bind 方法,可以為函式繫結作用域,無論函式在哪裡呼叫,都不會改變它的作用域。通過如下語句繫結作用域:

user.greeting = user.greeting.bind(user);

這樣我們就可以直接使用:

element.addEventListener('click', user.greeting);

常用事件和技巧

使用者的操作有很多種,所以有很多事件。為了開發方便,瀏覽器又提供了一些事件,所以有很多很多的事件。這裡只介紹幾種常用的事件和使用技巧。

load

load 事件在資源載入完成時觸發。這個資源可以是圖片、CSS 檔案、JS 檔案、視訊、document 和 window 等等。

比較常用的就是監聽 window 的 load 事件,當頁面內所有資源全部載入完成之後就會觸發。比如用 JS 對圖片以及其他資源處理,我們在 load 事件中觸發,可以保證 JS 不會在資源未載入完成就開始處理資源導致報錯。

同樣的,也可以監聽圖片等其他資源載入情況。

beforeunload

當瀏覽者在頁面上的輸入框輸入一些內容時,未儲存、誤操作關掉網頁可能會導致輸入資訊丟失。

當瀏覽者輸入資訊但未儲存時關掉網頁,我們就可以開始監聽這個事件,例如:

window.addEventListener("beforeunload", function( event ) {
    event.returnValue = "放棄當前未儲存內容而關閉頁面?";
});

這時候試圖關閉網頁的時候,會彈窗阻止操作,點選確認之後才會關閉。當然,如果沒有必要,就不要監聽,不要以為使用它可以為你留住瀏覽者。

resize

當節點尺寸發生變化時,觸發這個事件。通常用在 window 上,這樣可以監聽瀏覽器視窗的變化。通常用在複雜佈局和響應式上。

常見的視差滾動效果網站以及同類比較複雜的佈局網站,往往使用 JavaScript 來計算尺寸、位置。如果使用者調整瀏覽器大小,尺寸、位置不隨著改變則會出現錯位情況。在 window 上監聽該事件,觸發時呼叫計算尺寸、位置的函式,可以根據瀏覽器的大小來重新計算。

但需要注意一點,當瀏覽器發生任意變化都會觸發 resize 事件,哪怕是縮小 1px 的瀏覽器寬度,這樣調整瀏覽器時會觸發大量的 resize 事件,你的回撥函式就會被大量的執行,導致變卡、崩潰等。

你可以使用函式 Throttle 或者 debounce 技巧來進行優化,throttle 方法大體思路就是在某一段時間內無論多次呼叫,只執行一次函式,到達時間就執行;debounce 方法大體思路就是在某一段時間內等待是否還會重複呼叫,如果不會再呼叫,就執行函式,如果還有重複呼叫,則不執行繼續等待。關於它們更詳細的資訊,我後面會介紹一下發表在我的部落格上,這裡不再贅述。

error

當我們載入資源失敗或者載入成功但是隻載入一部分而無法使用時,就會觸發 error 事件,我們可以通過監聽該事件來提示一個友好的報錯或者進行其他處理。比如 JS 資源載入失敗,則提示嘗試重新整理;圖片資源載入失敗,在圖片下面提示圖片載入失敗等。該事件不會冒泡。因為子節點載入失敗,並不意味著父節點載入失敗,所以你的處理函式必須精確繫結到目標節點。

需要注意的是,對於該事件,你可以使用 addEventListener 等進行監聽,但是有時候會出現失效情況(看這個例子),這是因為 error 事件都觸發過了,你的 JS 監聽處理程式碼還沒有載入進來執行。為了避免這種情況,用內聯法更好一些:

<img src="not-found.jpg" onerror="doSomething" />

如果還有其他常用事件,歡迎留言補充。

用 JavaScript 模擬觸發內建事件

內建的事件也可以被 JavaScript 模擬觸發,比如下面函式模擬觸發單擊事件:

function simulateClick() {
  var event = new MouseEvent('click', {
    'view': window,
    'bubbles': true,
    'cancelable': true
  });
  var cb = document.getElementById('checkbox'); 
  var canceled = !cb.dispatchEvent(event);
  if (canceled) {
    // A handler called preventDefault.
    alert("canceled");
  } else {
    // None of the handlers called preventDefault.
    alert("not canceled");
  }
}

可以看這個 Demo 來了解更多。

自定義事件

我們可以自定義事件來實現更靈活的開發,事件用好了可以是一件很強大的工具,基於事件的開發有很多優勢(後面介紹)。

與自定義事件的函式有 EventCustomEvent 和 dispatchEvent

直接自定義事件,使用 Event 建構函式:

var event = new Event('build');

// Listen for the event.
elem.addEventListener('build', function (e) { ... }, false);

// Dispatch the event.
elem.dispatchEvent(event);

CustomEvent 可以建立一個更高度自定義事件,還可以附帶一些資料,具體用法如下:

var myEvent = new CustomEvent(eventname, options);

其中 options 可以是:

{
    detail: {
        ...
    },
    bubbles: true,
    cancelable: false
}

其中 detail 可以存放一些初始化的資訊,可以在觸發的時候呼叫。其他屬性就是定義該事件是否具有冒泡等等功能。

內建的事件會由瀏覽器根據某些操作進行觸發,自定義的事件就需要人工觸發。dispatchEvent 函式就是用來觸發某個事件:

element.dispatchEvent(customEvent);

上面程式碼表示,在 element 上面觸發 customEvent 這個事件。結合起來用就是:

// add an appropriate event listener
obj.addEventListener("cat", function(e) { process(e.detail) });

// create and dispatch the event
var event = new CustomEvent("cat", {"detail":{"hazcheeseburger":true}});
obj.dispatchEvent(event);

使用自定義事件需要注意相容性問題,而使用 jQuery 就簡單多了:

// 繫結自定義事件
$(element).on('myCustomEvent', function(){});

// 觸發事件
$(element).trigger('myCustomEvent');

此外,你還可以在觸發自定義事件時傳遞更多引數資訊:

$( "p" ).on( "myCustomEvent", function( event, myName ) {
  $( this ).text( myName + ", hi there!" );
});
$( "button" ).click(function () {
  $( "p" ).trigger( "myCustomEvent", [ "John" ] );
});

更詳細的用法請看 Introducing Custom Events,這裡不再贅述。

在開發中應用事件

當我們操作某一個 DOM,發出一個事件,我們可以在另一個地方寫程式碼捕獲這個事件執行處理邏輯。觸發操作和捕獲處理操作是分開的。我們可以根據這個特性來對程式解耦。

用事件解耦

我們可以將一個整個的功能,分割成獨立的小功能,每個小功能繫結一個事件,由一個“控制器”負責根據條件觸發某個事件。這樣,在外面觸發這個事件,也可以呼叫對應功能,使其更加靈活。

應用事件對程式解耦

在《基於 MVC 的 JavaScript Web 富應用開發》一書中,有更加具體的例項,有興趣的朋友可以買本看看。

釋出(Publish)和訂閱(Subscribe)模式

針對上面這種用法,繼續抽象一下,就是釋出和訂閱開發模式。正如其名,這種模式有兩個角色:釋出者和訂閱者,此外有一條通道,釋出者被觸發往這個通道里面發信,訂閱者從這個通道里面收信,如果收到特定信件則執行某個對應的邏輯。這樣,釋出者和訂閱者之間是完全解耦的,只有一條通道連線。這樣就非常容易擴充套件,也不會引入額外的依賴。

這樣如果需要新增新功能,只需要新增一個新的訂閱者(及其執行邏輯),監聽通道中某一類新的信件。再在應用中通過釋出者傳送一類新的信件即可。

具體實現,這裡推薦 cowboy 開發的 Tiny Pub Sub,通過 jQuery 實現,非常簡潔直觀,jQuery 太贊。程式碼就這幾行:

(function($) {

  var o = $({});

  $.subscribe = function() {
    o.on.apply(o, arguments);
  };

  $.unsubscribe = function() {
    o.off.apply(o, arguments);
  };

  $.publish = function() {
    o.trigger.apply(o, arguments);
  };

}(jQuery));

定義一個物件作為通道,然後提供了三個方法,訂閱者、取消訂閱、釋出者。

總結和擴充套件閱讀

事件有關的基礎知識基本就這些,更多的還有待你繼續挖掘。本文資料參考和推薦擴充套件閱讀如下(感謝他們):

相關文章