从Promise本质开始(二):TDD方式实现基于Promises/A+规范的简易Promise

之前我还讲过 如何为实现 Promise 搭建 TDD 环境,而本文的目的主要是探讨如何基于 TDD 这样的方式实现一个 Promise,并且描述了部分 Promises/A+ 规范,以此为基础实现的一个 Promise

项目地址

先实现一个最基础版本的 Promise

有了之前的准备,现在就可以进行 TDD(Test Driven Develop)测试驱动开发了,注意,以下的测试代码都写在测试文件 test/index.ts 中,并且文件大致的代码结构为如下所示

import { assert } from 'chai'
import { describe, it } from 'mocha'
import Promise from '../src/promise'

describe('Promise', () => {
// TODO
it('TODO', () => {
// TODO
})
})

并且源码文件为 src/promise.ts

Promise 是一个类

测试用例

测试用例是这样写

it('是一个类', () => {
assert.isFunction(Promise)
assert.isObject(Promise.prototype)
})

assert 断言

  • Promise 是一个 function
  • Promise 的原型 prototype 是一个对象

那么就可以证明 Promise 是一个类了

源码

class PromiseEasy {
constructor() {
// TODO
}
}

很简单吧,其实就是用 测试的需求 来推动 开发

new Promise() 如果接受的不是函数就报错

这里的逻辑是 Promise 需要接受一个函数作为参数,所以测试用例应该这么写

测试用例

it('new Promise() 如果接受的不是函数就报错', () => {
assert.throw(() => {
// @ts-ignore
new Promise()
})
assert.throw(() => {
// @ts-ignore
new Promise(1)
})
assert.throw(() => {
// @ts-ignore
new Promise(true)
})
})

这里 // @ts-ignore 的意思就是 注释会忽略下一行中产生的所有错误,但是因为这里要是不通过就很麻烦,所以还是加上

assert.throw 这个 API 的意思就是断言一段会报错的代码

源码

class PromiseEasy {
constructor(fn) {
if (typeof fn !== 'function') {
throw new Error('这里只接受函数')
}
}
}

逻辑也很简单,对传入的参数做一个判断即可

new Promise(fn) 会生成一个对象,该对象会有个 then 方法

嗯,该往 Promise 里面添加 then 方法了,不过之前还是得写测试用例

测试用例

it('new Promise(fn) 会生成一个对象,该对象会有个 then 方法', () => {
const promise = new Promise(()=>{})
assert.isFunction(promise.then)
})

源码

class PromiseEasy {
constructor(fn) {
//...
}
then() {}
}

new Promise(fn) 中的函数立即执行

这个的测试代码就很简单了

测试用例

it('new Promise(fn) 中的函数立即执行', () => {
let called = false
new Promise(() => {
called = true
})
// @ts-ignore
assert(called === true)
})

判断是否被调用就是判断 called 这个变量有没有变化

源码

那就很简单了,直接调用构造函数里面传入的 fn 即可

class PromiseEasy {
constructor(fn) {
//...
fn()
}
//...
}

Promise.then(success) 中的 success 会在 resolve 被调用后执行

所以这里的核心就是需要验证 then 之后里面 resolve 的函数有没有被执行过了

测试用例

it('promise.then(success) 中的 success 会在 resolve 被调用后执行', done => {
let called = false
const promise = new Promise((resolve, reject) => {
assert(called === false)
resolve()
setTimeout(() => {
assert(called === true)
done()
});
})
// @ts-ignore
promise.then(() => {
called = true
})
})

注意这里在 Promise 中使用了 setTimeout,这是因为

  • 这里只有等一会才能断言 called = true
  • 因为顺序是先 then -> 调用 fn -> 调用 succeed
  • 而 succeed 是放入了 setTimeout 中的

并且还使用了 done,这是因为

  • 如果代码里面需要异步的测试,则需要加 done
  • 表示异步测试的完成,告诉 mocha 可以检查其测试结果了
  • 不然很多个任务都是异步测试的话,mocha 就不知道哪个是先完成的(这里 mocha 对于测试用例是一个一个同步执行的)

源码

首先需要在 Promise 里面声明两个变量 succeedfail 分别保存成功和失败回调

class PromiseEasy {
succeed = null // 保存成功回调
fail = null // 保存失败回调
//...
}

then 函数执行时保存回调

class PromiseEasy {
succeed = null // 保存成功回调
fail = null // 保存失败回调
//...

then(succeed, fail) {
this.succeed = succeed
this.fail = fail
}
}

那么什么时候这个 succeed 函数被调用呢,就是 resolve 的时候。众所周知 Promise 不仅仅有 resolve,还有 reject,并且在 new Promise 中使用 resolve 时并不会立刻调用成功回调,而是在执行 then 之后才调用,所以 resolvereject 函数是异步的,这里仅就用 setTimeout 做一个简单的处理

class PromiseEasy {
succeed = null // 保存成功回调
fail = null // 保存失败回调

resolve() {
setTimeout(() => {
this.succeed()
}, 0)
}
reject() {
setTimeout(() => {
this.fail()
}, 0)
}

//...

then(succeed, fail) {
this.succeed = succeed
this.fail = fail
}
}

然后在构造函数中需要将 resolvereject 传给 fn 当参数就可以了,当然这里需要 bindthis,不然 resolvereject 函数中的 this 是访问不到的

class PromiseEasy {
//...
constructor(fn) {
//...
fn(this.resolve.bind(this), this.reject.bind(this))
}
//...
}

Promise.then(null, fail) 中的 fail 会在 reject 被调用后执行

这里的测试代码不用说了,和上面的基本差不多

it('promise.then(null, fail) 中的 fail 会在 reject 被调用后执行', done => {
let fail = sinon.fake()
const promise = new Promise((resolve, reject) => {
assert.isFalse(fail.called)
reject()
setTimeout(() => {
assert.isTrue(fail.called)
done()
});
})
// @ts-ignore
promise.then(null, fail)
})

至此,一个基本的简单的 Promise 框架就算完成了,剩下就是按照 A+ 规范标准在上面添砖加瓦了

遵循比较重要的 Promise/A+ 规范增加功能

2.2.1 Both onFulfilled and onRejected are optional arguments

onFulfilledonRejected 都是可选的参数

测试用例

这里的测试用例很简单

it('2.2.1 onFulfilled 和 onRejected 都是可选的参数', () => {
const promise = new Promise(resovle => {
resovle()
})

promise.then(false, null)
})

参数可选什么意思,就是 then 传的俩参数都是可以传或者可以不传,所以这里的测试用例很简单

源码

所以这个时候就需要在 3 个地方对参数做判断了

  • resolve 函数
  • reject 函数
  • then 函数
class PromiseEasy {
//...
resolve() {
setTimeout(() => {
if (typeof this.succeed === 'function') { // 添加判断
this.succeed()
}
}, 0)
}
reject() {
setTimeout(() => {
if (typeof this.fail === 'function') { // 添加判断
this.fail()
}
}, 0)
}
//...
then(succeed?, fail?) {
if (typeof succeed === 'function') { // 添加判断
this.succeed = succeed
}
if (typeof fail === 'function') { // 添加判断
this.fail = fail
}
}
}

2.2.2 If onFulfilled is a function

2.2.2 这里有三点

  1. it must be called after promise is fulfilled, with promise’s value as its first argument
  2. it must not be called before promise is fulfilled
  3. it must not be called more than once

总的来说,大意就是 此函数必须在 promise 完成(fulfilled) 后被调用,并把 promise 的 result 值作为它的第一个参数

这里先在测试用例中将以上 3 点都测试一遍,但是为了方便起见就只用一个标题

测试用例

it('2.2.2.1 此函数必须在 promise 完成(fulfilled) 后被调用,并把 promise 的 result 值作为它的第一个参数', done => {
const success = sinon.fake()
let promise = new Promise((resolve, reject) => {
assert.isFalse(success.called) // 测试第 2 点
resolve('hi')
resolve('hii') // 调用两次
setTimeout(() => {
assert(promise.state === 'fulfilled') // 测试第 1 点
assert.isTrue(success.calledOnce) // 测试第 3 点
assert.isTrue(success.calledWith('hi')) // 测试第 1 点
done()
}, 0)
})

promise.then(success)
})

这里使用了 sinon.fake 作为假函数,然后使用 assert.isTrue/assert.isFalse 判断 sinon.fake.called 是否为 true,为 true 说明其已经被调用了,使用 sinon.fake.calledOnce 判断其是否只被调用一次,使用 sinon.fake.calledWith 判断其是否在调用 resolve 时传了对应的参数

所以要实现这一条规范,我们就需要在 Promise 内部维护一个 state 状态判断当前是否是处于 初始状态/resolve/reject,现在我们定义 state 有 3 种状态

  • pending: 表示初始状态
  • fulfilled: 表示用户调用了 resolve 函数
  • rejected: 表示用户调用了 reject 函数

这里还要说明的是,state 状态只能是单向的变化,即只能是 pending -> fulfilled 或者 pending -> rejected,而不能三者两两互相变化

源码

所以源码应该是要对 resolve 以及 reject 做修改,并且还要增加一个 state

class PromiseEasy {
//...
state = 'pending'
resolve(result) {
setTimeout(() => {
if (this.state !== 'pending') return
this.state = 'fulfilled'
if (typeof this.succeed === 'function') {
this.succeed(result)
}
}, 0)
}
reject(reason) {
setTimeout(() => {
if (this.state !== 'pending') return
this.state = 'rejected'
if (typeof this.fail === 'function') {
this.fail(reason)
}
}, 0)
}
//...
}

在进入 resolve/ reject 之后,先判断其是否是在 pending 状态,否则就 return,即保证上述规范第3点,之后才将状态改变,并且对 succeed/fail 函数做判断,只有其是函数时才能被调用。其中还添加了 resolve 的参数 result 以及 reject 参数 reason

2.2.3 If onRejected is a function

  • it must be called after promise is rejected, with promise’s reason as its first argument
  • it must not be called before promise is rejected
  • it must not be called more than once

这个跟 2.2.2 其实很像的,就是做 reject 之后的逻辑

测试用例

it('2.2.3.1 此函数必须在 promise 失败(rejected) 后被调用,并把 promise 的值作为它的第一个参数', done => {
const fail = sinon.fake()
let promise = new Promise((resolve, reject) => {
assert.isFalse(fail.called)
reject('hi')
reject('hii')
setTimeout(() => {
assert(promise.state === 'rejected')
assert.isTrue(fail.calledOnce)
assert.isTrue(fail.calledWith('hi'))
done()
}, 0)
})

promise.then(null, fail)
})

源码

参考 2.2.2 If onFulfilled is a function

2.2.4 onFulfilled or onRejected must not be called until the execution context stack contains only platform code

这句话给人很疑惑的感觉,因为官方的翻译就是

在执行上下文堆栈(execution context)仅包含平台代码之前,不得调用 onFulfilled 和 onRejected

再去看对应的解释(3.1)

Here “platform code” means engine, environment, and promise implementation code. In practice, this requirement ensures that onFulfilled and onRejected execute asynchronously, after the event loop turn in which then is called, and with a fresh stack. This can be implemented with either a “macro-task” mechanism such as setTimeout or setImmediate, or with a “micro-task” mechanism such as MutationObserver or process.nextTick. Since the promise implementation is considered platform code, it may itself contain a task-scheduling queue or “trampoline” in which the handlers are called.

翻译一下,就是

这里的 “platform code” 表示 (JS 的) 引擎,(JS 的)环境和 promise 执行的代码。实际上,这个(规范)要求能确保,onFulfilledonRejected 在 eventLoop 之后调用 then, 它们可以(得到)异步地执行,然后(继续)使用新的堆栈。这里既可以使用 setTimeout/setImmediate 这样的 “宏任务” 机制实现,也可以使用 MutationObserver/process.nextTick 这样的 “微任务” 机制实现。由于 promise 的实现被看作是 platform code,所以它可能本身就包含一个任务调度队列或者用来处理调用(关系)的 “trampoline”

还是不理解?我举个例子好了,比如有一个函数叫做 XXX 吧,一个 promise 做了如下的操作

promise.then(XXX)

但是此时函数 XXX 还没有执行哦,因为规范要求,一定要在调用 then 之后执行。因为这才可以产生一个异步的操作,而这个异步的操作如何来?规范里面也写了,可以利用 setTimeout/setImmediate 这样的宏任务来解决,或者也可以使用 MutationObserver/process.nextTick 这样的微任务来解决。解决的关键点是什么呢?就是在 Promise 内部,其传入的函数里面执行的优先级比外面通过 promise.then 方式调用 XXX 的优先级 要高。那么怎么做到这种优先级要高呢? 就是在 resolve/reject 中加上 setTimeout。当然了,这里只是简单的处理,比如我写如下的测试代码你大概就懂了

如果实在不明白宏任务/微任务机制的可以点击参考我之前写的这篇

测试用例

it('2.2.4 在我的代码执行完之前,不得调用 then 后面的两个函数', done => {
const success = sinon.fake()
const promise = new Promise(resolve => {
resolve()
})
promise.then(success)
// 这个时候代码还没有执行完,success 函数还没有被调用
assert.isFalse(success.called)
setTimeout(() => {
// 这个时候代码执行完了,success 函数被调用了
assert.isTrue(success.called)
done()
}, 0)
})

it('2.2.4 失败回调', done => {
const fail = sinon.fake()
const promise = new Promise((resolve, reject) => {
reject()
})
promise.then(null, fail)
assert.isFalse(fail.called)
setTimeout(() => {
assert.isTrue(fail.called)
done()
}, 0)
})

所以你可以看到,外面的 setTimeout靠后 执行了,为什么,因为 success 函数先进入了 Promise 内部,其内部有个 setTimeout 函数将其包裹了起来,eventLoop 时运行环境会将这个里面的代码放到了一个宏任务队列中,而这个外面调的 setTimeout 也将其紧随其后放到这个队列里面。执行时会优先执行先进去的任务,那么就是 success 先在里面执行了,然后外部的 setTimeout 自然就会觉察到 success 被调用了。

源码

参考最开始写的 先实现一个最基础版本的 Promise

2.2.5 onFulfilled and onRejected must be called as functions (i.e. with no this value)

这里的意思是 onFulfilledonRejected 必须被当作函数调用(例如: 没有 this)

又懵逼了,啥玩意儿???还是看看说明 3.2

That is, in strict mode this will be undefined inside of them; in sloppy mode, it will be the global object

意思就是说

严格模式下,thisonFulfilled 以及 onRejected 内部是 undefined; 而在非严格模式下,this 在它们内部就是一个全局对象

所以这里我们就选择严格模式就好了,因为本来就是由类封装起来的对象,在这种情况下,thisonFulfilled 以及 onRejected 内部只能是 undefined,那么测试用例应该这么写

测试用例

it('2.2.5 onFulfilled 和 onRejected 必须被当做函数调用(并且里面没有 this)', done => {
const promise = new Promise(resolve => {
resolve()
})
promise.then(function() {
'use strict'
assert(this === undefined) // 断言这个函数里面的 this 为 undefined
done()
})
})

it('2.2.5 失败回调', done => {
const promise = new Promise((resolve, reject) => {
reject()
})
promise.then(null, function() {
'use strict'
assert(this === undefined)
done()
})
})

源码

源码就好改了,对应的 resolve 以及 reject 函数里面将对应的函数调用 call 一个 undefined 就好,如下

class PromiseEasy {
//...
resovle(result) {
//...
if (typeof this.succeed === 'function') {
this.succeed.call(undefined, result)
}
}
reject(reason) {
//...
if (typeof this.fail === 'function') {
this.fail.call(undefined, reason)
}
}
//...
}

2.2.6 then may be called multiple times on the same promise

then 可以在同一个 Promise 里被多次调用。那么基本上测试代码可以这么写

测试用例

it('2.2.6 then可以在同一个promise里被多次调用', done => {
const promise = new Promise(resolve => {
resolve()
})
const callbacks = [sinon.fake(), sinon.fake(), sinon.fake()]
promise.then(callbacks[0])
promise.then(callbacks[1])
promise.then(callbacks[2])

setTimeout(() => {
assert(callbacks[0].called)
assert(callbacks[1].called)
assert(callbacks[2].called)
// 如果/当 promise 完成执行(fulfilled),各个相应的onFulfilled回调 必须根据最原始的then 顺序来调用
assert(callbacks[1].calledAfter(callbacks[0]))
assert(callbacks[2].calledAfter(callbacks[1]))
done()
}, 0)
})

it('2.2.6 失败回调', done => {
const promise = new Promise((resolve, reject) => {
reject()
})
const callbacks = [sinon.fake(), sinon.fake(), sinon.fake()]
promise.then(null, callbacks[0])
promise.then(null, callbacks[1])
promise.then(null, callbacks[2])

setTimeout(() => {
assert(callbacks[0].called)
assert(callbacks[1].called)
assert(callbacks[2].called)
assert(callbacks[1].calledAfter(callbacks[0]))
assert(callbacks[2].calledAfter(callbacks[1]))
done()
}, 0)

这里 sinon.fake.calledAfter 表示在某个目标函数之后被调用

源码

这里的 promise 要求能够多次调用 then,而我们原来 Promise 的逻辑是 then 只是保存了一次 succeedfail,这样的话多次 then 就只会覆盖掉之前传入的函数。要能通过测试用例中的代码,我们很容易想到,每次 then 时,用数组来保存 succeedfail 即可。

class PromiseEasy {
//...
callbacks = [] // 用来保存成功以及失败回调的数组
//...
then(succeed?, fail?) {
const handle = []
if (typeof succeed === 'function') {
handle[0] = succeed
}
if (typeof fail === 'function') {
handle[1] = fail
}
this.callbacks.push(handle)
}
}

那么在调用的时候怎么办呢?只需要在 resolvereject遍历调用 即可

class PromiseEasy {
//...
resolve(result) {
setTimeout(() => {
//...
this.callbacks.forEach(handle => {
const succeed = handle[0]
if (typeof succeed === 'function') {
succeed.call(undefined, result)
}
})
}, 0)
}

reject(reason) {
setTimeout(() => {
//...
this.callbacks.forEach(handle => {
const fail = handle[1]
if (typeof fail === 'function') {
fail.call(undefined, reason)
}
})
}, 0)
}
}

可以看到,本质就是两步

  • 添加一个二维数组then 时保存 succeedfail 的数组
  • resolve/reject遍历,取出对应的 succeed/fail,并执行

与 Promises/A 规范的区别

至此,我们已经完成一个 Promise 的基本功能,这个功能包括了 Promise 的核心 API,即 new Promise 以及 then 和里面的成功以及失败回调,下面就来讲(fan)讲(yi) Promises/A+ 规范和 Promises/A 规范的几个比较重要的区别

Omissions

下面的几点都是 Promises/A 规范所遗漏的:

  1. 进度处理: 实际上,它并未被指定,并且当前在实现 promise 的社区中没有形成一致的意见
  2. 可交互的 promises: 这被认为超出了可互操作保证所必需的最小 API 的范围
  3. 对于 var promise2 = promise1.then(onFulfilled, onRejected) 这样的代码,promise1 !== promise2 并不是必要的

Clarifications

Promises/A+ 规范与 Promises/A 规范使用不同的术语,这体现在 Promise 的实现过程中,它们已经成为事实(标准)意义上的词汇。 特别是如下:

  1. 给定的 promise 的状态是 “pending”、”fulfilled”、”rejected”
  2. 当 promises 被 fulfilled 时,它们会有 “value”; 当 promises 被 rejected 时,它们会有 “reason”
  3. 它引入了与 “promises” 不同的术语叫 “thenable”,以便更准确地讨论实现互操作所必需的 duck 测试

Additions

Promises/A+ 规范另外指定:

  1. onFulfilledonRejected 返回一个 thenable 的行为时,应包括解析过程的详细信息
  2. 传给 onRejectedreason 在处理 throws 必须要抛异常
  3. onFulfilledonRejected 必须是被异步调用的
  4. onFulfilledonRejected 必须是当作函数来调用
  5. onFulfilledonRejected 的调用的严格排序,以便在同一个 promise 上调用 then

总结

一个基础的 Promises/A+ 规范的 Promise 的实现需要注意以下几点

  1. 构造函数需要传一个函数,并在构造函数内部调用这个函数,而这个函数传了两个参数作为参数: resolvereject,这两个函数都是 Promise 内部实现的函数
  2. 每次 then 时都将传入的 succeedfail 函数 push 进一个数组当中,而这个数组是一个二维数组,用来维护这一堆 succeedfail 函数
  3. Promise 内部的 resolve 函数和 reject 函数中都做了如下的操作
    • 判断 state 是否为 pending 状态,不是就 return
    • 修改状态为 fulfilled/ rejected
    • 遍历上述的二维数组,找到 succeed/fail 函数并调用(.call(undefined, result/reason))
  4. resolve/reject 函数里面使用 setTimeout 做异步执行

另外,对应的项目链接在这里

参考链接

Promises/A+

Differences from Promises/A