事件的调度

如果想让 JavaScript 中的某段代码将来再运行,可以将它放在回调中。
回调就是一种普通函数,只不过它是传给像 setTimeout 这样的函
数,或者绑定为像 document.onready 这样的属性。运行回调时,我们称已触发某事件(譬如延时结束或页面加载完毕)。

当然,可怕的总是那些细节,哪怕是像 setTimeout 这样看起来很简
单的东西。对 setTimeout 的描述通常像这样:

给定一个回调及 n 毫秒的延迟,setTimeout 就会在 n 毫秒后运
行该回调。

但是,正如我们将在这一节乃至这一章里看到的,以上描述存在严重
缺陷。大多数情况下,该描述只能算接近正确,而在其他情况下则完
全是谬误。要想真正理解 setTimeout,必须先大体理解 JavaScript
事件模型。

现在还是将来

在探究 setTimeout 之前,先来看一个简单的例子。该情形常常会迷
惑 JavaScript 新手,特别是那些刚刚从 Java 和 Ruby 等多线程语言迁
移过来的新手。

1
2
3
for (var i = 1; i <= 3; i++) {
setTimeout(function(){ console.log(i); }, 0);
};

4
4
4

大多数刚接触 JavaScript 语言的人都会认为以上循环会输出 1,2,3,
或者重复输出这 3 个数字,因为这里的 3 次延时都抢着要第一个触发
(每次暂停都调度为 0 毫秒后到时)。

要理解为什么输出是 4,4,4,需要知道以下 3 件事。

  • 这里只有一个名为 i 的变量,其作用域由声明语句 var i 定义(该
    声明语句在不经意间让 i 的作用域不是循环内部,而是扩散至蕴含
    循环的那个最内侧函数)。
  • 循环结束后,i===4 一直递增,直到不再满足条件 i<=3 为止。
  • JavaScript 事件处理器在线程空闲之前不会运行。

线程的阻塞

1
2
3
4
5
6
var start = new Date;
setTimeout(function(){
var end = new Date;
console.log('Time elapsed:', end - start, 'ms');
}, 500);
while (new Date - start < 1000) {};

按照多线程的思维定势,我会预计 500 毫秒后计时函数就会运行。不
过这要求中断欲持续整整一秒钟的循环。如果运行代码,会得到类似
这样的结果:

Time elapsed: 1002ms

大家得到的数字可能会稍有不同,这是因为 setTimeout 和 setInterval一样,其计时精度要比我们的期望值差很多(请参阅1.2.2节)。
不过,这个数字肯定至少是 1000,因为 setTimeout 回调在 while
循环结束运行之前不可能被触发。

那么,如果 setTimeout 没有使用另一个线程,那它到底在做什么
呢?

队列

调用 setTimeout 的时候,会有一个延时事件排入队列。然后
setTimeout 调用之后的那行代码运行,接着是再下一行代码,直到
再也没有任何代码。这时 JavaScript 虚拟机才会问:“队列里都有谁
啊?”

如果队列中至少有一个事件适合于“触发”(就像 1000 毫秒之前设定
好的那个为期 500 毫秒的延时事件),则虚拟机会挑选一个事件,并
调用此事件的处理器(譬如传给 setTimeout 的那个函数)。事件处
理器返回后,我们又回到队列处。

输入事件的工作方式完全一样:用户单击一个已附加有单击事件处
理器的 DOM(Document Object Model,文档对象模型)元素时,
会有一个单击事件排入队列。但是,该单击事件处理器要等到当前
所有正在运行的代码均已结束后(可能还要等其他此前已排队的事
件也依次结束)才会执行。因此,使用 JavaScript 的那些网页一不
小心就会变得毫无反应。

你可能听过事件循环这个术语,它是用于描述队列工作方式的。所谓
事件循环,就像代码从一个循环中不断取出而运行一样:

1
2
3
4
runYourScript();
while (atLeastOneEventIsQueued) {
fireNextQueuedEvent();
};

这隐含着一个意思,即触发的每个事件都会位于堆栈轨迹的底部。

事件的易调度性是 JavaScript 语言最大的特色之一。像 setTimeout
这样的异步函数只是简单地做延迟执行,而不是孵化新的线程。
JavaScript 代码永远不会被中断,这是因为代码在运行期间只需要排
队事件即可,而这些事件在代码运行结束之前不会被触发。

异步函数的类型

每一种 JavaScript 环境都有自己的异步函数集。有些函数,如
setTimeout 和 setInterval,是各种 JavaScript 环境普遍都有的。
另一些函数则专属于某些浏览器或某几种服务器端框架。JavaScript
环境提供的异步函数通常可以分为两大类:I/O 函数和计时函数。如
果想在应用中定义复杂的异步行为,就要使用这两类异步函数作为基
本的构造块。

异步的I/O函数

创造 Node.js,并不是为了人们能在服务器上运行 JavaScript,仅仅是
因为 Ryan Dahl 想要一个建立在某高级语言之上的事件驱动型服务器
框架。JavaScript 碰巧就是适合干这个的语言。为什么?因为 JavaScript
语言可以完美地实现非阻塞式 I/O。

1
2
3
4
5
6
var ajaxRequest = new XMLHttpRequest;
ajaxRequest.open('GET', url);
ajaxRequest.send(null);
while (ajaxRequest.readyState === XMLHttpRequest.UNSENT) {
// readyState 在循环返回之前不会有更改。
};

相反,我们需要附加一个事件处理器,随即返回事件队列。

1
2
3
4
5
6
var ajaxRequest = new XMLHttpRequest;
ajaxRequest.open('GET', url);
ajaxRequest.send(null);
ajaxRequest.onreadystatechange = function() {
// ...
};

就是这么回事。不论是在等待用户的按键行为,还是在等待远程服务
器的批量数据,所需要做的就是定义一个回调,除非 JavaScript 环境
提供的某个同步 I/O 函数已经替我们完成了阻塞。

在浏览器端,Ajax 方法有一个可设置为 false 的 async 选项(但永
远、永远别这么做),这会挂起整个浏览器窗格直到收到应答为止。
在 Node.js 中,同步的 API 方法在名称上会有明确的标示,譬如
fs.readFileSync。编写短小的脚本时,这些同步方法会很方便。但
是,如果所编写的应用需要处理并行的多个请求或多项操作,则应该
避免使用它们。可在今天,还有哪个应用不是这样的呢?

有些 I/O 函数既有同步效应,也有异步效应。举例来说,在现代浏览
器中操纵 DOM 对象时,从脚本角度看,更改是即时生效的,但从视
效角度看,在返回事件队列之前不会渲染这些 DOM 对象更改。这可
以防止 DOM 对象被渲染成不一致的状态。关于这点,可访问
http://jsfiddle.net/ TrevorBurnham/SNBYV/,查看一个简单的演示。

console.log是异步的吗?

WebKit的console.log由于表现出异步行为而让很多开发者惊诧
不已。在Chrome或Safari中,以下这段代码会在控制台记录

1
2
3
var obj = {};
console.log(obj);
obj.foo = 'bar';

怎么会这样?WebKit的console.log并没有立即拍摄对象快照,
相反,它只存储了一个指向对象的引用,然后在代码返回事件队
列时才去拍摄快照。

Node的console.log是另一回事,它是严格同步的,因此同样的
代码输出的却为{}。

JavaScript 采用了非阻塞式 I/O,这对新手来说是最大的一个障碍,但
这同样也是该语言的核心优势之一。有了非阻塞式 I/O,就能自然而
然地写出高效的基于事件的代码。

异步的计时函数

我们已经看到,异步函数非常适合用于 I/O 操作,但有些时候,我们
仅仅是因为需要异步而想要异步性。换句话说,我们想让一个函数在
将来某个时刻再运行——这样的函数可能是为了作动画或模拟。基于
时间的事件涉及两个著名的函数,即 setTimeout 与 setInterval。

遗憾的是,这两个著名的计时器函数都有自己的一些缺陷。其中有个缺陷是无法弥补的:当同一个 JavaScript
进程正运行着代码时,任何 JavaScript 计时函数都无法使其他代码运
行起来。但是,即便容忍了这一局限性,setTimeout 及 setInterval
的不确定性也会令人犯怵。下面是一个示例。

1
2
3
4
5
6
7
8
9
10
var fireCount = 0;
var start = new Date;
var timer = setInterval(function() {
if (new Date-start > 1000) {
clearInterval(timer);
console.log(fireCount);
return;
}
fireCount++;
}, 0);

如果使用 setInterval 调度事件且延迟设定为 0 毫秒,则会尽可能
频繁地运行此事件,对吗?那么,在运行于高速英特尔 i7 处理器之
上的现代浏览器中,此事件的触发频率到底如何呢?

大约为 200 次/秒。这是 Chrome、Safari 和 Firefox 等浏览器的平均值。
在 Node 环境下,此事件的触发频率大约能达到 1000 次/秒。(若使用
setTimeout 来调度事件,重复这些实验也会得到类似的结果。)作
为对比,如果将setInterval替换成简单的while循环,则在Chrome
中此事件的触发频率将达到 400 万次/秒,而在 Node 中会达到 500 万
次/秒!

这是怎么回事?最后我们发现,setTimeout 和 setInterval 就是
想设计成慢吞吞的!事实上,HTML 规范(这是所有主要浏览器都遵
守的规范)推行的延时/时隔的最小值就是 4 毫秒!①

那么,如果需要更细粒度的计时,该怎么办呢?有些运行时环境提供
了备选方案。

  • 在 Node 中,process.nextTick 允许将事件调度成尽可能快地触
    发。对于笔者的系统,process.nextTick 事件的触发频率可以超
    过 10 万次/秒。
  • 一些现代浏览器(含 IE9+)带有一个 requestAnimationFrame
    函数。此函数有两个目标:一方面,它允许以 60+帧/秒的速度运行
    JavaScript 动画;另一方面,它又避免后台选项卡运行这些动画,
    从而节约 CPU 周期。在最新版的 Chrome 浏览器中,甚至能实现亚
    毫秒级的精度。

尽管这些计时函数是异步 JavaScript 混饭吃的家伙什儿,但永远不要
忘记,setTimeout 和 setInterval 就是些不精确的计时工具。在
Node中,如果只是想产生一个短时延迟,请使用 process.nextTick。
在浏览器端,请尝试使用垫片技术( shim ) ③ :在支持
requestAnimationFrame 的浏览器中,推荐使用
requestAnimationFrame;在不支持 requestAnimationFrame 的
浏览器中,则退而使用 setTimeout。

到这里,关于 JavaScript 基本异步函数的简要概览就结束了。但怎样
才能知道一个函数到底何时异步呢?下一节中,我们在亲自编写异步
函数的同时再思考这个问题。