这可能是最易懂的Promise讲解

3,265 阅读12分钟

前面的文章说过我比现在还菜的时候对Promise浅薄的理解,虽然我现在回去看觉得很捞,但是这个视角还是很不错的,贴在下面。

从小程序简单的登录逻辑说promise

今天我们来搞懂Promise到底是个啥。Promise的内容一搜一大把,我这里从需求开始一步步理解每一步的关键设计,以及为什么要这样。

为什么需要Promise

先理解为什么需要Promise。我们要进行异步,最常用的方式是什么?

接收一个回调然后在适当时机执行

这样的问题有两个:

  • 嵌套产生的回调地狱
  • this丢失

所以我们需要一种新的方式来执行异步,这个方式需要满足一些需求:

  • 有一个方法来确保异步的代码在另一段代码后执行
  • 如果有多段异步代码的嵌套不会让深度无限变大
  • this能指向正确的值

这些需求的实现思路:

  • 设计一个机制控制异步回调的执行时机
  • 设计一个机制实现链式调用
  • 设计一个机制将回调绑定到正确的this

链式调用,有没有想到C++的>>运算符呢?

是什么

搞清楚定义是很重要的。这里我们以Promise A+规范来说.

“promise” is an object or function with a then method whose behavior conforms to this specification.

看前半段就行,Promise是一个有then方法的函数或对象then方法的逻辑在规范里面定义了。

所以then就是Promise标准的核心内容,但我们前半部分的眼光主要集中在如何设计一个异步执行方式并实现上。

状态机制

状态机是为了实现上面需求的第一点,控制回调执行时机。由一个状态量表达现在是否应当执行异步代码。为了防止一段异步代码被反复执行造成混乱,状态被设计为不可变的。

这里的设计都可以理解为:有效控制回调执行时机

Promise设计了三个状态量:pending, fulfilled, rejected

分别表达:不应当执行回调, 应当执行回调, 异常,应该执行异常处理回调

现在我们简单地实现一个状态机制。

const PENDING = 'PENDING'
const FULFILLED = 'FULFILLED'
const REJECTED = 'REJECTED'

class Promise {
    constructor () {
        this.status = PENDING
    }
    resolve () {
        this.status = FULFILLED
    }
    reject () {
        this.status = REJECTED
    }
}

执行同步代码

因为我们的需求是,在一段同步代码之后执行一段异步回调,所以说,Promise中一定有一部分代码是同步的。按照直觉上的设计,这段同步代码就应该放在最开始的地方。最开始的地方,那就是构造函数参数。

从这里我们理解了为什么传递给Promise构造函数的回调是同步的

那么我们怎么知道同步代码执行完成了呢?最简单的当然就是直接进行同步调用,调用完了之后根据结果进入完成态。

这样是可以的但是有局限性。我们没有暴露接口出去,那同步代码就不能自由地控制状态向结束转移,只能被动地return结束函数。你一个状态机不暴露状态转移的接口本身就是不make sense的。

如果不使用状态机而是纯函数式思想来做的话是合理的

既然暴露了接口,就将状态转移的控制权完全交给回调。这样可以避免Promise内部对同步回调执行结束后的状态做额外的判断。

所以我们做一些改动

  • 构造函数接收同步回调
  • 将状态转移接口暴露给回调
  • 由回调自己决定什么时候进行状态转移
class Promise {
    constructor (cb) {
        this.status = PENDING
        cb(this.resolve, this.reject)
    }
}

处理then中的异步回调

then中包含了两个回调,成功时的回调和失败是的回调。前面我们说到,Promise把同步代码直接调用回调的过程变成了由Promise来控制回调执行,那么显然Promise在构造时就需要将异步代码以某种形式存储起来等待执行时机到来。

下面我们实现一个满足这一需求的then方法,并在状态变化时执行回调。

class Promise {
    constructor (cb) {
        this.status = PENDING
        this.onFulfilledCb = [] // 注意是数组
        this.onRejectedCb = []
        // 注意这里要bind,否则this指向了cb
        cb(this.resolve.bind(this), this.reject.bind(this))
    }
    resolve () {
        this.status = FULFILLED
        this.onFulfilledCb.forEach(cb =>
            setTimeout(cb) // 实现异步执行
        ) 
    }
    reject () {
        this.status = REJECTED
        this.onRejectedCb.forEach(cb =>
            setTimeout(cb)
        )
    }
    then (onFulfilled, onRejected) {
        if (this.status === FULFILLED) {
            setTimeout(() => this.onFulfilledCb())
        }
        if (this.status === REJECTED) {
            setTimeout(() => this.onRejectedCb())
        }
        if (this.status === PENDING) {
            this.onFulfilledCb.push(onFulfilled)
            this.onRejectedCb.push(onRejected)
        }
    }
}

这里注意的几个点:

  • 回调是用数组存储的,因为一个Promise对象持有的异步回调可以有多个,这是为了实现其他接口设计的,我们这里不说。但是想一想使用数组存储同样是符合直觉的设计,如果只能有一个回调,设计会受到很大局限。
  • 异步回调要使用异步方法执行,没有为什么,规范这么写。

但是实际上,规范要求使用microtask队列来实现Promise,可是浏览器并没有给我们暴露操作microtask的接口,(node可以用nextTick)所以我们使用setTimeout实际是marotask异步。那么你可能会问,原生Promise怎么能做到呢?废话人家是在引擎里用C++写的

  • then中需要检查状态。因为引擎执行then的时候可能Promise已经resolve了。

很多人容易被Promise的执行逻辑搞晕,后面我们完整捋一遍函数调用的顺序。

链式调用

将嵌套回调打平为链式调用是一个符合直觉的设计。刚刚提到了C++的>>运算符,链式调用的关键就是每一步保证返回同一个类的对象。所以then的调用一定也是返回一个Promise。

我们先修改一下代码来实现这个特性。

then(onFulfilled, onRejected) {
    // fulfilled和rejected的执行方式是一样的,所以抽出来写一个helper
    function runCb (cb, resolve, reject) {
        setTimeout(() => {
            try {
                cb()
                resolve()
            } catch {
                reject()
            }
        })
    }
    if (this.status === FULFILLED) {
        return new Promise((resolve, reject) => {
            runCb(onFulfilled, resolve, reject)
        })
    }
    if (this.status === REJECTED) {
        return new Promise((resolve, reject) => {
            runCb(onRejected, resolve, reject)
        })
    }
    if (this.status === PENDING) {
        let that = this
        return new Promise((resolve, reject) => {
            that.onFulfilledCb.push(onFulfilled)
            that.onRejectedCb.push(onRejected)
        })
    }
}

梳理一下其中的点:

  • then的返回值一定是一个Promise
  • fulfilled和rejected状态下需要用异步方式,因为Promise构造函数的回调是同步执行的。
  • onFulfilledCbonRejectedCb中的函数是等待执行的异步代码,和Promise没有关系,所以只需要直接push进去。

到了这一部分能够解决一些刚学Promise人的困惑(比如以前的我):为什么我在Promise里面return了却不能得到值??

这样的实现每次调用then都会重新new一个Promise对象,开销比较大,能不能直接用一个Promise对象处理呢?

向then传递值

记住我们最开始的目的是设计一个新的异步方式,异步调用一定有传值的需求。

以前怎么传值呢?当然是在调回调的时候把需要的值传进去咯。

可是现在异步回调不被同步代码直接调用,传值就需要Promise对象代为完成。同时我们需要为同步代码暴露提供返回值的接口。自然地,我们可以在resolverejecte方法的参数中接受返回值。

注意需要考虑到then中调用callback的情况。

resolve(value) {
    this.status = FULFILLED
    this.value = value // 保存起来供then读取
    this.onFulfilledCb.forEach(cb =>
        setTimeout(() => cb(value))
    )
}
// reject同理
then(){
    let that = this
    function runCb (cb, resolve, reject) {
        setTimeout(() => {
            try {
                cb(that.value)
                resolve()
            } catch {
                reject(that.reason)
            }
        })
    }
}

链式调用中的传递

上面的实现有一个明显的缺点,值传递只发生在第一个then,后面的then拿到的值都是空的了。这并不符合预期,在一个链式的异步调用中,我们希望每一步都能接收上一步的值,这样每次调用看起来才是一致的。

因此我们下面的目标是将then中回调的返回值继续resolve出去,注意这里是返回值

记住,then中的回调就是一个纯粹的异步操作而已,和Promise没有任何联系,因此它不接受resolve、reject这一类的状态操作方法作为参数。因此then方法resolve出去的应当是回调的返回值。

这里能够理解另一个容易混淆的地方:构造函数回调使用resolve传递值而onFulfilled回调使用return传递值

浅显的实现

注意这个实现只是实现了我们设计的功能,标准实现并不是这么简单!

现在我们需要做的事情就是:

  • 得到onFulfilled的返回值
  • resolve出去
then(onFulfilled, onRejected) {
    // helper增加一个value参数
    function runCb (cb, value, resolve, reject) {
        setTimeout(() => {
            try {
                let x = cb(value)
                resolve(x) // resolve回调结果
            } catch(reason) {
                reject(reason)
            }
        })
    }
    let that = this
    if (this.status === FULFILLED) {
        return new Promise((resolve, reject) => {
            runCb(onFulfilled, that.value, resolve, reject)
        })
    }
    if (this.status === REJECTED) {
        return new Promise((resolve, reject) => {
            runCb(onRejected, that.reason, resolve, reject)
        })
    }
    if (this.status === PENDING) {
        let that = this
        return new Promise((resolve, reject) => {
            // 注意这里闭包的使用, 不需要修改resolve的实现
            that.onFulfilledCb.push((value) => 
                runCb(onFulfilled, value, resolve, reject)
            )
            that.onRejectedCb.push((reason) => 
                runCb(onRejected, reason, resolve, reject)
            )
        })
    }
}

到这里,这个Promise就已经可用了,我们总结一下实现的简单特性。

  • Promise构造函数接受一个同步函数作为参数
  • 在同步函数中调用resolve、reject方法来进入完成态以执行异步操作
  • resolve的值会作为then方法onFulfilled回调的参数
  • then方法会返回一个Promise,因此允许链式调用
  • onFulfill回调的返回值作为后续then中onFulfill的参数

测试一下功能

new Promise((resolve, reject) => {
    console.log('Promise同步代码部分')
    resolve('同步代码传的值')
}).then(value => {
    console.log('第一个then, value: ' + value)
    return '第一个then的返回值'
}).then(value => {
    console.log('第二个then, value: ' + value)
    throw new Error('第二个then抛出异常')
}).then(() => {}, err => console.log(err))

console.log('其他js同步代码')

输出

符合预期

完整代码

现在我们已经理解了Promise最基本的设计和实现过程,其中隐去了我认为A+中最重要的一个设计,以及Promise的众多接口。目的是能够专注到Promise最核心的异步实现上,下面我们将更进一步得地理解Promise标准的设计。


更进一步

制定标准的大佬们总是比我们想得更多一点。

thenable

“thenable” is an object or function that defines a then method.

A+定义了一个thenable的概念,说白了就是对Promise的定义做了一个泛化。目的是让这个标准能够有更强的包容性。你的实现可以不完全遵守A+,但你只要是thenable的,那我们就接受你。你可以根据你自己的喜好实现一个Promise,比如上面那个,需要的只是提供一个then方法。

当然也不能随便一个叫then的方法都行,至少需要和规范保持一致的函数签名

兼容thenable Object/Funtion

需要兼容什么?

Promise的resolve值是任意的,如果resolve了一个thenable的对象会存在一些问题,我们从头分析解决。

  • 需求:被resolve的thenable对象应当被执行完毕才继续执行Promise的then
  • -> Promise需要知道什么thenble对象的执行状态
  • -> 将Promise的接口暴露给thenble对象
  • -> Promise同步部分调用resolve接口时不能直接运行then,需要解析resolve的值
  • -> 将resolve的状态转换封装成一个独立函数处理thenable

于是我们得到了初步的解决方案,可以简单理解为使用一个中间函数来处理thenable对象而不是直接转换状态。

  • 需求:thenable对象应当允许不限长度的链式调用
  • -> 递归调用then方法
  • -> 出口条件:resolve一个非thenable对象

总结:设计一个递归函数,参数是需要resolve的值和Promise的resolve接口,递归调用该值的then方法直到返回一个非thenable对象或Error,通过Promise.resolve接口转换Promise状态

到这里我们已经基本理解了A+规范核心部分的设计。

为什么这么做?

This treatment of thenables allows promise implementations to interoperate, as long as they expose a Promises/A+-compliant then method. It also allows Promises/A+ implementations to “assimilate” nonconformant implementations with reasonable then methods.

这段话我们大概翻译一下

这种设计允许Promise实现间的交互,只要他们暴露了A+规范的then方法。同时,符合A+标准的实现接纳一些非标准实现,只要他们实现了合理的then方法(函数签名一样就行)。

这里已经写得非常明白了,至于其中的设计思想,大家就慢慢体会吧。

实现方式

Reference部分那位老哥的实现是符合A+规范的,我就不再写了,毕竟规范已经恨不得给你写伪码了。

说一下核心思想就行。

function resolvePromise(promise, x) {
    ...
    let then = x.then;
    if (typeof then === 'function') {
        then.call(x, y => {
            resolvePromise(promise, y);
        }, reason => {
            promise.reject(reason);
        })
    }
    ...
    promise.resolve(x)
}

promise是源Promise, x是需要resolve的值。另外一些实现细节诸如避免递归循环引用、重复执行之类的就去看看他的博客就好。

Reference

promisesaplus.com/

www.jianshu.com/p/459a856c4…

写在后面

这是【恶补系列】的第一篇文章,也是我自己第一次写这么长的技术文章来深入分析一个基础知识。希望能用我浅薄的理解启发到别人。

这篇文章本来只是想简单写一点,结果越写越多,前后拖了半个月才终于写完。想法和我有出入的铁汁们说出来一起聊聊,毕竟是我真的很菜啊!如果其中有理解错误还希望大佬们指正。

如果能点个赞就更好了,真的写了好久哦T-T(逃