第 11 章 期约与异步函数



前言

虽然之前在课堂上已经学过 JavaScript 的相关用法了,但是现在要开始找工作的时候才发现好多知识点都忘记了(正所谓基础不牢,地动山摇😂),而且有些知识点学校教材上都没有,便有了要写这个 JavaScript 学习系列文章的想法。

由于博主只是做知识点总结与学习笔记分享,导致本系列文章对于 JavaScript 的使用方面介绍较少,还望读者能够自己多勤学苦练。废话不多说,让我们开始第 11 章 期约与异步函数 的学习吧。


一、异步编程

1.同步与异步

同步行为对应内存中顺序执行的处理器指令。每条指令都会严格按照它们出现的顺序来执行,而每条指令执行后也能立即获得存储在系统本地(如寄存器或系统内存)的信息。这样的执行流程容易分析程序在执行到代码任意位置时的状态(比如变量的值)。

相对地,异步行为类似于系统中断,即当前进程外部的实体可以触发代码执行。异步操作经常是必要的,因为强制进程等待一个长时间的操作通常是不可行的(同步操作则必须要等)。如果代码要访问一些高延迟的资源,比如向远程服务器发送请求并等待响应,那么就会出现长时间的等待。

2.以往的异步编程模式

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

假设有以下异步函数,使用了 setTimeout 在一秒钟之后执行某些操作:

function double(value) { 
  setTimeout(() => setTimeout(console.log, 0, value * 2), 1000); 
} 

double(3); 
// 6(大约 1000 毫秒之后)
  1. 异步返回值
  2. 失败处理
  3. 嵌套异步回调

二、期约(promise)

1.Promise/A+规范

ECMAScript 6 增加了对 Promises/A+规范的完善支持,即 Promise 类型。一经推出,Promise 就大受欢迎,成为了主导性的异步编程机制。所有现代浏览器都支持 ES6 期约,很多其他浏览器 API(如 fetch()和 Battery Status API)也以期约为基础。

2.期约基础

ECMAScript 6 新增的引用类型 Promise,可以通过 new 操作符来实例化。创建新期约时需要传入执行器(executor)函数作为参数(后面马上会介绍),下面的例子使用了一个空函数对象来应付一下解释器:

let p = new Promise(() => {}); 
setTimeout(console.log, 0, p); // Promise <pending> 

之所以说是应付解释器,是因为如果不提供执行器函数,就会抛出 SyntaxError。

  1. 期约状态机
    期约是一个有状态的对象,可能处于如下 3 种状态之一:
     待定(pending)
     兑现(fulfilled,有时候也称为“解决”,resolved)
     拒绝(rejected)

    待定(pending)是期约的最初始状态。在待定状态下,期约可以落定(settled)为代表成功的兑现(fulfilled)状态,或者代表失败的拒绝(rejected)状态。无论落定为哪种状态都是不可逆的。只要从待定转换为兑现或拒绝,期约的状态就不再改变。而且,也不能保证期约必然会脱离待定状态。因此,组织合理的代码无论期约解决(resolve)还是拒绝(reject),甚至永远处于待定(pending)状态,都应该具有恰当的行为。

    重要的是,期约的状态是私有的,不能直接通过 JavaScript 检测到。这主要是为了避免根据读取到的期约状态,以同步方式处理期约对象。另外,期约的状态也不能被外部 JavaScript 代码修改。这与不能读取该状态的原因是一样的:期约故意将异步行为封装起来,从而隔离外部的同步代码。

  2. 解决值、拒绝理由及期约用例
    期约主要有两大用途。首先是抽象地表示一个异步操作。期约的状态代表期约是否完成。“待定”表示尚未开始或者正在执行中。“兑现”表示已经成功完成,而“拒绝”则表示没有成功完成。

    为了支持这两种用例,每个期约只要状态切换为兑现,就会有一个私有的内部值(value)。类似地,每个期约只要状态切换为拒绝,就会有一个私有的内部理由(reason)。无论是值还是理由,都是包含原始值或对象的不可修改的引用。二者都是可选的,而且默认值为 undefined。在期约到达某个落定状态时执行的异步代码始终会收到这个值或理由。

  3. 通过执行函数控制期约状态
    由于期约的状态是私有的,所以只能在内部进行操作。内部操作在期约的执行器函数中完成。执行器函数主要有两项职责:初始化期约的异步行为和控制状态的最终转换。其中,控制期约状态的转换是通过调用它的两个函数参数实现的。这两个函数参数通常都命名为 resolve()和 reject()。调用resolve()会把状态切换为兑现,调用 reject()会把状态切换为拒绝。另外,调用 reject()也会抛出错误(后面会讨论这个错误)。

  4. Promise.resolve()
    期约并非一开始就必须处于待定状态,然后通过执行器函数才能转换为落定状态。通过调用Promise.resolve()静态方法,可以实例化一个解决的期约。下面两个期约实例实际上是一样的:

let p1 = new Promise((resolve, reject) => resolve()); 
let p2 = Promise.resolve(); 

这个解决的期约的值对应着传给 Promise.resolve()的第一个参数。使用这个静态方法,实际上可以把任何值都转换为一个期约:

setTimeout(console.log, 0, Promise.resolve()); 
// Promise <resolved>: undefined 

setTimeout(console.log, 0, Promise.resolve(3));
// Promise <resolved>: 3 

// 多余的参数会忽略
setTimeout(console.log, 0, Promise.resolve(4, 5, 6)); 
// Promise <resolved>: 4 
  1. Promise.reject()
    Promise.reject()会实例化一个拒绝的期约并抛出一个异步错误(这个错误不能通过 try/catch 捕获,而只能通过拒绝处理程序捕获)。

  2. 同步/异步执行的二元性

try { 
  throw new Error('foo'); 
} catch(e) { 
  console.log(e); // Error: foo 
} 

try { 
  Promise.reject(new Error('bar')); 
} catch(e) { 
  console.log(e); 
} 
// Uncaught (in promise) Error: bar 

第一个 try/catch 抛出并捕获了错误,第二个 try/catch 抛出错误却没有捕获到。乍一看这可能有点违反直觉,因为代码中确实是同步创建了一个拒绝的期约实例,而这个实例也抛出了包含拒绝理由的错误。这里的同步代码之所以没有捕获期约抛出的错误,是因为它没有通过异步模式捕获错误。从这里就可以看出期约真正的异步特性:它们是同步对象(在同步执行模式中使用),但也是异步执行模式的媒介。

在前面的例子中,拒绝期约的错误并没有抛到执行同步代码的线程里,而是通过浏览器异步消息队列来处理的。因此,try/catch 块并不能捕获该错误。代码一旦开始以异步模式执行,则唯一与之交互的方式就是使用异步结构——更具体地说,就是期约的方法。

3.期约的实例方法

  1. 实现 Thenable 接口
    在 ECMAScript 暴露的异步结构中,任何对象都有一个 then()方法。这个方法被认为实现了 Thenable 接口。下面的例子展示了实现这一接口的最简单的类:

    class MyThenable { 
      then() {} 
    } 
    

    ECMAScript 的 Promise 类型实现了 Thenable 接口。这个简化的接口跟 TypeScript 或其他包中的接口或类型定义不同,它们都设定了 Thenable 接口更具体的形式。

  2. Promise.prototype.then()

    为期约实例添加处理程序的主要方法。

    这个 then()方法接收最多两个参数:onResolved 处理程序和 onRejected 处理程序。这两个参数都是可选的,如果提供的话,则会在期约分别进入“兑现”和“拒绝”状态时执行。

  3. Promise.prototype.catch()

    用于给期约添加拒绝处理程序。

    这个方法只接收一个参数:onRejected 处理程序。事实上,这个方法就是一个语法糖,调用它就相当于调用 Promise.prototype. then(null, onRejected)。

  4. Promise.prototype.finally()

    用于给期约添加 onFinally 处理程序,这个处理程序在期
    约转换为解决或拒绝状态时都会执行。

    这个方法可以避免 onResolved 和 onRejected 处理程序中出现冗余代码。但 onFinally 处理程序没有办法知道期约的状态是解决还是拒绝,所以这个方法主要用于添加清理代码。

  5. 非重入期约方法
    当期约进入落定状态时,与该状态相关的处理程序仅仅会被排期,而非立即执行。跟在添加这个处理程序的代码之后的同步代码一定会在处理程序之前先执行。即使期约一开始就是与附加处理程序关联的状态,执行顺序也是这样的。这个特性由 JavaScript 运行时保证,被称为“非重入”(non-reentrancy)特性。下面的例子演示了这个特性:

    // 创建解决的期约
    let p = Promise.resolve(); 
    
    // 添加解决处理程序
    // 直觉上,这个处理程序会等期约一解决就执行
    p.then(() => console.log('onResolved handler')); 
    
    // 同步输出,证明 then()已经返回
    console.log('then() returns'); 
    
    // 实际的输出:
    // then() returns 
    // onResolved handler
    
  6. 邻近处理程序的执行顺序
    如果给期约添加了多个处理程序,当期约状态变化时,相关处理程序会按照添加它们的顺序依次执行。无论是 then()、catch()还是 finally()添加的处理程序都是如此。

  7. 传递解决值和拒绝理由
    到了落定状态后,期约会提供其解决值(如果兑现)或其拒绝理由(如果拒绝)给相关状态的处理程序。拿到返回值后,就可以进一步对这个值进行操作。

    在执行函数中,解决的值和拒绝的理由是分别作为 resolve()和 reject()的第一个参数往后传的。然后,这些值又会传给它们各自的处理程序,作为 onResolved 或 onRejected 处理程序的唯一参数。下面的例子展示了上述传递过程:

    let p1 = new Promise((resolve, reject) => resolve('foo')); 
    p1.then((value) => console.log(value)); // foo 
    
    let p2 = new Promise((resolve, reject) => reject('bar')); 
    p2.catch((reason) => console.log(reason)); // bar
    
  8. 拒绝期约与拒绝错误处理
    拒绝期约类似于 throw()表达式,因为它们都代表一种程序状态,即需要中断或者特殊处理。在期约的执行函数或处理程序中抛出错误会导致拒绝,对应的错误对象会成为拒绝的理由。

4.期约连锁与期约合成

  1. 期约连锁
    把期约逐个地串联起来是一种非常有用的编程模式。之所以可以这样做,是因为每个期约实例的方法(then()、catch()和 finally())都会返回一个新的期约对象,而这个新期约又有自己的实例方法。这样连缀方法调用就可以构成所谓的“期约连锁”。比如:

    let p = new Promise((resolve, reject) => { 
      console.log('first'); 
      resolve(); 
    }); 
    p.then(() => console.log('second')) 
     .then(() => console.log('third')) 
     .then(() => console.log('fourth')); 
     
    // first 
    // second 
    // third 
    // fourth 
    
  2. 期约图
    因为一个期约可以有任意多个处理程序,所以期约连锁可以构建有向非循环图的结构。这样,每个期约都是图中的一个节点,而使用实例方法添加的处理程序则是有向顶点。因为图中的每个节点都会等待前一个节点落定,所以图的方向就是期约的解决或拒绝顺序。

  3. Promise.all()和 Promise.race()
    Promise.all()静态方法创建的期约会在一组期约全部解决之后再解决。这个静态方法接收一个可迭代对象,返回一个新期约。

    Promise.race()静态方法返回一个包装期约,是一组集合中最先解决或拒绝的期约的镜像。这个方法接收一个可迭代对象,返回一个新期约。

  4. 串行期约合成
    即将多个函数合成为一个函数,比如:

    function addTwo(x) {return x + 2;} 
    function addThree(x) {return x + 3;} 
    function addFive(x) {return x + 5;} 
    
    function addTen(x) { 
      return addFive(addTwo(addThree(x))); 
    } 
    
    console.log(addTen(7)); // 17 
    

5.期约扩展

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

  2. 期约进度通知
    ECMAScript 6 期约并不支持进度追踪,但是可以通过扩展来实现。一种实现方式是扩展 Promise 类,为它添加 notify()方法。

三、异步函数

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

1.异步函数

ES8 的 async/await 旨在解决利用异步结构组织代码的问题。为此,ECMAScript 对函数进行了扩展,为其增加了两个新关键字:async 和 await。

  1. async
    async 关键字用于声明异步函数。这个关键字可以用在函数声明、函数表达式、箭头函数和方法上:

    async function foo() {} 
    
    let bar = async function() {}; 
    
    let baz = async () => {}; 
    
    class Qux { 
      async qux() {} 
    }
    
  2. await
    因为异步函数主要针对不会马上完成的任务,所以自然需要一种暂停和恢复执行的能力。使用 await 关键字可以暂停异步函数代码的执行,等待期约解决。来看下面这个本章开始就出现过的例子:

    let p = new Promise((resolve, reject) => setTimeout(resolve, 1000, 3)); 
    
    p.then((x) => console.log(x)); // 3 
    

    使用 async/await 可以写成这样:

    async function foo() { 
      let p = new Promise((resolve, reject) => setTimeout(resolve, 1000, 3)); 
      console.log(await p); 
    } 
    
    foo(); 
    // 3 
    
  3. await 的限制
    await 关键字必须在异步函数中使用,不能在顶级上下文如<script>标签或模块中使用。不过,定义并立即调用异步函数是没问题的。

2.停止和恢复执行

async function foo() { 
  console.log(2); 
  console.log(await Promise.resolve(8)); 
  console.log(9); 
} 

async function bar() { 
  console.log(4); 
  console.log(await 6); 
  console.log(7); 
} 

console.log(1); 
foo(); 
console.log(3); 
bar(); 
console.log(5); 

// 1 
// 2 
// 3 
// 4 
// 5 
// 6 
// 7 
// 8 
// 9 

运行时会像这样执行上面的例子:
(1) 打印 1;
(2) 调用异步函数 foo();
(3)(在 foo()中)打印 2;
(4)(在 foo()中)await 关键字暂停执行,向消息队列中添加一个期约在落定之后执行的任务;
(5) 期约立即落定,把给 await 提供值的任务添加到消息队列;
(6) foo()退出;
(7) 打印 3;
(8) 调用异步函数 bar();
(9)(在 bar()中)打印 4;
(10)(在 bar()中)await 关键字暂停执行,为立即可用的值 6 向消息队列中添加一个任务;
(11) bar()退出;
(12) 打印 5;
(13) 顶级线程执行完毕;
(14) JavaScript 运行时从消息队列中取出解决 await 期约的处理程序,并将解决的值 8 提供给它;
(15) JavaScript 运行时向消息队列中添加一个恢复执行 foo()函数的任务;
(16) JavaScript 运行时从消息队列中取出恢复执行 bar()的任务及值 6;
(17)(在 bar()中)恢复执行,await 取得值 6;
(18)(在 bar()中)打印 6;
(19)(在 bar()中)打印 7;
(20) bar()返回;
(21) 异步任务完成,JavaScript 从消息队列中取出恢复执行 foo()的任务及值 8;
(22)(在 foo()中)打印 8;
(23)(在 foo()中)打印 9;
(24) foo()返回。

3.异步函数策略

  1. 实现 sleep()
    有了异步函数之后,就不一样了。一个简单的箭头函数就可以实现 sleep():

    async function sleep(delay) { 
      return new Promise((resolve) => setTimeout(resolve, delay)); 
    } 
    
    async function foo() { 
      const t0 = Date.now(); 
      await sleep(1500); // 暂停约 1500 毫秒
      console.log(Date.now() - t0); 
    } 
    foo(); 
    // 1502 
    
  2. 利用平行执行

  3. 串行执行期约

  4. 栈追踪与内存管理


总结

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

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

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

以上就是本篇文章的全部内容了,目前本系列文章就更新到这里了。如果想要第一时间获取相关文章,欢迎关注博主,并订阅专栏。您的支持与鼓励将成为我不断前行地动力!

最后,如果本篇文章对正在阅读的您有所帮助或启发,请不要吝啬您的点赞收藏评论及分享,这样就有可能帮助到更多的人了。同时也欢迎留下您遇到的问题,让我们一起探讨学习,共同进步!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值