阅读 3098

看懂此文,手写十种Promise!

摸鱼酱的文章声明:内容保证原创,纯技术干货分享交流,不打广告不吹牛逼。

前言:这篇文章应该会和你见到的大部分手写Promise文章都不一样,文中不会讲到Promises/A+规范,也不会提到Promise.race / race等语法糖。在本文中,我会大量使用到面向对象的思维方式,并且只关注Promise的核心思想及其实现,相信在您认真看完之后,会对Promise产生一个更加结构性的理解。

好的,屁话说完,进入正题。对于Promise,MDN中对它的解释为:Promise 对象用于表示一个异步操作的最终完成 (或失败)及其结果值。在本文中,我以面向对象编程的思想作为出发点,给它下了另一个定义:Promise 对象是一个可用于存储异步操作结果状态和数据的容器

围绕这个概念,我用UML类图对Promise对象做了一个整理,以便阁下更好地理解我所说的容器概念,图示如下:

其实上述类图只能用于描述一个用于管理 同步操作 结果状态和数据的容器对象结构。因为实际的Promise容器对象需要支持管理 异步操作 所产生的结果状态和数据,所以它的类图需要加上两个用于暂存回调函数的内部属性(原因下文会说),此时它的类图会是这样:

下文中对Promise的所有探讨和实现都会围绕这幅图,如果您有兴趣继续往下看下去,我希望您能在这幅图上的理解上多花一些时间,以更好地阅读下文。

好的,接下来我就以用面向对象编程的编程实现步骤作为思路,分成以下几个部分来达到手写Promise的目的:

  • 以容器概念作为切入点,实现Promise对象的基本结构。
  • 分析Promise容器和异步操作的关系,实现Promise的构造方法constructor。
  • 理清Promise容器中数据的写入方式,实现Promise的resolve和reject方法。
  • 理清Promise容器中数据的读取方式,实现Promise的then方法。
  • 给then方法加个需求,支持链式调用,方便处理异步操作流。

碰到需要手写Promise的笔试面试场景时,个人也会围绕上图并按照上述思路步骤来实现。

一:以容器概念作为切入点,设计并实现Promise对象的基本结构

在分析给出答案前,我希望您能根据上幅图,预先思考一下以下两个问题:

  • Promise容器里面装的是什么数据?
  • Promise容器里面的数据如何读写?

在往前,我把这里的容器读写操作称之为填充和取出,后来考虑到Promise容器中的数据是支持一次填充,无数次取出的,就像磁盘数据一样,所以之后我都称这些操作为读写。

1.容器中存储的数据(对象属性)

  • state:容器状态,分为三种,即容器未就绪状态pending和容器已就绪状态fulfilled、rejected
  • value:容器fulfilled状态下的数据
  • reason:容器rejected状态下的数据
  • onResolvedTodoList:容器fulfilled状态下的回调行为数组,pending状态时暂存,fulfilled状态后消费
  • onRejectedTodoList:容器rejected状态后的回调行为数组,pending状态时暂存,rejected状态后消费

为了让变量名字更加统一和见名知意,下面的手写Promise实现中我们以resolved状态代替fulfilled状态(换个名而已,无需纠结)。

2.容器中数据的读写(对象方法)

  • 写入数据:容器内部定义并暴露一个可以设置内部状态和数据的resolve和reject方法,以供外部调用为容器写入数据(state、value / reason)。
  • 读取数据:容器内部定义并暴露一个可以读取内部状态和数据的then方法,以供外部调用读取容器中的数据,根据容器状态触发传入的相应回调行为。

3.容器对象代码实现

理解了Promise对象的封装组成(属性、方法)之后,根据上文的UML类图,我们做出以下代码实现:

温馨提示:请确保理解以下代码结构,这是Promise的大局,我们以大局为重。

class Container {
    state = undefined;
    value = undefined;
    reason = undefined;
    onResolvedTodoList = [];
    onRejectedTodoList = [];
    
    constructor(excutor) { // 构造容器 }
    
    resolve = value => { // 写容器数据 }
    reject = reason => { // 写容器数据 }
    
    then(onResolved, onRejected) { // 读容器数据 }
}

Container.PENDING = 'pending';
Container.RESOLVED = 'resolved';
Container.REJECTED = 'rejected';
复制代码

为了更加突出容器概念,我这里给我们要手写的Promise类取了个Container类名。

二:分析Promise容器和异步操作的关系,实现Promise的构造方法constructor

在具体实现Promise的构造方法constructor之前,我们先分析一波Promise容器和异步操作的关系:

  • Promise容器:它是一个异步操作容器,可以存储数据并管理它们的读写。
  • 异步操作:一些同步、异步语句的结合体,是我们真正的代码逻辑。
  • Promise容器和异步操作的关系:异步操作把其执行结果 托付 给Promise容器对象代为管理。

托付这个词,能够非常好地表示异步操作和Promise容器之间的关系。

1.从托付这个词来理解异步操作和Promise之间的联系

从UML类图中,我们阐述了Promise的对象结构,现在我们再以托付这个动作为中心,彻底理解异步操作和Promise容器之间的联系

  • 建立托付关系:外部意愿,外部选择Promise容器管理异步操作后的结果。(ps:ES6之前就是回调函数一把梭)。
  • 托付数据给容器:外部意愿,外部在异步操作中调用resolve和reject方法决定具体托付给容器什么状态和数据。需要注意的是,我们经常在某个异步语句的回调中托付数据。
  • 索要容器中数据:外部意愿,外部决定什么时候索要数据以及索要数据之后做什么行为。需要注意的是,在外部读取容器内数据时,托付数据经常是异步的,所以很容易造成容器数据读写的时序错乱问题

从需求出发,根据以容器对象管理异步操作结果的思路,你完全可以设计出一个你自己的Promise,一百个人会写出一百种Promise。

好的,在取得外部将异步操作的结果托付给Promise容器管理这个核心认识之后,我们就可以理解官方Promise的设计逻辑了。接下来我们实现官方版Promise容器的构造方法constructor,这其中涉及到异步操作和Promise的托付关系建立以及托付数据给容器两个过程。

至于为什么是官方版Promise设计成这样,这是由需求决定的,我们是要把 异步操作结果交由容器管理才会创建这个容器(你品,你细品),所以它把这两个过程都放在了构造方法中实现(当然,我们自己设计的promise可能不会是这样,但是也能实现目的,但我相信从封装后的易用性来看肯定不如官方版的友好)。

2.实现Promise的构造方法constructor

(1): 代码实现示例

class Container {
    state = undefined;
    value = undefined;
    reason = undefined;
    onResolvedTodoList = [];
    onRejectedTodoList = [];
    
	// 接收excutor函数作为构造参数并立即调用,根据excutor函数形参约定进行实参传递。
    constructor(excutor) {
        try {
            excutor(this.resolve, this.reject);
            this.state = Container.PENDING;
        } catch (e) {
            this.reject(e)
        }
    }

    resolve = value => { // 写容器数据 }
    reject = reason => { // 写容器数据 }
    
    then(onResolved, onRejected) { // 读容器数据 }
}

Container.PENDING = 'pending';
Container.RESOLVED = 'resolved';
Container.REJECTED = 'rejected';
复制代码

(2): 调用构造示例

// 外部定义excutor函数,约定形参resolve和reject
const p1 = new Container((resolve, reject) => {
  // 外部决定什么时候写入容器数据,写入什么数据
  setTimeout(() => {
    resolve(0)
  })
})
复制代码

(3): 函数式编程思想看excutor

根据上面分析官方版实现方式的构造方法需要建立异步操作和Promise容器之前的托付关系以及托付数据给容器这两个职责来来看,excutor函数它要实现两个功能:

  • 异步操作载体:函数对象形式
  • 授权外部设置容器中管理的数据和状态:容器数据写入方法resolve和reject

根据函数职责,剖析一下的它的函数输入输出以加深理解和印象:

  • 函数输入:向容器内部写入数据的方法,即resolve和reject
  • 函数输出:副作用形式,写入容器数据
  • 映射逻辑:在异步操作的执行过程中,根据调用者需要,向容器写入数据

三:理清Promise容器中数据的写入方式,实现Promise的resolve和reject方法

所谓的写入容器数据,实质上也就是表现为容器内方法为容器内数据赋值。如果仅仅是简单赋值那自然没什么学问可以探讨,但在官方版Promise中却有几个细节需要注意,先来看看仿官方版Promise的实现示例吧:

1.实现示例和调用示例

官方版Promise是把容器数据划分为互斥存在的value和reason两个数据,并且对应于已就绪状态fullfilled和rejected,如果我们自己设计,也许不会这样。

class Container {
	// ...attr
    constructor(excutor) {} 

    resolve = value => {
        if (this.state != Container.PENDING) return
        this.status = Container.RESOLVED;
        this.value = value;
        while (this.onResolvedTodoList.length) this.onResolvedTodoList.shift()() // 取出第一个
    }

    reject = reason => {
        if (this.state != Container.PENDING) return
        this.status = Container.REJECTED;
        this.reason = reason;
        while (this.onRejectedTodoList.length) this.onRejectedTodoList.shift()()
    }
    
    then(onResolved, onRejected) {}
}
复制代码

封装完,再看看调用示例吧:

const p1 = new Promise((resolve, reject) => {
  // 同步方式往容器中写入数据
  // resolve(0)
  // 异步方式往容器中写入数据
  setTimeout(() => {
    resolve(1)
  })
})
复制代码

2.问题:为什么写入数据时非pending状态什么也不做?

Promise容器管理的异步数据是操作后的结果数据,操作执行结束后,数据就不应该再变化。这正好与它的命名强对应,Promise意为承诺,只有操作后的数据不再变化,外部才能托付并信任这个容器中的数据。

这也意味着我们在日常使用Promise时,只能调用一次resolve方法和一次rejected方法,多次调用时,只有第一次有效。

3.问题:为什么在写入数据后还需要消费暂存回调队列?

消费暂存回调队列中存储的是容器在pending状态时,外部向容器添加的回调行为。很多情况下,外部在调用then方法读取容器中的状态和数据去做一些行为时,由于异步的原因,容器的状态和数据尚未被写入,所以容器并不能马上响应外部的读取后回调行为。但Promise是可靠的,它向我们承诺,一旦自身状态和数据被定下来就会马上去做我们给它安排的事情,一件也不会落下。

4.问题:为什么resolve方法和reject方法需要被设置为箭头函数?

有些朋友曾经问过我这个问题,他问为什么resolve和reject函数被要求设置为箭头函数而不能是function声明。其实以function声明的方式也未尝不可,之所以设置为箭头函数,是因为以箭头函数形式声明的函数,调用者调用时就不能通过任何方式去改变其内部的this指向,即使使用call、apply、bind函数也是一样。

所以说,这里设置为箭头函数只是对外部的一种约束,让外部不能更改resolve和reject函数中this的指向,强制约定resolve方法和reject方法只能用来写入当前容器对象的数据。

四:理清Promise容器中数据的读取方式,实现Promise的then方法

所谓的读取Promise容器中的数据,按照我们常规的理解,其实也就是定义一个get方法返回容器内部数据。从then方法的命名中也可以看出,它可不仅仅只是get数据这么简单,它的中文含义是然后,也就是异步操作的下一步操作该做什么。下面我们先写一个最简单的then方法,以加深对then方法概念的理解:

const obj = {
	value: 1,
    then: function (fn) {
    	fn(this.value)
    }
} 
obj.then(console.log);
复制代码

学过函子的朋友,可以对比一下Promise的then方法与函子map方法的区别。思考一下同样是容器,为什么函子适合用来做数据转换流,Promise适合用来做异步操作流。

有认真在思考的朋友可能会说到promise的then函数中的回调函数参数是以微任务的方式执行的呀。很高兴您能提出这个问题,没错,上述示例中的then方法明显是同步执行的,不符合promise的then方法要求,下面我们把它再改造一下,让它的回调函数参数以微任务的形式执行,代码如下:

const obj = {
  value: 1,
  then: function (fn) {
    process.nextTick(() => {
      fn(this.value);
    });
  },
};
obj.then(console.log);
复制代码

温馨提示:process是node环境下的api,在bom环境下是不能用的哦!

好的,屁话说的又多了,下面我们直接先来看一个在不支持链式调用情况下(第五点支持),如何实现一个仿官方版Promise的then方法示例吧。

1.实现示例和调用示例

在无需支持链式调用的情况下,then函数功能及其实现都非常简单,它负责接收用户给定的两个回调参数,而后容器内存储的数据在就绪后就会根据容器状态来决定调用哪个回调函数,同时以容器内存储的数据作为参数来具体调用该函数。在实现一个函数之前,我们先用函数式的编程思想来分析一下这个then方法。

  • then函数输入:接收value/reason数据作为参数的onResolved/onRejected回调函数。
  • then函数输出:副作用形式,某个参数回调函数的调用。
  • then函数映射逻辑:判断容器是否就绪,未就绪时把回调参数函数暂存起来,已就绪时则根据状态来决定执行哪个回调函数。

下面是超简单超短以致于一看就懂的then函数实现示例和调用示例:

实现示例:

class Container {
  state = undefined;
  value = undefined;
  reason = undefined;
  onResolvedTodoList = [];
  onRejectedTodoList = [];

  constructor(excutor) {}

  resolve = value = >{}
  reject = reason = >{}

  then(onResolved, onRejected) {
    // 问题:为什么对参数onResolved和onRejected做缺省处理?
    onResolved = onResolved ? onResolved: value => value;
    onRejected = onRejected ? onRejected: reason => {	// 问题:为什么onRejected回调缺省处理逻辑不是reason => reason?
      throw reason
    };
    switch (this.state) {
    // 问题:为什么需要判断pending状态,,这个状态下的代码逻辑为什么是暂存回调函数而不是调用回调函数?
    case Container.PENDING:
      // 问题:为什么将回调函数onResolved和onRejected放入暂存队列中时用箭头函数包裹后传入而不是直接传入onResolved或者onRejected?
      this.onResolvedTodoList.push(() = >{
        // 问题:then函数的回调函数参数不是以微任务形式调用吗,为什么你这里写成宏任务的形式呢?
        setTimeout(() => {
          // 问题:为什么回调函数的调用不做异常处理?
          onResolved(this.value);
        });
      });
      this.onRejectedTodoList.push(() = >{
        setTimeout(() => {
          onRejected(this.reason);
        });
      });
      break;
    case Container.RESOLVED:
      setTimeout(() => {
        onResolved(this.value);
      });
      break;
    case Container.REJECTED:
      setTimeout(() => {
        onRejected(this.reason);
      });
      break;
    }
  }
}
复制代码

调用示例:

p1.then((value) => {
  console.log(value)
}, (reason) => {
  console.log(reason)
})
复制代码

下面我们对代码中的几个核心逻辑和问题展开探讨。

2.问题:为什么对参数onResolved和onRejected做缺省处理?

个人认为主要是出于以下两个方面考虑:

  • 避免onResolved和onRejected回调函数调用时大量的判空逻辑。
  • 在链式调用时,支持在不传入某一个回调函数或者都不传的情况下,可以把结果状态和数据穿透下去。(原因会在第五点说明)

3.问题:为什么onRejected回调缺省处理逻辑不是reason => reason?

onRejected回调缺省处理逻辑设计为reason => { throw reason }是有道理的,个人认为这个主要是受到需求方面的影响。因为promise容器用于管理一个操作后的结果,而操作常常意味着成功或者失败,所以我们在使用时常常就会把resolve状态视为成功来读写容器内数据,reject状态视为失败来读写容器内数据。同时我们在执行一段同步或者异步操作代码逻辑时,有时候并不需要关注它成功后的值,但是操作失败我们是必须要知道的,因为这意味着托付给它的操作可能没有成功,也没有产生我们希望它产生的副作用。

reason状态和reject状态并不一定是代表成功或者失败状态,对应的数据也未必一定是代表成功数据或者失败数据。promise仅仅是一个容器,其中管理的状态和数据的含义一直都是由调用者决定的。

4.问题:为什么需要判断pending状态,这个状态下的代码逻辑为什么是暂存回调函数而不是调用回调函数?

promise的pending状态设计的目的就是用来解决异步操作导致时容器数据读写时序混乱的问题。这个pending状态我们可以称之为容器已构造但未就绪状态,此时容器内的状态为pending,数据为undefined,外部在调用该promise容器的then方法以执行下一步操作时,无法拿到容器中存储的上一步操作的状态和数据,所以无法调用。

可靠的是,虽然无法立即调用,但是promise并不会把外部在此时添加的回调函数视为无效,它会把这些回调函数暂存起来,然后等待着容器就绪时消费,消费具体表现为resolve方法和reject方法中的onResolvedTodoList数组和onResolvedTodoList数组的遍历有序调用。

5.问题:为什么将回调函数onResolved和onRejected放入暂存队列中时用箭头函数包裹后传入而不是直接传入onResolved或者onRejected?

首先明确需求:暂存队列遍历调用回调函数时是需要携带value或者reason作为参数调用的,也就是带参回调函数的执行。至于为什么不在resolve或者reject函数内遍历调用时传入呢?我觉得在此处这样做也并没有什么不妥。

如果阁下对这个问题有不同的见解,欢迎评论处告知,谢谢!

具体要不要用箭头函数再包裹一层闭包结构,我觉得可以从以下三个方面来考虑:

  • 你希望这个回调函数在什么时间执行?
  • 你希望这个回调函数在什么作用域环境下执行?
  • 你希望这个回调函数中的this指向什么?(函数this指向与函数调用密切相关)

6.问题:then函数的回调函数参数不是以微任务形式调用吗,为什么你这里写成宏任务的形式呢?

很高兴您能提出这个问题,您说的对,按照官方版promise的做法,我们确实不应该用以setTimeout注册成宏任务的形式来执行then方法的回调函数参数。但是这也无可奈何,如果希望我们手写的promise能够在bom环境下使用,那么就不能使用process.nextTick方法,另外又找不到能够在bom环境下使用并且合适的微任务api,所以这个以及下面的示例中我都写成了宏任务的形式(手写node环境下使用的promise就没这个问题啦)。问题不大啦,手写promise你尽管使用setTimeout,面试官问为什么的时候您能答出个所以然就行。

有些朋友可能还会问到,为什么官方版的promise可以做到呢,它的then方法的回调函数参数就是以微任务的形式调用的呀。那没得办法呀,人家的promise是内置类(也可以叫对象,看你怎么理解),都不是用JavaScript写的,自然不会受限于JavaScript和bom。

如果这篇文章还骗不到你的赞的话,我就退出掘金了o( ̄ヘ ̄o#)!

7.问题:为什么回调函数的调用不做异常处理?

有的朋友在看完链式then方法的实现后,可能会产生这个问题。我想说的是,我们做异常处理并不是为做而做。只有做异常处理真正有价值时才做。这里做不做异常处理有什么区别吗?在这里捕捉或者在由全局捕捉这个回调函数的调用异常都是一样的。我们并不需要像链式时一样,碰到异常就告知下下次操作,下次操作出现了问题,所以下下个操作应该走错误回调函数。

如果阁下对此有什么高见,还请告知,谢谢!

五:给then方法加个需求,支持链式调用,方便处理异步操作流

promise的最大魅力就是它将以往对异步操作流的实现,从回调嵌套编码形式转变现如今的then“同步调用”的编码形式,彻底解决了折磨前端开发者好多年的回调地狱编码问题。

这里先提一提我瞎扯的异步操作流是什么意思,操作流可以理解为操作 -> 下步操作 -> 下下步操作 -> 下下下步操作 -> ...这么一个过程,异步操作流也就是指上述操作流中有一些操作带有异步逻辑啦。形象点说,就是我们通过多个then方法先把生产车间拼好(你品,你细品)。

then的链式调用特性让promise能够支持处理异步操作任务流式处理,如果从需求方面理解then的链式调用,我建议大家始终围绕这异步操作流这五个字来理解。

如果要为理解Promise贴上几个标签的话,我会给它贴上三个,它们分别是:容器、异步、操作流。

那么如何实现呢,对于then的链式调用,用屁股想一下就知道在then方法中return一个新的promise容器对象即可。不过这里有几个关键问题需要明确,我们先看链式then方法的示例实现和调用示例吧。

1.链式then方法实现示例和调用示例

温馨提示:建议对比一下未链式调用的then方法实现示例,在对比的过程中,找出为了实现链式调用,then方法发生的变化。

class Container {
    constructor(excutor) {}

    resolve = value => {}
    reject = reason => {}

    onResolvedTodoList = [];
    onRejectedTodoList = [];

    then(onResolved, onRejected) {
        onResolved = onResolved ? onResolved : value => value;
        onRejected = onRejected ? onRejected : reason => { throw reason };
        let containerBack = new Container((resolve, reject) => {
            switch (this.state) {
                case Container.PENDING:
                    this.onResolvedTodoList.push(() => {
                        setTimeout(() => {
                        	// 问题:为什么这里又开始异常处理了呢?
                            try {
                                const value = onResolved(this.value);
                                // 问题:为什么不直接把新的value作为新容器管理的数据,而是封装一个resolveContainer函数?
                                resolveContainer(containerBack, value, resolve, reject);
                            } catch (e) {
                                reject(e);
                            }
                        })
                    });
                    this.onRejectedTodoList.push(() => {
                        setTimeout(function () {
                            try {
                                const value = onRejected(this.reason);
                                resolveContainer(containerBack, value, resolve, reject);
                            } catch (e) {
                                reject(e);
                            }
                        })
                    });
                    break;
                case Container.RESOLVED:
                    setTimeout(() => {
                        try {
                            const value = onResolved(this.value);
                            resolveContainer(containerBack, value, resolve, reject);
                        } catch (e) {
                            reject(e);
                        }
                    })
                    break;
                case Container.REJECTED:
                    setTimeout(function () {
                        try {
                            const value = onRejected(this.reason);
                            resolveContainer(containerBack, value, resolve, reject);
                        } catch (e) {
                            reject(e);
                        }
                    })
                    break;
            }
        });
        return containerBack
    }
}

function resolveContainer(containerBack, value, resolve, reject) {
    if (!(value instanceof Container)) {
      resolve(value)
    } else {
      if (value !== containerBack) {
        value.then(resolve, reject);
      } else {
        reject(new TypeError('Chaining cycle detected for promise #<Promise>'));
      }
    }
  }
复制代码

调用示例:

const p2 = p1.then((value) => {
  console.log(value)
}, (reason) => {
  console.log(reason)
})
p2.then((value) => {
  console.log('p2', value)
}, (reason) => {
  console.log('p2', reason)
}).then((value) => {
  console.log('p3', value)
}, (reason) => {
  console.log('p3', reason)
})
复制代码

2.问题:为什么这里又开始异常处理了呢?

那是因为在链式调用的情况下,我们可以把回调函数的内部操作视为下一个promise所托管的异步操作的核心部分(下一点会说特殊情况),如果这个新的异步操作出现异常,我们就认为新的promise的状态为reject,并且reason数据为异常对象。为什么不直接不捕捉而让外部报错呢?这是因为在链式调用情况下,从需求上说,用户是可以把reject状态和数据也当作一种状态来处理的,promise无权中断(从需求上你品,你细品)。

3.问题:为什么不直接把新的value作为新容器管理的数据,而是封装一个resolveContainer函数?

我们直接来看resolveContainer函数:

function resolveContainer(containerBack, value, resolve, reject) {
    if (!(value instanceof Container)) {	// 回调函数返回一个非promise容器对象,这时候回调函数基本上等同于外部构造新容器时传入该回调函数作为异步操作
      resolve(value)
    } else {
      if (value !== containerBack) {	
        value.then(resolve, reject);	// 回调函数返回一个promise容器对象,非自身,新的promise接管这个回调函数返回的promise容器对象的状态和数据
      } else {	// 回调函数返回一个promise容器对象,并且是新容器自身,这时新的promise接管新的promise容器对象的状态和数据(无限套娃)
        reject(new TypeError('Chaining cycle detected for promise #<Promise>'))
      }
    }
  }
复制代码

这一点听我说可能不如直接从上述代码以及注释中自己找答案。为什么会是这样?我是这样理解的,从需求上说,我们并不能简单把回调函数简单地作为新的promise的异步操作核心部分来处理,这一点认识很关键,很多人就是没有搞清楚新的promise所管理的异步操作主体是什么,它与then函数的回调函数参数是什么关系(你品,你细品),然后导致不知到如果构造这个新的promise容器对象。

还有问题吗?欢迎评论处提问哦。如果能指出我的问题,让我有一次改正的机会,那就更谢谢啦!

文章分类
前端
文章标签