阅读 807

超详细的 Promise 理解与实现


你微微地笑着,不同我说什么话。而我觉得,为了这个,我已等待得很久了

前言

在混合阅读完 《你不知道的JavaScript》(中卷)第二部分前三章+阮一峰老师的《ECMAScript 6 入门》第16节Promise 对象后,对Promise的理解提升了一个层次。

在偶然间刷到 要就来45道Promise面试题一次爽到底 这篇文章时,本着检验一下学习成果的心理,耐心做完了,作者可谓确实用心,这篇文章最后的面试题确实不错,不过根据promise/A+实现一个自己的promise这道题,应该是难度最大的,也是常考的面试题,当然面试只要求简答,网上的文章千篇一律,也不知道谁抄谁的。我也不能说自己能写出啥新鲜感来,不过不自己手动实现,我就永远都跨不过心中的那道坎,眼睛会了不等于手也会了,毕竟实践是检验真理的唯一标准。

本篇我打算就目前自己所掌握的,做一个关于Promise汇总性的介绍,其中顺便也扩展了一些涉及到的知识。

为什么需要Promise?

任何一个事物的产生都定有它存在的意义,所以我们需要清楚Promise为什么出现,它解决了什么问题。

回调嵌套

Promise未诞生以前,我们通过回调表达程序异步和管理并发,当然现在一些老的项目为了保证兼容性仍在使用。回调是JavaScript中实现异步最简单的方式,你可以将回调理解为程序的延续,即在当前同步代码执行完毕以后才会在未来某个时间执行。当我们的回调需要用到上一个回调的结果时,就产生了嵌套,类似下面这样:

ajax1(null, function (value1) {
    ajax2(value1, function(value2) {
        ajax3(value2, function(value3) {
            ajax4(value3, function(value4) {
                // Do something with value4
            });
        });
    });
});
复制代码

嵌套的层级多了,就产生了所谓的回调地狱,也称毁灭金字塔。但实际情况远比上面展现的要复杂得多,这使得追踪代码执行顺序的难度成倍增加。你也一定遇到过结尾少写}),排查大半天的情况,这就是因为代码太复杂了。

回调地狱所表现出来的问题,归纳下面几点:

  • 难以追踪代码的执行顺序
  • 代码复杂且极其脆弱
  • 程序若出现执行顺序偏离的异常情况,结果难以预测(前面的回调会阻塞后面代码的执行,若前面失败,后面都不会执行)

信任问题

我们经常使用第三方的 API(如Ajax),在将回调交付给第三方时,就意味着将控制权转交给了第三方,这叫控制反转。我们的回调由第三方负责调用,但对于第三方,我们能充分信任吗?

这里列举一些情况:

  • 调用回调过早
  • 调用回调过晚
  • 调用回调的次数过多
  • 回调未调用
  • 未能传递参数/环境值
  • 吞掉可能出现的错误或异常

对于这些情况,我们可能都要在回调函数中做些处理,并且是每次执行回调函数的时候都要做这些相同的处理,这就带来了很多重复的代码。

所以Promise的出现致力于解决这两大主要问题。

Promise是如何解决上述问题的?

回调嵌套

Promise对于回调而言,也只是改变了回调的位置,让代码看上去更有顺序性。

Promise对象的原型链上定义了then方法,调用then方法会自动创建一个新的 Promise 从调用返回,从而支持链式调用。链式调用的目的就是,让程序顺序执行,解决回调嵌套,以更符合我们人类大脑顺序思考的方式。上面的例子,我们使用Promise后如下:

// request 方法
function request(param){
    return new Promise(function(resolve, reject){
        ajax(param, function(val){
            resolve(val)
        })
    })
}
// 链式调用,顺序执行(1,2,3,4为了表明顺序)
request1(null)
.then(value1 => request2(value1))
.then(value2 => request3(value2))
.then(value3 => request4(value3))
复制代码

信任问题

信任问题产生的最主要原因就是我们把回调的控制权转交给了第三方,试想如果控制权在我们手中,我们还会让它出现上面所列举的情况吗?

答案是肯定不会,所以Promise的本质做法就是让控制权再反转一次,即控制权反转再反转,经过两次反转,让控制权重新回到我们手中。结合上面代码讲,它的具体实现思路就是:原来第三方 ajax方法的回调中应该是我们余下要执行的操作(ajax2、ajax3...),现在用resolve方法代替,这里面的思想就是在第三方方法执行完成时,给到一个通知,通知你去执行下一步,当然通知里面包含了你所需要的信息,具体下一步执不执行由你决定,控制权又重新回到自己手中的感觉怎么样?Perfect

所以说,Promise 并没有完全摆脱回调,它只是改变了传递回调的位置。对照上面列出来的问题,再结合你所掌握的Promise知识,考虑这些问题Promise是不是都解决了?

  • 调用回调过早

    提供给then(..) 的回调总会被异步调用(微队列),不存在调用过早,此问题解决

  • 调用回调过晚

    可以确信已决议 Promisethen(..) 注册的观察回调一定会在下一个异步事件点上被触发,此问题解决

  • 调用回调的次数过多

    Promise 的定义方式使得它只能被决议一次,所以只会调用1次,此问题解决

  • 回调未调用

    Promise 在决议时总是会调用完成回调和拒绝回调中的一个,若 Promise 本身永远不被决议,即使这样,Promise 也提供了解决方案:

    const p = Promise.race([
      fetch('/resource-that-may-take-a-while'),
      new Promise(function (resolve, reject) {
        setTimeout(() => reject(new Error('request timeout')), 5000)
      })
    ]);
    p
    .then(val => console.log(val))  // 及时完成
    .catch(err => console.error(err));  // 超时
    复制代码
  • 未能传递参数/环境值

    如果你没有用任何值显式决议,那么这个值就是 undefined,此问题解决

  • 吞掉可能出现的错误或异常

    可以用then的第二参数或catch方法捕获到异常,此问题解决

Promise 的特性就是专门用来为回调编码的信任问题提供一个有效的可复用的答案。有了它,我们就不再需要为了防止上述所列意外情况的发生,而在每次执行回调函数的时候都做相同重复的处理。

Promise规范

promise最早是在commonjs社区提出来的,当时提出了很多规范。比较接受的是promise/A规范。但是promise/A规范比较简单,后来人们在这个基础上,提出了promise/A+规范,也就是实际上的业内推行的规范;ES6也是采用的这种规范,但是ES6在此规范上还加入了Promise.all、Promise.race、Promise.catch、Promise.resolve、Promise.reject等方法。

promise/A+规范为如下:

  1. 只有一个then方法,没有catchraceall等方法,甚至没有构造函数

    Promise标准中仅指定了Promise对象的then方法的行为,其它一切我们常见的方法/函数都并没有指定,包括catchraceall等常用方法,甚至也没有指定该如何构造出一个Promise对象

  2. then方法返回一个新的Promise

  3. 不同Promise的实现需要可以相互调用(interoperable)

  4. Promise的初始状态为pending,它可以由此状态转换为fulfilled或者rejected,一旦状态确定,就不可以再次转换为其它状态,状态确定的过程称为settle

  5. 更具体的标准见这里 英文版 中文版

Promise实现

构造函数

Promise本质上其实是一个状态机,由pendingfulfilledrejected这个三个状态组成,初始状态为pending。对于状态机的解释,可以看这里 👉👉👉,可以看到Promise很符合定义。

因此,我们定义常量来保存这3种状态,用state代表当前状态,因为调用构造器生成的每个promise实例都有自己的状态,new绑定原则下this就指向实例,所以我们将state挂载在this上。现在代码如下:

const PENDING = 'pending';
const FULFILLED = 'fulfilled';
const REJECTED = 'rejected';

function Promise(executor){
    this.state = PENDING;
}
复制代码

我们通过new操作符调用Promise构造函数时,构造函数会立即调用传入的执行函数executor,而函数executor又有2个参数resolve、reject,且都为函数类型。

这里扩展一个细节:为什么executor的第一个参数总命名为resolve而不是fulfill

🙋:决议(resolve)、完成(fulfill)、拒绝(reject)。按照常理第一参数确实应该命名 fulfill,表示完成。但定义中第一个参数回调通常用于标识 Promise 已经完成,这里的完成指的是可能完成也可能拒绝。有些绕,举个例子,比如 Promise.resolve(..),对传入的 thenable 会展开。如果这个 thenable 展开得到一个拒绝状态,那么从 Promise.resolve(..) 返回的 Promise 实际上就是一个拒绝状态。同样Promise(..) 构造器的第一个参数回调也会展开 thenable(和 Promise.resolve(..) 一样)或 真正的 Promise,所以这里使用 resolve 命名很精确。

此时我们的代码是这样:

const PENDING = 'pending';
const FULFILLED = 'fulfilled';
const REJECTED = 'rejected';

function Promise(executor){
    this.state = PENDING;
    
    function resolve(value){
        // TODO
    }
    function reject(reason){
        // TODO
    }
    
    executor(resolve, reject);
}
复制代码

再考虑,executor可能会出错,所以我们用try/catch块给包起来,在catch到异常后reject出去。

为什么要reject出去而不是往外抛? 因为考虑到后面then的第二参数就是处理上一次拒绝情况的。

现在我们的代码是这样:

const PENDING = 'pending';
const FULFILLED = 'fulfilled';
const REJECTED = 'rejected';

function Promise(executor){
    this.state = PENDING;
    
    function resolve(value){
        // TODO
    }
    function reject(reason){
        // TODO
    }
    
    try{
        executor(resolve, reject);
    }catch(error){
        reject(error);
    }
}
复制代码

接下来的就是实现resolvereject这两个函数。这两个函数就是改变当前promise实例状态的,注意只有当前实例状态为pending时才能改变,还要注意到this指向的改变,不能在resolvereject函数中直接写this,此时this是指向Window对象的,严格模式指向undefined。我们所需要的this是指向实例的,所以我们还需要拿到指向实例的this。综合代码如下:

const PENDING = 'pending';
const FULFILLED = 'fulfilled';
const REJECTED = 'rejected';

function Promise(executor){
    const _this = this;
    _this.state = PENDING;
    
    function resolve(value){
        if(_this.state === PENDING){
            _this.state = FULFILLED;
        }
    }
    function reject(reason){
        if(_this.state === PENDING){
            _this.state = REJECTED;
        }
    }
    
    try{
        executor(resolve, reject);
    }catch(error){
        reject(error);
    }
}
复制代码

构造函数并没有完成,先考虑到这里,接下来实现then方法,实现的过程中再回来补充构造函数。

then方法

Promise实例的状态改变之后,不管成功还是失败,都会触发then回调函数。它能链式调用,那必然是挂载在原型上的方法,它的两个参数也都是函数,分别接收成功结果与失败原因。

这里就产生问题了,我们该如何拿到Promise实例的状态值呢,又该如何拿到成功结果或失败原因呢?

🙋:是Promise实例调用的then方法,根据隐式绑定原则,then方法内部的this就指向Promise实例,所以我们直接从this上拿状态值。对于成功结果或失败原因,自然我们也得从this上拿。

因此我们修改构造函数如下:

const PENDING = 'pending';
const FULFILLED = 'fulfilled';
const REJECTED = 'rejected';

function Promise(executor){
    const _this = this;
    _this.state = PENDING;
    _this.value = void 0;
    _this.reason = void 0;
    
    function resolve(value){
        if(_this.state === PENDING){
            _this.state = FULFILLED;
            _this.value = value;
        }
    }
    function reject(reason){
        if(_this.state === PENDING){
            _this.state = REJECTED;
            _this.reason = reason;
        }
    }
    
    try{
        executor(resolve, reject);
    }catch(error){
        reject(error);
    }
}
复制代码

注意这里初始化valuereason写的是void 0,为什么这样写?

🙋:undefined不只是基本类型之一,它还是一个变量,并不是关键字。

void后面可以跟任意表达式,在计算完表达式后,都返回undefined

对于使用void 0 代替 undefined的原因,我认为就是更安全而已,节省的那丁点内存能干啥。对于更详细的的说明,我直接引用网上的:

  1. 在 ES5 之前,window 下的 undefined 是可以被重写的,于是导致了某些极端情况下使用 undefined 会出现一定的差错。所以,用 void 0 是为了防止 undefined 被重写而出现判断不准确的情况。
  2. 可以减少字节。void 0 代替 undefined 省3个字节。

注: ES5 之后的标准中,规定了全局变量下的 undefined 值为只读,不可改写的,但是局部变量中依然可以对之进行改写。 备注:非严格模式下,undefined 是可以重写的,严格模式则不能重写。

此时then的实现如下:

Promise.prototype.then = function(onFulfilled, onRejected){
    if(this.state === FULFILLED){
        typeof onFulfilled === 'function' && onFulfilled(this.value);
    }
    if(this.state === REJECTED){
        typeof onRejected === 'function' && onRejected(this.reason);
    }
    if(this.state === PENDING){
        // TODO
    }
}
复制代码

上面同时也对onFulfilledonRejected进行了类型判断,因为规范规定如下:

onFulfilled 和 onRejected 都是可选参数。如果 onFulfilled 不是函数,其必须被忽略。如果 onRejected 不是函数,其必须被忽略。

这里扩展一下,若onFulfilled不是函数,那么一个默认的onFulfilled函数会被顶替上来,这就是造成Promise值穿透的本质原因:

var p = Promise.resolve( 42 ); 
p.then( 
    // 假设的完成处理函数,如果省略或者传入任何非函数值
    // function onFulfilled(v) { 
    //     return v; 
    // } 
    null, 
    function onRejected(err){ 
        // 永远不会到达这里
    } 
)
复制代码

同样的,若onRejected不是函数,那么一个默认的onRejected函数会被顶替上来,这其实就是catch方法的实现:

var p = Promise.reject( 42 ); 
var p2 = p.then( 
    function onFulfilled(){ 
        // 永远不会达到这里
    } 
     // 假定的拒绝处理函数,如果省略或者传入任何非函数值
     // function onRejected(err) { 
     //     throw err; 
     // } 
)
复制代码

所以接着修改then方法,如下:

Promise.prototype.then = function(onFulfilled, onRejected){
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : value => value;
    onRejected = typeof onRejected === 'function' ? onRejected : reason => { throw reason };
    
    if(this.state === FULFILLED){
        onFulfilled(this.value);
    }
    if(this.state === REJECTED){
        onRejected(this.reason);
    }
    if(this.state === PENDING){
        // TODO
    }
}
复制代码

规范规定:then方法返回一个新的Promise,所以我们不能将this(即new调用产生的Promise实例)返回。接着修改如下:

Promise.prototype.then = function(onFulfilled, onRejected){
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : value => value;
    onRejected = typeof onRejected === 'function' ? onRejected : reason => { throw reason };
    
    const promise2 = new Promise((resolve, reject) => {
        if(this.state === FULFILLED){
            let x = onFulfilled(this.value);
            resolve(x);
        }
        if(this.state === REJECTED){
            let x = onRejected(this.reason);
            reject(x);
        }
        if(this.state === PENDING){
            // TODO
        }
    })
    return promise2;
}
复制代码

因为onFulfilledonRejected函数可能出错,所以我们要加上try/catch块。再考虑若上面得到的x值就是一个Promise对象,我们该怎样处理?很简单,我们可以用x去调用then方法,通过递归的方式,直到得到的x值不是一个Promise对象。慢着,先别急着修改代码,规范中有这样以句话:

如果 onFulfilled 或者 onRejected 返回一个值 x ,则运行下面的 Promise 解决过程[[Resolve]](promise2, x)

Promise 解决过程是一个抽象的操作,其需输入一个 promise 和一个值,再加之我们拿到x值后还需要处理,所以总共传入4个参数。我们将这个解决过程起名resolvePromise,先将它抽象出来,随后将刚才考虑到的情况及别的情况在里面处理,所以修改如下:

Promise.prototype.then = function(onFulfilled, onRejected){
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : value => value;
    onRejected = typeof onRejected === 'function' ? onRejected : reason => { throw reason };
    
    const promise2 = new Promise((resolve, reject) => {
        if(this.state === FULFILLED){
            try{
                let x = onFulfilled(this.value);
                resolvePromise(promise2, x, resolve, reject);
            }catch(error){
                reject(error);
            }
        }
        if(this.state === REJECTED){
            try{
                let x = onRejected(this.reason);
                resolvePromise(promise2, x, resolve, reject);
            }catch(error){
                reject(error);
            }
        }
        if(this.state === PENDING){
            // TODO
        }
    })
    return promise2;
}
复制代码

代码写到这里,注意then方法中我一直保留着对于pending状态未处理的情况。有人就会说,这完全没必要啊,promise决议后的状态值只能是fulfilledrejected,这种考虑太多余。试想promise压根就没有决议或由于某些原因延迟决议,这两种情况下,调用then方法,方法内部获取到的state状态值必然是pending。状态还未发生改变,但此时then已经被调用走完了,我们此时该怎么处理,能够让未来状态值发生改变后,将我们之前传入的onFulfilled、 onRejected方法再执行一遍?

🙋:判断状态值为pending时,我们可以先将传入的onFulfilled、 onRejected方法先分别保存起来,然后在构造函数里面决议(即调用resolvereject方法)后再去调用。所以我们需要在构造函数里面且在实例上定义两个变量以供存储,又考虑到一个promise实例上可以注册多个then方法(即p.then(onFulfilled, onRejected); p.then(onFulfilled, onRejected);),为了避免保存onFulfilled、 onRejected方法时后来的覆盖前面的,这里供存储的变量需要定义为数组。再来修改构造函数,如下:

const PENDING = 'pending';
const FULFILLED = 'fulfilled';
const REJECTED = 'rejected';

function Promise(executor){
    const _this = this;
    _this.state = PENDING;
    _this.value = void 0;
    _this.reason = void 0;
    _this.onResolvedCallback = [];
    _this.onRejectedCallback = [];
    
    function resolve(value){
        if(_this.state === PENDING){
            _this.state = FULFILLED;
            _this.value = value;
            _this.onResolvedCallback.length > 0 && 
            _this.onResolvedCallback.forEach(fn => fn());
        }
    }
    function reject(reason){
        if(_this.state === PENDING){
            _this.state = REJECTED;
            _this.reason = reason;
            _this.onRejectedCallback.length > 0 && 
            _this.onRejectedCallback.forEach(fn => fn());
        }
    }
    
    try{
        executor(resolve, reject);
    }catch(error){
        reject(error);
    }
}
复制代码

perfect!构造函数算是彻底搞定了。按照刚才上面讲的,then方法也需要修改,同时考虑到then方法异步调用的问题,现在我们直接调用then方法,它肯定同步执行,为了达到异步,这里我们先用setTimeout函数来实现,当然它属于宏任务,在浏览器中应该用MutationObserver来产生微任务,这放到后面我们去实现。遂修改如下:

Promise.prototype.then = function(onFulfilled, onRejected){
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : value => value;
    onRejected = typeof onRejected === 'function' ? onRejected : reason => { throw reason };
    
    const promise2 = new Promise((resolve, reject) => {
        if(this.state === FULFILLED){
            setTimeout(()=>{
                try{
                    let x = onFulfilled(this.value);
                    resolvePromise(promise2, x, resolve, reject);
                }catch(error){
                    reject(error);
                }
            })
        }
        if(this.state === REJECTED){
            setTimeout(()=>{
                try{
                    let x = onRejected(this.reason);
                    resolvePromise(promise2, x, resolve, reject);
                }catch(error){
                    reject(error);
                }
            })
        }
        if(this.state === PENDING){
            this.onResolvedCallback.push(() => {
                setTimeout(()=>{
                    try {
                        let x = onFulfilled(this.value);
                        resolvePromise(promise2, x, resolve, reject);
                    }catch(error){
                        reject(error);
                    }
                })
            });
            this.onRejectedCallback.push(() => {
                setTimeout(()=>{
                    try {
                        let x = onRejected(this.reason);
                        resolvePromise(promise2, x, resolve, reject);
                    }catch(error){
                        reject(error);
                    }
                })
            });
        }
    })
    return promise2;
}
复制代码

注意上面我一直使用的箭头函数,所以不存在this指向改变的问题。

上面setTimeout函数第二可选参数未指定,默认为0,但这个延迟就真的是0ms吗?

有人说setTimeout 的最小时延是 4ms,但这是有前提条件的,所以并不准确。以chrome为例, chrome 的最低时延是 1ms。而如果 timer 嵌套层级>=5,那么最低时延是 4ms。再看如下chrome下的测试代码,你自然就会明白:

setTimeout(()=>{console.log(5)},5)
setTimeout(()=>{console.log(4)},4)
setTimeout(()=>{console.log(3)},3)
setTimeout(()=>{console.log(2)},2)
setTimeout(()=>{console.log(1)},1)
setTimeout(()=>{console.log(0)},0)
// 结果: 1 0 2 3 4 5
setTimeout(() => {
  setTimeout(() => {
    setTimeout(() => {
      setTimeout(() => {
        setTimeout(() => {
          // 这就是 timer 嵌套层级
        }, 0)        
      }, 0)      
    }, 0)
  }, 0)
},0)
复制代码

关于时延更详细的你可以看这里 👉👉👉

好了,就差一个Promise 解决过程的实现了。因为 Promise 是先有社区实现,再逐渐形成规范,许多早期实现的 Promise 库与规范并不完全一致。对此,为了兼容早期那些并不完全符合规范的实现,规范里明确定义了各种情况的处理方式,所以我们也不用煞费苦心的去考虑种种情况了,人家都列好了,我们对照着一步步来实现就好了。让我们着手来实现这个resolvePromise函数。

Promise 解决过程

  1. xpromise 相等

    如果 promisex 指向同一对象,以 TypeError 为据因拒绝执行 promise

function resolvePromise(promise2, x, resolve, reject){
    if(x === promise2)  return reject(new TypeError('Chaining cycle detected for promise #<Promise>'));
}
复制代码

考虑如果是同一对象且把自己返回出去,即var p2 = p.then(() => p2),这样就陷入了死循环,所以先排除这种情况。

  1. xPromise

    如果 x 为 Promise ,则使 promise 接受 x 的状态 :

    • 如果 x 处于等待态, promise 需保持为等待态直至 x 被执行或拒绝
    • 如果 x 处于执行态,用相同的值执行 promise
    • 如果 x 处于拒绝态,用相同的据因拒绝 promise

这条可以直接略过,因为任何具有 then(..) 方法的对象和函数(即thenable)都会被识别为 Promise 对象。instanceof 并不足以作为检查方法,考虑库或框架可能会选择实现自己的 Promise,而不是使用原生 ES6 Promise 实现。就像我们现在在做的一样,我们定义了个 Promise 函数,然后在原型上添加了 then 方法,我们就认为它是个 Peromise 对象。

往下第 3 条就处理了 xthenable 的情况,你也可以看为即是处理了 xPromise 的情况。介于此,我就不在此处费功夫了,事实也证明了我的想法是正确的,因为后面的测试完美通过。(多一事不如少一事啦~~~

  1. x为对象或函数

    • x.then 赋值给 then
    • 如果取 x.then 的值时抛出错误 e ,则以 e 为据因拒绝 promise
    • 如果 then 是函数,将 x 作为函数的作用域 this 调用之。传递两个回调函数作为参数,第一个参数叫做 resolvePromise ,第二个参数叫做 rejectPromise:
      • 如果 resolvePromise 以值 y 为参数被调用,则运行 [[Resolve]](promise, y)
      • 如果 rejectPromise 以据因 r 为参数被调用,则以据因 r 拒绝 promise
      • 如果 resolvePromiserejectPromise 均被调用,或者被同一参数调用了多次,则优先采用首次调用并忽略剩下的调用
      • 如果调用 then 方法抛出了异常 e
        • 如果 resolvePromiserejectPromise 已经被调用,则忽略之
        • 否则以 e 为据因拒绝 promise
      • 如果 then 不是函数,以 x 为参数执行 promise
    • 如果 x 不为对象或者函数,以 x 为参数执行 promise
    function resolvePromise(promise2, x, resolve, reject){
        if(x === promise2)  
            return reject(new TypeError('Chaining cycle detected for promise #<Promise>'));
        if(x && typeof x === 'object' || typeof x === 'function'){
            let called = false; // 为了判断是否被调用
            try{
                let then = x.then;  // 这一行一定要放在 try/catch 块里
                if(typeof then === 'function'){
                    then.call(x, y => {
                        if(called)  return;
                        called = true;
                        resolvePromise(promise2, y, resolve, reject);   // 这里就是递归处理
                    }, r => {
                        if(called)  return;
                        called = true;
                        reject(r);
                    });
                }else{
                    resolve(x);
                }
            }catch(e){
                if(called)  return;
                called = true;
                reject(e);
            }
        }else{
            resolve(x);
        }
    }
    复制代码

    上面取 x.then 的值时,我们在前面的判断中已经排除了 xnull、undefined 的情况,那什么情况下还会抛出异常呢?

    🙋:考虑访问器属性下的极端情况:

    let x = {
        get then(){
            throw '错误';
        }
    }
    x.then; // Uncaught 错误
    复制代码

    所以取 x.then 值时,一定要放进 try/catch 块中。

Nice完成,我们可以看到难点就一处:Promise 解决过程,但对照着规范来实现也不是难事。至此Promise大法就已经练成了,我们接下来将它的一些扩展功能也实现一下,然后再进行测试。

Promise.prototype.catch()

这个最简单,将then方法的第一参数置为null就好了,个人认为,只要不是函数都可以,当然传null是比较好的选择。

Promise.prototype.catch = function (onRejected) {
  return this.then(null, onRejected);
};
复制代码

Promise.prototype.finally()

finally()方法用于指定不管 Promise 对象最后状态如何,都会执行的操作。等同于同样的语句为成功和失败两种情况各写一次。finally方法的回调函数不接受任何参数,因为它不依赖于 Promise 的执行结果。

Promise.prototype.finally = function (callback) {
  let P = this.constructor; // P 指向当前 promise 实例的构造器,简单说就是 Promise 构造函数
  return this.then(
    value  => P.resolve(callback()).then(() => value),
    reason => P.resolve(callback()).then(() => { throw reason })
  );
};
复制代码

Promise.resolve()

讲道理,我们上面已经实现了一遍了,就是Promise 解决过程实现中的resolvePromise函数,我们将它简单封装一下就得到了Promise.resolve()。考虑一种情况,value 如果是 promise 类型,不作处理直接返回。

Promise.resolve = function(value) {
    if(value instanceof Promise)    return value
    const promise = new Promise(function(resolve, reject) {
        resolvePromise(promise, value, resolve, reject)
    });
    return promise;
}
复制代码

Promise.reject()

这没什么好说的,直接返回一个拒绝的promise实例。

Promise.reject = function(reason) {
    return new Promise(function(resolve, reject) {
        reject(reason)
    });
}
复制代码

Promise.all()

Promise.all()方法用于将多个 Promise 实例,包装成一个新的 Promise 实例。所有实例的状态都变成fulfilled,新生成实例的状态才会变成fulfilled;只要有一个实例被rejected,新生成实例的状态就变成rejected

需要注意的是:

  • fulfilled状态下返回值是一个按顺序存储每一个实例返回值的数组,而rejected状态下返回值则是第一个被拒绝实例的返回值
  • Promise.all()方法的参数可以不是数组,但必须具有 Iterator 接口,且返回的每个成员都是 Promise 实例
Promise.all = function(iterable){   
    // 假定传入的参数就是数组或字符串等具有 Iterator 接口的对象,这里对参数省略过多校验
    const results = []; // 用于存放结果
    let index = 0;  // 记录下标
    let count = 0;  // 记录已存放结果个数
    return new Promise(function(resolve, reject){
        if(iterable[Symbol.iterator]().next().done) // 判断传入的可迭代对象是空的
            return resolve([]);     // return 终止
        for(let item of iterable){  
            let ind = index++;  // 只要循环下标就 +1,使用 let 来让每一次循环存储一个独有的下标
            Promise.resolve(item)   // 迭代出来的值无论是 promise 类型还是基本类型值,都给它封成 promise 
            .then(value =>{
                results[ind] = value;
                count++;    // 存放结果后才 +1
                if(count === iterable.length)   // 长度相同即判定都完成了
                    resolve(results);
            }, reason => {
                reject(reason); // //只要有一个失败,return 的 promise 状态就为 reject
            })
        }
    });
}
复制代码

对于上面技术细节的考虑:

  • 很多人在循环中判断每一项是不是Promise对象,准确说应该是判断是不是thenable对象,我认为不可取,thenable对象并不一定安全,用Promise.resolve()封装一下最好也最安全

  • for...of循环用来遍历迭代器对象,很多人用原始for循环来遍历,这没有考虑定义了Iterator 接口的对象

  • for...of循环不支持下标,并且为了确保返回数据的顺序,我们需要用下标来赋值,而不是push()

  • 数组用下标赋值,有个需要小心的地方:

    var a = [];
    a[3] = 3;
    a.length;   // 4
    a;  //  [empty × 3, 3]   这里是用空位填充
    复制代码

    数组的长度不足以表明结果的个数,所以我定义了个count变量来记录

  • 对于传入的可迭代对象是空的判断,我考虑调用它的遍历器接口生成遍历器,然后第一次调用next()得到的对象done属性为true,该可迭代对象必为空。空的可迭代对象,for...of是不会作用的

证明一下我最后一点的考虑:

// 空字符串 '' 与 空数组 [] 都是空的可迭代对象,因为 String 类型与 Array 类型部署有 Symbol.iterator 接口
''[Symbol.iterator]().next().done;  // true
[][Symbol.iterator]().next().done;  // true
// 我直接 resolve([]),原因出于下面测试
Promise.all('').then(val=>{console.log(val)});  // []
Promise.all([]).then(val=>{console.log(val)});  // []
复制代码

当然对于Promise.all()的说明MDN上说的很详细,这里就不考虑那么多细节,简单实现一下。

Promise.race()

Promise.race()方法同样是将多个 Promise 实例,包装成一个新的 Promise 实例。只要有一个实例率先改变状态,新生成实例的状态就跟着改变。那个率先改变的 Promise 实例的返回值,就传递给新生成实例的回调函数。

Promise.race = function(iterable){
    // 假定传入的参数就是数组或字符串等具有 Iterator 接口的对象,这里对参数省略过多校验
    return new Promise(function(resolve, reject){
        for(let item of iterable){
            Promise.resolve(item)
            .then(value => {
                resolve(value);
            }, reason => {
                reject(reason);
            })
        }
    })      
}
复制代码

Promise.race()相比Promise.all()实现就比较简单了。这里并没有判断传入的可迭代对象是空的,因为这是特别需要注意的一点:

若向 Promise.all([ .. ]) 传入空数组,它会立即完成,但 Promise.race([ .. ]) 会挂住,且永远不会决议。

因为永不决议,所以无需处理,for...of也不可能对一个空的可迭代对象进行任何操作。

Promise.allSettled()

Promise.allSettled()方法同样接受一组 Promise 实例作为参数,包装成一个新的 Promise 实例。只有等到所有这些参数实例都返回结果,不管是fulfilled还是rejected,包装实例才会结束。

该方法返回的新的 Promise 实例,一旦结束,状态总是fulfilled,不会变成rejected。状态变成fulfilled后,Promise 的监听函数接收到的参数是一个数组,每个成员对应一个传入Promise.allSettled()Promise 实例。

对于每个结果对象,都有一个 status 字符串。如果它的值为 fulfilled,则结果对象上存在一个 value 。如果值为 rejected,则存在一个 reasonvalue(或 reason )反映了每个 promise 决议(或拒绝)的值。

Promise.allSettled = function(iterable){    
    // 假定传入的参数就是数组或字符串等具有 Iterator 接口的对象,这里对参数省略过多校验
    const results = []; // 用于存放结果
    let index = 0;  // 记录下标
    let count = 0;  // 记录已存放结果个数
    return new Promise(function(resolve, reject){
        if(iterable[Symbol.iterator]().next().done) // 判断传入的可迭代对象是空的
            return resolve([]);     // return 终止
        for(let item of iterable){   
            let ind = index++;  // 只要循环下标就 +1,使用 let 来让每一次循环存储一个独有的下标
            Promise.resolve(item)   // 迭代出来的值无论是 promise 类型还是基本类型值,都给它封成 promise 
            .then(value =>{
                results[ind] = { status: 'fulfilled', value };
                count++;
                if(count === iterable.length)   // 长度相同即判定都完成了
                    resolve(results);
            }, reason => {
                results[ind] = { status: 'rejected', reason };
                count++;
                if(count === iterable.length)   // 长度相同即判定都完成了
                    resolve(results);   // 因为包装实例的状态只会变为 fulfilled
            })
        }
    });
}
复制代码

Promise.all()的基础上改起来也很方便。

Promise.any()

Promise.any()方法接受一组 Promise 实例作为参数,包装成一个新的 Promise 实例。只要参数实例有一个变成fulfilled状态,包装实例就会变成fulfilled状态;如果所有参数实例都变成rejected状态,包装实例就会变成rejected状态。

注意: Promise.any()抛出的错误,不是一个一般的错误,而是一个 AggregateError 实例。它相当于一个数组,每个成员对应一个被rejected的操作所抛出的错误。

new AggregateError() extends Array -> AggregateError

const err = new AggregateError();
err.push(new Error("first error"));
err.push(new Error("second error"));
throw err;
复制代码

虽然Promise.any()还是实验性质的,但有了上面的思路,我们简单实现下:

Promise.any = function(iterable){
    // 假定传入的参数就是数组或字符串等具有 Iterator 接口的对象,这里对参数省略过多校验
    const err = new AggregateError();   // 用于存放错误
    let index = 0;  // 记录下标
    let count = 0;  // 记录已存放结果个数
    return new Promise(function(resolve, reject){
        if(iterable[Symbol.iterator]().next().done) // 判断传入的可迭代对象是空的
            return resolve([]);     // return 终止
        for(let item of iterable){
            let ind = index++;  // 只要循环下标就 +1,使用 let 来让每一次循环存储一个独有的下标
            Promise.resolve(item)
            .then(value => {
                resolve(value);
            }, reason => {
                err[ind] = (new Error(reason));
                count++;
                if(count === iterable.length)   // 长度相同即判定都完成了
                    reject(err);
            })
        }
    })      
}
复制代码

如果传入的参数是一个空的可迭代对象, 这个方法将会同步返回一个已经完成的 promise,所以我加上了对空的可迭代对象的判断。既然AggregateError继承自Array,必然会有array该有的属性。

Promise测试

上面完成了Promise的实现及常用的扩展API,这里我们测试一下。遵循网上常规的做法,首先贴下整体代码:

const PENDING = 'pending';
const FULFILLED = 'fulfilled';
const REJECTED = 'rejected';

function Promise(executor){
    const _this = this;
    _this.state = PENDING;
    _this.value = void 0;
    _this.reason = void 0;
    _this.onResolvedCallback = [];
    _this.onRejectedCallback = [];
    
    function resolve(value){
        if(_this.state === PENDING){
            _this.state = FULFILLED;
            _this.value = value;
            _this.onResolvedCallback.length > 0 && 
            _this.onResolvedCallback.forEach(fn => fn());
        }
    }
    function reject(reason){
        if(_this.state === PENDING){
            _this.state = REJECTED;
            _this.reason = reason;
            _this.onRejectedCallback.length > 0 && 
            _this.onRejectedCallback.forEach(fn => fn());
        }
    }
    
    try{
        executor(resolve, reject);
    }catch(error){
        reject(error);
    }
}

function resolvePromise(promise2, x, resolve, reject){
    if(x === promise2)  
        return reject(new TypeError('Chaining cycle detected for promise #<Promise>'));
    if(x && typeof x === 'object' || typeof x === 'function'){
        let called = false; // 为了判断是否被调用
        try{
            let then = x.then;
            if(typeof then === 'function'){
                then.call(x, y => {
                    if(called)  return;
                    called = true;
                    resolvePromise(promise2, y, resolve, reject);   // 这里就是递归处理
                }, r => {
                    if(called)  return;
                    called = true;
                    reject(r);
                });
            }else{
                resolve(x);
            }
        }catch(e){
            if(called)  return;
            called = true;
            reject(e);
        }
    }else{
        resolve(x);
    }
}

Promise.prototype.then = function(onFulfilled, onRejected){
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : value => value;
    onRejected = typeof onRejected === 'function' ? onRejected : reason => { throw reason };
    
    const promise2 = new Promise((resolve, reject) => {
        if(this.state === FULFILLED){
            setTimeout(()=>{
                try{
                    let x = onFulfilled(this.value);
                    resolvePromise(promise2, x, resolve, reject);
                }catch(error){
                    reject(error);
                }
            })
        }
        if(this.state === REJECTED){
            setTimeout(()=>{
                try{
                    let x = onRejected(this.reason);
                    resolvePromise(promise2, x, resolve, reject);
                }catch(error){
                    reject(error);
                }
            })
        }
        if(this.state === PENDING){
            this.onResolvedCallback.push(() => {
                setTimeout(()=>{
                    try {
                        let x = onFulfilled(this.value);
                        resolvePromise(promise2, x, resolve, reject);
                    }catch(error){
                        reject(error);
                    }
                })
            });
            this.onRejectedCallback.push(() => {
                setTimeout(()=>{
                    try {
                        let x = onRejected(this.reason);
                        resolvePromise(promise2, x, resolve, reject);
                    }catch(error){
                        reject(error);
                    }
                })
            });
        }
    })
    return promise2;
}
复制代码

再在代码底部放上我们实现的Promise.resolve()Promise.reject()方法,其实这个放不放的无所谓啦。之后再加点额外的供测试的代码,要加的总共如下:

Promise.resolve = function(value) {
    if(value instanceof Promise)    return value
    var promise = new Promise(function(fulfill, reject) {
        resolvePromise(promise, value, fulfill, reject)
    })
    return promise
}

Promise.reject = function(reason) {
    return new Promise(function(fulfill, reject) {
        reject(reason)
    })
}

Promise.deferred = Promise.defer = function() {
    var dfd = {}
    dfd.promise = new Promise(function(fulfill, reject) {
        dfd.resolve = fulfill
        dfd.reject = reject
    })
    return dfd
}

module.exports = Promise
复制代码

然后两步走:

  1. promises-aplus-tests安装:npm install -g promises-aplus-tests

  2. 进入你保存文件(我是promise.js)所在的目录运行:promises-aplus-tests promise.js

当我们看到这个872 passing时,就证明测试通过啦!!!

对于扩展方法的测试,自己还是要写几个样例,自测一下,避免出错!!!

function 版本的 promise 都实现了, 那 class 版本的换汤不换药,稍改改就出来了,留心 this,这里我就不多啰嗦了。

通过MutationObserver实现真正意义上的Promise

微任务(MicroTask)屈指可数:Process.nextTickNode独有)、PromiseObject.observe(废弃)、MutationObserver

在上面为了达到异步,我们使用的是setTimeout函数,但这是宏任务,Promise是微任务,目前浏览器中也只有MutationObserver能产生微任务了。关于MutationObserver,可以去 MDN 上了解一下。

为什么一定要使用微任务来实现?

🙋:

setTimeout单单一个4ms的延迟可能在一般的web应用中并不会有什么问题,但是考虑极端情况,我们有20个Promise链式调用,加上代码运行的时间,那么这个链式调用的第一行代码跟最后一行代码的运行很可能会超过100ms,如果这之间没有对UI有任何更新的话,虽然本质上没有什么性能问题,但可能会造成一定的卡顿或者闪烁,虽然在web应用中这种情形并不常见,但是在Node应用中,确实是有可能出现这样的case的,所以一个能够应用于生产环境的实现有必要把这个延迟消除掉。在Node中,我们可以调用process.nextTick。总的来说,就是我们需要实现一个函数,行为跟setTimeout一样,但它需要异步且尽早的调用所有已经加入队列的函数。

这里提供一个用MutationObserver实现的微任务nextTick函数:

function nextTick(fn) {
  if(process !== undefined && typeof process.nextTick === 'function') 
    return process.nextTick(fn)
  else {
    var counter = 1
    var observer = new MutationObserver(fn)
    var textNode = document.createTextNode(String(counter))

    observer.observe(textNode, {
      characterData: true
    })

    counter = counter+1
    textNode.data = String(counter)
  }
}
复制代码

我们只需将上面函数放入已实现代码并且将代码中的setTimeout替换为nextTick就可以了。当然测试也是通过的,但这是Node环境下的测试,必然使用的是上面nextTick函数中process.nextTick生成的微任务,至于浏览器下的测试我就不得而知了。

关于Promise的实际应用

谈到实际应用,我认为最主要就是看你工作上的业务需求了。当然 要就来45道Promise面试题一次爽到底 这最后的大厂面试题就挺好的,你可以看看,我就不往过搬了。

结语

更多时候,我们大家的状态应该是下面这样,这才是最真实的。不过能弄懂的就尽量弄懂吧,先努力让自己达到大众水平。

其实还有一个问题:一直没有 resolve 也没有 reject 的 Promise 会造成内存泄漏吗,我看在网上争论挺激烈的,但最后还是归结于看浏览器厂商的实现,只要引擎实现得当就不会泄露。大家怎么看?