前言
前段时间博客园里有篇很火的帖子2016十家公司前端面试小记,主要讲作者的前端求职面试经历,其中提到了面试官会考察手写一个简单的事件模型:
“如果上述都ok的话,那么极有可能要求让你【实现事件模型】,即写一个类或是一个模块,有两个函数,一个bind一个trigger,分别实现绑定事件和触发事件,核心需求就是可以对某一个事件名称绑定多个事件响应函数,然后触发这个事件名称时,依次按绑定顺序触发相应的响应函数。”
如果了解观察者模式,那么事件模型应该不算太难。本着深入钻研的精神,我试着来手写一下DOM事件模型。DOM事件模型由于会和DOM有关系,因此会稍微难一点。
DOM事件模型
在实现一个东西之前,我们要了解它的原理。首先我们得知道什么是事件,事件是整个HTML DOM的核心,没有了事件,就谈不上交互!我们与HTML进行交互时,事件就在不断的发生。比如鼠标点击某个元素、按下某个按键、文档加载完毕等等。我们可以对事件进行监听,在事件发生后就能进行一些处理。标准的添加事件监听函数的代码如下:
addEventListener('click',doSomeThing,false);
其中click是事件类型,soSomeThing是事件监听函数,false表示事件处理是采用冒泡的方式。IE9以前的版本稍微有一些不同,使用的是attachEvent(),而且只支持冒泡方式,这里不做过多介绍。另外在HTML标签中通过属性添加事件监听或者JavaScript中通过元素属性添加事件监听这种过时而且极容易出问题的做法也不讲。
事件处理的几个阶段
说到冒泡就引出了事件处理的三个阶段:捕获、目标、冒泡。为什么要有这三个阶段?啊,这涉及到Netscape和IE的浏览器之争这段历史了,大家可自行度娘。
捕获阶段:在该阶段,事件沿着DOM树从根结点往目标元素走,目标元素的上级元素如果也注册了该事件的监听处
理函数(捕获类型),则调用之。
目标阶段:在该阶段,事件到达目标元素,根据该事件的监听处理函数注册的顺序调用之。
冒泡阶段:在该阶段,事件从目标元素开始往DOM树顶层冒泡,如果目标上级元素也注册了该事件的监听处理函数
(冒泡类型),则调用之。
以span元素的click事件为例,那么整个事件处理流程可以通过下面的图表示:
事件对象
事件发生后,就会生成一个事件对象Event,它包含了事件相关的详细信息。事件对象通常作为参数传递给事件处理程序(IE8之前通过window.event获得)。所有事件对象都有事件类型(type)与事件目标(target,IE8之前为srcElement)。各个事件的事件参数不一样,通用的又比较重要的下面给大家列出来:
最最最重要的是下面四个属性:
type:事件类型,例如"click"事件。
target:触发事件的目标元素,例如上面图中是span元素。
currentTarget:事件目前传递到的那个元素,事件处理函数内部this指向的就是currentTarget。
eventPhase:事件处理阶段:1 捕获阶段;2 目标阶段;3 冒泡阶段
bubbles:表明事件是否支持冒泡。大部分事件都支持冒泡,但有的事件不支持冒泡,比如focus事件。
cancelable:表明是否可以取消事件的默认行为。
defaultPrevented:为true表明已经调用了preventDefault。
preventDefault:取消事件默认行为,cancelable是true时可以调用此方法。调用后该元素上关联的处理函数不执行。
stopPropagation:取消事件进一步传递,包括捕获和冒泡。但当前元素上关联的后续处理函数会执行完。
stopImmediatePropagation:立即取消事件传递,当前元素上关联的后续处理函数也不会执行。
开始设计
知道了DOM事件模型的基本概念之后,我们需要分析现有DOM事件模型中的重要对象和重要流程,然后定义相关的对象和方法。从上面的DOM事件模型的介绍可以看出来其中最重要的对象当属DOM元素和事件对象这两个了。DOM元素
dom元素至少应该有个内部id,这样才能获取到该元素。不过浏览器并没有将其放出来,除了IE!IE中的元素都有SourceIndex属性,它就是元素在浏览器内部的id!而且dom元素的SourceIndex是根据dom树的先根遍历递增的。例如上图中的dom文档结构中,所有元素的内部id如下:
需要注意的是,其实html之上还有document和window两个结点,这里为了不增加实现难度就以html为根结点。
此外DOM元素应该有事件的注册、注销和派发三个方法,对应于addEventListener、removeEventListner和dispatchEvent。另外dom还应该保存一份注册的处理函数的集合。如果你了解设计模式中的观察者模式的话,那么这都太自然不过了,因为事件模型就是基于观察者模式实现的!好了,那么目前为止DOM元素应该是下面这个样子:
customeDom={ sourceId:"1",//在浏览器内部的id handlers:{},//注册的处理函数 addEventHandler: function(){},//对应于addEventListener removeEventHandler:function(){},//对应于removeEventListener dispatchEvt:function(){}//对应于dispachEvent }
事件对象
关于事件对象我们可以参考原生的event对象来设计,一个事件对象至少应该包括事件类型、目标对象、当前处理对象、事件处理阶段四个属性。由于DOM事件模型存在捕获和冒泡两个阶段,那么事件需要在DOM树上流动,一个事件的发生其实就确定了一条根结点到目标结点的路径,这个路径肯定需要保存下来,所以事件应该还有一个属性,它保存了这条路径上的所有结点。那么事件对象至少看起来应该是这样子:
cusotmeEvent ={ eventType:"click",//事件类型 target:"span元素",//触发事件的元素 currentTarget:"当前处理元素",//当前处理元素 eventPhase:"1"//事件处理阶段 path:[ ]//事件流动的路径上结点集合 }
其它结构
因为我们自己实现了dom,那么在内存中应该维护着dom元素的列表domList,然后可以随时获取到。我想浏览器中肯定有类似这样保存dom元素的列表。
/** * 定义cutomeDom对象列表 * */ var domList=[];
整个事件流程
1、注册事件监听函数(以span元素的click事件为例)
var obj = document.getElementById('span'); obj.addEventListener('click',doSomeThing,false);
从上面注册事件监听函数可以看到,在注册事件监听函数时可以获取的信息如下:注册事件的元素,事件类型,事件处理函数,是否捕获。那么这个时候span元素的内容应该是:
span={ sourceId:"7", handlers:{ click:[{capture:fasle,listener:doSomeThing}] }, addEventHandler:function(){ }, removeEventHandler:function(){ }, dispatchEvt:function(){ } }
2、触发事件
在鼠标点击span之后,就会产生click事件,这时会生成一个event对象:
event={ type:"click", target:"null", currentTarget:"null", eventPhrase:"null", path: [ ] }
然后调用obj.dispachEvent(event),对事件进行派发。在派发时可以获取到target和path:
event={ type:"click", target:"obj", currentTarget:"null", eventPhrase:"null", path: [0,3,4,5,7 ] }
接下来就是进行事件的处理,这里需要根据事件流程的三阶段分别进行处理。通过path可以很好的获取到捕获、目标、冒泡阶段参与处理的元素。
3、注销事件监听
obj.removeEventListener('click',doSomeThing,false);
从代码可以看出,注销时提供的信息有事件类型,处理函数,是否捕获。我们可以根据这些信息找到dom元素handlers列表下的事件处理函数,并将其移除。
实现
分析完事件模型原理、设计出DOM和event对象后,我们就可以着手实现它了!
自定义DOM
DOM对象的创建必须先知道DOM文档结构,而且要保存DOM树的结构。为了简单起见,这里直接用原生DOM的id属性模拟sourceId,而且通过原生DOM获取事件的传播路径。
/** * 定义Dom对象 * * */ function customeDom(DOM){ this.sourceId = DOM.id; //直接用id模拟IE 中的sourceIndex this.handlers={};//该元素上注册的事件处理函数集合 //在这里添加其它和DOM类似的属性,方法 //add here }然后还需要为DOM添加addEventHandler、removeEventHandler和dispatchEvt三个方法:
//为customeDom添加方法 customeDom.prototype = { /** * 注册事件 * */ addEventHandler : function (type,listener,useCapture) { //添加事件处理函数到事件处理函数集合 }, /** * 注销事件 * */ removeEventHandler :function (type,listener,useCapture) { //从事件处理函数集合中删除 }, /** * 分发事件 * */ dispatchEvt :function (event) { event.target = this; //获取事件路径 var rootNode = document.getElementsByTagName('html');//默认html为根结点(实际上面还有document和window两个对象) var target = document.getElementById(this.sourceId);//通过原生DOM获取事件传播路径 event.path = getPath(target,rootNode); //进行事件处理 dealEvent(event); } }
自定义Event
/** * 定义事件对象 */ function customeEvent(type){ this.target = "";//目标元素 this.currentTarget = "";//当前元素 this.eventPhase = "";//事件处理阶段 this.eventType = type;//事件类型 this.path=[];//注册了该事件处理函数的结点(实际上是元素的sourceId),因为有捕获和冒泡的存在,所以结点不止一个,而是目标到根结点的路径 //上面是最重要的一些属性,其它类似原生event的属性和方法添加到下面。 // 为了突出重点,下面的属性和方法都未实现。 // this.bubbles = true;//是否是起泡事件 // this.cancelable = false;//是否取消默认动作 // this.isTrusted = true;//是否是系统事件 // this.stopPropagation =function(){};//停止事件传播 // this.preventDefault = function(){};//取消事件处理 // 更多属性和方法请参考event对象,地址如下:https://developer.mozilla.org/en-US/docs/Web/API/Event }
事件处理
/** * 事件处理 * */ function dealEvent(event){ //捕获阶段 event.eventPhase = 1; for(var i=0;i<event.path.length-1;i++){ //设置currentTarget event.currentTarget = getElementBySourceId(event.path[i]); //如果注册了捕获类型的事件处理函数则调用 } //目标阶段 event.eventPhase = 2; //设置currentTarget event.currentTarget = event.target; //根据事件处理函数注册顺序调用 .... //冒泡阶段 event.eventPhase = 3; for(var i=event.path.length-1;i>0;i--){//从父元素开始冒泡,不包括目标元素 //设置currentTarget event.currentTarget = getElementBySourceId(event.path[i-1]); //如果注册了冒泡类型的事件处理函数则调用 } }
测试代码
<!DOCTYPE html> <html id="0"> <head id ="1"> <script id="2" src="visitor.js"></script> </head> <body id="3"> <div id="4"> <div id="5"> <p id="6">this is p</p> <span id="7">this is span</span> </div> </div> <div id="8"> div 2 </div> <script id="9"> //先初始化customDom对象到domList中,然后就可以通过sourceId获取到customeDom元素了 initDomList(); //注册事件监听函数 var obj = getElementBySourceId('7'); obj.addEventHandler('click',trigger,false); function trigger(event){ console.log('target ok! target:'+event.target.sourceId+',currentTarget:'+event.currentTarget.sourceId); } //测试捕获阶段 getElementBySourceId('5').addEventHandler('click',capture,true); function capture(event){ console.log('capture ok! target:'+event.target.sourceId+',currentTarget:'+event.currentTarget.sourceId); } //测试冒泡阶段 getElementBySourceId('4').addEventHandler('click',bubble,false); function bubble(event){ console.log('bubble ok! target:'+event.target.sourceId+',currentTarget:'+event.currentTarget.sourceId); } //通过点击div#8,模拟触发span#7的click事件 document.getElementById('8').addEventListener('click',fireEvent,false); function fireEvent(){ //创建一个事件对象并初始化 var event = new customeEvent('click'); getElementBySourceId('7').dispatchEvt(event); } //测试注销事件监听 //obj.removeEventHandler('click',trigger,false); </script> </body> </html>
运行效果:
家庭作业
1、完善event属性
这个事件模型中,对于event对象,我们仅仅实现了最核心的几个属性如eventType、target、currentTarget、eventPhase。读者可以试着添加cancelable和defaultPrevented等属性并实现它们。其中cancelable表明是否可以取消事件的默认行为,为true时可以调用event.prevnetDefault()方法来取消事件默认行为;defaultPreventd指示是否调用了event.preventDefault()方法。
2、实现stopPropagation和preventDefault方法
stopPropagation()是取消事件进一步传递,包括捕获和冒泡。但当前元素上关联的后续处理函数会执行完。preventDefault()方法是取消事件默认行为,在cancelable为true时可以调用。这两个方法中应该设置一些条件变量,然后在处理事件之前判断这些条件变量。具体做法请借鉴JQuery事件触发源码。
存在的问题
1、通过id模拟sourceId
在我们实现的DOM事件模型中,DOM元素在浏览器内部的id(sourceId)是通过元素的id来模拟的,真实的情况是需要自己去实现这个sourceId。如果是这样,我们就还得知道有多少元素,也就是说我们得知道HTML文档结构。
2、dom没有保存文档树结构,借助了原生DOM获取path。
在实现中,事件的传播路径是通过原生DOM的parentElement获取的。自定义的DOM创建时没有保存文档DOM树的结构,这个跟问题1的根源是一样的。
3、事件类型的支持
本文实现的DOM事件模型,仅仅是简单的事件模拟,因此只需要基本的event对象即可。事实上不同事件类型需要保存的信息大不一样,因此需要设计对应的事件对象。下图是MDN(Mozilla Developer NetWork)上的各种事件对象:
4、模型的健壮性
本文仅仅是完成了DOM事件模型的核心功能,没有做充分的测试,许多判断都没有加,一些函数的容错性会因此大打折扣。
后记:
当初实现的时候,其实并没有想到要自己设计DOM,而是在现有的DOM上添加sourceId属性、handlers和注册监听函数、派发事件和注销监听函数等方法,然后模拟事件流程。其中添加方法是没问题,只是添加handlers属性的时候,发所有的元素都共用了同一个handlers,而不是每个元素各自维持一个handlers。这个问题没有解决,后来才想到自己重新设计DOM。DOM事件模型不同于一般的事件模型,一般的事件模型只需要实现一个观察者模式就基本上就OK了,DOM事件模型需要和DOM元素打交道,而且还存在捕获、冒泡等特有的处理流程,因此会复杂一点。关于DOM事件模型的模拟还有一篇好文JavaScript事件机制底层实现原理,大家可以参考参考。