ThinkChat🤖让你学习和工作更高效,注册即送10W Token,即刻开启你的AI之旅 广告
[TOC] # 24. 异步编程 (背景知识) 本章介绍了JavaScript中异步编程的基础。它为下一章ES6 Promise提供背景知识。 ## 24.1 JavaScript调用堆栈 当函数`f`调用了函数`g`时,函数`g`需要知道在它完成之后返回到哪里(在函数`f`内部)。这些信息通常是用堆栈来管理的,就是**调用堆栈 call stack**。让我们来看一个例子: ```js function h(z) { // Print stack trace console.log(new Error().stack); // (A) } function g(y) { h(y + 1); // (B) } function f(x) { g(x + 1); // (C) } f(3); // (D) return; // (E) ``` 最初,当上述程序启动时,这个调用堆栈为空。在`f(3)`D行中的函数调用之后,堆栈有一个条目: - 全局作用域中的位置 在C行的函数`g(x + 1)`调用之后,堆栈有两个条目: - 函数`f`的位置 - 全局作用域中的位置 在B行的函数`h(y + 1)`调用之后,堆栈有三个条目: - 函数`g`的位置 - 函数`f`的位置 - 全局作用域中的位置 在行A中打印的堆栈跟踪显示了调用堆栈的情况: ~~~ Error at h (stack_trace.js:2:17) at g (stack_trace.js:6:5) at f (stack_trace.js:9:5) at <global> (stack_trace.js:11:1) ~~~ 接下来,每个函数都终止并且每次从堆栈中删除顶部条目。函数f执行结束后,回到全局作用域并且此时的调用堆栈是空的。在行E时,返回并且堆栈为空,此时整个程序结束。 ## 24.2 浏览器事件循环 简单来说,每个浏览器选项卡运行(在)一个进程中:[事件循环](https://html.spec.whatwg.org/multipage/webappapis.html#event-loop).。这个循环执行与浏览器相关的东西(即所谓的任务),它通过一个任务队列来提供。任务示例如下 1. 解析HTML 2. 在脚本元素中执行JavaScript代码 3. 响应用户输入(鼠标点击,按键等) 4. 处理异步网络请求的结果 2-4条是通过浏览器内置的引擎运行JavaScript代码的任务。当代码终止时,它们终止。以下图(由[Philip Roberts的幻灯片](http://vimeo.com/96425312)启发))概述了所有这些机制是如何连接的。 ![](https://box.kancloud.cn/864790a5238e3d35738d945c215a7a2b_1245x1195.jpg) 事件循环被其他并行运行的进程所包围(计时器、输入处理等)。这些进程通过向其队列添加任务来与之通信。 ### 24.2.1 计时器 浏览器可以使用`setTimeout()`创建了一个计时器,等到它被触发时,会被添加到队列。示例: ```js setTimeout(callback, ms) ``` 等待`ms`毫秒后,函数`callback`被添加到任务队列中。 重要的是要注意,ms只指定何时**添加**回调,而不是实际执行回调。这可能会发生得比指定的`ms`更迟,特别是如果事件循环被阻塞(如本章后面所述)。 通常的变通方案是把`setTimeout()`的`ms`设置为0,来立刻向任务队列添加任务。但是,某些浏览器不允许`ms`低于浏览器的默认最小值(Firefox中为4 ms);`ms`过低,浏览器就会自动把它设置为默认的值。 ### 24.2.2 DOM的更改 对于大多数DOM更改(特别是那些涉及重新布局的),显示不会立即更新。“页面布局每隔16毫秒就会刷新一次”(@[bz_moz](https://twitter.com/bz_moz/status/513777809287028736)),并且必须通过事件循环来运行。 有一些方法可以与浏览器协调**频繁的DOM更新**,以避免与其布局节奏发生冲突。查阅[该文档](https://developer.mozilla.org/en/docs/Web/API/window.requestAnimationFrame)的`requestAnimationFrame()`详细信息。 ### 24.2.3 运行至完成 的语义 JavaScript有所谓的**运行至完成 **的语义: **当前任务总是在执行下一个任务之前完成**。 这意味着每个任务都对所有当前状态有完全的控制并且不需要担心**并发修改**。 该示例: ```js setTimeout(function () { // (A) console.log('Second'); }, 0); console.log('First'); // (B) ``` 从行A开始的函数立即被添加到任务队列中,但是只在当前代码完成之后才执行(特别是行B!)。这意味着这段代码的输出将永远是: ~~~ First Second ~~~ ### 24.2.4 阻塞事件循环 如我们已经看到的,每个标签(在一些浏览器中,完整的浏览器)由单个进程(用户界面和所有其他计算)进行管理。这意味着您可以通过在该过程中执行长时间运行的计算来冻结用户界面,下面的代码演示了这一点: ```js <a id="block" href="">Block for 5 seconds</a> <p> <button>This is a button</button> <div id="statusMessage"></div> <script> document.getElementById('block') .addEventListener('click', onClick); function onClick(event) { event.preventDefault(); setStatusMessage('Blocking...'); // Call setTimeout(), so that browser has time to display // status message setTimeout(function () { sleep(5000); setStatusMessage('Done'); }, 0); } function setStatusMessage(msg) { document.getElementById('statusMessage').textContent = msg; } function sleep(milliseconds) { var start = Date.now(); while ((Date.now() - start) < milliseconds); } </script> ``` [在线运行](http://rauschma.github.io/async-examples/blocking.html)。 每当点击`Block for 5 seconds`的链接时,`onClick()`被触发调用。它使用 - 同步sleep()功能来阻止事件循环5秒钟。在这段时间内,用户界面不起作用。例如,您不能单击“This is a button”按钮。 ### 24.2.5 避免阻塞 避免以两种方式阻塞事件循环: * 首先,在主进程中不执行长时间运行的计算,将它们移动到不同的进程。这可以通过[Worker API](https://developer.mozilla.org/en/docs/Web/API/Worker)实现。 * 其次,您不要(同步)等待长时间运行的计算(在工作进程中的自己的算法,网络请求等)的结果,您继续执行事件循环,当计算完成时,让计算通知您结果。实际上,在浏览器中,我们必须要这样做。例如,没有内置的同步睡眠方式(如在之前实现的`sleep()`)。相反,`setTimeout()`让您以异步方式睡眠。 下一节将介绍为异步等待结果的技术。 ## 24.3 异步接收结果 异步接收结果的两种常见模式是:事件和回调。 ### 24.3.1 异步结果方式:事件 在这种异步接收结果的模式中,您可以为每个请求创建一个对象,并使用它注册事件处理程序:一个处理成功,另一个用于处理错误。下面的代码为`XMLHttpRequest`API的示例: ```js var req = new XMLHttpRequest(); req.open('GET', url); req.onload = function () { if (req.status == 200) { processData(req.response); } else { console.log('ERROR', req.statusText); } }; req.onerror = function () { console.log('Network Error'); }; req.send(); // 向任务队列添加请求 ``` 请注意,最后一行实际没有执行请求,它将其添加到任务队列中。因此,你也可以在`open()`之后、在设置`onload`和`onerror`函数之前调用该方法。由于JavaScript**执行至完成**的语义,代码还是会如期执行。 #### 24.3.1.1 隐式的请求 浏览器API IndexedDB有一种稍微特殊的事件处理风格 ```js var openRequest = indexedDB.open('test', 1); openRequest.onsuccess = function (event) { console.log('Success!'); var db = event.target.result; }; openRequest.onerror = function (error) { console.log(error); }; ``` 首先创建一个请求对象,向其中添加通知结果的事件侦听器。但是,您不需要显式排队请求,这是由`open()`完成的。它是在当前任务完成后执行的。这就是为什么你可以(并且事实上必须)在调用`open()`后注册事件处理程序。 如果你习惯于多线程编程语言,这种处理请求的风格可能看起来很奇怪,好像它可能容易出现竞态条件。但是,由于**执行至完成**的语义,运行总是安全的。 #### 24.3.1.2 单个结果的事件不能正常工作 如果您多次收到结果,那么这种处理异步计算结果的方式是可以的。然而,如果只有一个结果,那么冗长度就成了一个问题。对于这种用例,很流行做法就是使用回调。 ### 24.3.2 异步结果方式:回调 通过回调方式处理异步结果,就是将**回调函数作为尾随参数传递给异步函数或方法调用**。 以下是Node.js中的一个示例。我们通过异步`fs.readFile()`调用读取文本文件的内容: ```js // Node.js fs.readFile('myfile.txt', { encoding: 'utf8' }, function (error, text) { // (A) if (error) { // ... } console.log(text); }); ``` 如果`readFile()`成功,则A行中的回调通过参数`text`接收结果。如果失败了,则回调`Error`的第一个参数会获得一个错误(通常是构造函数 `Error`的实例或子构造函数) 经典的函数式编程风格的代码是这样的: ```js // 函数式 readFileFunctional('myfile.txt', { encoding: 'utf8' }, function (text) { // success console.log(text); }, function (error) { // failure // ... }); ``` ### 24.3.3 继续传递风格 使用回调的编程风格(特别是前面所示的函数式方式)也被称为**继续传递风格**(CPS),因为下一步(续)被显式地作为参数传递。这就给了一个被调用的函数更多地控制接下来发生的事情。 下面的代码演示了CPS: ```js console.log('A'); identity('B', function step2(result2) { console.log(result2); identity('C', function step3(result3) { console.log(result3); }); console.log('D'); }); console.log('E'); // 输出: A E B D C function identity(input, callback) { setTimeout(function () { callback(input); }, 0); } ``` 对于每个步骤,程序的控制流程在回调内继续。这导致嵌套函数,这有时被称为**回调地狱**。但是,您可以经常避免嵌套,因为JavaScript的函数声明被提升(它们的定义在其范围的开始处被编译器执行解析)。这意味着您可以提前调用并调用程序后面定义的函数。以下代码使用提升来平坦化前一个示例。 ```js console.log('A'); identity('B', step2); function step2(result2) { // The program continues here console.log(result2); identity('C', step3); console.log('D'); } function step3(result3) { console.log(result3); } console.log('E'); ``` 有关CPS的[更多信息](http://www.2ality.com/2012/06/continuation-passing-style.html)中给出。 ### 24.3.4 用CPS风格编写代码 在正常的JavaScript风格中,您可以通过以下方式撰写代码片段: 1. 把它们一个接一个地。这很明显,但提醒自己,以正常风格连接的代码是顺序组合是很好的。 2. 数组方法,例如`map()`,`filter()`和`forEach()`。 3. 循环如`for`和`while` [Async.js](https://github.com/caolan/async)库提供了组合器,让您在 **连续传递风格(CPS)** 中使用Node.js风格样式的回调进行类似的操作。在下面的例子中,它被用于加载三个文件的内容,这些文件的名称存储在一个数组中。 ```js var async = require('async'); var fileNames = [ 'foo.txt', 'bar.txt', 'baz.txt' ]; async.map(fileNames, function (fileName, callback) { fs.readFile(fileName, { encoding: 'utf8' }, callback); }, // Process the result function (error, textArray) { if (error) { console.log(error); return; } console.log('TEXTS:\n' + textArray.join('\n----\n')); }); ``` ### 24.3.5 回调的优点和缺点 使用回调会导致完全不同的编程风格,CPS。CPS的主要优点是它的基本机制很容易理解。但也有缺点: * 错误处理变得更加复杂:现在有两种方式通过回调和异常来报告错误。你必须小心地把两者结合起来。 * 不太优雅的签名:在同步函数中,输入(参数)和输出(函数结果)之间存在明显的关注点分离。在使用回调的异步函数中,这些关注点是混合的:函数结果并不重要,一些参数用于输入,另一些参数用于输出。 * 组合更复杂:由于关注的“output”出现在参数中,通过组合器编写代码更加复杂。 在Node.js风格样式中的回调函数有三个缺点(与函数式风格的比较) - 错误处理的if语句增加了冗度. - 重用错误处理程序更加困难. - 提供一个默认的错误处理程序也会更加困难。如果您进行函数调用并且不想编写自己的处理程序,那么默认的错误处理是非常有用的。如果调用者没有指定处理程序,它也可以被函数使用。 ## 24.4 展望未来 下一章涵盖了Promises和ES6 Promise API。在底层中,Promises 比回调更复杂。作为交换,它们带来了几个显著的优点,并且消除了前面提到的回调的大部分缺点。 ## 24.5 延伸阅读 [1] “[Help, I’m stuck in an event-loop](http://vimeo.com/96425312)” by Philip Roberts (video). [2] 在HTML规范中的“[Event loops](https://html.spec.whatwg.org/multipage/webappapis.html#event-loops)” 。 [3] “[Asynchronous programming and continuation-passing style in JavaScript](http://www.2ality.com/2012/06/continuation-passing-style.html)” by Axel Rauschmayer.