代码改变世界

javascript打造跨浏览器事件处理机制

2010-07-11 14:31  BlueDream  阅读(4507)  评论(9)    收藏  举报

由于浏览器兼容的复杂性.打造一个较优的跨浏览器事件处理函数.不是件容易的事情.各大类库也都通过了种种方案去抽象一个庞大的事件机制.

使用类库可以比较容易的解决兼容性问题.但这背后的机理又是如何呢? 下面我们就一点点铺开来讲.

 

首先,DOM Level2为事件处理定义了两个函数addEventListenerremoveEventListener, 这两个函数都来自于EventTarget接口. 

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

EventTarget接口通常实现自NodeWindow接口.也就是所谓的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定义了attachEventdetachEvent两个函数取代了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参数传递兼容. 

点击测试文本

测试代码如下:

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title> Event Test UseCase </title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
</head>
<body>
<div id="odiv" style="width:200px;height:100px;background-color:red; text-align:center">测试文本</div>
</body>
<script>
var isHostMethod = function (object, methodName) {
    
var t = typeof object[methodName];
    
return ((t === 'function' || t === 'object'&& !!object[methodName]) || t === 'unknown';
};

var addListener, removeListener,
    
/* test element */
    docEl 
= document.documentElement;

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

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


// Test UseCase
var o = document.getElementById('odiv');
addListener(o, 
'click'function(event) {
    
this.style.backgroundColor = 'blue';
    alert((event.target 
|| event.srcElement).innerHTML);
});
</script>
</html>

我们只需这样调用方法:

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.

 

点击查看IE与FF事件顺序差别

其他细节不一一道来.

 

整个文章为了记录自己的思路.所以显得比较啰嗦.但那个相对完美的事件函数还是有稍许参考价值, 希望会对大家有稍许帮助.

如果大家有好的意见和提议,望指教.谢谢. 

 

【源码下载】

event