JS深入系列:探究 JavaScript Promises 的详细实现

1,221 阅读17分钟

写在前面:

这是一篇总结文章,但也可以理解为是一篇翻译,主体脉络参考自下面这篇文章:

www.mattgreer.org/articles/pr…

若英文阅读无障碍,墙裂推荐该文章的阅读。

前言

在日常写代码的过程中,我很经常会用到 promises 语法。当我自以为了解 promises 详细用法时,却在一次讨论中被问住了:“你知道 promises 内部的实现过程是怎样的么?” 是的,回想起来,我只是知道该如何使用它,却不知道其内部真正的实现原理。这篇文章正是我自己的关于 promises 的回顾与总结。如果你看完了整篇文章,希望你也会更加理解 promises 的实现与原理。

我们将会从零开始,逐步实现一个自己的 promises。最终的代码将会和 Promises/A+ 规范相似,并且将会明白 promises 在异步编程中的重要性。当然,本文会假设读者已经拥有了关于 promises 的基础知识。

最简单的 Promises

让我们从最简单的 promises 实现开始吧。当我们想要将下面的代码

doSomething(function(value) {
  console.log('Got a value:' + value);
});

转变为

doSomething().then(function(value) {
  console.log('Got a value:' + value);
});

这个时候,我们需要怎么做呢?非常简单的方式就是,将原来的 doSomething()函数从原来的写法

function doSomething(callback) {
  var value = 42;
  callback(value);
}

转变为如下这种 'promise' 写法:

function doSomething() {
  return {
    then: function(callback) {
      var value = 42;
      callback(value);
    }
  };
}

上面只是一个 callback 写法的一种语法糖包装而已,看起来毫无意义。不过,这是个非常重要的转变,我们已经开始触达了 promises 的一个核心理念:

Promises 捕获最终值( eventual values ),并将其放入到一个 Object 中。

Ps: 这里有必要解释“最终值”的概念。它是异步函数的返回值,状态是不确定的,有可能成功,也有可能失败(如下图)。

eventual value

关于 Promises 与最终值( eventual values ),下文会包含更多的讨论。

定义一个简单的 Promise 函数

上面简单的改写并不足以对 promise 的特性做任何的说明,让我们来定义一个真正的 promise 函数吧:

function Promise(fn) {
  var callback = null;
  this.then = function(cb) {
    callback = cb;
  };

  function resolve(value) {
    callback(value);
  }

  fn(resolve);
}

代码解析:将then的写法拆分,同时引入了resolve函数,方便处理 Promise 的传入对象(函数)。同时,使用callback作为沟通then函数与resolve函数的桥梁。这个代码实现,有一点 Promise 该有的样子了,不是么?

在此基础上,我们的doSomething()函数将会写成这种形式:

function doSomething() {
  return new Promise(function(resolve) {
    var value = 42;
    resolve(value);
  });
}

当我们尝试执行的时候,会发现执行会报错。这是因为,在上面的代码实现中,resolve()会比then更早被调用,此时的callback还是null。为了解决这个问题,我们使用setTimeout的方式 hack 一下:

function Promise(fn) {
  var callback = null;
  this.then = function(cb) {
    callback = cb;
  };

  function resolve(value) {
    // 强制此处的 callback 在 event loop 的下一个
    // 迭代中调用,这样 then()将会在其之前执行
    setTimeout(function() {
      callback(value);
    }, 1);
  }

  fn(resolve);
}

经过这样的修改之后,我们的代码将可以成功运行。

这样的代码糟糕透了

我们设想的实现,是可以在异步情况下也可以正常工作的。但是此时的代码,是非常脆弱的。只要我们的then()函数中包含有异步的情况,那么变量callback将会再次变成null。既然这个代码这么渣渣,为什么还要写下来呢?因为上面的模式很方便我们待会的拓展,同时,这个简单的写法,也可以让大脑对thenresolve的工作方式有一个初步的了解。下面我们考虑在此基础上做一定的改进。

Promises 拥有状态

Promises 是拥有状态的,我们需要先了解 Promises 中都有哪些状态:

一个 promise 在等待最终值的时候,将会是 pending 状态,当得到最终值的时候,将会是 resolved 状态。

当一个 promise 成功得到最终值的时候,它将会一直保持这个值,不会再次 resolve。

(当然,一个 promise 的状态也可以是 rejected,下文会细述)

为了将状态引入到我们的代码实现中,我们将原来的代码改写为下面:

function Promise(fn) {
  var state = 'pending';
  //value 表示通过resolve函数传递的参数
  var value;
  //deferred 用于保存then()里面的函数参数
  var deferred;

  function resolve(newValue) {
    value = newValue;
    state = 'resolved';

    if(deferred) {
      handle(deferred);
    }
  }

  function handle(onResolved) {
    if(state === 'pending') {
      deferred = onResolved;
      return;
    }

    onResolved(value);
  }

  this.then = function(onResolved) {
    handle(onResolved);
  };

  fn(resolve);
}

这个代码看起来更加复杂了。不过此时的代码可以让调用方任意调用then()方法,也可以任意使用resolve()方法了。它也可以同时运行在同步、异步的情况下。

代码解析:代码中使用了state这个flag。同时,then()resolve()将公共的逻辑提取到了一个新的函数handle()中:

  • then()resolve()更早被调用的时候,此时的状态是 pending,对应的 value 值并没有准备好。我们将then()里面对应的回调参数保存在 deferred 中,方便 promise 在获取到 resolved 的时候调用。
  • resolve()then()更早被调用的时候,此时的状态设置为 resolved,对应的 value 值也已经得到。当then()被调用的时候,直接调用then()里面对应的回调参数即可。
  • 由于then()resolve()将公共的逻辑提取到了一个新的函数handle()中,因此不管上面的两个 case 谁被触发,最终都会执行 handle 函数。

如果你仔细看会发现,此时的setTimeout已经不见了。我们通过 state 的状态控制,已经得到了正确的执行顺序。当然,下面的文章中,还有会使用到setTimeout的时候。

通过使用 promise,我们调用对应方法的顺序将不会受到任何影响。只要符合我们的需求,在任何时刻调用resolve()then()都不会影响其内部逻辑。

此时,我们可以尝试多次调用then方法,会发现每一次得到的都是相同的 value 值。

var promise = doSomething();

promise.then(function(value) {
  console.log('Got a value:', value);
});

promise.then(function(value) {
  console.log('Got the same value again:', value);
});

链式 Promises

在我们日常针对 promises 的编程中,下面的链式模式是常见的:

getSomeData()
.then(filterTheData)
.then(processTheData)
.then(displayTheData);

getSomeData()返回的是一个 promise,此时可以通过调用then()方法。但值得注意的是,第一个then()方法的返回值也必须是一个 promise,这样才可以让我们的链式 promises 一直延续下去。

then()方法必须永远返回一个 promise。

为了实现这个目的,我们将代码做进一步的改造:

function Promise(fn) {
  var state = 'pending';
  var value;
  var deferred = null;

  function resolve(newValue) {
    value = newValue;
    state = 'resolved';

    if(deferred) {
      handle(deferred);
    }
  }

  function handle(handler) {
    if(state === 'pending') {
      deferred = handler;
      return;
    }

    if(!handler.onResolved) {
      handler.resolve(value);
      return;
    }

    var ret = handler.onResolved(value);
    handler.resolve(ret);
  }

  this.then = function(onResolved) {
    return new Promise(function(resolve) {
      handle({
        onResolved: onResolved,
        resolve: resolve
      });
    });
  };

  fn(resolve);
}

呼啦~ 现在的代码让人看起来似乎有点抓狂😩。哈哈哈,你是否会庆幸一开始的时候我们代码不是那么复杂呢?这里面真正的一个关键点在于:then()方法永远返回一个新的 promise。

doSomething().then(function(result){
  console.log("first result : ", result);
  return 88;
}).then(function(secondResult){
  console.log("second result : ", secondResult);
  return 99;
})

让我们来详细看看第二个 promise 的 resolve 过程。它接收来自第一个 promise 的 value 值。详细的过程发生在 handle()方法的底部。入参handler带有两个参数:一个是 onResolved回调,一个是对resolve()方法的引用。在这里,每一个新的 promise 都会有一个对内部方法resolve()的拷贝以及对应的运行时闭包。这是连接第一个 promise 与第二个 promise 的桥梁。

在代码中,我们可以得到第一个 promise 的 value 值:

var ret = handler.onResolved(value);

在上面的例子中,handler.onResolved表示的是:

function(result){
  console.log("first result : ", result);
  return 88;
}

也就是说,handler.onResolved实际上返回的是第一个 promise 的 then 被调用时候的传入参数(函数)。第一个 handler 的返回值被用于第二个 promise 的 resolve 传入参数。

这就是整个链式 promise 的工作方式。

如果我们想要将所有的 then 返回的结果,该怎么做呢?我们可以使用一个数组,来存放每一次的返回值:

doSomething().then(function(result) {
  var results = [result];
  results.push(88);
  return results;
}).then(function(results) {
  results.push(99);
  return results;
}).then(function(results) {
  console.log(results.join(', ');
});

// the output is
//
// 42, 88, 99

promises 永远 resolve 返回的是一个值。当你想要返回多个值的时候,可以通过创建某些符合结构来实现(如数组、object等)。

then 中的传入参数是可选的

then() 中的传入参数(回调函数)是并不是必填的。如果为空,在链式 promise 中,将会返回前一个 promise 的返回值。

doSomething().then().then(function(result) {
  console.log('got a result', result);
});

// the output is
//
// got a result 42

你可以查看handle()中的实现方式,当前一个 promise 没有 then 的传入参数的时候,它会 resolve 前一个 promise 的value 值:

if(!handler.onResolved) {
  handler.resolve(value);
  return;
}

在链式 promise 中返回新的 promise

我们的链式 promise 实现,依然显得有些简单。这里的 resolve 返回的是一个简单的值。假如想要 resolve 返回的是一个新的 promise 呢?比如下面的方式:

doSomething().then(function(result) {
  // doSomethingElse 返回的是一个promise
  return doSomethingElse(result);
}).then(function(finalResult) {
  console.log("the final result is", finalResult);
});

如果是这样的情况,那么我们上面的代码似乎无法应对这样的情况。对于紧随其后的那个 promise 而言,它得到的 value 值将会是一个 promise。为了得到预期的值,我们需要这样做:

doSomething().then(function(result) {
  // doSomethingElse 返回的是一个promise
  return doSomethingElse(result);
}).then(function(anotherPromise) {
  anotherPromise.then(function(finalResult) {
    console.log("the final result is", finalResult);
  });
});

OMG... 这样的实现实在是太糟糕了。难道作为使用者,我还要每一次都需要自己来手动书写这些冗余的代码么?是否可以在 promise 代码内部处理一下这些逻辑呢?实际上,我们只需要在已有代码中的 resolve()中增加一点判断即可:

function resolve(newValue) {
  if(newValue && typeof newValue.then === 'function') {
    newValue.then(resolve);
    return;
  }
  state = 'resolved';
  value = newValue;

  if(deferred) {
    handle(deferred);
  }
}

上面的代码逻辑中我们看到,resolve()中如果遇到的是 promise,将会一直迭代调用resolve()。直到最后获得的值不再是一个 promise,才会依照已有的逻辑继续执行。

还有一个值得注意的点:看看代码中是如何判定一个对象是不是具有 promise 属性的?通过判定这个对象是否有then方法。这种判定方法被称为 "鸭子类型"(我们并不关心对象是什么类型,到底是不是鸭子,只关心行为)。

这种宽松的界定方式,可以使得具体的不同 promise 实现彼此之间有一个很好地兼容。

Promises 的 rejecting

在链式 promise 章节中,我们的实现已经相对而言是非常完整的。但是我们并没有讨论到 promises 中的错误处理。

在 promise 的决议过程中,如果发生了错误,那么 promise 将会抛出一个拒绝决议,同时给出对应的理由。对于调用者,怎么知道错误发生了呢?可以通过 then()方法的第二个传入参数(函数):

doSomething().then(function(value) {
  console.log('Success!', value);
}, function(error) {
  console.log('Uh oh', error);
});

正如上面提到的,一个 promise 会从初始状态 pending 转换为要么是resolved 状态,要么是 rejected 状态。这两者,只能有一个作为最终的状态。对应到then()的两个参数,只有一个会被真正执行。

在 promise 内部实现中,同样允许有一个reject()函数来处理 reject 状态,可以看做是 resolve()函数的孪生兄弟。此时,doSomething()函数也将会被改写为支持错误处理的方式:

function doSomething() {
  return new Promise(function(resolve, reject) {
    var result = somehowGetTheValue();
    if(result.error) {
      reject(result.error);
    } else {
      resolve(result.value);
    }
  });
}

对于此,我们的代码该做如何的对应改造呢?来看代码:

function Promise(fn) {
  var state = 'pending';
  var value;
  var deferred = null;

  function resolve(newValue) {
    if(newValue && typeof newValue.then === 'function') {
      newValue.then(resolve, reject);
      return;
    }
    state = 'resolved';
    value = newValue;

    if(deferred) {
      handle(deferred);
    }
  }

  function reject(reason) {
    state = 'rejected';
    value = reason;

    if(deferred) {
      handle(deferred);
    }
  }

  function handle(handler) {
    if(state === 'pending') {
      deferred = handler;
      return;
    }

    var handlerCallback;

    if(state === 'resolved') {
      handlerCallback = handler.onResolved;
    } else {
      handlerCallback = handler.onRejected;
    }

    if(!handlerCallback) {
      if(state === 'resolved') {
        handler.resolve(value);
      } else {
        handler.reject(value);
      }

      return;
    }

    var ret = handlerCallback(value);
    handler.resolve(ret);
  }

  this.then = function(onResolved, onRejected) {
    return new Promise(function(resolve, reject) {
      handle({
        onResolved: onResolved,
        onRejected: onRejected,
        resolve: resolve,
        reject: reject
      });
    });
  };

  fn(resolve, reject);
}

代码解析:不仅仅新增了一个reject()函数,而且handle()方法内部也增加了对 reject的逻辑处理:通过对state的判断,来决定具体执行handlerreject/resolved

不可知的错误,同样应该引发rejection

上面的代码,只对已知的错误进行了处理。当发生某些不可知错误的时候,同样应该引发 rejection。需要在对应的处理函数中增加try...catch

首先是在resolve()方法中:

function resolve(newValue) {
  try {
    // ... as before
  } catch(e) {
    reject(e);
  }
}

同样的,在 handle()执行具体 callback的时候,也可能发生未知的错误:

function handle(handler) {
  // ... as before

  var ret;
  try {
    ret = handlerCallback(value);
  } catch(e) {
    handler.reject(e);
    return;
  }

  handler.resolve(ret);
}

Promises 会吞下错误

有时候,对于 promises 的错误解读,将会导致 promises 吞下错误。这是个经常坑开发者的点。

让我们来考虑这个例子:

function getSomeJson() {
  return new Promise(function(resolve, reject) {
    var badJson = "<div>uh oh, this is not JSON at all!</div>";
    resolve(badJson);
  });
}

getSomeJson().then(function(json) {
  var obj = JSON.parse(json);
  console.log(obj);
}, function(error) {
  console.log('uh oh', error);
});

这段代码将会如何进行呢?在then()中的 resolve 执行的是对 JSON 的解析。它以为能够执行,结果却抛出了异常,因为传入的 value 值并不是 JSON 格式。我们写了一个 error callback 来捕获这个错误。这样是没有问题,对吧?

不,结果可能并不符合你的期望。此时的 error callback 并不会触发。结果将会是:控制台上没有任何的 log 输出。这个错误就这样被平静地吞掉了。

为什么会这样?因为我们的错误发生在then()的 resolve 回调内部,源码上看是发生在 handle()方法内部。这将会导致的是,then()返回的新的 promise 将会被触发 reject,而不是现有的这个 promise 会触发 reject:

function handle(handler) {
  // ... as before

  var ret;
  try {
    ret = handlerCallback(value);
  } catch(e) {
  	// 到达这里,触发的是handler.reject()
  	// 这是then()返回的新的promise的reject()
  	// 如果改成 handler.onRejected(ex),将会触发本promise的reject()
    handler.reject(e);
    return;
  }

  handler.resolve(ret);
}

如果将上面代码中的catch部分改写成:handler.onRejected(ex);将会触发的是本 promise 的reject()。但这就违背了 promises 的原则:

一个 promise 会从初始状态 pending 转换为要么是 resolved 状态,要么是 rejected 状态。这两者,只能有一个作为最终的状态。对应到then()的两个参数,只有一个会被真正执行。

因为已经触发了 resolved 状态,那么久不可能再次触发 rejected 状态。错误是在具体执行 resolved 函数的时候发生的,那么这个 error,将会被下一个 promise 捕获。

我们可以这样验证:

getSomeJson().then(function(json) {
  var obj = JSON.parse(json);
  console.log(obj);
}).then(null, function(error) {
  console.log("an error occured: ", error);
});

这可能是 promises 中最坑人的一个点了。当然,只要理解了其中的缘由,那么就可以很好地避免。为了更好地体验,我们有什么解决方法来规避这个坑呢?请看下一节:

done()来帮忙

大部分的 promise 库都包含有一个 done()方法。它实现的功能和then()方法相似,只是很好的规避了刚刚提到的then()的坑。

done()方法可以像then()那样被调用。两者之间主要有两点不同:

  • done()方法返回的不是一个 promise
  • done()中的任何错误将不会被 promise 实现捕获(直接抛出)

在我们的例子中,如果使用done()方法,将会更加保险:

getSomeJson().done(function(json) {
  // when this throws, it won't be swallowed
  var obj = JSON.parse(json);
  console.log(obj);
});

从rejection中恢复

从 promise 中的 rejection 恢复是有可能的。如果在一个包含有 rejection 的 promise 中增加更多的then()方法,那么从这个then() 开始,将会延续链式 promise 的正常处理流程:

aMethodThatRejects().then(function(result) {
  // won't get here
}, function(err) {
  // since aMethodThatRejects calls reject()
  // we end up here in the errback
  return "recovered!";
}).then(function(result) {
  console.log("after recovery: ", result);
}, function(err) {
  // we won't actually get here
  // since the rejected promise had an errback
});

// the output is
// after recovery: recovered!

Promise 决议必须是异步的

在本文的开头,我们使用了一个 hack 来让我们的简单代码能够正确允许。还记得么?使用了一个 setTimeout。当我们完善了对应的逻辑之后,这个 hack 就没有再使用了。但事实是:Promises/A+ 规范要求 promise 决议必须是一步的。为了实现这个需求,最简单的做法就是再次使用 setTimeout将我们的handle()方法包装一层:

function handle(handler) {
  if(state === 'pending') {
    deferred = handler;
    return;
  }
  setTimeout(function() {
    // ... as before
  }, 1);
}

非常简单的实现。但是,实际上的 promises 库并不倾向于使用setTimeout。如果对应的库是用于 NodeJS,那么它们倾向于使用 process.nextTick,如果对应的库是用于浏览器,那么它们倾向于使用setImmediate

为什么

具体的做法我们知道了,但是为什么规范中会有这样的要求呢?

为了确保一致性与可信赖的执行过程。让我们考虑这样的情况:

var promise = doAnOperation();
invokeSomething();
promise.then(wrapItAllUp);
invokeSomethingElse();

上面的代码会被怎样执行呢?基于命名,你可能设想这个执行过程会是这样的:invokeSomething() -> invokeSomethingElse() -> wrapItAllUp()。但实际上,这取决于在我们当前的实现过程中,promise 的 resolve 过程是同步的还是异步的。如果doAnOperation()的 promise 执行过程是异步的,那么其执行过程将会是设想的流程。如果doAnOperation()的 promise 执行过程是同步的,它真实的执行过程将会是invokeSomething() -> wrapItAllUp() -> invokeSomethingElse()。这时,可能会导致某些意想不到的后果。

因此,为了确保一致性与可信赖的执行过程。promise 的 resolve 过程被要求是异步的,即使本身可能只是简单的同步过程。这样做,可以让所有的使用体验都是一直的,开发者在使用过程中,也不再需要担心各种不同的情况的兼容。

结论

如果读到了这里,那么可以确定是真爱了!我们将 promises 的核心概念都讲了一遍。当然,文章中的代码实现,大部分都是简陋的。可能也会和真正的代码库实现有一定的出入。但希望不妨碍您对整体 promises 的理解。更多的关于 promises 的实现细节(如:all()race等),可以查看更多的文档与源码实现。

当真正理解了 promises 的工作原理以及它的一些边界情况,我才真正喜欢上它。从此我的项目中关于 promises 的代码也变得更加简洁。关于 promises,还有很多内容值得去探讨,本文只是一个开始。


JavaScript 深入系列文章:

"var a=1;" 在JS中到底发生了什么?

为什么24.toString会报错?

这里有关于“JavaScript作用域”的你想要了解的一切

关于JS中的"this",多的是你不知道的事

JavaScript是面向对象的语言。谁赞成,谁反对?

JavaScript中的深浅拷贝

JavaScript与Event Loop

从 Iterator 讲到 Async/Await

探究 JavaScript Promises 的详细实现