DOM 事件模型
事件是用户与浏览器交互的基础,用户在界面的操作产生事件,浏览器捕获事件后对用户作出反馈。 Ajax 技术通过引入异步调用使 web 应用的开发产生了革命性的变化,另一方面 Ajax 也使广大开发人员认识到用户和浏览器的交互可以如此精彩,Web 页面不再死板,开始鲜活起来,开发人员也不再厌恶编写 javascript 的事件处理代码。在 web 页面中,事件一般作用于 DOM 树节点,所以有必要先了解 DOM 的事件模型,包括模型支持那些事件,如何处理 DOM 树结构上的节点的事件等。
清单 1
<html> <body> <script> function sayHello() { alert("hello!"); } </script> <input id="btn" type="button" onclick="sayHello()" value="hello" /> </body> </html>
清单 1 应该是最为 web 开发人员熟知的事件处理方式了,直接把事件处理函数和控件上的事件属性绑定起来。当用户点击 hello 按钮时,将调用 sayHello() 函数。当然也可以把事件处理函数的代码作为 onclick 的值,参见清单 2,使用这种方式时,onclick 对应的处理脚本应比较简单短小,在 onclick 后面写上一大串 javascript 脚本可不是什么好主意。
清单 2
<html> <body> <input id="btn" type="button" onclick="javascript:alert('hello');" value="hello" /> </body> </html>
另一种略微高级的方法是在控件之外绑定控件的事件处理函数,见清单 3 。
清单 3
<html> <body> <input id="btn" type="button" value="hello" /> <script> document.getElementById("btn").onclick=sayHello; function sayHello() { alert('Hello'); } </script> </body> </html>
在清单 3 的例子中,首先通过 document.getElementById 获取需要绑定事件的控件,再把控件的 onclick 事件设置为事件处理函数,其效果与前面的例子是一样的。需要注意的是,script 脚本放到了控件后面,因为使用了 document.getElementById 去获控件,而 javascript 是解释执行的,必须保证控件在执行到 getElementById 之前已经创建了,否则会出现找不到控件的错误。但 sayHello 为什么会在事件绑定语句的后面呢?按照刚才的原则,不是必须确保 sayHello 已经预先定义好了吗?其实不然,事件处理函数的代码直到事件发生时才被调用,此时才会检查变量是否已经定义,函数是否存在,而页面初次加载时按钮上的 click 事件是不会发生的。页面加载后用户再点击按钮,sayHello 函数已经完全加载到页面中,函数是存在的。当然如果是普通的函数调用,一定要保证被调用函数出现在调用函数之前。采用清单 3 所示的这种方式时,在 web 应用比较复杂时,可以把事件处理函数集中放在一起,比如单独存放在一个文件中,方便以后查找,修改。这个例子也很好的说明了 javascript 是一种解释执行的脚本语言。
前面三种事件处理方式是在 W3C DOM Level0 中定义的,是不是简单易用?但是似乎太简单了,缺少一些东西。首先一个事件只能绑定一个处理函数,不支持多个事件处理函数的绑定。如果开发人员被迫把事件处理代码都放在一个函数中,代码的模块性会很差。其次解除事件处理函数的绑定的方式很不友好,只能把它设为空值或者空串。
document.getElementById("btn").onclick=null; document.getElementById("btn").onclick="";
W3C DOM Level2 标准有了新的事件模型,新模型最大的变化有两点:
首先,事件不再只传播到目标节点,事件的传播被分为三个阶段:捕获阶段,目标节点阶段,冒泡阶段。一个事件将在 DOM 树中传递两次,首先从 DOM 根节点到目标节点(捕获阶段),然后从目标节点传递到根节点(冒泡阶段)。在这三个阶段都可以捕获事件进行处理,也可以阻止事件继续传播。 W3C 的官方网站有关于这三个阶段的详细说明。在 DOM Level0 定义的事件模型中,事件只能被目标节点处理,其实这也是大部分支持事件处理的编程语言采用的机制,比如 Java,C# 。但是这种方式可能并不适合结构比较复杂的 web 页面。比如很多链接都需要自定义的 tooltip,在 DOM Level0 的方式下,需要给每个链接的 mouseover,mouseout 事件提供事件处理函数,工作量很大。而在 DOM Level2 模型中,我们可以在这些链接的公共父节点上处理 mouseover,mouseout 事件,在 mouseover 时显示一个 tooltip,mouseout 时隐藏这个 tooltip 。这样只需要对一处进行更改即可给每个链接添加上自定义的 tooltip 。所以 DOM Level2 的设计者定义出分为三个阶段的事件模型也是为了适应复杂的 web 页面,让开发人员在处理事件上有更大的自由度。
其次,支持一个事件注册多个事件处理函数,也能够删除掉这些注册的事件处理函数。一个事件可以注册多个事件处理函数同样是大部分的编程语言的事件处理机制支持的方式。这种方式在面向对象的开发中尤为重要,因为可能很多对象都需要监听某一事件,有了这种方式,这些对象可以随时为这一事件注册一个事件处理函数,事件处理函数的注册是分散的,而不像在 DOM Level0 中,事件处理是集中式的,使用这种方式使得事件的“影响力”大大增强。
清单 4
<html> <body> <input id="btn" type="button" value="hello" /><p /> <input id="rme" type="button" value="remove" /> <script> function sayHello(event) { alert("hello"); }; function sayWorld(event) { alert("world"); }; function remove() { btn.removeEventListener("click", sayHello, false); btn.removeEventListener("click", sayWorld, false); } var btn = document.getElementById('btn'); btn.addEventListener("click", sayHello, false); btn.addEventListener("click", sayWorld, false); document.getElementById('rme').addEventListener("click", remove, false); </script> </body> </html>
清单 4 是使用 DOM Level2 定义的事件模型的例子,在这个例子中,首先为 hello 按钮的 click 事件注册了两个事件处理函数,分别用来显示“ hello ”和“ world ”警示框。然后为 remove 按钮的 click 事件处理了一个事件处理函数,用来删除注册在 hello 按钮上的事件处理函数。例子很简单,但是足够说明 DOM Level2 中的事件处理机制。
- addEvenetListener(/*String*/eventName, /*function*/handler, /*bool*/useCapture)
为某一 HTML 元素注册事件处理函数,eventName:该元素上发生的事件名; handler:要注册的事件处理函数,useCapture:是否在捕获阶段调用此事件处理函数,一般为 false,即只在事件的冒泡阶段调用这一事件处理函数。
- reomveEvenetListener(/*String*/eventName, /*function*/handler, /*bool*/useCapture);
删除某一 HTML 元素上注册的事件处理函数,函数声明与 addEventListener 一样,参数意义也相同,即注册、删除事件处理函数时也需要使用同样的参数。这点不太方便,比较好的做法是 addEventListener 返回一个句柄,然后把这个句柄传递作为 removeEventListener 的参数。
sayHello, sayWorld 是两个事件处理函数,他们的参数 event 是一个事件对象,对象的属性包括事件类型(在本例中是 click),事件发生的 X,Y 坐标(这两个属性在实现 tooltip 时特别有用),事件目标(即事件的最终接收节点)等。
从这个例子中也可以看出事件处理包括三个方面:事件源、事件对象、事件处理函数。事件处理机制就是把这三个方面有机的联系起来。
注意,清单 4 的例子不能运行在 IE 浏览器里,因为 IE 浏览器采用是一种介乎 DOM level0 和 DOM Level2 之间的事件模型。比如在 IE 中,应该使用 attachEvent(), detachEvent() 来注册、注销事件处理函数。这只是 IE 中的事件模型与标准 DOM Level2 事件模型不一致部分的冰山一角,其他的诸如事件对象的传播方式、事件对象的属性、阻止事件传播的函数等,IE 与 DOM Level2 都有很大差异。这也是为什么 Dojo 会再提供一些事件处理的 API 的原因:屏蔽底层浏览器的差异,让开发人员在写编写事件处理代码时面对的是“透明”的浏览器,即不需要关心浏览器是什么。前面花了很大篇幅来介绍 DOM 事件模型,因为 Dojo 的事件处理机制是基于 DOM Level2 定义的事件模型的,然后对浏览器不兼容的情况做了很多处理,以保证使用 Dojo 的事件处理机制编写的代码能在各个浏览器上运行。下面来介绍 Dojo 的事件处理机制。
使用 Dojo 处理 DOM 事件
当 Dojo 运行在支持 DOM Level2 事件模型的浏览器中时,Dojo 只是把事件处理委托给浏览器来完成。而在与 DOM Level2 的事件模型不兼容的浏览器(比如 IE)中,Dojo 会尽量使用浏览器的 API 模拟 DOM Level2 中的事件处理函数。 Dojo 最终提供给开发者一个称为“简单连接”的事件处理机制来处理 DOM 事件。
为什么叫“简单连接”呢,因为绑定事件处理函数的函数名叫 dojo.connect,相应的注销的函数是 dojo.disconnect 。
- dojo.connect = function(/*Object|null*/ obj, /*String*/ event, /*Object|null*/ context, /*String|Function*/ method, /*Boolean*/ dontFix)
参数 obj 事件源对象,比如 DOM 树中的某一节点; event 参数表示要连接的事件名,如果是 dojo.global(在浏览器中一般是 window 对象)域上的事件,则 obj 参数可以置为 null,或者不写。 context 指事件处理函数所在的域(比如一个对象); method 表示事件处理函数名,如果是全局函数,则 context 参数可置为 null,或者不写这个参数; dontFix 表示不需要处理浏览器兼容的问题,默认为 false ;如果你的应用只在支持 DOM Level2 事件模型的浏览器上运行,则可以把它设为 true,但是这种几率太小了,因为 IE 就不是完全支持 DOM Level2 事件模型。 dojo.connect 函数可以返回一个 handle,在 dojo.disconnect 中会用到。
- dojo.disconnect = function(/*Handle*/ handle)
dojo.disconnect 函数用来注销已注册的事件处理函数,参数是一个 dojo.connect 时返回的 handle 。
下面来看看如何使用 Dojo 的简单连接机制处理 DOM 事件。
清单 5
<html> <head> <script type="text/javascript" djConfig="parseOnLoad: true, isDebug: true" src="../dojo/dojo/dojo.js"></script> </head><body><script> function $(id) { return document.getElementById(id); } function handler(eventObj) { console.info("eventType=" + eventObj.type + "; node=" + eventObj.target.id + "; currentTarget=" + eventObj.currentTarget.id); //if Shift Key pressed if (eventObj.shiftKey) { //stop bubbling eventObj.stopPropagation(); } } function handler2(eventObj) { console.info("this is for test"); } function connect() { dojo.connect($("book"), "click" , handler); dojo.connect($("cpp"), "click" , handler); dojo.connect($("b1"), "mouseover" , handler); dojo.connect($("b1"), "mousedown" , handler); dojo.connect($("b1"), "click" , handler); dojo.connect($("b2"), "click" , handler); dojo.connect($("b2"), "click" , handler2); dojo.connect($("b3"), "click" , handler); } dojo.addOnLoad(connect); </script> <div id="book"> <ol id="cpp"> <li id="b1">C++ primer</li> <li id="b2">Thinking in C++</li> <li id="b3">Inside C++ object model</li> </ol> </div> </body> </html>
清单 5 的页面中有一个跟 c++ 相关的书的列表,列表的每一项都通过 dojo.connect 绑定了一个或多个事件处理函数。第一项“ c++ primer ”给 mouseover,mousedown,click 三个事件注册了事件处理函数,第二项“ Thinking in c++ ”注册了两个 click 事件处理函数 handler 和 handler2,第三项“ Inside C++ object model ”绑定了 click 事件。这个例子可以说包括了“简单连接”机制的方方面面。
首先来看看 dojo.connect 的使用,dojo.connect 能够把多个事件处理函数绑定在一个事件上,第二项“ Thinking in c++ ”的 click 事件就绑定了两个事件处理函数。在本例中,并没有给 dojo.connect 函数传递事件处理函数的 context,因为默认的是 dojo.global,而两个事件处理函数 hander 和 hander2 都是全局函数,所以不需要显示传递 dojo.global 。
再来看事件处理函数 handler 和 handler2 。 handler2 只是用来说明 dojo.connect 可以绑定多个事件处理函数,不多说; handler 是主要的事件处理函数,在 handler 里先输出了事件对象的三个属性,type、target、currentTarget,type 表示事件的类型,target 表示事件目标节点,currentTarget 表示当前事件传递到哪个节点了,输出他们三个是为了说明 Dojo 也是在冒泡阶段处理事件的(还记得在 DOM 事件模型部分对事件的三个阶段的描述吗?)。所以当点击第三项 b3 时,在浏览器的模拟控制台输出是
eventType=click; node=b3; currentTarget=b3 eventType=click; node=b3; currentTarget=cpp eventType=click; node=b3; currentTarget=book
可以看出,首先会触发 b3 的事件处理函数,然后是 id 为 cpp 的 ol 元素的 click 事件处理函数,最后是 id 为 book 的 div 。所以毫无疑问 Dojo 是在事件的冒泡阶段处理事件的,capture 阶段并不做任何处理。 handler 的最后是关于阻止事件传播的代码,如果按住 shift 键,再点击第三项时,只会在模拟控制台输出:
eventType=click; node=b3; currentTarget=b3
后面两个事件没有发生,因为 click 事件被 stopPropagation 阻止了,没有再往上冒。事实上,可以在任何一级对象上调用 stopPropagation 阻止事件继续往上传递。
然后是事件对象 eventObj,事件对象是对事件的描述,在前面已经介绍了事件对象的几个有用的属性。 Dojo 的事件对象其实基于 DOM Level2 的事件对象,更详细的属性信息可以参考 Dojo 的官方文档,这里对用户操作触发的事件和事件的继承结构做些说明。当用户点击第一项 b1 时,在浏览器输出的是
eventType=mouseover; node=b1; currentTarget=b1 eventType=mousedown; node=b1; currentTarget=b1 eventType=click; node=b1; currentTarget=b1 eventType=click; node=b1; currentTarget=cpp eventType=click; node=b1; currentTarget=book
从上面的输出可以看出,在 b1 这个节点上一共监测到了三个事件(事实上产生的事件不止三个),mouseover、 mousedown、click 。所以表面上一个点击操作背后却藏着大文章。同理用户点击提交按钮提交一个表单也会触发很多事件,但一般我们只处理了最上层的 submit 事件。这些现象揭示了事件是有类别,层次的,底层事件可以触发高层事件。底层事件一般都是与设备有关的事件,比如鼠标移动,按键产生的事件;高层事件一般指页面元素上的事件,比如链接的 click 事件,表单的 submit 事件等。与设备无关的事件往往由几个与设备有关的事件触发。比如一个单击页面上按钮的 click 事件,可以分解为 mouseover, mousedown, mouseup 三个事件,在这三个事件发生之后,将触发按钮的 click 事件。开发人员应该了解这些知识,因为它有助于写出高效的事件处理程序。
最后是事件目标,在 W3C DOM Level2 的事件模型里,事件目标不仅仅是 DOM 树种最底层的接收事件的节点,它可以是从这个底节点到跟节点路径上的任何一个节点。
Dojo 目前支持的事件类别包括 UIEvent,HTMLEvent, MouseEvent,每类事件具有的属性并不一样,比如只能在 MouseEvent 里才能获得事件发生时鼠标的位置等。
使用 Dojo 处理用户自定义事件
既然 W3C 已经定义了标准的 DOM Level2 事件模型,为什么 Dojo 还要提供 connect 函数来注册事件处理函数呢,为何不使用 DOM Level2 的 addEventListener 函数?从前面的叙述中也看不出 connect 与 addEventListener 有明显的不同之处。确实在处理 DOM 事件上,Dojo 的 connect 与 addEventListener 无甚大的不同,但是 Dojo 的 connect 函数还可以处理用户自定义事件。这是 addEventListener 所不具备的。下面来看看怎么使用 dojo.connect 来处理用户自定义事件。
用户自定义事件是指用户指定的函数被调用时触发的“事件”,当指定函数被调用时,将促发监听函数被调用。有点类似于 AOP 的编程思想,但在 Javascript 中实现 AOP 比起面向对象的编程语言要简单得多。
清单 6
<html> <head> <script type="text/javascript" djConfig="parseOnLoad: true, isDebug: true" src="../dojo/dojo/dojo.js"></script> </head> <body> <script type="text/javascript"> function print(fName, args) { var message = "In " + fName + "; the arguments are: " dojo.forEach(args, function(args) { message += args.toString() + " "; }) ; console.log(message); } function handler1() { print("handler1", arguments); } function handler2(a1, a2) { print("handler2", [a1, a2]); } function userFunction() { print("userFunction", arguments); } dojo.connect("userFunction", null, "handler1"); dojo.connect("userFunction", null, "handler2"); userFunction(1, 2); </script> </body> </html>
运行清单 6 的例子,会在页面中的一个模拟控制台中输出:
In userFunction; the arguments are: 1 2 In handler1; the arguments are: 1 2 In handler2; the arguments are: 1 2
调用 userFunction 时,handler1 和 handler2 也被触发了。 userFunction 就像是一个事件源,它的调用像一个事件,而 handler1 和 hander2 就是事件处理函数。那么这种情况下,事件对象又在哪呢? handler1 事件处理函数没有显式的参数,通过在控制台的输出可以得知它实际上有两个参数,值分别为 1 和 2 ; handler2 有两个显式参数,值也为 1 和 2 。所以 Dojo 只是把 userFunction 的两个参数传递给了事件处理函数,不像在处理 DOM 事件时,提供一个封装好的事件对象。在本例中 userFunction 只“连接”了两个函数,很显然它还可以连接更多的事件处理函数,这些事件将按连接的先后顺序来执行。
Dojo 的订阅/发布模式
dojo.connect 函数用来处理某一个实体上发生的事件,不管处理的是 DOM 事件还是用户自定义事件,事件源和事件处理函数是通过 dojo.connect 直接绑定在一起的,Dojo 提供的另一种事件处理模式使得事件源和事件处理函数并不直接关联,这就是“订阅/发布”。“订阅/发布”模式可以说是一个预订系统,用户先预定自己感兴趣的主题,当此类主题发布时,将在第一时间得到通知。这跟我们熟知的网上购物系统不一样,网上购物是先有物,用户再去买,而在订阅/发布模式下,预订的时候并不确定此类主题是否已存在,以后是否会发布。只是在主题发布之后,会立即得到通知。订阅/发布模式是靠主题把事件和事件处理函数联系起来的。在 Dojo 中,跟主题订阅 / 发布有关的函数有三个:
- dojo.subscribe = function(/*String*/ topic, /*Object|null*/ context, /*String|Function*/ method)
subscribe 函数用来订阅某一主题;参数 topic 表示主题名字,是一个字符串; context 是接收到主题后调用的事件处理函数所在的对象,function 是事件处理函数名。
- dojo.unsubscribe = function(/*Handle*/ handle)
取消对于某一主题的订阅;参数 handle 是 dojo.subscribe 返回的句柄,跟 dojo.connect 与 dojo.disconnect 的工作方式一样。
- dojo.publish = function(/*String*/ topic, /*Array*/ args)
发布某一主题;参数 topic 是主题的名字,args 表示要传递给主题处理函数的参数,它是一个数组,可以通过它传递多个参数给事件处理函数。
订阅 / 发布模式看上去很神秘,但实现是比较简单的。 dojo 维护了一个主题列表,用户订阅某一主题时,即把此主题及其处理函数添加到主题列表中。当有此类主题发布时,跟这一主题相关的处理函数会被顺序调用。注意:如果用户使用了相同的处理函数重复订阅某一主题两次,在主题列表中这是不同的两项,只是他们都对同一主题感兴趣。当此类主题发布时,这两个处理函数都会被调用,而不会出现第二个处理函数覆盖第一个处理函数的状况。清单 7 的例子展示了订阅 / 发布模式是如何工作的。
清单 7
<html> <head> <script type="text/javascript" djConfig="parseOnLoad: true, isDebug: true" src="../dojo/dojo/dojo.js"></script> </head> <body> <script> var NewsReporter = { sports : function(message) { for (var i = 0; i < message.length; i++) console.info("sports:" + message[i]); }, entertainment: function(message) { for (var i = 0; i < message.length; i++) console.info("entertainment:" + message[i]); } , mixed: function (sportsNews, entermaintainNews) { console.info("mixed"); this.sports(sportsNews); this.entertainment(entermaintainNews); } } /*first subscribe*/ handle1 = dojo.subscribe("sports news", NewsReporter, "sports"); dojo.publish("sports news", [["China will rank first in the 29th Olympic"]]); handle2 = dojo.subscribe("sports news", NewsReporter, "sports"); dojo.subscribe("entertainment news", NewsReporter, "entertainment"); dojo.subscribe("mixed news", NewsReporter, "mixed"); /*then publish*/ dojo.publish("sports news", [["America will rank second in the 29th Olympic", "Russia will third forth in the 29th Olympic"]]); dojo.publish("entertainment news", [["Red Cliff earns over 200 million in its first week"]]); dojo.publish("mixed news", [["Yao Ming gives Red Cliff high comments"], ["Jay and S.H.E wish Beijing Olympic success"]]); //unsubscribe two sports news reporter dojo.unsubscribe(handle1); dojo.unsubscribe(handle2); dojo.publish("sports news", [["this news has no consumer!"]]); </script> </body> </html>
在清单 7 的例子中,模拟了一个“新闻记者”(NewsReporter 对象),专门跑体育和娱乐新闻,任何此类新闻他都不会放过。 Dojo 就像一个新闻中心,发布各类新闻。
记者先在新闻中心注册,说自己对体育新闻感兴趣,接着新闻中心发布了一条新闻“ China will rank first in the 29th Olympic ”,这时新闻记者将立即收到这条消息,并报道出来(在本例中就是在浏览器的模拟控制台输出这条新闻)。然后记者又再次向新闻中心注册对体育和娱乐新闻以及跨这两个领域的新闻都感兴趣,然后新闻中心分别发布了这三个主题的新闻。记者当然不敢懈怠又马上输出了这些新闻,最后新闻记者不打算再跑体育新闻了,就在新闻中心取消了对体育新闻的注册。这个例子最终将在浏览器的模拟控制台输出:
sports:China will rank first in the 29th Olympic sports:America will rank second in the 29th Olympic sports:Russia will third forth in the 29th Olympic sports:America will rank second in the 29th Olympic sports:Russia will third forth in the 29th Olympic entertainment:Red Cliff earns over 200 million in its first week mixed sports: Yao Ming gives Red Cliff high comments entertainment: Jay and S.H.E wish Beijing Olympic success
从这个例子中我们可以得到几个使用订阅/发布模式时的注意事项。
- 先订阅,再发布。主题发布的时候,订阅了这一主题的事件处理函数会被立即调用。
- 发布函数的参数为数组,发布第一条新闻时使用的是
[["China will rank first in the 29th Olympic"]],这是一个二维数组,因为事件处理函数 NewsReporter.sports,NewsReporter.entertainment,以及 NewsReporter.mixed 的参数已经是一个数组,所以在发布时必须把新闻事件这个数组再放在另一个数组中才能传递给这些事件处理函数。而“ mixed ”新闻的处理函数有两个参数,所以发布“ mixed ”的新闻时,参数为:
[["Yao Ming gives Red Cliff high comments"], ["Jay and S.H.E wish Beijing Olympic success"]]
二维数组中的第一个数组表示体育新闻,第二个数组表示娱乐新闻。
- 取消订阅时,必须把所有的订阅都取消。重复的订阅行为返回的句柄是不一样的,在本例中 handle1 和 handle2 是不同的,必须都注销。只有在 handle1 和 handle2 都被注销后,新闻中心发布的体育新闻才不会被这个记者接收到。
结束语
浏览器在事件处理机制上的差异使得 web 开发人员在处理事件时需异常小心,Dojo 的事件处理 API 却能在各个浏览器上工作的很好,减少了开发人员在处理跨浏览器问题上的工作量。 Dojo 参考 W3C DOM Level2 的事件模型实现的事件处理机制,即能处理 DOM 事件,也能处理用户自定义事件,而全新的“订阅 / 发布”模式也给了开发人员在处理事件时更多的选择。