【译】异步:现在与将来(基础篇)

原文:https://github.com/getify/You-Dont-Know-JS/blob/master/async%20%26%20performance/ch1.md
译者:熊贤仁

使用像 JavaScript 这样的语言编程时,很重要的并且还常被人误解的部分是,如何表达和控制程序在一段时间内分散的行为。

这不仅是 for 循环的开始到结束,这一过程当然也要花费一些时间(几微妙到几毫秒),而是当你的程序的一部分现在开始运行,而另一部分将来才运行—— “现在” 和 “将来” 之间存在一个程序并未有效执行的间隙。

几乎所有重要的程序当初在被编写的时候,都不得不以某种方式管理这个间隙。这段间隙可能是存在于等待用户输入,请求数据库或文件系统的数据,通过网络发送数据并等待响应,或者是定时运行一个重复任务(比如动画)之中。在诸如此类的场景中,你的程序必须及时管理这段跨间隙时间的状态。正如伦敦地铁门和站台上贴的很有名的一句话:“小心间隙”。

事实上,程序中现在和将来的关系是异步编程的核心。

毫无疑问,自 JS 诞生以来就伴随着异步编程。但是大多数 JS 开发者从未真正思考过程序如何出现异步的,以及为何要出现异步,或者去探索更多的解决之道。普通的回调函数一直以来成为一种足够好的方法。时至今日还有许多人坚持认为回调函数已经够用了。

但是,为了迎合运行在浏览器和服务器,以及每一种可能的设备上日益拓展的需求,JS 的规模和复杂度日渐增长。管理异步变得越来越困难成了开发者的痛点,我们迫切需要更给力和更合理的方案。

刚才说的这些可能有些抽象,随着本书的展开,我保证你将对这些概念有更完整具体的理解。我们将在接下来的章节探索各种
JavaScript 异步编程的技巧。

但是在这之前,我们要更深入地理解异步的概念,以及 JS 中异步的运转机制。

分块的程序

你可能把 JS 程序写进一个 .js 文件,但是程序多半分成了一些块,其中只有一块现在执行,剩下的在将来执行。最常见的块单位是 function

大多数 JS 新手看起来会遇到的问题是,将来执行的部分并没有在现在执行部分结束后立即执行。换句话说,当前不能完成的任务将会异步完成,因此阻塞行为并不会如你直觉所想般发生。

考虑这个例子:

// ajax(..) is some arbitrary Ajax function given by a library
var data = ajax( "http://some.url.1" );

console.log( data );
// Oops! `data` generally won't have the Ajax results

你可能注意到标准的 Ajax 请求没有同步的完成,这意味着 ajax(..) 函数现在还没有任何可以赋给 data 变量的返回值。如果 ajax(..) 可以在返回响应之前一直阻塞,那么 data = .. 赋值将正常运行。

但是我们并不是这么使用 Ajax 的。“现在” 发起一个异步的 Ajax 请求,“将来” 才得到返回的结果。

从“现在” 一直等待到“将来”,最简单的方式(但绝不是唯一,甚至也不是最好的)是使用一个函数,通常被称为回调函数:

// ajax(..) is some arbitrary Ajax function given by a library
ajax( "http://some.url.1", function myCallbackFunction(data){

console.log( data ); // Yay, I gots me some `data`!

} );

警告: 你可能也听说过可以使 Ajax 请求同步化。然而即使技术上可行,你也绝不应该在任何情况下这样做,因为这会锁住浏览器的 UI(按钮,菜单,滚动等等),会阻塞所有的用户交互。这个想法很糟糕,应该永远避免。

你可能想提出抗议,千万别,你希望避免回调函数带来的混乱,然而这并不足以成为使用阻塞式同步 Ajax 的理由。

举个例子,考虑下列代码:

function now() {
return 21;
}

function later() {
answer = answer * 2;
console.log( "Meaning of life:", answer );
}

var answer = now();

setTimeout( later, 1000 ); // Meaning of life: 42

这段程序分为两块:现在执行的部分和将来执行的部分。这两块的内容显而易见,但我们还是明确地指出来:
现在:

function now() {
return 21;
}

function later() { .. }

var answer = now();

setTimeout( later, 1000 );

将来:

answer = answer * 2;
console.log( "Meaning of life:", answer );

现在这一块在程序运行后立刻执行。但是 setTimeout(...) 还设置了一个在将来执行的事件(定时),所以 later() 函数的内容会在之后的某个时间(从现在之后 1000 毫秒)。

任何时候将一部分代码包裹进 function ,并指定它在响应某个事件(定时器,鼠标点击,Ajax响应等待)时执行,你就在创建一个 “将来” 执行的代码块,这样就在程序中引入了异步。

异步控制台

关于 console.* 方法的工作原理,现在还没有规范或者需求集——因为它们不属于正式的 JavaScript,而是通过宿主环境(见本书的类型和语法章节)被加入到 JS 中来。

因此,不同的浏览器和 JS 环境各行其是,有时导致出现令人困惑的行为。

特别是,在某些条件下,一些浏览器的 console.log(...) 并没有按照给定的内容立即输出。主要原因可能是因为 I/O 在很多程序(不只是 JS )中是一个很慢并且阻塞的部分。所以,浏览器在后台异步处理 console I/O ,性能可能会更好, 这时甚至你可能都察觉不到发生了 I/O。

一个不算很常见,但可能出现的场景是, 这一过程是可观察的(不是从代码本身,而是从外部):

var a = {
index: 1
};

// later
console.log( a ); // ??

// even later
a.index++;

我们通常会认为 a 对象在 console.log(...) 语句执行的时候生成了快照,打印出像 { like: 1} 这样的内容,然后当 a.index++ 执行, 对其进行了修改,这句会严格的在输出 a 之后才执行。

多数时候,前面的代码很可能在开发者控制台中生成了一个与你期望一致的对象表示。但是,当浏览器将控制台 I/O 推迟到后台进行,同样这段代码可能在浏览器控制台输出对象的时候,a.index++ 已经执行过了,因此会输出 { index: 2}

究竟何时 console I/O 会被推迟,或者是否可观察,这些都是不确定的。只是在调试时遇到在 console.log(...) 语句后,对象被修改了,你要意识到这可能是 I/O 中的异步化造成的。

注意: 如果你遇到这种特殊情况,不要依赖 console 输出,最好是在 JS debugger 中使用断点。另一个不错的选择是把对象序列化为一个字符串,以强制执行一次 “快照”,比如使用 JSON.stringify(...)

事件循环

我们来澄清一件事(可能很震惊):尽管 JS 支持异步代码(比如我们刚才见过的 timeout )是显而易见的,然而直到最近(ES6),JavaScript 还没有内建任何直接的异步概念。

什么!?这看起来很疯狂,对吧?事实上,这是真的。JS 引擎本身当需要的时候,在给定任意时刻都只在执行程序中的单个块。

“需要”。谁需要?这就是重点所在!

JS 引擎并不运行于隔离的环境中。它在宿主环境下运行,对于多数开发者而言,这个宿主环境就是 Web 浏览器。在过去几年中(但肯定不完全是),JS 已经超出了浏览器的范围,进入到其他环境中,比如通过 Node.js 打入了服务器领域。事实上,现在 JavaScript 已经嵌入到各种设备中,从机器人到灯泡。

但是所有这些环境都有一个共同 “点”(无论如何,这都不是一个精妙的异步笑话),它们都有一个机制来处理程序中多个块的执行,并与此同时调用 JS 引擎,这被称之为 “事件循环”。

换句话说,JS 引擎对时间并不敏感,但是有一个按需执行任意 JS 代码的环境。“事件”调度(JS 代码执行)总是在其所在的环境进行。

举个例子,当 JS 程序发起一个 Ajax 请求,从服务器上拉取一些数据,你把 “响应” 代码写在一个函数(通常被称为 “回调函数”)中,然后 JS 引擎告知宿主环境, “嘿,我现在要将代码执行挂起,但是一旦你完成了网络请求并拿到数据,请调用这个函数”。

浏览器将为网络请求建立监听,当一些数据过来后,它会将回调函数插入到事件循环中,以实现回调函数的调度执行。

那么什么是事件循环?

我们首先通过一些伪代码来说明:

// `eventLoop` is an array that acts as a queue (first-in, first-out)
var eventLoop = [ ];
var event;

// keep going "forever"
while (true) {
// perform a "tick"
if (eventLoop.length > 0) {
// get the next event in the queue
event = eventLoop.shift();

// now, execute the next event
try {
event();
}
catch (err) {
reportError(err);
}
}
}

当然,这是为了说明概念而写的非常简化的伪代码。但这也足以帮助我们更好的理解事件循环了。

正如你所看到的,while 循环不断运行,循环的每次迭代被称为一次 “tick”。对于每个 tick,如果一个事件在队列中等待,它会被取出来并执行。这些事件就是回调函数。

需要注意的是,setTimeout(..) 并没有把回调函数放进事件循环队列中。它做的是建立一个定时器;当定时器过期后,执行环境将回调函数放进事件循环,一些将来的 tick 会将其取出来并执行。

如果这时事件循环中已经有20个事件了呢?回调函数将会等待。它会排在其他事件之后——通常不能插队。这解释了为什么 setTimeout(..) 定时器的时间精度不高。只能保证(大体来说)回调函数不会在你指定的时间间隔之前执行,但可以在那个时间点或者之后才执行,这取决于事件队列的状态。

换句话说,你的程序通常被分为许多小块,在事件循环队列中一个接一个的执行。其他和你的程序不直接相关的事件也可以被插入到队列中。

注意: 我们提到的 “直到最近” 是说ES6 从本质上改变了从何处管理事件循环队列。这主要是一个正式的技术细则,ES6 现在指明了事件循环的工作原理,这意味着从技术上来说,事件循环属于 JS 引擎的范畴,而不仅仅是宿主环境。这项改变的一个主要原因是 ES6 Promise 的引入,我们将在第三章讨论它,因为这项技术要去在事件循环队列的任务调度上做直接、细粒度的控制(见 “协作” 部分对 setTimeout(..0) 的讨论 )。


本系列下一部分将介绍并行线程。