前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片NEW
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 业务场景题真实业务问题与追问
  • 查漏补缺常见问题解析
  • AI 模拟面试NEW模拟真实面试 + 报告
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
    • AI 定制路线NEW按你的简历现排
    • AI 知识地图NEW串起全站知识点
  • 动态
    • AI 热点NEWAI 每日动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
AI 助手NEW
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片NEW
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 业务场景题真实业务问题与追问
  • 查漏补缺常见问题解析
  • AI 模拟面试NEW模拟真实面试 + 报告
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
    • AI 定制路线NEW按你的简历现排
    • AI 知识地图NEW串起全站知识点
  • 动态
    • AI 热点NEWAI 每日动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
AI 助手NEW
旧版

浅析Promise原理与手写实现

首页2018-12-20 16:30:43Front-End
PromiseJavaScript手写实现源码分析

手写 Promise 大概是前端面试里出现频率最高的一道代码题。多数人能背出「三种状态、状态不可逆、then 返回新 Promise」,但真拿笔写,写到第三步就卡住了,卡的地方几乎都一样:then 里返回一个 Promise 时,外层那个 Promise 怎么知道该等它。

这道题的价值不在于背,而在于每一步都是被一个具体问题逼出来的。为什么要用 setTimeout 包一层?因为同步 resolve 会跑在 then 注册之前。为什么要引入状态?因为异步完成之后再注册的回调也得能执行。为什么 resolve 里要检查参数有没有 then 方法?因为链式调用要靠它衔接。这篇按这个顺序,从一个十行的雏形一路加到完整实现,每加一段都先说清楚它在解决什么。

在本篇文章中,我们将从浅入深,和大家一起学习以下知识:

  • Promise 的三种状态和 A+ 规范里最关键的几条约束
  • 十行代码的极简雏形:then 注册 + resolve 触发
  • 为什么必须加延时机制,setTimeout 和微任务的差别
  • 状态机解决了什么,为什么状态只能转一次
  • 链式 Promise 的衔接机制,bridge promise 是什么
  • 失败处理与错误冒泡是怎么自然实现出来的
  • resolutionProcedure 逐段对照 A+ 规范条款
  • 观察者模式视角下的 Promise

# 一、先把要实现的行为定下来

动手写之前得先明确目标行为。这一节把原生 Promise 的用法过一遍,后面每实现一步都能对回来验证。

# 1.1 基本用法

new Promise(function(resolve, reject) {
    //待处理的异步逻辑
    //处理结束后,调用resolve或reject方法
})
@前端进阶之旅: 代码已经复制到剪贴板

新建一个 promise 很简单,只需要 new 一个 promise 对象即可。Promise 是一个构造函数,它接受一个函数作为参数,并且会返回 promise 对象,这就给链式调用提供了基础。

Promise 函数的使命,就是构建出它的实例,并且负责帮我们管理这些实例。而这些实例有以下三种状态:

  • pending:初始状态,未履行也未拒绝
  • fulfilled:操作成功完成
  • rejected:操作失败

原文这里把 pending 写成了「位履行或拒绝」,是个错别字,应为「未履行」。

pending 状态的 Promise 对象可能以 fulfilled 状态返回了一个值,也可能被某种理由(异常信息)拒绝(reject)了。当其中任一种情况出现时,Promise 对象的 then 方法绑定的处理方法(handlers)就会被调用。then 方法分别指定了成功和失败两个回调函数。

Promise 三种状态与 then 回调的触发关系示意图

var promise = new Promise(function(resolve, reject) {
  if (/* 异步操作成功 */){
    resolve(value);
  } else {
    reject(error);
  }
});
promise.then(function(value) {
  // 如果调用了resolve方法,执行此函数
}, function(value) {
  // 如果调用了reject方法,执行此函数
});
@前端进阶之旅: 代码已经复制到剪贴板

这段代码展示了 promise 对象运行的基本骨架,new 的时候把异步逻辑放进去,成功调 resolve,失败调 reject,外面用 then 接。

下面这个例子更接近真实用法,把一个基于回调的 XMLHttpRequest 包成 Promise:

var getJSON = function(url) {
  var promise = new Promise(function(resolve, reject){
    var client = new XMLHttpRequest();
    client.open("GET", url);
    client.onreadystatechange = handler;
    client.responseType = "json";
    client.setRequestHeader("Accept", "application/json");
    client.send();
    function handler() {
      if (this.status === 200) { 
              resolve(this.response); 
          } else { 
              reject(new Error(this.statusText)); 
          }
    };
  });
  return promise;
};
getJSON("/posts.json").then(function(json) {
  console.log('Contents: ' + json);
}, function(error) {
  console.error('出错了', error);
});
@前端进阶之旅: 代码已经复制到剪贴板

这段值得多看两眼,因为它是 Promise 最原始的用途,把回调风格的 API 转成 Promise 风格。注意 handler 里成功走 resolve、失败走 reject,一次只会调一个。这也是「Promise 化」这个词的来源,Node 的 util.promisify 干的就是这件事。

resolve 方法和 reject 方法调用时都带有参数,它们的参数会被传递给回调函数。reject 方法的参数通常是 Error 对象的实例,而 resolve 方法的参数除了正常的值以外,还可能是另一个 Promise 实例,比如像下面这样:

var p1 = new Promise(function(resolve, reject){
  // ... some code
});
var p2 = new Promise(function(resolve, reject){
  // ... some code
  resolve(p1);
})
@前端进阶之旅: 代码已经复制到剪贴板

上面代码中,p1 和 p2 都是 Promise 的实例,但是 p2 的 resolve 方法将 p1 作为参数,这时 p1 的状态就会传递给 p2。如果调用的时候 p1 的状态是 pending,那么 p2 的回调函数就会等待 p1 的状态改变;如果 p1 的状态已经是 fulfilled 或者 rejected,那么 p2 的回调函数将会立刻执行。

这一条先在这里记下,它是后面第二部分最难实现的一块。「resolve 一个 Promise 就等于把状态权交给它」这个行为,在规范里对应 resolutionProcedure,也是链式调用能成立的根本原因。

# 1.2 promise 捕获错误

Promise.prototype.catch 方法是 Promise.prototype.then(null, rejection) 的别名,用于指定发生错误时的回调函数。

这句「别名」很重要。catch 不是什么特殊机制,它就是 then 的第二个参数,只不过换了个位置写。理解这一点,第二部分实现 catch 就只是一行转发的事。

getJSON("/visa.json").then(function(result) {
  // some code
}).catch(function(error) {
  // 处理前一个回调函数运行时发生的错误
  console.log('出错啦!', error);
});
@前端进阶之旅: 代码已经复制到剪贴板

Promise 对象的错误具有「冒泡」性质,会一直向后传递,直到被捕获为止。错误总是会被下一个 catch 语句捕获。

getJSON("/visa.json").then(function(json) {
  return json.name;
}).then(function(name) {
  // proceed
}).catch(function(error) {
    //处理前面任一个then函数抛出的错误
});
@前端进阶之旅: 代码已经复制到剪贴板

冒泡这个特性看着像是额外设计的,实际上它是「then 没传失败回调时把错误原样往下传」的自然结果,第二部分的 2.7 节实现完你会发现它是白送的。

# 1.3 常用的 promise 方法

这一节把要实现的静态方法行为定下来。这几个组合方法在真实业务里怎么选、allSettled 和 any 又补上了什么,我单独整理在 Promise.all、race、allSettled、any 四个组合方法怎么选 这篇里,这里只讲实现所需要的规则。

Promise.all 方法

Promise.all 方法用于将多个 Promise 实例包装成一个新的 Promise 实例。

var p = Promise.all([p1,p2,p3]);
@前端进阶之旅: 代码已经复制到剪贴板

上面代码中,Promise.all 方法接受一个数组作为参数,p1、p2、p3 都是 Promise 对象的实例。参数不一定是数组,但必须具有 iterator 接口。

p 的状态由 p1、p2、p3 决定,分成两种情况:

  • 只有 p1、p2、p3 的状态都变成 fulfilled,p 的状态才会变成 fulfilled,此时三者的返回值组成一个数组传递给 p 的回调函数
  • 只要 p1、p2、p3 之中有一个被 rejected,p 的状态就变成 rejected,此时第一个被 reject 的实例的返回值会传递给 p 的回调函数

自己实现 all 的时候,这两条规则对应两个要点。一是要维护一个计数器和一个结果数组,结果按下标存而不是按完成顺序 push,这样才能保证顺序和输入一致。二是失败要直接 reject 整个外层 Promise,而且因为状态只能转一次,后续失败会被自动忽略,不需要额外加锁。

// 生成一个Promise对象的数组
var promises = [2, 3, 5, 7, 11, 13].map(function(id){
  return getJSON("/get/addr" + id + ".json");
});
Promise.all(promises).then(function(posts) {
  // ...  
}).catch(function(reason){
  // ...
});
@前端进阶之旅: 代码已经复制到剪贴板

Promise.race 方法

Promise.race 方法同样是将多个 Promise 实例包装成一个新的 Promise 实例。

var p = Promise.race([p1,p2,p3]);
@前端进阶之旅: 代码已经复制到剪贴板

只要 p1、p2、p3 之中有一个实例率先改变状态,p 的状态就跟着改变。那个率先改变的 Promise 实例的返回值,就传递给 p。

race 的实现比 all 简单得多,遍历一遍,每个都挂上 then(resolve, reject) 就完事了。全靠「状态只能转一次」这条规则兜着,第一个 settle 的赢,后面的调用全部无效。这也是为什么状态机那一步必须做对,做对了后面很多东西就是免费的。

如果 Promise.all 和 Promise.race 的参数不是 Promise 实例,就会先调用下面讲到的 Promise.resolve 方法,将参数转为 Promise 实例再进一步处理。

Promise.resolve

有时需要将现有对象转为 Promise 对象,Promise.resolve 方法就起到这个作用。

var jsPromise = Promise.resolve($.ajax('/whatever.json'));
@前端进阶之旅: 代码已经复制到剪贴板

上面代码将 jQuery 生成的 deferred 对象转为一个新的 ES6 的 Promise 对象。这一步能成,靠的就是 deferred 上有 then 方法,也就是它是个 thenable。

fe
  • 一、先把要实现的行为定下来
    • 1.1 基本用法
    • 1.2 promise 捕获错误
    • 1.3 常用的 promise 方法
    • 1.4 上层语法糖:async/await 与 Generator
  • 二、Promise 实现原理剖析
    • 2.1 Promise 标准
    • 2.2 要实现哪些东西
    • 2.3 第一步:极简雏形
    • 2.4 第二步:加入延时机制
    • 2.5 第三步:加入状态
    • 2.6 第四步:链式 Promise
    • 2.7 第五步:失败处理
    • 2.8 第六步:异常处理
    • 2.9 完整实现
    • 2.10 小结
  • 三、几个和标准实现的差距
  • 总结
  • 参考

← React Redux 之 connect 高阶组件原理与源码拆解React Router 原理详解,history 库与路由匹配机制 →