Promise的出现
在 Promise 出现以前,在我们处理多个异步请求嵌套时,代码往往是个回掉地狱
1 | let fs = require('fs') |
为了拿到回调的结果,我们必须一层一层的嵌套,可以说是相当恶心了。而且基本上我们还要对每次请求的结果进行一系列的处理,使得代码变的更加难以阅读和难以维护,这就是传说中臭名昭著的回调地狱~产生回调地狱的原因归结起来有两点:
- 嵌套调用,第一个函数的输出往往是第二个函数的输入;
- 处理多个异步请求并发,开发时往往需要同步请求最终的结果。
原因分析出来后,那么问题的解决思路就很清晰了:
- 消灭嵌套调用:通过 Promise 的链式调用可以解决;
- 合并多个任务的请求结果:使用 Promise.all 获取合并多个任务的错误处理。
Promise 正是用一种更加友好的代码组织方式,解决了异步嵌套的问题。
我们来看看上面的例子用 Promise 实现是什么样的:
1 | let fs = require('fs') |
在传统的异步编程中,如果异步之间存在依赖关系,就需要通过层层嵌套回调的方式满足这种依赖,如果嵌套层数过多,可读性和可以维护性都会变得很差,产生所谓的“回调地狱”,而 Promise 将嵌套调用改为链式调用,增加了可阅读性和可维护性。也就是说,Promise 解决的是异步编码风格的问题。 那 Promise 的业界实现都有哪些呢? 业界比较著名的实现 Promise 的类库有 bluebird、Q、ES6-Promise。
Promise/A+手写步骤分析
我们想要手写一个 Promise,就要遵循 Promise/A+ 规范,业界所有 Promise 的类库都遵循这个规范。
其实 Promise/A+ 规范对如何实现一个符合标准的 Promise 类库已经阐述的很详细了。每一行代码在 Promise/A+ 规范中都有迹可循,所以在下面的实现的过程中,我会尽可能的将代码和 Promise/A+ 规范一一对应起来。
中文译文:https://www.ituring.com.cn/article/66566
本文讲解使用ES5方式实现,代码仓库中补充ES6 class方式实现
结合Promise/A+的规范,边分析基础特征,边一步步实现代码:
promise 有三个状态:pending
,fulfilled
,or rejected
根据三种状态,我们先定义成枚举,方便后续使用
1 | const State = { |
当处于某一种状态时又需要满足以下的条件:
- 等待态(Pending)
- 可以迁移至执行态或拒绝态
- 执行态(Fulfilled)
- 不能迁移至其他任何状态
- 必须拥有一个不可变的终值
- 拒绝态(Rejected)
- 不能迁移至其他任何状态
- 必须拥有一个不可变的据因
根据以上条件当改变状态时,抽象成一个通用改变State的方法:
1 | /** |
Promise 是一个拥有 then
方法的对象或函数,且new Promise()
时,需要传递一个executor()
函数执行器。
那么我们先定义两个工具函数,后续也会用到
1 | // 检测是否是一个函数 |
那么根据定义可以实现一下代码
1 | /** |
executor
并接受两个参数,分别是fulfill
和reject
,且该函数执行器立即执行。
并且这里要结合第一点:
Promise
中有变量来代表当前的状态,且状态默认为Pending
Promise
有一个value
保存成功状态的值,可以是undefined/thenable/promise
;「规范 Promise/A+ 1.3」
Promise
有一个reason
保存失败状态的值;「规范 Promise/A+ 1.5」
1 | // 调用方法时改变Promise状态且拥有一个终值 |
通常fulfill的定义也可以表示为resolve,但是本文和后续方法名字有点冲突不方便查看所以定义为fulfill,平常所用的resolve意义一样。
一个 promise 必须提供一个 then
方法以访问其当前值、终值和据因。且接受两个参数onFulfilled
, onRejected
onFulfilled
和 onRejected
都是可选参数,并且两者不为函数则都需要忽略(内部实现用默认函数替代)
如果 onFulfilled
是函数:
- 当
promise
执行结束后其必须被调用,其第一个参数为promise
的终值 - 在
promise
执行结束前其不可被调用 - 其调用次数不可超过一次
如果 onRejected
是函数:
- 当
promise
被拒绝执行后其必须被调用,其第一个参数为promise
的据因 - 在
promise
被拒绝执行前其不可被调用 - 其调用次数不可超过一次
onFulfilled
和 onRejected
只有在执行环境堆栈仅包含平台代码时才可被调用(后面再解释)
根据以上定义,完善现在的代码
1 | // 增加try catch保证稳定执行,因为changeState在不正当切换State时会抛出错误 |
then
方法必须返回一个 promise
对象
Promise的优势就在于链式调用,在我们使用Promise的时候,当then函数中返回了一个值,不管是什么值,我们都能在下一个then中获取到,这就是所谓的then的链式调用。而且,当我们不再then
中放入参数,例如:promise.then().then()
,那么其后面的then
依然可以得到之前then
返回的值,这就是值得穿透。这也是规范实现得思路。
1 | const promise2 = promise1.then(onFulfilled, onRejected); |
- 如果
onFulfilled
或者onRejected
返回一个值x
,则运行下面的 Promise 解决过程:[[Resolve]](promise2, x)
- 如果
onFulfilled
或者onRejected
抛出一个异常e
,则promise2
必须拒绝执行,并返回拒因e
- 如果
onFulfilled
不是函数且promise1
成功执行,promise2
必须成功执行并返回相同的值 - 如果
onRejected
不是函数且promise1
拒绝执行,promise2
必须拒绝执行并返回相同的据因
理解上面的“返回”部分非常重要,即:不论
promise1
被reject
还是被resolve
时promise2
都会被resolve
,只有出现异常时才会被rejected
。
分析一下上述可以看到,then方法需要返回一个新的Promise对象(promise2),且需要进行处理promise1中then
两个参数的返回值
并且多了一个resolve
方法定义Promise解决过程
,方法的参数传入(promise2,x)
。具体下面再实现,先进行调用。
1 | /** |
Promise解决过程 (The Promise Resolution Procedure)
我们先看看规范是怎么定义的:
Promise 解决过程是一个抽象的操作,其需输入一个 promise 和一个值,我们表示为[[Resolve]](promise, x)
,如果 x 有 then 方法且看上去像一个 Promise ,解决程序即尝试使 promise 接受 x 的状态;否则其用 x 的值来执行 promise 。
这种
thenable
的特性使得 Promise 的实现更具有通用性:只要其暴露出一个遵循 Promise/A+ 协议的 then 方法即可;这同时也使遵循 Promise/A+ 规范的实现可以与那些不太规范但可用的实现能良好共存。
运行 [[Resolve]](promise, x)
需遵循以下步骤:
x 与 promise 相等
如果 promise 和 x 指向同一对象,以TypeError
为据因拒绝执行 promisex 为 Promise时 ,则使 promise 接受 x 的状态 :
如果 x 处于等待态, promise 需保持为等待态直至 x 被执行或拒绝
如果 x 处于执行态,用相同的值执行 promise
如果 x 处于拒绝态,用相同的据因拒绝 promise
x 为对象或函数时
把
x.then
赋值给 then如果取
x.then
的值时抛出错误 e ,则以 e 为据因拒绝 promise如果 then 是函数,将 x 作为函数的作用域
this
调用之。传递两个回调函数作为参数,第一个参数叫做resolvePromise
,第二个参数叫做rejectPromise
:
(1) 如果resolvePromise
以值 y 为参数被调用,则运行[[Resolve]](promise, y)
(2) 如果rejectPromise
以据因 r 为参数被调用,则以据因 r 拒绝 promise
(3) 如果resolvePromise
和rejectPromise
均被调用,或者被同一参数调用了多次,则优先采用首次调用并忽略剩下的调用
(4) 如果调用 then 方法抛出了异常 e, 如果resolvePromise
或rejectPromise
已经被调用,则忽略之。否则以 e 为据因拒绝 promise
(5) 如果 then 不是函数,以 x 为参数执行 promise如果 x 不为对象或者函数,以 x 为参数执行 promise
注:如果一个 promise 被一个循环的
thenable
链中的对象解决,而[[Resolve]](promise, thenable)
的递归性质又使得其被再次调用,根据上述的算法将会陷入无限递归之中。算法虽不强制要求,但也鼓励施者检测这样的递归是否存在,若检测到存在则以一个可识别的TypeError
为据因来拒绝 promise。
那么根据定义我们来完善一下先前空实现的resolve
方法:
1 | /** |
看到这里问题来了,Promise的异步概念还没有体现到,这是因为规范后面有一个注释:
有英文能力的可以看一下原文:https://promisesaplus.com/#notes
注释
在本文第四点提到的平台代码指的是引擎、环境以及 promise 的实施代码。实践中要确保
onFulfilled
和onRejected
方法异步执行,且应该在then
方法被调用的那一轮事件循环之后的新执行栈中执行。这个事件队列可以采用“宏任务(macro-task)”机制或者“微任务(micro-task)”机制来实现。由于 promise 的实施代码本身就是平台代码(译者注:即都是 JavaScript),故代码自身在处理在处理程序时可能已经包含一个任务调度队列。译者注:这里提及了 macrotask 和 microtask 两个概念,这表示异步任务的两种分类。在挂起任务时,JS 引擎会将所有任务按照类别分到这两个队列中,首先在 macrotask 的队列(这个队列也被叫做 task queue)中取出第一个任务,执行完毕后取出 microtask 队列中的所有任务顺序执行;之后再取 macrotask 任务,周而复始,直至两个队列的任务都取完。
两个类别的具体分类如下:
- macro-task: script(整体代码),
setTimeout
,setInterval
,setImmediate
,I/O
,UI rendering
- micro-task:
process.nextTick
,Promises
(这里指浏览器实现的原生 Promise),Object.observe
,MutationObserver
详见 stackoverflow 解答 或 这篇博客
使用setTimeout模拟异步处理
最终完善一下then
方法,直接贴所有代码
1 | /** |
测试手写Promise/A+代码
需要安装mocha
以及promises-aplus-tests
库
1 | $ npm i -D mocha promises-aplus-tests |
新建test.js文件,并添加以下代码:
1 | var Promise = require('./promise-step'); |
手动执行命令
1 | $ ./node_modules/mocha/bin/mocha |
或者在package.json
中添加scripts
1 | "scripts": { |
部分运行结果如下
1 | The value is `1` with `Number.prototype` modified to have a `then` method |
Promise的API
Promise.resolve
我们来看看MDN上的定义:Promise.resolve()
**Promise.resolve(value)**
方法返回一个以给定值解析后的Promise
对象。如果这个值是一个 promise ,那么将返回这个 promise如果这个值是thenable(即带有"then"
方法),返回的promise会“跟随”这个thenable的对象,采用它的最终状态;否则返回的promise将以此值完成。此函数将类promise对象的多层嵌套展平。
根据定义我们来手写实现一下
1 | Promise.resolve = function (value) { |
测试一下:
1 | Promise.resolve(new Promise((resolve, reject) => { |
控制台等待 3s
后输出:
1 | "ok success" |
Promise.reject
Promise.reject()
方法返回一个带有拒绝原因的Promise
对象。
1 | Promise.reject = function (reason) { |
Promise.prototype.catch
catch() 方法返回一个Promise (en-US),并且处理拒绝的情况。它的行为与调用Promise.prototype.then(undefined, onRejected)
相同。
1 | Promise.prototype.catch = function (callback) { |
Promise.prototype.finally
finally()
方法返回一个Promise
。在promise结束时,无论结果是fulfilled或者是rejected,都会执行指定的回调函数。这为在Promise
是否成功完成后都需要执行的代码提供了一种方式。
这避免了同样的语句需要在then()
和catch()
中各写一次的情况。
注意: 在
finally
回调中throw
(或返回被拒绝的promise)将以throw()
指定的原因拒绝新的promise.
1 | Promise.prototype.finally = function (callback) { |
Promise.all
Promise.all() 方法接收一个promise的iterable类型(注:Array,Map,Set都属于ES6的iterable类型)的输入,并且只返回一个Promise
实例, 那个输入的所有promise的resolve回调的结果是一个数组。这个Promise
的resolve回调执行是在所有输入的promise的resolve回调都结束,或者输入的iterable里没有promise了的时候。它的reject回调执行是,只要任何一个输入的promise的reject回调执行或者输入不合法的promise就会立即抛出错误,并且reject的是第一个抛出的错误信息。
1 | Promise.all = function(arr) { |
测试一下
1 | const promise1 = Promise.resolve(3); |
总结
从 promise 的使用方法入手,构造出了 promise 的大致框架,然后根据 promise/A+ 规范填充代码,重点实现了 then 的链式调用和值的穿透;然后使用测试脚本对所写的代码是否符合规范进行了测试;最后完成了 Promise 的 API 的实现。弄懂 promise 其实并不复杂,归根结底还是孰能生巧。
且涉及到了JS 的循环机制EventLoop(主线程、微任务、渲染、宏任务)。