Javascript的时序和同步机制

<译自http://dev.opera.com/articles/view/timing-and-synchronization-in-javascript/>

时序问题是Javascript应用程序中最难解错误的来源之一。在开发中从来不出现的问题可能在终端用户的慢速网络和机器上冒出来。这些问题也可能是间断性的,难以重现。

考虑一个简单的例子:一个按钮,以及与之关联的事件处理函数。当按钮被按下时,其它元素被修改。如果用户在将被修改的元素被解析生成前按下按钮,脚本将会失败。开发人员可能不会注意到这个问题,因为他是在快速的网络和机器环境下测试的,在这种环境下整个页面的解析和生成只在一瞬间就完成了。

本文将尝试解释当前浏览器中各种跟时间有关的Javascript问题。

基本知识

浏览器窗口有一个线程来执行HTML的解析、事件派发以及Javascript代码的执行。Javascript代码以下面两种方式之一执行:

  1. 在<script>标签中的顶级代码在页面载入时执行。
  2. 事件处理函数在事件派发过程中被处理。

由浏览器发起这两种执行,它们都在同一个线程中运行,任何时候都只有一个代码单元在运行。

基本上浏览器由事件驱动(代码通过响应用户输入而运行),但在页面载入期间,还受解析线程的驱动。

事件流

事件是浏览器发出的一个信号,表明窗口正要发生,或者已经发生了某件事。

事件处理程序是一个Javascript函数,注册到一个对象和事件名上。当对应的事件发生在注册的对象上时,事件处理程序即被调用。

所有的事件处理程序都是顺序执行,在任何一个事件完全处理完毕(包括事件沿DOM树冒泡及缺省响应)之前,下一下事件不会被处理。

缺省响应

缺省响应是浏览器事件模型中的最有意思的一环:它发生在没有任何Javascript代码需要执行的时候。比如,一个链接上的点击事件的缺省响应是导航到该URL上。单选框按钮的点击事件的缺省响应是选中该单选框,等等。

缺省响应并不是一个事件处理程序,我们不能移除它或者改写它,这一点是与我们的自定义事件处理程序不同的地方。但是,我们可以在事件派发过程中使用'preventDefault'函数来取消缺省响应的执行(在IE中通过event.returnValue来取消)。即使缺省响应被取消,相关的自定义事件处理器仍然会被激发,只是在此之后,缺省响应不再执行。

派发序列

象load这样的事件只发生在相应的对象上('window'或者'document')。然而,针对文档里特定元素的事件,则在它们祖先级别节点的事件处理程序也会被激发。
当事件在目标节点上被激发之前,存在一个'捕获(cpaturing)'阶段,即位于目标节点的祖先节点上的那些节点可能截获事件。然而事件捕获并不是在所有的浏览器上都能可靠工作。
事件'起泡',即当它们在目标元素上激发之后,会依次在DOM树上的祖先节点上也得到激发,直到遇到document对象,则在所有浏览器上适用。
事件在所有相关元素上被激发以及执行缺省响应,被称作事件派发。
对非冒泡事件,派发顺序是:
  1. 捕获阶段:事件从上至下发生在所有祖先节点上。
  2. 事件在目标元素上激发,即注册在该元素上的事件处理程序被执行(以不确定的顺序)。
  3. 缺省动作被执行(如果没被某个处理器取消掉)。

对冒泡事件,派发顺序是:

  1. 捕获阶段:事件从上至下发生在所有祖先节点上。
  2. 事件在目标元素上激发。
  3. 冒泡阶段:事件在所有祖先元素上被激发,从下而上。
  4. 执行缺省响应(除非被某个事件处理器取消)。

通过调用'stopPropagation()(在IE中是cancelBubble())可以防止事件继续冒泡,但缺省响应仍然会执行。因此取消冒泡和取消缺省响应是分开和独立的操作。

事件模型的每个阶段在DOM 3 Events规范中有详细地解释。

存在着一些奇怪的情况,缺省响应会在事件派发之前产生--但可能依然被取消。举例来说,当一个单选框被点击一,勾选标志被生成,'checked'属性也在事件派发前就已更新。然而,如果缺省动作在事件派发过程中被取消,那么该update会在缺省响应阶段被回滚:勾选标志被移除,'checked'属性被翻转回去。

批量事件

一些事件成批到来:用户的一次输入会导致多个事件被派发。比如,当焦点从一个表单字段移到另一个字段时,前一个字段上发生'blur'(失去焦点)事件,后一个字段上发生'focus'(焦点移入)事件。两个事件在概念上是同时发生(因为它们是对同一个用户输入的响应),但实际上还是顺序派发。

如果是冒泡事件,则只有该事件的捕获/冒泡阶段和缺省响应完全执行以后,下一个事件才会被派发。

该顺序的一个例子是鼠标点击某个按钮并释放的动作。此时'mouseup'事件和'click'事件都被激发,顺序是:

'Mouse-up'事件的派发:

  1. 'click'事件的派发阶段--所有捕获事件处理器都已执行。
  2. 目标元素:事件在目标元素上激发。
  3. 'mouseup'的冒泡阶段:事件在所有祖先级元素上激发。
  4. 缺省响应(本事件无缺省响应)。

'Click'事件的派发

  1. 捕获阶段-所有捕获处理器都已执行。
  2. 目标元素:事件在目标元素上激发。
  3. 冒泡阶段:'click'事件在所有祖先级元素上激发。
  4. 'click'的缺省响应被执行。

在事件派发中只能取消本事件的缺省响应。例如,'mouseup'事件处理程序取消了缺省响应(无意义,因为'mouseup'没有缺省响应)。这并不会阻止'click'事件的激发,因为它们是不同的事件。

然而,缺省响应可能引起其它事件。假如在'submit'按钮上发生一个'click'事件,其缺省动作是提交当前的表单,因此进而产生一个'submit'事件。因此,取消'click'事件上的缺省响应也一并取消了下一个事件。

事件队列

事件的派发是为了响应用户输入(通过鼠标或者键盘),或者象页面载入完成这样的内部事件。然而,输入和派发是两个异步操作。

用户输入可能发生在事件处理器正在执行的时候。这时动作被缓冲起来,当事件派发器再次执行时,这些被缓冲的动作对应的事件被一一派发。事件总是以正确的顺序派发,但在动作和事件派发之间可能会有明显的延迟,如果某些事件处理器代码比较花时间的话。

当事件处理器执行时,IE和Mozilla完全停止对用户的响应,就连工具条也会看上去被锁住一样。尽管用户可能可以进行某些操作,比如点击按钮,但这些动作都只是被缓冲起来,而且不会有任何可见的反馈。这可能看上去让用户困惑,他们极有可能重复点击按钮,从而造成不期望的后果。甚至用户可能认为浏览器已经崩溃,因为它似乎停止响应。

Opera比起来更能响应用户操作,比如如果在脚本还在执行时点击了一个按钮,它能对用户的操作给出可见的反馈。然而,事件仍然被存入缓冲队列,仍然要顺序派发,就象其它浏览器所做的那样。因此缺省响应直到事件处理完毕之前,也不会被执行。同样,这也会使用户困惑,尽管可能不如象IE和Mozilla那样。

基本准则是,事件处理代码不能占用太多时间。特别要当心异步的XMLHttpRequest请求,因为它们可能造成显著的延迟,从而冻住浏览器或者文档窗口。

嵌套事件

有一个特别的场合,事件不是序列化的,而是嵌套的。如果在脚本中通过dispactchEvent(在IE中是fireEvent方法)方法来显式地发出一个事件,该事件会被立即派发。只有当该事件(也包括缺省响应)处理完成后,原来的脚本才会继续运行。

同样,DOM变更事件(这些事件在IE中不支持)也会被立即派发,并与DOM变更同步,比如当appendChild()被调用时。

渲染的时机

编程方式修改的DOM或者样式并不会立即被渲染呈现给用户。这一切取决于浏览器的实现。

例如,如果某元素的背景色被改变,DOM会立即反映这一修改(而且DOM修改事件会被立即派发且同步处理),但我们不能确知何时浏览器引擎会将该变化呈现在屏幕上。似乎Opera会立即将修改渲染出来,而Mozilla和IE则会等该事件处理完毕之后才做渲染。

Timeouts

方法setTimeout将指定的函数计划在指定的时间以后被调度执行:

window.setTimeout(someFunc, 1000);

定时脚本在某种程度上象事件处理器一样工作。尽管它们是响应某个超时,而非用户输入,但象用户事件一样被事件分派线程顺序处理。

因此,你不能指望超时函数在指定的时间被运行。如果其它事件或者批量事件正在执行,超时脚本会被排入队列等待。基本上我们可以确保该函数会在1秒中以后执行,但可能要等待的时间会长于1秒。

令入惊讶地由此产生了一个有用的功能。如果一个处理程序注册为超时0秒后执行,处理程序不会被立即执行,而是被立即存入队列。它会在当前事件(包含缺省响应)执行完毕后立即执行。如果超时事件在一批事件的处理程序中间被创建(比如blur/focus,mouseup/click),超时事件会在该批事件完全完成后被调度。

<译注:在“长时间运行的脚本”一节,作者演示了为什么这个功能实际上相当重要。>

非用户事件

其它非用户发起的事件有:

  • 页面加载事件
  • 超时事件
  • 异步XMLHttpRequest操作数据接收完成时的回调

这些事件象用户发起的其它事件一样被放入事件分发队列。这意味着,XMLHttpRequest响应处理程序不会在数据接收完成时立刻调用,而是被排在事件分发队列中等待执行。

Alerts

警告对话框(以及相关联的confirm/prompt对话框)有一些奇怪的特性。

当脚本发起对话框后,脚本被挂起,只到对话框关闭。从这个意义上讲它们是同步执行的。脚本等待alert()函数返回后才能继续运行。

微妙的是有一些浏览器在alert对话框出现并等待用户操作时仍然允许事件分发。这意味着当脚本被挂起,等待alert()函数返回时,其它事件分发处理中的还可能有函数运行。

用户接口事件,比如mouseup和click不会在alert对话框存在期间被激发,因为alert对话框是模态对话框且捕获所有用户输入。但非用户发起的事件,比如页面加载,超时事件和异步XMLHttpRequest事件仍然有可能被激活。

页面载入

浏览器下载文档时的同时也渐进式地解析和渲染页面。

大多数外部资源,如图片,插件媒体,是被异步地载入的。当解析器遇到img,embed,iframe和object标签时,会产生一个新的线程。这个线程会在主页面解析线程之外独立地下载、解析和渲染该外部资源。在iframes中的页面也是异步加载的。

外部样式表是一个例外。一些浏览器象对待图片一样异步地下载它们,一些浏览器同步下载它们,以避免样式表载入时全部重新渲染整个文档。(这也防止了早期已显示的内容在更换样式表时闪烁)。换句话说,不要依赖这些特殊的行为。

Javascript块的执行

脚本元素被同步解析。当一个脚本元素指向外部链接时,页面的解析工作暂停,直到该外部脚本完全下载并解析运行之后才恢复。

内联脚本块在结束标签遇到时完成解析并执行。

脚本块的执行

Javascript脚本分两阶段处理。先是解析,然后是执行。在解析阶段,验证代码是否符合语言规范,如果有语法错误,脚本不会被执行。

在执行阶段,所有顶级语句会被执行。顶级语句是指除开函数内部代码以外的所有语句。顶级语句可能含有对同一块代码中函数的前向引用,由于函数的声明已经在代码解析阶段被处理过了,所以下面的代码可以工作:

<script>
  var x = getMagicNumber();
  function getMagicNumber() { return 117; }
</script>

然而,下面的代码不会工作,因为函数表达式的求值是在运行时完成的:

<script>
  var x = getMagicNumber(); // ERROR! getMagicNumber is undefined!
  var getMagicNumber = function() { return 117; }
</script>

下面的代码也不能正常工作,因为代码段都是在遇到结束标签时立即执行的:

<script>
  alert(getMessage());
</script>
<script>
  function getMessage() { return "Hello!"; }
</script>

使用document.write()

脚本可能使用document.write()直接产生HTML输出。该输出首先被缓存,直到该段脚本结束执行时才被解析。输出中可能又包含脚本段,它们会在解析的过程中被执行。

生成的HTML输出紧跟在当前脚本段的后面。

DOM构建

解析器在页面加载时增量式地构建DOM树。每遇到一个新标签,一个空的元素就被插入到DOM树中。当开始标签解析完成后,一个非空标签就被插入到DOM中<译注:原文:An empty element is inserted in the DOM when the tag is parsed. A non-empty element is inserted when the opening tag is parsed.可能是指元素的属性值指定在开始标签中,因此当该标签解析完成时,元素就不为空。>例如,当解析器开始解析文档内容时,body元素就在DOM中存在且可用了。

注意DOM不一定完全对应输入的HTML内容。例如HTML和head标签,即便它们不出现在HTML中,这些元素也会被构建在DOM中。

如果HTML源代码无效,比如,title元素出现在body元素中,浏览器会重调DOM,使之有效。因此不能认为DOM树的构建是按序构建的。

延迟的代码段加载

脚本的同步加载有一个缺点:如果文档头里有太多代码要下载和执行,那么页面的渲染可能就会有显著地延迟。

为了减轻这个副作用,我们可以使用script元素的defer属性值。该属性指示浏览器可以异步加载脚本。然而,我们不能肯定当脚本执行时,它是在页面加载完成以前还是以后。Opera浏览器则完全忽略该属性。

<script defer> 
   alert("this message will appear at some unpredictable time during page load"); 
</script>

延迟脚本不能使用document.write(),因为它们不与解析器同步。

脚本块总是按他们在文档中出现的顺序来执行,无论它们是否有着defer属性。因此,如果没有defer属性的脚本元素在有defer属性的元素后面,解析器必须先完成延迟脚本的加载和执行,然后才能进行非延迟脚本的解析和运行。这就消除了defer属性的意义,因此必须将非延迟脚本始终放在延迟脚本的前面。

由于这些原因,defer属性在决定代码段运行时机上并不可靠,它仅仅允许部分浏览器在处理一个代码段的同时继续解析文档。

渐进式渲染

页面的视觉呈现的渲染并非与DOM构建同步。这个时机基本无法预言。取决于网络速度和页面大小,浏览器可能等到整体页面完全下载完毕后再渲染,也可能一次渲染一小部分。

当页面开始渲染时,用户接口就开始响应用户输入事件。如果事件处理程序引用了还未生成的DOM元素,这就可能导致前向引用问题。

可能出错的代码示例:

<button 
οnclick="document.getElementById('lamp').backgroundColor = 'yellow'">
  Click here to turn on lamp!
</button>
<div id='lamp'>O</div>

问题可能发生在当用户点击按钮时,lamp元素还没有生成。事件处理程序应该避免引用在其后定义的元素。

在更为复杂的用户接口中,想要在页面控制间避免前向引用可能是不切实际的。相反,所有控制应该一开始处于禁用状态,直到'onload'事件处理程序来激活它们。此时可以确保所有页面元素都已加载。

注意'onload'事件需要等待所有的图片(以及桢等)完成加载。如果页面上有一些大的图片,这可能需要占用一时间。一个变通方法是在页面底部嵌入一个内联脚本,来激活整个页面。这将在页面完成加载时得到执行,但不依赖外部资源的加载。

长时间运行的脚本

理想地,Javascript代码不应该长时间运行,因为它们会中断用户体验。然而有时候可能无法避免这些长时间运行的脚本。在这种情况下,应该给用户显示一个“请等待”的消息或者进度条,来指示浏览器并没有死掉。问题是这条消息必须在可能长时间运行的处理开始之前就要显示。

下面是一段示例伪代码:

headlineElement.innerHTML = "Please wait...";
performLongRunningCalculation();
headlineElement.innerHTML = "Finished!";

在IE和Mozilla中,“请等待”的消息并不会向用户显示,因为该消息只在脚本结束时,浏览器才会将其渲染到飞屏幕上。在Opera中,“请等待”消息则会在计算正在进行时显示。

如果我们想要消息也能正常地在IE和Mozilla中显示,我们必须将立即控制交还给UI,以便消息可以在计算开始前被渲染:

headlineElement.innerHTML = "Please wait...";
function doTheWork() {
   performLongRunningCalculation();
   headlineElement.innerHTML = "Finished!";
}
setTimeout(doTheWork, 0);

现在setTimout的技巧保证了消息在浏览器被阻塞前就能显示出来。当然浏览器仍然可能在计算进行时被“冻”住,因此这个技巧并不是十分优雅。如果我们想要完全防止浏览器被“冻”住,则需要将计算进程分解成一些小的函数,并使用setTimeout将它们链接在一起。不过这样立即就增加了代码的复杂度。

竞争条件

每个窗口(以及桢)都有自己的消息队列。

在Opera中,每个窗口都有自己的Javascript线程。在iframes中的窗口也是这样。结果是在不同桢中激活的消息处理程序可能同时执行。如果这些同时执行的代码共享了数据(比如顶层窗口的属性),我们就有可能遇到竞争条件。

我不在此赘述竞争条件的危害,只想指出这可能导致非常令人困惑的错误。

解决方案之一是,让消息处理函数始终在顶层窗口的消息处理队列中排队,即使它们是被其它桢激发的事件。

假设一个页面含有一个iframe。这个iframe有一个页面'onload'处理函数,将在页面中执行。

// bad onload function in frame:
window.top.notifyFrameLoaded()

这样做是危险的,因为'onload'事件可能在包含页面执行其它脚本时执行。该消息可以被放入队列:

// good onload function in frame
window.parent.setTimeout(window.top.notifyFrameLoaded, 0)

这段代码中重要的部分是使用setTimeout将消息处理排入父窗口消息队列中。

关于时间的建议

  • 不要写长时间运行的脚本
  • 不要使用同步的XMLHttpRequest
  • 不要使在不同桢中激活代码访问全局状态。
  • 不要使用alert对话框来调试,因为这有可能完全改变程序的逻辑。
  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值