期约与异步函数

  • 异步编程
  • 期约
  • 异步函数

ECMAScript 6 及之后的几个版本加大了对异步编程机制的支持。

ECMAScript 6 新增了正式的 Promise 引用类型,支持优雅地定义和组织异步编程逻辑。

接下来的几个版本增加了使用 async 和 await 关键字定义异步函数的机制。

异步编程

同步与异步

同步行为对应内存中顺序执行的处理器指令。 每条指令严格按照出现的顺序执行。 每条指令执行后也能立即获得存储在系统本地的信息。

异步行为类似于系统中断,当前进程外部的实体可以触发代码执行。 异步操作是必要的,代码执行一些高延时的操作,如果在同步操作中,必须要等待操作完成才能进行下一个指令,那么就会出现很长时间的等待。 而异步操作可以避免等待,直接进行下一个指令。

如果需要用到异步执行的代码返回的数据,可以使用回调或者事件系统。

以往的异步编程模式

使用 setTimeout + 回调函数,延迟若干毫秒,把回调函数推到消息队列上去等待执行。

回调函数 可以设置为 成功回调、失败回调;成功回调也可以是一个异步操作。

回调地狱就是因为回调函数中存在新的异步操作的回调函数,层层嵌套,不具有扩展性。

期约

期约是对尚不存在结果的一个替身。

Promise/A+ 规范

ECMAScript 6 增加了对 Promise/A+ 规范的完善支持,即 Promise 类型。 所有的现代浏览器都支持 ES6 期约,很多其他浏览器 API 也以期约为基础。

期约基础

ECMAScript 6 新增的引用类型 Promise,可以通过 new 操作符来实例化。

创建新期约时需要传入执行器函数作为参数。

let p = new Promise(() => {}) // 如果不提供执行器函数,就会抛出 SyntaxError
console.log(p)
// Promise {<pending>}
  1. 期约状态机
  • 待定 pending
  • 兑现 fulfilled
  • 拒绝 rejected

pending 是期约的最初始状态。此状态下可以落定为 代表成功的兑现状态,或者代表失败的拒绝状态。

落定后的状态不再改变。并且状态是私有的,不能通过 JavaScript 检测到。

  1. 解决值、拒绝理由及期约用例

期约有两大用途。

  • 抽象地表示一个异步操作:pending 正在执行中,fulfilled 成功完成,rejected 没有成功完成。
  • 期约封装异步操作会实际生成某个值,期约状态改变时可以访问这个值。

为了支持这两种用例,每个期约只要状态切换为 fulfilled 就会有一个私有的内部值 value,类似的,每个期约只要状态切换为拒绝,就会有一个私有的内部理由 reason。

  1. 通过执行函数控制期约状态

由于期约的状态是私有的,所以只能在内部进行操作。

内部操作在期约的执行器函数中完成。

执行器函数主要有两个职责:初始化期约的异步行为、控制状态的最终转换。

执行器函数有两个参数:resolve()、reject()。

  • 调用 resolve() 会把状态切换为 fulfilled。
  • 调用 reject() 会把状态切换为 rejected。
let p1 = new Promise((resolve, reject) => resolve())
let p2 = new Promise((resolve, reject) => reject())

console.log(p1) // Promise {<fulfilled>}
console.log(p2) // Promise {<rejected>}

当 resolve() 或 reject() 其中一个执行后,后续的 resolve() 和 reject() 都会静默失败。

let p = new Promise((resolve, reject) => {
  resolve()
  reject() // 无效
})
  1. Promise.resolve()

期约并非一开始就必须处于待定状态,然后通过执行器才能转换为落定状态。

通过调用 Promise.resolve() 静态方法,可以实例化一个解决的期约。

// 以下两种生成实例其实是一样的
let p1 = new Promise((resolve, reject) => resolve())
let p2 = Promise.resolve()

这个解决的期约的值对应着传给 Promise.resolve() 的第一个参数,多余的参数会忽略。 使用这个静态方法,实际上可以把任何值都转换为一个期约。 并且 Promise.resolve() 对于传参是一个期约的时候,相当于一个空包装,可以说具有幂等性。

Promise.resolve(3)
// Promise <fulfilled>: 3
  1. Promise.reject()

与 Promise.resolve() 类似。

区别:当 Promise.reject() 接受一个期约对象时,这个期约会成为它返回的拒绝期约的理由。

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

期约抛出的错误需要通过异步模式捕获。

期约真正的异步特性:是同步对象(可以在同步执行模式中使用),但也是异步执行模式的媒介。

期约的实例方法

  1. 实现 Thenable 接口

在 ECMAScript 暴露的异步结构中,任何对象都有一个 then() 方法。这个方法被认为实现了 Thenable 接口。

class MyThenable {
  then() {}
}

Promise 类型 实现了 Thenable 接口。

  1. Promise.prototype.then()

Promise.prototype.then() 是为期约实例添加处理程序的主要方法。

这个 then() 方法接收最多的两个参数:onResolved 处理程序 和 onRejected 处理程序。

then() 接收到非函数处理程序时会被静默忽略。

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

// onResolved 不传入的规范写法
p1.then(null, () => console.log('p1'))

then() 方法返回一个新的期约实例。

let p1 = new Promise(() => {})
let p2 = p1.then()

console.log(p1) // Promise <pending>
console.log(p2) // Promise <pending>
console.log(p1 === p2) // false

如果没有显式的返回语句,则 Promise.resolve() 会包装默认返回的 undefined。

如果有显式的返回语句,则 Promise.resolve() 会包装这个值。

抛出异常会返回拒绝的 期约。

  1. Promise.prototype.catch()

Promise.prototype.catch() 用于给期约添加拒绝处理程序。 只接收一个参数:onRejected 处理程序。

相当于调用 Promise.prototype.then(null, onRejected)

let p = Promise.reject()
let onRejected = function(e) {
  setTimeout(console.log, 0, 'rejected')
}
// 这两种添加拒绝处理程序的方式是一样的:
p.then(null, onRejected)
// rejected
p.catch(onRejected)
// rejected

返回期约与 then() 方法一样。

  1. Promise.prototype.finally()

用于清理代码。会把期约原模原样的向后传递。

  1. 非重入期约方法

当期约进入落定状态时,与该状态相关的处理程序仅仅会被排期,而非立即执行。

如:一个解决期约上调用 then() 会把 onResolved() 处理程序推进消息队列。在当前线程上的同步代码执行完成前不会执行。

即,进入微任务队列,等待同步代码执行完毕后继续执行。

let synchronousResolve
// 创建一个期约并将解决函数保存在一个局部变量中
let p = new Promise((resolve) => {
  synchronousResolve = function() {
    console.log('1: invoking resolve()')
    resolve()
    console.log('2: resolve() returns') // 同步代码,在 resolve() 之后也会执行
  }
})

p.then(() => console.log('4: then() handler executes'))
synchronousResolve()
console.log('3: synchronousResolve() returns')
// 实际的输出:
// 1: invoking resolve()
// 2: resolve() returns
// 3: synchronousResolve() returns
// 4: then() handler executes

期约连锁与期约合成

  1. 期约连锁

即,链式调用,执行一串同步任务。

串行化异步任务,每个后续期约都等待之前的期约。

将生成期约的代码提取到一个工厂函数中。

  1. 期约图

因为一个期约可以有任意多个处理程序,所以期约连锁可以构建有向非循环图的结构。

  1. Promise.all() 和 Promise.race()

  2. 串行期约合成

异步产生值并将其串给处理程序。

基于后续期约使用之前期约的返回值来串联期约是期约的基本功能。

类似于函数合成。

function addTwo(x) { return x + 2 }
function addThree(x) { return x + 3 }
function addFive(x) { return x + 5 }

// 函数合成
function addTen(x) {
  return addFive(addThree(addTwo(x)))
}

// 使用期约也可以合成起来,渐进地消费一个值,并返回一个结果
function addTenThen(x) {
  return Promise.resolve(x)
    .then(addFive)
    .then(addThree)
    .then(addTwo)
}

// 也可以使用 reduce 简写
function addTenReduce(x) {
  return [addTwo, addThree, addFive]
    .reduce((promise, fn) => promise.then(fn), Promise.resolve(x))
}

// 抽取一个合成函数
function compose(...fns) {
  return (x) => fns.reduce((promise, fn) => promise.then(fn), Promise.resolve(x))
}

let addTenCompose = compose(addTwo, addThree, addFive)

addTenCompose(8).then(console.log) // 18

期约扩展

期约取消

ES6 期约被认为是 激进的:只要期约的逻辑开始执行,就没有办法阻止它执行到完成。

所以,在现有实现基础上提供一种临时性的封装,以实现取消期约的功能。

这里用到 取消令牌 cancel token。生成的令牌实例提供了一个接口,利用这个接口可以取消期约; 同时也提供了一个期约的实例,可以用来出发取消后的操作并求值取消状态。

class CancelToken {
  constructor(cancelFn) {
    this.promise = new Promise((resolve, reject) => {
      concelFn(resolve)
    })
  }
}
<button id="start">Start</button>
<button id="cancel">Cancel</button>

<script>
class CancelToken {
  constructor(cancelFn) {
    this.promise = new Promise((resolve, reject) => {
      cancelFn(() => {
        setTimeout(console.log, 0, "delay cancelled")
        resolve()
      })
    })
  }
}

const startButton = document.querySelector('#start')
const cancelButton = document.querySelector('#cancel')

function cancellableDelayedResolve(delay) {
  setTimeout(console.log, 0, "set delay")
  return new Promise((resolve, reject) => {
    const id = setTimeout((() => {
      setTimeout(console.log, 0, "delayed resolve")
      resolve()
    }), delay)

    const cancelToken = new CancelToken((cancelCallback) =>
      cancelButton.addEventListener("click", cancelCallback)
    )

    cancelToken.promise.then(() => clearTimeout(id))
  })
}

startButton.addEventListener("click", () => cancellableDelayedResolve(1000))
</script>

期约进度通知

ECMAScript 6 期约并不支持进度追踪,但是可以通过扩展来实现。

  1. 扩展 Promise 类,添加 notify 方法。
class TrackablePromise extends Promise {
  constructor(executor) {
    const notifyHandlers = []

    super((resolve, reject) => {
      return executor(resolve, reject, (status) => {
        notifyHandlers.map((handler) => handler(status))
      })
    })

    this.notifyHandlers = notifyHandlers
  }

  notify(notifyHandler) {
    this.notifyHandlers.push(notifyHandler)
    return this
  }
}

异步函数

异步函数,也称为 async/await,是 ES6 期约模式在 ECMAScript 函数中的应用。

async/await 是 ES8 规范新增的,增强了 JavaScript,让以同步方式写的代码能够异步执行。

  1. async

声明异步函数。

异步函数中如果使用了 return 关键字返回了值。会被 Promise.resolve() 包装成一个期约对象。

异步函数始终返回期约对象。

异步函数内抛出错误会返回拒绝的期约,但拒绝期约的错误不能被异步函数捕获。

async function foo() {
  console.log(1)
  throw 3
}

foo().catch(console.log)
console.log(2)
// 1
// 2
// 3
async function foo() {
  console.log(1)
  Promise.reject(3)
}

foo().catch(console.log)
console.log(2)
// 1
// 2
// Uncaught (in promise): 3
  1. await

await 可以暂停异步函数代码的执行,等待期约解决。

await 让出 JavaScript 运行时的执行线程。

catch 会捕获错误。不会捕获 Promise.reject()。

但是 对拒绝的期约使用 await 则会释放错误值。

async function foo() {
  console.log(1)
  await (() => { throw 3 })()
}

// 给返回的期约添加一个拒绝处理程序
foo().catch(console.log)
console.log(2)
// 1
// 2
// 3
async function foo() {
  console.log(1)
  await Promise.reject(3)
  console.log(4) // 这行代码不会执行
}

// 给返回的期约添加一个拒绝处理程序
foo().catch(console.log)
console.log(2)
// 1
// 2
// 3

await 与 async 搭配使用。

除非支持 top-level await

停止和恢复执行

async/await 中真正起作用的是 await。

JavaScript 运行时在碰到 await 关键字时,会记录在哪里暂停执行。 等到 await 右边的值可用了,JavaScript 运行时回想消息对立中推送一个任务,这个任务会恢复异步函数的执行。

即使 await 后面跟着一个立即可用的值,函数的其余部分也会被异步求值。

异步函数策略

实现 sleep()

async function sleep(delay) {
  return new Promise((resolve) => setTimeout(resolve, delay))
}

async function foo() {
  const t0 = Date.now()
  await sleep(1500)
  console.log(Date.now() - t0)
}

foo()
// 1502

利用平行执行

如果使用 await 时不留心,则很有可能错过平行加速的机会。

例子,顺序等待 5 个随机的超时

async function randomDelay(id) {
  // 延迟 0~1000 毫秒
  const delay = Math.random() * 1000
  return new Promise((resolve) => setTimeout(() => {
    console.log(`${id} finished`)
    resolve()
  }, delay))
}
async function foo() {
  const t0 = Date.now()
  for (let i = 0 i < 5 i++) {
    await randomDelay(i)
  }
  console.log(`${Date.now() - t0}ms elapsed`)
}
foo()
// 0 finished
// 1 finished
// 2 finished
// 3 finished
// 4 finished
// 877ms elapsed

即使期约之间没有依赖,异步函数也会依次暂停,等待每个超时完成。会保证执行顺序,但总执行时间会变长。

如果顺序不是必须保证的,那么可以先一次性初始化所有期约,然后分别等待他们的结果。

async function randomDelay(id) {
// 延迟 0~1000 毫秒
  const delay = Math.random() * 1000
  return new Promise((resolve) => setTimeout(() => {
    setTimeout(console.log, 0, `${id} finished`)
    resolve()
  }, delay))
}
async function foo() {
  const t0 = Date.now()
  const promises = Array(5).fill(null).map((_, i) => randomDelay(i))
  for (const p of promises) {
    await p
  }
  setTimeout(console.log, 0, `${Date.now() - t0}ms elapsed`)
}
foo()

// 4 finished
// 3 finished
// 0 finished
// 2 finished
// 1 finished
// 877ms elapsed

虽然期约没有按照顺序执行,但 await 按顺序收到了每个期约的值。

async function randomDelay(id) {
// 延迟 0~1000 毫秒
  const delay = Math.random() * 1000
  return new Promise((resolve) => setTimeout(() => {
    setTimeout(console.log, 0, `${id} finished`)
    resolve()
  }, delay))
}
async function foo() {
  const t0 = Date.now()
  const promises = Array(5).fill(null).map((_, i) => randomDelay(i))
  for (const p of promises) {
    console.log(`awaited ${await p}`)
  }
  setTimeout(console.log, 0, `${Date.now() - t0}ms elapsed`)
}
foo()

// 4 finished
// 3 finished
// 0 finished
// 2 finished
// 1 finished
// awaited 0
// awaited 1
// awaited 2
// awaited 3
// awaited 4
// 654ms elapsed

栈追踪与内存管理

JavaScript 引擎会在创建期约时尽可能保留完整的调用栈。在抛出错误时,调用栈可以由运行时的错误处理逻辑获取,因而就会出现在栈追踪信息中。因此会多占用一些内存。

如果是异步函数,栈追踪信息准确地反映了当前的调用栈。JavaScript 运行时可以简单地在嵌套函数中存储指向包含函数的指针,这个指针存储在内存中,可用于在出错时生成栈追踪信息。不会像之前那样带来额外的消耗。

小结

长期以来,掌握单线程 JavaScript 运行时的异步行为一直都是个艰巨的任务。随着 ES6 新增了期约 和 ES8 新增了异步函数,ECMAScript 的异步编程特性有了长足的进步。通过期约和 async/await,不仅 可以实现之前难以实现或不可能实现的任务,而且也能写出更清晰、简洁,并且容易理解、调试的代码。 期约的主要功能是为异步代码提供了清晰的抽象。可以用期约表示异步执行的代码块,也可以用期 约表示异步计算的值。在需要串行异步代码时,期约的价值最为突出。作为可塑性极强的一种结构,期 约可以被序列化、连锁使用、复合、扩展和重组。 异步函数是将期约应用于 JavaScript 函数的结果。异步函数可以暂停执行,而不阻塞主线程。无论 是编写基于期约的代码,还是组织串行或平行执行的异步代码,使用异步函数都非常得心应手。异步函 数可以说是现代 JavaScript 工具箱中最重要的工具之一