首先,DOM Level2為事件處理定義了兩個函數addEventListener和removeEventListener, 這兩個函數都來自於EventTarget接口.
復制代碼 代碼如下:
element.addEventListener(eventName, listener, useCapture);
element.removeEventListener(eventName, listener, useCapture);
EventTarget接口通常實現自Node或Window接口.也就是所謂的DOM元素.
那麼比如window也就可以通過addEventListener來添加監聽.
復制代碼 代碼如下:
function loadHandler() {
console.log('the page is loaded!');
}
window.addEventListener('load', loadHandler, false);
移除監聽通過removeEventListener同樣很容易做到, 只要注意移除的句柄和添加的句柄引用自一個函數就可以了.
window.removeEventListener('load', loadHandler, false);
如果我們活在完美世界.那麼估計事件函數就此結束了.
但情況並非如此.由於IE獨樹一幟.通過MSDHTML DOM定義了attachEvent和detachEvent兩個函數取代了addEventListener和removeEventListener.
恰恰函數間又存在著很多的差異性,使整個事件機制變得異常復雜.
所以我們要做的事情其實就轉移成了.處理IE浏覽器和w3c標准之間對於事件處理的差異性.
在IE下添加監聽和移除監聽可以這樣寫
復制代碼 代碼如下:
function loadHandler() {
alert('the page is loaded!');
}
window.attachEvent('onload', loadHandler); // 添加監聽
window.detachEvent('onload', loadHandler); // 移除監聽
從表象看來,我們可以看出IE與w3c的兩處差異:
1. 事件前面多了個"on"前綴.
2. 去除了useCapture第三個參數.
其實真正的差異遠遠不止這些.等我們後面會繼續分析.那麼對於現在這兩處差異我們很容易就可以抽象出一個公用的函數
復制代碼 代碼如下:
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;
}
}
上面函數有兩處需要注意一下就是:
1. 第一個分支最好先測定w3c標准. 因為IE也漸漸向標准靠近. 第二個分支監測IE.
2. 第三個分支是留給既不支持(add/remove)EventListener也不支持(attach/detach)Event的浏覽器.
性能優化
對於上面的函數我們是運用"運行時"監測的.也就是每次綁定事件都需要進行分支監測.我們可以將其改為"運行前"就確定兼容函數.而不需要每次監測.
這樣我們就需要用一個DOM元素提前進行探測. 這裡我們選用了document.documentElement. 為什麼不用document.body呢? 因為document.documentElement在document沒有ready的時候就已經存在. 而document.body沒ready前是不存在的.
這樣函數就優化成
復制代碼 代碼如下:
var addListener, removeListener,
/* test element */
docEl = document.documentElement;
// addListener
if (docEl.addEventListener) {
/* if `addEventListener` exists on test element, define function to use `addEventListener` */
addListener = function (element, eventName, handler) {
element.addEventListener(eventName, handler, false);
};
}
else if (docEl.attachEvent) {
/* if `attachEvent` exists on test element, define function to use `attachEvent` */
addListener = function (element, eventName, handler) {
element.attachEvent('on' + eventName, handler);
};
}
else {
/* if neither methods exists on test element, define function to fallback strategy */
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具備了add/remove方法.那麼element就一定具備(雖然大多數情況如此).但這顯然是不夠安全.
不安全的檢測
下面兩個例子說明.在某些情況下這種檢測不是足夠安全的.
復制代碼 代碼如下:
// In Internet Explorer
var xhr = new ActiveXObject('Microsoft.XMLHTTP');
if (xhr.open) { } // Error
var element = document.createElement('p');
if (element.offsetParent) { } // Error
如: 在IE7下 typeof xhr.open === 'unknown'. 詳細可參考feature-detection
所以我們提倡的檢測方式是
復制代碼 代碼如下:
var isHostMethod = function (object, methodName) {
var t = typeof object[methodName];
return ((t === 'function' || t === 'object') && !!object[methodName]) || t === 'unknown';
};
這樣我們上面的優化函數.再次改進成這樣
復制代碼 代碼如下:
var addListener, docEl = document.documentElement;
if (isHostMethod(docEl, 'addEventListener')) {
/* ... */
}
else if (isHostMethod(docEl, 'attachEvent')) {
/* ... */
}
else {
/* ... */
}
丟失的this指針
this指針的處理.IE與w3c又出現了差異.在w3c下函數的指針是指向綁定該句柄的DOM元素. 而IE下卻總是指向window.
復制代碼 代碼如下:
// IE
document.body.attachEvent('onclick', function () {
alert(this === window); // true
alert(this === document.body); // false
});
// W3C
document.body.addEventListener('onclick', function () {
alert(this === window); // false
alert(this === document.body); // true
});
這個問題修正起來也不算麻煩
復制代碼 代碼如下:
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 {
/* ... */
}
我們只需要用一個包裝函數.然後在內部將handler用call重新修正指針.其實大伙應該也看出了,這裡還偷偷的修正了一個問題就是.IE下event不是通過第一個函數傳遞,而是遺留在全局.所以我們經常會寫event = event || window.event這樣的代碼. 這裡也一並做了修正.
修正了這幾個主要的問題.我們這個函數看起來似乎健壯了很多.我們可以暫停一下做下簡單的測試, 測試三點
1. 各浏覽器兼容 2. this指針指向兼容 3. event參數傳遞兼容.
測試代碼如下:
[Ctrl+A 全選 注:如需引入外部Js需刷新才能執行]
我們只需這樣調用方法:
復制代碼 代碼如下:
addListener(o, 'click', function(event) {
this.style.backgroundColor = 'blue';
alert((event.target || event.srcElement).innerHTML);
});
可見'click' , this, event 都做到了浏覽器一致性. 這樣是不是我們就萬事大吉了?
其實這只是萬裡長征的第一步.由於IE浏覽器下和諧的內存洩露,使我們的事件機制要考慮的比上面復雜的多.
看下我們上面的一處修正this指針的代碼
element.attachEvent('on' + eventName, function () {
handler.call(element, window.event);
});
element --> handler --> element 很容易的形成了個循環引用. 在IE下就內存洩露了.
解除循環引用
解決內存洩露的方法就是切斷循環引用. 也就是將handler --> element這段引用給切斷. 很容易想到的方法,也是至今還有很多類庫在使用的方法.就是在window窗體unload的時候將所有handler指向null .
基本代碼如下
代碼
復制代碼 代碼如下:
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 {
/* ... */
}
也就是將listener用數組保存起來.在window.unload的時候循環一次全部指向為null.從此切斷引用.
這看起來是個很不錯的方法.很好的解決了內存洩露問題.
避免內存洩露
在我們剛剛要松口氣的時候.又一個令人咂舌的事情發生了.bfcache這個被大多主流浏覽器實現的頁面緩存機制.介紹上赫然寫了幾條會導致緩存失效的幾個條款
the page uses an unload or beforeunload handler
the page sets "cache-control: no-store"
the page sets "cache-control: no-cache" and the site is HTTPS.
the page is not completely loaded when the user navigates away from it
the top-level page contains frames that are not cacheable
the page is in a frame and the user loads a new page within that frame (in this case, when the user navigates away from the page, the content that was last loaded into the frames is what is cached)
第一條就是說我們偉大的unload會殺掉頁面緩存.頁面緩存的作用就是.我們每次點前進後退按鈕都會從緩存讀取而不需每次都去請求服務器.這樣一來就矛盾了...
我們既想要頁面緩存.但又得切斷內存洩露的循環引用.但卻又不能使用unload事件...
最後只能使用終極方案.就是禁止循環引用
這個方案仔細介紹起來也很麻煩.但如果見過DE大神最早的事件函數.應該理解起來就不難了. 總結起來需要做以下工作.
1. 為每個element指定一個唯一的uniqueID.
2. 用一個獨立的函數來創建監聽. 但這個函數不直接引用element, 避免循環引用.
3. 創建的監聽與獨立的uid和eventName相結合
4. 通過attachEvent去觸發包裝的事件句柄.
經過上面的一系列分析.我們得到了最終的這個相對最完美的事件函數
復制代碼 代碼如下:
(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;
}
// 獲取唯一ID
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 branch
listeners = {},
// DOM L0 branch
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);
至此.我們的整個事件函數算是發展到了比較完美的地步.但總歸還是有我們沒照顧到的地方.只能驚歎IE和w3c對於事件的處理相差太大了.
遺漏的細節
盡管我們洋洋灑灑的上百行代碼修正了一個兼容的事件機制.但仍然有需要完善的地方.
1. 由於MSHTML DOM不支持事件機制不支持捕獲階段.所以第三個參數就讓他缺失去吧.
2. 事件句柄觸發順序.大多數浏覽器都是FIFO(先進先出).而IE偏偏就要來個LIFO(後進先出).其實DOM3草案已經說明了specifies the order as FIFO.
其他細節不一一道來.
整個文章為了記錄自己的思路.所以顯得比較啰嗦.但那個相對完美的事件函數還是有稍許參考價值, 希望會對大家有稍許幫助.
如果大家有好的意見和提議,望指教.謝謝.
代碼打包下載