期约与异步编程

ECMAScript 6 及之后的几个版本逐步加大了对异步编程机制的支持,提供了令人眼前一亮的新特性。ECMAScript 6 新增了正式的 Promise(期约)引用类型,支持优雅地定义和组织异步逻辑。接下来几个版本增加了使用 async 和 await 关键字定义异步函数的机制

异步编程

同步与异步

  • 同步行为:对应内存中顺序执行的处理器指令。每条指令都会严格按照它们出现的顺序来执行,而每条指令执行后也能立即获得存储在系统本地(如寄存器或系统内存)的信息
  • 异步行为:类似于系统中断,即当前进程外部的实体可以触发代码执行

以往的异步编程模式

在早期的 JavaScript 中,只支持定义回调函数来表明异步操作完成。串联多个异步操作是一个常见的问题,通常需要深度嵌套的回调函数(“回调地狱”)

setTimeout 可以定义一个在指定时间之后会被调度执行的回调函数。对这个例子而言,1000 毫秒之后,JavaScript 运行时会把回调函数推到自己的消息队列上去等待执行。推到队列之后,回调什么时候出列被执行对 JavaScript 代码就完全不可见了。还有一点,double()函数在 setTimeout 成功调度异步操作之后会立即退出

  • 异步返回值:给异步操作提供一个回调,这个回调中包含要使用异步返回值的代码(作为回调的参数),接收 setTimeout 操作的返回值
  • 失败处理:try…catch… 成功回调及失败回调
  • 嵌套异步回调: 异步返回值又依靠另一个异步返回值,形成嵌套回调
  1. function double(value) {
  2. setTimeout(() => setTimeout(console.log, 0, value * 2), 1000);
  3. }
  4. double(3);

期约

Promise,对尚不存在结果的一个替身,描述的是一种异步程序执行的机制。

Promises/A+规范:ECMAScript 6 增加了对 Promises/A+规范的完善支持,即 Promise 类型

期约基础

  • 期约状态机: 期约是一个有状态的对象,可能处于如下三种状态之一(待定(pending),兑现(fulfilled,有时候也称为“解决”,resolved),拒绝(reject)),只要从待定转换为兑现或拒绝,期约的状态就不再改变,期约的状态是私有的,不能直接通过JavaScript检测或修改。
  • 解决值、拒绝理由及期约用力: 每个期约只要状态切换为兑现,就会有一个私有的内部值(value)。类似地,每个期约只要状态切换为拒绝,就会有一个私有的内部理由(reason)。无论是值还是理由,都是包含原始值或对象的不可修改的引用。二者都是可选的,而且默认值为 undefined。在期约到达某个落定状态时执行的异步代码始终会收到这个值或理由
  • 通过执行函数控制期约状态:期约的状态是私有的,所以只能在内部进行操作。内部操作在期约的执行器函数中完成。执行器函数主要有两项职责:初始化期约的异步行为和控制状态的最终转换。其中,控制期约状态的转换是通过调用它的两个函数参数实现的。这两个函数参数通常都命名为 resolve()和 reject()。调用resolve()会把状态切换为兑现,调用 reject()会把状态切换为拒绝。另外,调用 reject()也会抛出错误
  • Promise.resolve(): 期约并非一开始就必须处于待定状态,然后通过执行器函数才能转换为落定状态。通过调用Promise.resolve()静态方法,可以实例化一个解决的期约
  • romise.reject()会实例化一个拒绝的期约并抛出一个异步错误(这个错误不能通过 try/catch 捕获,而只能通过拒绝处理程序捕获)
  • 同步/异步执行的二元性:同步代码无法捕获期约抛出的错误
  1. try {
  2. throw new Error('foo');
  3. } catch(e) {
  4. console.log(e); // Error: foo
  5. }
  6. try {
  7. Promise.reject(new Error('bar'));
  8. } catch(e) {
  9. console.log(e);
  10. }
  11. // Uncaught (in promise) Error: bar

期约的实例方法

  • then() 方法返回一个 Promise 。它最多需要有两个参数:Promise 的成功和失败情况的回调函数。
  • catch() 方法返回一个Promise,并且处理拒绝的情况。它的行为与调用Promise.prototype.then(undefined, onRejected) 相同。 (事实上, calling obj.catch(onRejected) 内部calls obj.then(undefined, onRejected)).
  • finally() 方法返回一个Promise,在promise执行结束时,无论结果是fulfilled或者是rejected,在执行then()和catch()后,都会执行finally指定的回调函数。这为指定执行完promise后,无论结果是fulfilled还是rejected都需要执行的代码提供了一种方式,避免同样的语句需要在then()和catch()中各写一次的情况。
  • all(iterable) 方法返回一个 Promise 实例,此实例在 iterable 参数内所有的 promise 都“完成(resolved)”或参数中不包含 promise 时回调完成(resolve);如果参数中 promise 有一个失败(rejected),此实例回调失败(reject),失败原因的是第一个失败 promise 的结果
  • race(iterable) 方法返回一个 promise,一旦迭代器中的某个promise解决或拒绝,返回的 promise就会解决或拒绝。
  • Promise.resolve()和 Promise.reject()在被调用时就会接收解决值和拒绝理由。同样地,它们返回的期约也会像执行器一样把这些值传给 onResolved 或 onRejected 处理程序
    正常情况下,在通过 throw()关键字抛出错误时,JavaScript 运行时的错误处理机制会停止执行抛出错误之后的任何指令,但是,在期约中抛出错误时,因为错误实际上是从消息队列中异步抛出的,所以并不会阻止运行时继续执行同步指令

期约连锁与期约合成

  • 期约连锁:为每个期约实例的方法(then()、catch()和 finally())都会返回一个新的期约对象,而这个新期约又有自己的实例方法。这样连缀方法调用就可以构成所谓的“期约连锁”,每个后续的处理程序都会等待前一个期约解决,然后实例化一个新期约并返回它。这种结构可以简洁地将异步任务串行化,解决之前依赖回调的难题。
  1. let p1 = new Promise((resolve, reject) => {
  2. console.log('p1 executor');
  3. setTimeout(resolve, 1000);
  4. });
  5. p1.then(() => new Promise((resolve, reject) => {
  6. console.log('p2 executor');
  7. setTimeout(resolve, 1000);
  8. }))
  9. .then(() => new Promise((resolve, reject) => {
  10. console.log('p3 executor');
  11. setTimeout(resolve, 1000);
  12. }))
  13. .then(() => new Promise((resolve, reject) => {
  14. console.log('p4 executor');
  15. setTimeout(resolve, 1000);
  16. }));
  17. // p1 executor(1 秒后)
  18. // p2 executor(2 秒后)
  19. // p3 executor(3 秒后)
  20. // p4 executor(4 秒后)
  21. // 把生成期约的代码提取到一个工厂函数中
  22. function delayedResolve(str) {
  23. return new Promise((resolve, reject) => {
  24. console.log(str);
  25. setTimeout(resolve, 1000);
  26. });
  27. }
  28. delayedResolve('p1 executor')
  29. .then(() => delayedResolve('p2 executor'))
  30. .then(() => delayedResolve('p3 executor'))
  31. .then(() => delayedResolve('p4 executor'))
  32. // p1 executor(1 秒后)
  33. // p2 executor(2 秒后)
  34. // p3 executor(3 秒后)
  35. // p4 executor(4 秒后)

期约扩展

  • 期约取消:在现有实现基础上提供一种临时性的封装,以实现取消期约的功能。这可以用到 Kevin Smith 提到的“取消令牌”(cancel token)。生成的令牌实例提供了一个接口,利用这个接口可以取消期约;同时也提供了一个期约的实例,可以用来触发取消后的操作并求值取消状态
  • 期约进度通知

异步函数

异步函数,也称为“async/await”(语法关键字),是 ES6 期约模式在 ECMAScript 函数中的应用。async/await 是 ES8 规范新增的。这个特性从行为和语法上都增强了 JavaScript,让以同步方式写的代码能够异步执行。

  • async: async 关键字用于声明异步函数。使用 async 关键字可以让函数具有异步特征,但总体上其代码仍然是同步求值的。而在参数或闭包方面,异步函数仍然具有普通 JavaScript 函数的正常行为
  • await: await关键字可以暂停异步函数代码的执行,等待期约解决。await 关键字必须在异步函数中使用,不能在顶级上下文如<\script>标签或模块中使用。

停止与恢复执行

  • JavaScript 运行时在碰到 await 关键字时,会记录在哪里暂停执行。等到 await 右边的值可用了,JavaScript 运行时会向消息队列中推送一个任务,这个任务会恢复异步函数的执行。即使 await 后面跟着一个立即可用的值,函数的其余部分也会被异步求值。
  • 如果 await 后面是一个期约,则问题会稍微复杂一些
  1. async function foo() {
  2. console.log(2);
  3. await null;
  4. console.log(4);
  5. }
  6. console.log(1);
  7. foo();
  8. console.log(3);
  9. // 1
  10. // 2
  11. // 3
  12. // 4
  13. 控制台中输出结果的顺序很好地解释了运行时的工作过程:
  14. (1) 打印 1
  15. (2) 调用异步函数 foo();
  16. (3)(在 foo()中)打印 2
  17. (4)(在 foo()中)await 关键字暂停执行,为立即可用的值 null 向消息队列中添加一个任务;
  18. (5) foo()退出;
  19. (6) 打印 3
  20. (7) 同步线程的代码执行完毕;
  21. (8) JavaScript 运行时从消息队列中取出任务,恢复异步函数执行;
  22. (9)(在 foo()中)恢复执行,await 取得 null 值(这里并没有使用);
  23. (10)(在 foo()中)打印 4
  24. (11) foo()返回。
  1. async function foo() {
  2. console.log(2);
  3. console.log(await Promise.resolve(8));
  4. console.log(9);
  5. }
  6. async function bar() {
  7. console.log(4);
  8. console.log(await 6);
  9. console.log(7);
  10. }
  11. console.log(1);
  12. foo();
  13. console.log(3);
  14. bar();
  15. console.log(5);
  16. // 1
  17. // 2
  18. // 3
  19. // 4
  20. // 5
  21. // 6
  22. // 7
  23. // 8
  24. // 9
  25. 运行时会像这样执行上面的例子:
  26. (1) 打印 1
  27. (2) 调用异步函数 foo();
  28. (3)(在 foo()中)打印 2
  29. (4)(在 foo()中)await 关键字暂停执行,向消息队列中添加一个期约在落定之后执行的任务;
  30. (5) 期约立即落定,把给 await 提供值的任务添加到消息队列;
  31. (6) foo()退出;
  32. (7) 打印 3
  33. (8) 调用异步函数 bar();
  34. (9)(在 bar()中)打印 4
  35. (10)(在 bar()中)await 关键字暂停执行,为立即可用的值 6 向消息队列中添加一个任务;
  36. (11) bar()退出;
  37. (12) 打印 5
  38. (13) 顶级线程执行完毕;
  39. (14) JavaScript 运行时从消息队列中取出解决 await 期约的处理程序,并将解决的值 8 提供给它;
  40. (15) JavaScript 运行时向消息队列中添加一个恢复执行 foo()函数的任务;
  41. (16) JavaScript 运行时从消息队列中取出恢复执行 bar()的任务及值 6
  42. (17)(在 bar()中)恢复执行,await 取得值 6
  43. (18)(在 bar()中)打印 6
  44. (19)(在 bar()中)打印 7
  45. (20) bar()返回;
  46. (21) 异步任务完成,JavaScript 从消息队列中取出恢复执行 foo()的任务及值 8
  47. (22)(在 foo()中)打印 8
  48. (23)(在 foo()中)打印 9
  49. (24) foo()返回。

异步函数策略

  • 实现 sleep():
  1. async function sleep(delay) {
  2. return new Promise((resolve) => setTimeout(resolve, delay));
  3. }
  4. async function foo() {
  5. const t0 = Date.now();
  6. await sleep(1500); // 暂停约 1500 毫秒
  7. console.log(Date.now() - t0);
  8. }
  9. foo();
  10. // 1502
  • 利用平行执行
  • 串行执行期约
  • 栈追踪与内存管理

小结

长期以来,掌握单线程 JavaScript 运行时的异步行为一直都是个艰巨的任务。随着 ES6 新增了期约和 ES8 新增了异步函数,ECMAScript 的异步编程特性有了长足的进步。通过期约和 async/await,不仅可以实现之前难以实现或不可能实现的任务,而且也能写出更清晰、简洁,并且容易理解、调试的代码。

期约的主要功能是为异步代码提供了清晰的抽象。可以用期约表示异步执行的代码块,也可以用期约表示异步计算的值。在需要串行异步代码时,期约的价值最为突出。作为可塑性极强的一种结构,期约可以被序列化、连锁使用、复合、扩展和重组。

异步函数是将期约应用于 JavaScript 函数的结果。异步函数可以暂停执行,而不阻塞主线程。无论是编写基于期约的代码,还是组织串行或平行执行的异步代码,使用异步函数都非常得心应手。异步函数可以说是现代 JavaScript 工具箱中最重要的工具之一