期约与异步函数
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>}
- 期约状态机
- 待定 pending
- 兑现 fulfilled
- 拒绝 rejected
pending 是期约的最初始状态。此状态下可以落定为 代表成功的兑现状态,或者代表失败的拒绝状态。
落定后的状态不再改变。并且状态是私有的,不能通过 JavaScript 检测到。
- 解决值、拒绝理由及期约用例
期约有两大用途。
- 抽象地表示一个异步操作:pending 正在执行中,fulfilled 成功完成,rejected 没有成功完成。
- 期约封装异步操作会实际生成某个值,期约状态改变时可以访问这个值。
为了支持这两种用例,每个期约只要状态切换为 fulfilled 就会有一个私有的内部值 value,类似的,每个期约只要状态切换为拒绝,就会有一个私有的内部理由 reason。
- 通过执行函数控制期约状态
由于期约的状态是私有的,所以只能在内部进行操作。
内部操作在期约的执行器函数中完成。
执行器函数主要有两个职责:初始化期约的异步行为、控制状态的最终转换。
执行器函数有两个参数: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() // 无效
})
- 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
- Promise.reject()
与 Promise.resolve() 类似。
区别:当 Promise.reject() 接受一个期约对象时,这个期约会成为它返回的拒绝期约的理由。
- 同步/异步执行的二元性
期约抛出的错误需要通过异步模式捕获。
期约真正的异步特性:是同步对象(可以在同步执行模式中使用),但也是异步执行模式的媒介。
期约的实例方法
- 实现 Thenable 接口
在 ECMAScript 暴露的异步结构中,任何对象都有一个 then() 方法。这个方法被认为实现了 Thenable 接口。
class MyThenable {
then() {}
}
Promise 类型 实现了 Thenable 接口。
- 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() 会包装这个值。
抛出异常会返回拒绝的 期约。
- 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() 方法一样。
- Promise.prototype.finally()
用于清理代码。会把期约原模原样的向后传递。
- 非重入期约方法
当期约进入落定状态时,与该状态相关的处理程序仅仅会被排期,而非立即执行。
如:一个解决期约上调用 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
期约连锁与期约合成
- 期约连锁
即,链式调用,执行一串同步任务。
串行化异步任务,每个后续期约都等待之前的期约。
将生成期约的代码提取到一个工厂函数中。
- 期约图
因为一个期约可以有任意多个处理程序,所以期约连锁可以构建有向非循环图的结构。
-
Promise.all() 和 Promise.race()
-
串行期约合成
异步产生值并将其串给处理程序。
基于后续期约使用之前期约的返回值来串联期约是期约的基本功能。
类似于函数合成。
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 期约并不支持进度追踪,但是可以通过扩展来实现。
- 扩展 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,让以同步方式写的代码能够异步执行。
- 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
- 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 工具箱中最重要的工具之一