共用方式為


原味 JavaScript 的跨瀏覽器事件處理

Juriy Zaytsev | 2010 年 6 月 7 日

 

處理跨瀏覽器事件不是件容易的事。幸好多數盛行的 JavaScript 函式庫,已經透過抽象化,解決這個棘手過程。而這幕後的藏鏡人,是瀏覽器的不一致性,要鏟平這些差異,需要的是讓事件處理器能正常運作。讓我們來看看,如何才能建立強大的抽象化事件處理。不管你是想建立自己的事件處理工具,或是評估/優化既有工具,也或許純粹只是為了學習,這篇文章也許都起有所俾益。

事件處理的基礎,是從元素上加入或移除監聽事件的函式。而這個函式,正是我們要討論的對象。我們不會觸及發動事件的抽化象處理,這個議題過於深入,無法含蓋在本篇的範圍。

DOM 2 級定義 addEventListener and removeEventListenerEventTarget 介面 的一部分。

element.addEventListener(eventName, listener, useCapture);
element.removeEventListener(eventName, listener, useCapture);

實作 Node 或 Window 介面的物件,通常會實作 EventTarget 介面。這個意思是那個 DOM 元素,以及 window 物件,它們可以接受 addEventListener/removeEventListener 方法,而事件監聽器可以透過合作的物件加入或移除事件:

window.addEventListener('load', function (event) {  
  console.log('window\'s load event fired');
}, false);


// 或是


function bodyHandler() {
  console.log('click event was fired on body element');
}
document.body.addEventListener('click', bodyHandler, false);

removeEventListener 同樣也相當直覺(注意,事件處理器應該使用相同的函式,也就是使用當初加到監聽器的那個函式—就像範例中的 bodyHandler):

document.body.removeEventListener('click', bodyHandler, false);

如果我們生活在一個完美的世界裡,要讓事件處理正常運作,需要的就是這兩個函式了。可惜事情沒有這麼美好,要達到這個目的,需要多花點工夫。橫在我們眼前的主要障礙,來自於 Internet Explorer 所使用的非標準事件處理模型。MSHTML DOM(用於 Internet Explorer 的文件物件模型)定義 attachEventdetachEvent 方法,而不是採用 addEventListener/removeEventListener。這兩個方法,接受事件名稱字串作為第一個參數,而將事件處理函式物件作為第二個參數。

window.attachEvent('onload', function () {
  console.log('window\'s load event fired');
});


function bodyHandler() {
  console.log('click event was fired on body element');
}
document.body.attachEvent('onclick', bodyHandler);
document.body.detachEvent('onclick', bodyHandler);

如你所見,attachEvent 和 detachEvent 和標準有些微的不同。事件的名稱都會加上「on」這個前飾字,而第三個參數 useCapture 消失了。還有一些無法立即看到的不同之處,不過稍後我們會再解加介紹。

在有了這些知識之後,現在應該不難建立這兩個不同模型的抽象概念。讓我們來建立下面的包裝方法,addListener 和 removeListener:

function addListener(element, eventName, handler) {
  if (element.addEventListener) {
    element.addEventListener(eventName, handler, false);
  }
  else if (element.attachEvent) {
    element.attachEvent('on' + eventName, handler);
  }
  else {
    element['on' + eventName] = handler;
  }
}


function removeListener(element, eventName, handler) {
  if (element.addEventListener) {
    element.removeEventListener(eventName, handler, false);
  }
  else if (element.detachEvent) {
    element.detachEvent('on' + eventName, handler);
  }
  else {
    element['on' + eventName] = null;
  }
}

為了確保日後的相容性和互動性,優先測試標準方法(例如 addEventListener)是否存在相當重要,然後再去檢測特殊的方法(像是 attachEvent)。

要注意的是,除了 addEventListener 和 attachEvent 之外,還有第三個分支,就是元素同時缺少 addEventListener和attachEvent 方法。遇到這種情況,我們就退回非標準事件處理器的指派方法。

效能最佳化

注意每次 addListener/removeListener 如何檢測,這裡需要處理程式分支。方法透過元素被呼叫,而下一步的動作則取決於他們是否存在。我們可以透過在「載入期間」定義不同的函式(而非「執行期間」),消除所有非必要的工作。舉例來說,在宣告 addListener 的時候,我們可以檢查 addEventListener 或 attachEvent 在同一 DOM 元素中是否存在,這時會存取一次 DOM。這個元素是文件中的根元素,他是像 HTML 的 <html>...</html> 標籤,並且可以透過 DOM 的 document.documentElement 來存取。相對於 body 元素(可由 DOM 中的 document.body 存取 ),document.documentElement 在 document 還沒「準備就緒」前,就已經存在。這顯然讓它成為頁面尚未完成下載前,進行檢測工作的完美對象。而另一個「測試元素」的侯選人,是用動態方式建立任何元素,例如 document.createElement('div')。

讓我們來看載入期間分析是如何完成的:

/* 首先,宣告變數指派給函式使用*/
var addListener, removeListener,


    /* 測試元素 */
    docEl = document.documentElement;


if (docEl.addEventListener) {


  /* 假如「addEventListener」存在於測試元素,定義函式使用「addEventListener」*/
  addListener = function (element, eventName, handler) {
    element.addEventListener(eventName, handler, false);
  };
}
else if (docEl.attachEvent) {


 /* 假如「attachEvent」存在於測試元素,定義函式使用「attachEvent」 */
  addListener = function (element, eventName, handler) {
    element.attachEvent('on' + eventName, handler);
  };
}
else {


  /* 假如兩個函式在測試元素上均不存在,定義函式退回策略 */
  addListener = function (element, eventName, handler) {
    element['on' + eventName] = handler;
  };
}

而這對 removeListener 也同樣適用;函式在載入期間被定義,依據詢問函式存在於否的結果。

if (docEl.removeEventListener) {
  removeListener = function (element, eventName, handler) {
    element.removeEventListener(eventName, handler, false);
  };
}
else if (docEl.detachEvent) {
  removeListener = function (element, eventName, handler) {
    element.detachEvent('on' + eventName, handler);
  };
}
else {
  removeListener = function (element, eventName, handler) {
    element['on' + eventName] = null;
  };
}

值得一提的是,這個最佳化有它的缺點。除了增加程式大小,它用了一個稍微脆弱、迂迴的推論。意思是它放棄直接在目標元素上檢測方法是否存在,而改在測試測試元素測試,並且假設這個方法也一樣存在於目標元素上。只有要假設,就有失敗的風險。理論上 document.documentElement 可以實作 addEventListener,但是另一個元素,傳給 addListener 那個,就不會有這個方法。就實作的經驗顯示,當它是 addEventListener/removeEventListener 的時候,這樣的假設通常是安全的,只要我們在相同物件類型上測試這個方法,例如都在元素物件上測試,而不是 window 或 document 物件。

強化假設

說到迂迴的假設,我們可以做一件小事來降低失敗的機率。我們也可以測試 window 物件是否存在方法。畢竟 addListener 通常會被傳給 window 物件(監聽像是「load」、「resize」、「scroll」等事件):

var addListener, docEl = document.documentElement;

if (docEl.addEventListener && window.addEventListener) {
  addListener = function (element, eventName, handler) {
    /* ... */
  };
}
else if (docEl.attachEvent && window.attachEvent) {
  addListener = function (element, eventName, handler) {
    /* ... */
  };
}
else {
  /* ... */
}

在 window 物件上缺少 addEventListener 或 attachEvent 實作,將不會再危害 addListener 了。從 document.documentElement 上是否存在這個方法,我們假設所有實作 Node 介面的物件上,都會有這個方法。(像是 DOM 元素實作了 Element 介面,或是文件實作了 Document 介面)。而從 window 存在這個方法,我們假設所有的 window 物件都擁有這個方法。

安全故障(Fail-safe)功能測試

我們測試 DOM 元素(還有其他物件,像是 window)的方式,是藉由執行布林值的型別轉換。布林值型別轉換工作會發生,是在if宣告中的值,被當成是表達式。(例如:if (docEl.addEventListener) { ... })。這樣的轉換存在著問題,它會在某些實作中失敗

// 在 Internet Explorer 中


var xhr = new ActiveXObject('Microsoft.XMLHTTP');
if (xhr.open) { } // 錯誤


var element = document.createElement('p');
if (element.offsetParent) { } // 錯誤

在 Internet Explorer,這樣的物件被實作為 ActiveX 物件,而 他們的型別轉換結果有誤。這裡有趣的地方在於,這些物件的型別(當由 typeof 運算子返回時)通常是「unknown」。假如你覺得這是一個奇怪的型別,那是因為它的確是。不過按照規格書(ECMA-262,第三版),typeof 運算子可以返回任何已知物件的值(像是「foo」、「bar」、「number」、「undefined」或甚至於空字串)。

所以我們該如何處理這些詭異的物件?既然我們不知道他們的真正型別,最安全的策略是在任何物件上避用型別轉換。改絃易轍的作法,我們可以透過偵測型別(當 typeof 運算子返回時),推斷方法存在與否或它的能力。假如型別是「object」或「function」,我們假定這個物件可以呼叫。假如型別是「unknown」,我們也可假定這個物件同樣可以呼叫,只是這個物件是一個 ActiveX 物件,但仍可以呼叫。

為了抽象化這個型別檢查,一些函式使用所謂的 isHostMethod(由 David Mark 而知名,現在使用在 FuseJS 和 My Library 中):

var isHostMethod = function (object, methodName) {
  var t = typeof object[methodName];
  return ((t === 'function' || t === 'object') && !!object[methodName]) || t === 'unknown';
};

我們現在可以取代所有物件的型別檢查,讓測試更為安全,像是 isHostMethod 為基礎的檢查:

var addListener, docEl = document.documentElement;


if (isHostMethod(docEl, 'addEventListener')) {
  /* ... */
}
else if (isHostMethod(docEl, 'attachEvent')) {
  /* ... */
}
else {
  /* ... */
}

document.documentElement 實作的 addEventListener 或 attachEvent 的方法詭異,現在處理時已經不會再有錯誤了。

正規化 IE 中的事件處理器

我之前提過 MSHTML 事件模型與標準事件模型在幾個地方有所出入。這些差異之中,有一個是發生在事件處理器被調用時的處理方式。處理器藉由 addEventListener 初始化,總是被監聽器被附加的元素的情境(context)中被呼叫。在 document.body.addEventListener(...) 的例子中,事件處理器被呼叫,是在 document.body 的情境中。而像在 window.addEventListener('...') 例子中,事件處理器是在 window 物件的情境中被呼叫。

然而 attachEvent 的行為與其他不同,它的事件處理器被呼叫時,總是在 window 物件的情境中:

document.body.attachEvent('onclick', function () {
  console.log(this === window); // 真
  console.log(this === document.body); // 假
});

因此,我們的 addListener 如同它現在所代表的,和事件處理器的情境並不一致。假如你計劃使用它,消除這個危險的不一致性是個好主意。讓我們看看如何辦到:

if (isHostMethod(docEl, 'addEventListener')) {
  /* ... */
}
else if (isHostMethod(docEl, 'attachEvent')) {
  addListener = function (element, eventName, handler) {
    element.attachEvent('on' + eventName, function () {
      handler.call(element, window.event);
    });
  };
}
else {
  /* ... */
}

不將事件處理器直接傳給 attachEvent,我們改採傳遞一個包裝函式,它會改變呼叫事件處理器的情境。現在會一直參照監聽器被附加的元素。

addListener(document.body, function () {
  console.log(this === window); // 假
  console.log(this === document.body); // 真
});

你也許已經注意到,不止事件處理器在一個元素的情境中被呼叫,而且它也被傳遞給 window.event 物件作為第一個參數— handler.call(element, window.event)。

這樣做是為了解決另一個 MSHTML 事件模式的問題,缺少事件物件作為事件處理器的第一個函式。當 addEventListener 確保事件處理器被指定為給一個事件物件,Internet Explorer 讓事件物件可以被存取,藉由全域的 window.event 屬性。沒有任何東西被傳遞給事件處理器。

透過明確傳遞 window.event 給事件處理器,藉由 handler.call(element, window.event),我們確保它總是會有適當、可存取的物件模型作為第一個參數。

addListener(document.body, function (event) {
  console.log(typeof event != 'undefined'); // 真
});

清除 IE 的記憶體洩露

雖然前面的方面解決了問題,卻也帶來了另一個問題,那就是偷偷摸摸的記憶體洩露。Internet Explorer 惱人的記憶體洩露問題已經在這被詳細描述過還有這裡,所以再此我們就不再多說。然而,讓我們很快地來看一下究竟是什麼樣特別的情況會造成這個問題:

...
addListener = function (element, eventName, handler) {
  element.attachEvent('on' + eventName, function () {
    handler.call(element, window.event);
  });
};
...

當 addListener 函式執行時,一個循環參照發生了個元素參照到事件處理器,而事件處理器又透過範圍鏈參照到元素。接下來的模式就是一個典型的記憶體洩露,這也是我們需要特別處理的地方。

所以如何修復這個洩露?一個最簡單的作法,就是在頁面卸載時,中斷這個反覆循環參照。這個想法很簡單:在 window 卸載時觸發事件,遍歷所有存在的事件監聽器,然後清除掉。所謂清除動作,我們指的是移除事件,取消它的參照。這樣可以有效地中斷循環參照。

讓我們來檢視這樣的系統可能的實作方法:

function wrapHandler(element, handler) {
  return function (e) {
    return handler.call(element, e || window.event);
  };
}


function createListener(element, eventName, handler) {
  return {
    element: element,
    eventName: eventName,
    handler: wrapHandler(element, handler)
  };
}


function cleanupListeners() {
  for (var i = listenersToCleanup.length; i--; ) {
    var listener = listenersToCleanup[i];
    litener.element.detachEvent(listener.eventName, listener.handler);
    listenersToCleanup[i] = null;
  }
  window.detachEvent('onunload', cleanupListeners);
}


var listenersToCleanup = [ ];


if (isHostMethod(docEl, 'addEventListener')) {
  /* ... */
}
else if (isHostMethod(docEl, 'attachEvent')) {
  addListener = function (element, eventName, handler) {


    var listener = createListener(element, eventName, handler);
    element.attachEvent('on' + eventName, listener.handler);
    listenersToCleanup.push(listener);
  };


  window.attachEvent('onunload', cleanupListeners);
}
else {
  /* ... */
}

在 addListener 宣告的時候,我們也附加了「unload」的監聽器到 window 上。這個方法結束所有儲存在 listenersToCleanup 陣列的監聽器,然後針對它們呼叫 detachEvent 方法,接著清空參照。

在 addListener 執行時,我們建立一個抽象的監聽器,一個封裝元素的物件,eventName,然後正規化處理器,之後再將監聽器放入 listenersToCleanup 陣列,以便進行稍後的清除工作。

避免在 IE 中造成記憶體洩露

上一節我們所寫的清除程序在和記憶體洩露保持距離上做得不錯。但是,另一個問題卻偷偷摸摸的抬頭-使用卸載監聽器,造成頁面暫存失效,一個在現代瀏覽器都有實作的驚人功能。他以 bfcache 之名廣為人知,頁面暫存是一個提供頁面立即顯示功能,能讓使用者點選上一頁/下一頁時使用,而不用再跟伺服器要資料。不幸的是,一旦「unload」監聽器被加到 window,頁面暫存會被停用。頁面暫存被停用的原因,是避免在卸載時的事件處理器中,文件可能有所修改。這時使用暫存的版本可能會有風險。因此,文章必須重新和伺服器要資料。

由於禁用暫存的緣故,我們不能使用 unload 監聽器,但我們也不想讓記憶體洩露的循環參照就擺在那裡。該如何處理這個棘手的問題?

其實解決方案異外簡單;一開始就別造成循環參照

我們需要清除的原因在於難以找出循環參照。現在讓我們試試找出那段偷偷摸摸的程式:

addListener = function (element, eventName, handler) {
  ...
  element.attachEvent('on' + eventName, listener.handler);
  ...
};
function wrapHandler(element, handler) {
  return function (e) {
    return handler.call(element, e || window.event);
  };
}
function createListener(element, eventName, handler) {
  return {
    ...
    handler: wrapHandler(element, handler)
  };
}

注意 addListener 如何藉由 attachEvent 附加 listener.handler 到元素上去。現在反過來,listener.handler 是由 wrapHandler(element, handler) 所建立。假如我們檢視一下 wrapHandler,就會變得清晰,它返回函式(變成事件處理器那個)關閉了問題元素。再一次強調,一個元素參考一個事件處理器,而事件處理器參考到一個元素。那就生成了一個循環參照。

所以怎麼樣才可以避免產生這樣的循環參照?讓我們來看看一個可能的實作。

下面程式的目標是避免在事件處理器中去參考一個元素。這是消除巢狀循環參照的關鍵。一反傳遞元素到處理器的建造者,我們傳一個獨特的 id,讓我們之後可用來存取元素。那麼讓如何讓一個元素一開始就有id呢?這可以在 addListener 一開始的時候,透過指定唯一的 id 來達成。

因此概括來說,主要實作 addListener 的步驟如下:

  1. 取得元素的唯一 id
  2. 建立一個監聽器,它有一個包裝處理器(已經正規化過),這個包裝處理器永遠不會接受一個實際的元素,以避免循環參照。
  3. 新建立的監聽器和唯一的 id 和事件型別作關聯
  4. 包裝處理器(正規化過)被附加到一個元素(藉由 attachEvent)。

循環參照成功地避開。

var getUniqueId = (function () {
  if (typeof document.documentElement.uniqueID != 'undefined') {
    return function (element) {
      return element.uniqueID;
    };
  }
  var uid = 0;
  return function (element) {
    return element.__uniqueID || (element.__uniqueID = 'uniqueID__' + uid++);
  };
})();


var getElement, setElement, listeners = { };


(function () {
  var elements = { };
  getElement = function (uid) {
    return elements[uid];
  };
  setElement = function (uid, element) {
    elements[uid] = element;
  };
})();


function createListener(uid, handler) {
  return {
    handler: handler,
    wrappedHandler: createWrappedHandler(uid, handler)
  };
}


function createWrappedHandler(uid, handler) {
  return function (e) {
    handler.call(getElement(uid), e || window.event);
  };
}


var addListener = function (element, eventName, handler) { 
  var uid = getUniqueId(element);
  setElement(uid, element);


  if (!listeners[uid]) {
    listeners[uid] = { };
  }
  if (!listeners[uid][eventName]) {
    listeners[uid][eventName] = [ ];
  }
  var listener = createListener(uid, handler);
  listeners[uid][eventName].push(listener);
  element.attachEvent('on' + eventName, listener.wrappedHandler);
};


var removeListener = function (element, eventName, handler) {
  var uid = getUniqueId(element), listener;
  if (listeners[uid] && listeners[uid][eventName]) {
    for (var i = 0, len = listeners[uid][eventName].length; i < len; i++) {
      listener = listeners[uid][eventName][i];
      if (listener && listener.handler === handler) {
        element.detachEvent('on' + eventName, listener.wrappedHandler);
        listeners[uid][eventName][i] = null;
      }
    }
  }        
};

在撰寫這篇文章的時候,只有少數幾個 JavaScript 函式庫,應用了事件處理器抽象化,無需指派 unload 監聽器。FuseJS 是其中之一。其他處理這個問題的辦法,是只在 IE 指派 unload 監聽器,判斷是否為 IE,是由偵測使用者代理程式字串或基於同類型的推斷測試(以 jQuery 1.4.2 來說,只在  window.attachEvent 存在時才指派 unload,如果是 window.addEventListener 就不指派)。和脆弱、難以預測的使用者代理程式字串比較,物件推斷(例如 jQuery 使用的方法)是用較安全的方法來代替。不過如同其他許多推論,它有產生誤報的風險。在一開始就避免附加 unload 監聽器,是避免任何潛在失敗的實作。

修正 DOM 0 級的分支

我們已經做過正規化、處理 IE 事件分支,並且避免記憶體洩露發生。而且我們不止避免了記憶體洩露發生,還保留頁面的暫存機制。我們使用穩健的測試和推論。我們也在載入時期,優化分支方法的效能。我們還有忘了什麼?不幸的是,我們實作的第三個分支,那個處理既非 addEventListener,也不是 attachEvent 的時候的備用方案,目前已經消失了。

如果你仔細查看,問題應該很明顯。

var addListener;
if (isHostMethod(docEl, 'addEventListener')) {
  /* ... */
}
else if (isHostMethod(docEl, 'attachEvent')) {
  /* ... */
}
else {
  addListener = function (element, eventName, handler) {
    element['on' + eventName] = handler;
  };
}

每次 addListener 被執行時,一個新的處理器會覆寫的既有的!此外,目前的 removeListener,會完全移除元素上的所有事件處理器。這當然和其他實作不相符合,因此要不就是移掉第三個分支,不然就是修好它。

讓我們先來看一下,如果要移除第三個分支,應該怎麼做。一些較老舊的瀏覽器(像是 Netscape Navigator 4、Opera 6)同時缺少 addEventListener 和 attachEvent,所以第三個分支是他們唯一可用的方案。然而,這些瀏覽器現在可能已經絕跡,更需要關注的可能是行動裝置的瀏覽器,這些瀏覽器缺少基本 DOM 的支援已經是眾所皆知。假如我們不希望第三個分支運作,我們至少應該避免錯誤發生,並通知使用者他們在這個實作上有所不足。

var addListener;
...
else {
  addListener = function () {
    /* noop */
  };
  addListener.defunct = true;
}

注意我們仍然定義了 addListene 函式,但不讓它運作。我們標註它作為「停用」藉由指定「defunct」屬性為真。用戶端程式現在可以發現 addListener 是否在運作。如果功能停用,應用程式可以優雅地降級使用。

if (!addListener.defunct) {
 /* 
    Tab面板只在「addListener」正常運作時才會初始化,
    因此「click」事件能正常地處理
    此外,原來不具程式功能的版本會被顯示
(例如章節的列表)



    這個可以防止我們初化化tab面板
    以免click事件無法處理,而造成壞掉的感覺
  */

  tabPanel.initialize();
  addListener(tabPanel, 'click', handleTabClick);
}

從另一方面來說,如果目標是支援沒有 addEventListener / attachEvent 的瀏覽器,以 DOM 0 級作為實作的基準,保持這樣的一致性就是個好主意。

在我們開始檢視實作前,下面是事件流程的摘要。當 addListener 被調用時,會去找一個包含唯一 id 的元素。我們也會在 attachEvent 分支中使用相同的協助函式。下一步,這個 id 關聯到一個物件,這個物件會包含所有這個元素的處理器。再下一步,一個事件處理器被存入佇列中,這個佇列存放所有特定元素和型別的事件處理器。最後,既存的事件處理器,被一個發送器(dispatcher)取代,它的工作是遍歷所有特定元素/型別組合的事件處理器,然後在特定的情境(元素)和適當的參數(事件)時執行事件處理器。

...
else {


  var createDispatcher = function (uid, eventName) {
    return function (e) {
      if (handlers[uid] && handlers[uid][eventName]) {
        var handlersForEvent = handlers[uid][eventName];
        for (var i = 0, len = handlersForEvent.length; i < len; i++) {
          handlersForEvent[i].call(this, e || window.event);
        }
      }
    };
  };

  var handlers = { };


  addListener = function (element, eventName, handler) {
    var uid = getUniqueId(element);
    if (!handlers[uid]) {
      handlers[uid] = { };
    }
    if (!handlers[uid][eventName]) {
      handlers[uid][eventName] = [ ];
      var existingHandler = element['on' + eventName];
      if (existingHandler) {
        handlers[uid][eventName].push(existingHandler);
      }
      element['on' + eventName] = createDispatcher(uid, eventName);
    }
    handlers[uid][eventName].push(handler);
  };


  removeListener = function (element, eventName, handler) {
    var uid = getUniqueId(element);
    if (handlers[uid] && handlers[uid][eventName]) {
      var handlersForEvent = handlers[uid][eventName];
      for (var i = 0, len = handlersForEvent.length; i < len; i++) {
        if (handlersForEvent[i] === handler) {
          handlersForEvent.splice(i, 1);
        }
      }
    }
  };
}

現在 DOM 0 級分支已經被和其他實作有一致性。

還有什麼沒被處理

即使最後實作的結果強大而完整,不過還是有幾件事我們沒有處理到。其中之一是在 Internet Explorer 中支援補捉階段(capturing phase)。MSHTML 事件模型中缺少補捉階段的支援,就是 addListener 不允許我們指定 useCapture 參數的原因(即使標準的 addEventListener 可以處理)。

另一個不一致性是 Internet Explorer 中的事件處理器執行順序。在 DOM 2 級中,事件模組沒有指定事件處理器的觸發順序,多數的瀏覽器依循 FIFO(先進先出),而非 MSHTML 事件模型中的 LIFO(後進先出)。DOM 3 級的事件模型(目前是草稿)則 明確指定 FIFO 的順序,也就是「所有已經註冊到目標的事件處理器依他們的註冊順序」執行。

我們也還沒有處理 DOM 0 級分支的發送器在獨立事件處理器的錯誤。一般來說,確保沒有事件處理器被其他事件處理器所影響是個好主意。

當我們取得一個元素的特定 ID,我們沒有處理萬一這個元素不是元素,而是像 window 物件時該怎麼辦。

我們可以在下一次解決這些「問題」。就目前而言,它們就當作是給讀者的練習。最後,下面是約 150 行左右,完整 addListener / removeListener 實作的程式。

最終實作內容

(function(global){


  function areHostMethods(object) {
    var methodNames = Array.prototype.slice.call(arguments, 1), 
        t, i, len = methodNames.length;


    for (i = 0; i < len; i++) {
      t = typeof object[methodNames[i]];
      if (!(/^(?:function|object|unknown)$/).test(t)) return false;
    }
    return true;
  }


  var getUniqueId = (function () {
    if (typeof document.documentElement.uniqueID !== 'undefined') {
      return function (element) {
        return element.uniqueID;
      };
    }
    var uid = 0;
    return function (element) {
      return element.__uniqueID || (element.__uniqueID = 'uniqueID__' + uid++);
    };
  })();


  var getElement, setElement;
  (function () {
    var elements = { };
    getElement = function (uid) {
      return elements[uid];
    };
    setElement = function (uid, element) {
      elements[uid] = element;
    };
  })();


  function createListener(uid, handler) {
    return {
      handler: handler,
      wrappedHandler: createWrappedHandler(uid, handler)
    };
  }


  function createWrappedHandler(uid, handler) {
    return function (e) {
      handler.call(getElement(uid), e || window.event);
    };
  }

  function createDispatcher(uid, eventName) {
    return function (e) {
      if (handlers[uid] && handlers[uid][eventName]) {
        var handlersForEvent = handlers[uid][eventName];
        for (var i = 0, len = handlersForEvent.length; i < len; i++) {
          handlersForEvent[i].call(this, e || window.event);
        }
      }
    };
  }


  var addListener, removeListener,

      shouldUseAddListenerRemoveListener = (
        areHostMethods(document.documentElement, 'addEventListener', 'removeEventListener') &&``
        areHostMethods(window, 'addEventListener', 'removeEventListener')),


      shouldUseAttachEventDetachEvent = (
        areHostMethods(document.documentElement, 'attachEvent', 'detachEvent') &&
        areHostMethods(window, 'attachEvent', 'detachEvent')),


      // IE 分支
      listeners = { },


      // 0 級 DOM 分支
      handlers = { };


  if (shouldUseAddListenerRemoveListener) {


    addListener = function (element, eventName, handler) {
      element.addEventListener(eventName, handler, false);
    };


    removeListener = function (element, eventName, handler) {
      element.removeEventListener(eventName, handler, false);
    };


  }
  else if (shouldUseAttachEventDetachEvent) {


    addListener = function (element, eventName, handler) { 
      var uid = getUniqueId(element);
      setElement(uid, element);


      if (!listeners[uid]) {
        listeners[uid] = { };
      }
      if (!listeners[uid][eventName]) {
        listeners[uid][eventName] = [ ];
      }
      var listener = createListener(uid, handler);
      listeners[uid][eventName].push(listener);
      element.attachEvent('on' + eventName, listener.wrappedHandler);
    };


    removeListener = function (element, eventName, handler) {
      var uid = getUniqueId(element), listener;
      if (listeners[uid] && listeners[uid][eventName]) {
        for (var i = 0, len = listeners[uid][eventName].length; i < len; i++) {
          listener = listeners[uid][eventName][i];
          if (listener && listener.handler === handler) {
            element.detachEvent('on' + eventName, listener.wrappedHandler);
            listeners[uid][eventName][i] = null;
          }
        }
      }        
    };
  }
  else {


    addListener = function (element, eventName, handler) {
      var uid = getUniqueId(element);
      if (!handlers[uid]) {
        handlers[uid] = { };
      }
      if (!handlers[uid][eventName]) {
        handlers[uid][eventName] = [ ];
        var existingHandler = element['on' + eventName];
        if (existingHandler) {
          handlers[uid][eventName].push(existingHandler);
        }
        element['on' + eventName] = createDispatcher(uid, eventName);
      }
      handlers[uid][eventName].push(handler);
    };


    removeListener = function (element, eventName, handler) {
      var uid = getUniqueId(element);
      if (handlers[uid] && handlers[uid][eventName]) {
        var handlersForEvent = handlers[uid][eventName];
        for (var i = 0, len = handlersForEvent.length; i < len; i++) {
          if (handlersForEvent[i] === handler) {
            handlersForEvent.splice(i, 1);
          }
        }
      }
    };
  }


  /* 匯出作為全域屬性*/
  global.addListener = addListener;
  global.removeListener = removeListener;


})(this);

About the Author

Juriy Zaytsev, otherwise known as "kangax", is a front end web developer based in New York. Most of his work involves exploring and taming various aspects of Javascript. He blogs about some of his findings at http://perfectionkills.com/. Juriy has been contributing to various projects ranging from libraries and frameworks, to articles and books.

Find Juriy on:

More from Juriy:

 

Videos

Articles