一、一个实例
假设你在你家客厅里玩游戏,口渴了,需要到厨房开一壶水,等水开了的时候,为了防止水熬干,你需要及时把火炉关掉。为了及时了解到水是否烧开,你有三种策略可以选择:
1. 守在厨房内,等水烧开
这种策略显然是很愚蠢的,采取这种策略,在烧水的过程中你将不能做任何事情,效率极低。
2. 呆在客厅玩游戏,每隔一两分钟跑到厨房看一次
这种策略,在计算机科学中称为轮询,即每隔一定的时间,监测一次。在这里,也是很不明智的,在玩游戏时需要不断的分心。
3. 在水壶上安装一个报警器,当水开了的时候,发出警报
这种策略是最好的,既不耽误自己玩游戏,又能在水开了的时候使自己及时获得通知。这种策略在计算机中通过事件机制来实现。
二、事件机制的组成
通过上面的实例,我们可以抽象出一个事件机制有三个组成部分:
1.事件源:即事件的发送者,在上例中为水壶;
2.事件:事件源发出的一种信息或状态,比如上例的警报声,它代表着水开了;
3.事件侦听者:对事件作出反应的对象,比如上例中的你。在设计事件机制时一般把侦听者设计为一个函数,当事件发送时,调用此函数。比如上例中可以把倒水设计为侦听者。
三、初步实现
可以使用面向对象设计中的组合模式,把事件侦听者当做事件源内部的一个对象,当事件发生时,调用侦听者即可:
1 事件源:水壶{ 2 事件侦听者:你关火;//事件源持有事件侦听者 3 4 发送(事件:“水开了”){ 5 你关火(); 6 } 7 }
四、出现多个事件侦听者的情况
如果你和你女朋友都在客厅玩游戏,水开的时候应该谁去关火呢?假设精明(懒惰)的你,听到水开的警报声,马上假装上厕所,你女朋友只能无奈地去关火。这种情况下,水壶发出的警报声导致了两个反应:1.你上厕所,2.你女朋友去关火。此时我们要如何实现呢?我们依然可以采用上面的实现方案,再在事件源中添加一个事件侦听者:
1 事件源:水壶{ 2 事件侦听者:你上厕所; 3 事件侦听者:你女朋友关火; 4 5 发送(事件:“水开了”){ 6 你上厕所(); 7 你女朋友关火(); 8 } 9 10 }
但这种设计有一个重大缺陷:事件源和事件侦听者过度耦合。所有侦听者都是硬编码入事件源中,在程序执行过程中无法更改,灵活性极差。比如,有一天你女朋友外出了,只能你去关火,那么上面的事件源就需要重新修改。我们可以采用下面的方法使事件源和事件侦听者解耦:
1.事件源中定义一个列表,比如数组,用来存储所有侦听者;
2.为列表留一个增删数据的接口,用来随时添加和删除侦听者;
3.当发送事件时,遍历并执行列表中的侦听者
实现如下:
1 事件源:水壶{ 2 3 事件侦听者:侦听者列表[]; 4 5 添加事件侦听者(侦听者){ 6 侦听者列表加入侦听者 7 } 8 删除事件侦听者(侦听者){ 9 侦听者列表移除侦听者 10 } 11 12 发送(事件){ 13 //遍历并执行列表中的侦听者 14 for(侦听者 in 侦听者列表){ 15 执行侦听者 16 } 17 } 18 19 }
这种实现方案即为观察者设计模式,可以让侦听者预订事件。
五、事件源可发送多种事件的情况
假设你家的水壶有点智能,当水温达到90度的时候,会发出一个“水快开了”的警报,为你提前逃到厕所偷懒留出了充足的时间,这种情况下的事件和侦听者的对应关系如下:
我们可以在添加和删除侦听者的时候,把事件类型和侦听者绑定成一个数组(或对象),再加入侦听者列表。在发送事件时,在列表中查找和当前事件绑定的侦听器执行:
事件源:水壶{ 事件侦听者:侦听者列表[]; 添加事件侦听者(事件类型,侦听者){ 带类型侦听者=[事件类型,侦听者];//通过数组把事件类型和侦听者绑定 侦听者列表加入带类型侦听者; } 删除事件侦听者(事件类型,侦听者){ 通过事件类型和侦听者查找列表中对应的侦听器删除; } 发送(事件类型){ //遍历并执行列表中的侦听者 for(带类型侦听者 in 侦听者列表){ if(带类型侦听者[0]==事件类型){ 带类型侦听者[1]()//执行对应的侦听器 } } } }
把上面的文字描述翻译成伪码如下:
1 //水壶类 2 Kettle{ 3 4 array:Listeners[]; 5 6 addEventListener(eventType,listener){ 7 typeListener=[eventType,listener];//通过数组把事件类型和侦听者绑定 8 Listeners.push(typeListener); 9 } 10 11 removeEventListener(eventType,listener){ 12 Listeners.delete([eventType,listener]); 13 } 14 15 dispatch(eventType){ 16 //遍历并执行列表中的侦听者 17 for(typeListener in Listeners){ 18 if(typeListener[0]==eventType){ 19 typeListener[1]()//执行对应的侦听器 20 } 21 } 22 } 23 24 } 25 26 goWc(){ 27 //你上厕所 28 } 29 30 turnOffFire(){ 31 //女朋友关火 32 } 33 34 kettle=new Kettle(); 35 //水壶注册水快开了事件 36 kettle.addEventListener("水快开了",goWC); 37 kettle.addEventListener("水开了",turnOffFire); 38 kettle.dispatch("水快开了");
优化:遵循"针对接口编程"的设计原则,应该为水壶、事件、侦听器设计一个基类,其他具体的类继承这些基类;
六、显示对象上的事件:理解事件流
当事件发生在显示对象上(比如浏览器)的时候,会遇到一个很有趣的问题:页面的那一部分会拥有某个特定的事件?比如当你点击页面上的一栋小房子的时候,根据视角的远近,你点击的对象会发生变化。从最远处来看你点击的是页面,镜头拉近你点击的是小房子,再拉近你点击的是房子上的一面墙,再拉近你点击的是墙上的一块砖。也就是说,你点击一次页面也许会有很多显示对象发生了点击事件,如果你在每一个显示对象上都绑定了点击处理程序,那么这些程序都会执行。这里会遇到一个问题:这些程序按什么顺序执行。这取决于显示对象接受到点击事件的顺序,一般有两种模式:事件冒泡和事件捕获。这种事件在显示对象上按顺序发生的过程称为事件流。
1. 事件冒泡
事件冒泡,即事件开始时由最具体的元素(比如上例的砖块)接受,然后逐级向上传播到较为不具体的节点(文档);
2. 事件捕获
事件捕获的思想是不太具体的元素(文档)更早的接受事件,而最具体的元素最后接受到事件(砖块)。事件捕获的用意在于事件到达预订目标之间捕获它。
在JavaScript中为DOM中的元素添加事件处理程序时,有三个参数,其中第三个参数是一个布尔值,当为true时,表示在捕获阶段调用事件处理程序,为false时,表示在冒泡阶段调用事件处理程序,举例如下:
1 <body> 2 <div id="outer"> 3 <div id="inner" > 4 </div> 5 </div> 6 </body> 7 8 //例一 9 var btn1=document.getElementById("outer"); 10 btn1.addEventListener("click",function(){ 11 alert('outer') 12 },false); 13 14 var btn2=document.getElementById("inner"); 15 btn2.addEventListener("click",function(){ 16 alert('inner') 17 },false); 18 19 //例二 20 var btn1=document.getElementById("outer"); 21 btn1.addEventListener("click",function(){ 22 alert('outer') 23 },false); 24 25 var btn2=document.getElementById("inner"); 26 btn2.addEventListener("click",function(){ 27 alert('inner') 28 },false);
上面例一的事件处理程序都发生在冒泡阶段,所以会先输出inner,再输出outer。例二中id为outer元素上的事件处理程序发生在捕获阶段,所以会先输出outer,再输出inner。
注意:事件流发生在父元素和子元素之间,而不是两个同级的元素。