从零手写一个 Promise:深入理解 JavaScript 异步编程的本质

每个前端和 Node.js 开发者每天都在用 Promise,但有多少人真正理解它内部是怎么运转的?今天,我们不复述 MDN 文档,而是从零开始,亲手实现一个符合 Promises/A+ 规范的迷你 Promise,把异步编程的本质看个通透。

为什么你要关心 Promise 的实现?

你可能觉得:"我会用 async/await,会用 .then(),够了。" 但你是否遇到过这些困惑:

  • 为什么 new Promise 里的代码是立即执行的,但 .then() 的回调却不是?
  • 为什么 Promise 的 .then() 能链式调用,而且能 "穿透" 返回值?
  • 事件循环里,Promise 的微任务到底排在哪个位置?

这些问题的答案,全都藏在 Promise 的实现细节里。理解了它,你就不再是 API 的使用者,而是异步模型的掌控者。


第一步:Promise 的状态机模型

Promise 的本质是一个有限状态机,只有三种状态:

bash 复制代码
PENDING ──resolve──→ FULFILLED
   │
   └──reject──→ REJECTED

一旦从 PENDING 变成 FULFILLED 或 REJECTED,就再也无法改变 ------ 这就是 "承诺" 的含义。

让我们先把骨架搭起来:

javascript 复制代码
class MiniPromise {
  static PENDING = 'pending';
  static FULFILLED = 'fulfilled';
  static REJECTED = 'rejected';

  constructor(executor) {
    this.status = MiniPromise.PENDING;
    this.value = undefined;   // 成功的值
    this.reason = undefined;  // 失败的原因
    this.onFulfilledCallbacks = []; // 成功回调队列
    this.onRejectedCallbacks = [];  // 失败回调队列

    // resolve 和 reject 用来改变状态
    const resolve = (value) => {
      if (this.status === MiniPromise.PENDING) {
        this.status = MiniPromise.FULFILLED;
        this.value = value;
        // 状态改变后,执行所有排队的回调
        this.onFulfilledCallbacks.forEach(fn => fn());
      }
    };

    const reject = (reason) => {
      if (this.status === MiniPromise.PENDING) {
        this.status = MiniPromise.REJECTED;
        this.reason = reason;
        this.onRejectedCallbacks.forEach(fn => fn());
      }
    };

    // executor 立即执行!这就是为什么 new Promise 里的代码同步运行
    try {
      executor(resolve, reject);
    } catch (err) {
      reject(err);
    }
  }
}

注意一个关键细节:executor(resolve, reject) 是在构造函数中同步调用的。这就解释了下面这段代码的行为:

javascript 复制代码
console.log('1');
new MiniPromise((resolve) => {
  console.log('2');
  resolve('done');
});
console.log('3');
// 输出:1 → 2 → 3  (全是同步的!)

第二步:实现 .then() ------ Promise 的灵魂

.then() 是 Promise 最核心的方法,它需要处理三种情况:

  1. 调用 .then() 时 Promise 已经 fulfilled → 直接执行 onFulfilled
  2. 调用 .then() 时 Promise 已经 rejected → 直接执行 onRejected
  3. 调用 .then() 时 Promise 还在 pending → 把回调存起来,等状态改变再执行

但还有一个最关键的要求:根据 Promises/A+ 规范,.then() 的回调必须异步执行(即作为微任务)。在浏览器中我们用 queueMicrotask,在 Node.js 中可以用 process.nextTick。

javascript 复制代码
then(onFulfilled, onRejected) {
  // 参数穿透:如果你不传回调,值/错误要能传递给下一个 .then()
  onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : value => value;
  onRejected = typeof onRejected === 'function' ? onRejected : reason => { throw reason; };

  // 返回一个新的 Promise,实现链式调用
  const promise2 = new MiniPromise((resolve, reject) => {

    const handleFulfilled = () => {
      // 关键:微任务异步执行
      queueMicrotask(() => {
        try {
          const x = onFulfilled(this.value);
          resolvePromise(promise2, x, resolve, reject);
        } catch (err) {
          reject(err);
        }
      });
    };

    const handleRejected = () => {
      queueMicrotask(() => {
        try {
          const x = onRejected(this.reason);
          resolvePromise(promise2, x, resolve, reject);
        } catch (err) {
          reject(err);
        }
      });
    };

    if (this.status === MiniPromise.FULFILLED) {
      handleFulfilled();
    } else if (this.status === MiniPromise.REJECTED) {
      handleRejected();
    } else {
      // 还在 PENDING,先排队
      this.onFulfilledCallbacks.push(handleFulfilled);
      this.onRejectedCallbacks.push(handleRejected);
    }
  });

  return promise2;
}

这里有两个精妙的设计:

  1. 每次 .then() 都返回一个新 Promise ------ 这就是链式调用的秘密。
  2. 参数穿透 ------ value => value 和 reason => { throw reason } 确保了即使你不写 .catch(),错误也能沿着链条传递下去。

第三步:resolvePromise ------ 处理 "返回值是 Promise" 的情况

这是整个实现中最烧脑的部分。.then() 的回调可能返回各种东西:普通值、另一个 Promise、甚至是一个 thenable 对象。规范对此有严格定义:

javascript 复制代码
function resolvePromise(promise2, x, resolve, reject) {
  // 1. 不能返回自身,否则死循环
  if (promise2 === x) {
    return reject(new TypeError('Chaining cycle detected!'));
  }

  // 2. 如果 x 是 MiniPromise 实例,等它决议
  if (x instanceof MiniPromise) {
    x.then(resolve, reject);
    return;
  }

  // 3. 如果 x 是 thenable(有 .then 方法的对象/函数)
  //    这是为了兼容其他 Promise 库(如 bluebird、Q)
  if (x !== null && (typeof x === 'object' || typeof x === 'function')) {
    let called = false; // 防止多次调用
    try {
      const then = x.then;
      if (typeof then === 'function') {
        then.call(
          x,
          (y) => {
            if (called) return;
            called = true;
            // 递归解析,因为 resolve 的值可能还是一个 Promise
            resolvePromise(promise2, y, resolve, reject);
          },
          (r) => {
            if (called) return;
            called = true;
            reject(r);
          }
        );
      } else {
        // x 是个普通对象,直接 resolve
        resolve(x);
      }
    } catch (err) {
      if (called) return;
      called = true;
      reject(err);
    }
  } else {
    // 4. x 是普通值,直接 resolve
    resolve(x);
  }
}

called 标志位是一个防御性编程的细节:如果外部 thenable 同时调用了 resolve 和 reject,我们只认第一个。


第四步:补全常用静态方法

有了核心骨架,静态方法就是水到渠成:

javascript 复制代码
// Promise.resolve
static resolve(value) {
  if (value instanceof MiniPromise) return value;
  return new MiniPromise((resolve) => resolve(value));
}

// Promise.reject
static reject(reason) {
  return new MiniPromise((_, reject) => reject(reason));
}

// Promise.all ------ 全部成功才成功
static all(promises) {
  return new MiniPromise((resolve, reject) => {
    const results = [];
    let count = 0;
    const list = Array.from(promises);

    if (list.length === 0) return resolve([]);

    list.forEach((p, i) => {
      MiniPromise.resolve(p).then(
        (value) => {
          results[i] = value; // 注意:用 i 保证顺序
          if (++count === list.length) resolve(results);
        },
        reject // 任一失败即失败
      );
    });
  });
}

// Promise.race ------ 谁先决议就用谁
static race(promises) {
  return new MiniPromise((resolve, reject) => {
    for (const p of promises) {
      MiniPromise.resolve(p).then(resolve, reject);
    }
  });
}

// catch 和 finally 只是 then 的语法糖
catch(onRejected) {
  return this.then(null, onRejected);
}

finally(callback) {
  return this.then(
    value => MiniPromise.resolve(callback()).then(() => value),
    reason => MiniPromise.resolve(callback()).then(() => { throw reason; })
  );
}

注意 Promise.all 中 resultsi = value 这一行 ------ 它用索引 i 而非 push 来保证结果数组的顺序与输入一致,这是一个容易被忽略的正确性细节。


完整测试:看看我们的 Promise 能不能跑起来

javascript 复制代码
// 测试 1:基本链式调用
new MiniPromise((resolve) => {
  setTimeout(() => resolve('Hello'), 100);
})
  .then(msg => msg + ' World')
  .then(msg => {
    console.log(msg); // "Hello World"
  });

// 测试 2:错误穿透
MiniPromise.reject('出错了!')
  .then(v => v)           // 跳过
  .then(v => v)           // 跳过
  .catch(err => console.log('捕获:', err)); // "捕获: 出错了!"

// 测试 3:Promise.all
MiniPromise.all([
  MiniPromise.resolve(1),
  new MiniPromise(r => setTimeout(() => r(2), 50)),
  MiniPromise.resolve(3)
]).then(results => {
  console.log(results); // [1, 2, 3]
});

// 测试 4:嵌套 Promise(递归解析)
new MiniPromise(resolve => {
  resolve(new MiniPromise(r => setTimeout(() => r('深层值'), 100)));
}).then(v => console.log(v)); // "深层值"

// 测试 5:事件循环验证
console.log('同步 1');
new MiniPromise(r => {
  console.log('同步 2');
  r();
}).then(() => console.log('微任务'));
console.log('同步 3');
// 输出顺序:同步 1 → 同步 2 → 同步 3 → 微任务

回到最初的问题

现在你可以回答开头的三个问题了:

Q1:为什么 new Promise 里的代码立即执行,但 .then() 的回调不是?

因为 executor(resolve, reject) 在构造函数中是同步调用的,而 .then() 的回调被包裹在 queueMicrotask() 中,作为微任务异步调度。

Q2:为什么 .then() 能链式调用并穿透返回值?

每次 .then() 都返回一个新的 Promise。回调的返回值会通过 resolvePromise 递归解析,如果是普通值就 resolve,如果是 Promise 就等它决议。"穿透" 是参数默认函数 value => value 的功劳。

Q3:Promise 微任务在事件循环中的位置?

微任务队列在当前宏任务执行完毕后、下一个宏任务开始前清空。所以 .then() 回调总是在 setTimeout 之前执行。


更进一步:async/await 只是 Promise 的语法糖

当你理解了 Promise 的内部机制,async/await 就不再神秘:

javascript 复制代码
// async/await
async function fetchUser() {
  const res = await fetch('/api/user');
  const data = await res.json();
  return data;
}

// 等价的 Promise 写法
function fetchUser() {
  return fetch('/api/user').then(res => res.json());
}

async 函数本质上是一个返回 Promise 的函数,await 就是 .then() 的语法糖 ------ 它把后续的代码放进了 .then() 的回调里。JavaScript 引擎帮你做了这个 "编译" 工作。


总结

我们用大约 150 行代码实现了一个基本完整的 Promise。回顾核心设计:

设计要素 实现方式
状态机 PENDING → FULFILLED / REJECTED,单向不可逆
异步执行 queueMicrotask 将回调放入微任务队列
链式调用 每次 .then() 返回新 Promise
递归解析 resolvePromise 处理返回值是 Promise 的情况
错误穿透 默认回调 reason => { throw reason }
兼容性 thenable 协议(检查 .then 方法)

读源码最好的方式,不是去看别人的分析文章,而是自己写一遍。 当你亲手处理了每一个边界情况,那些曾经让你困惑的异步行为,都会变成理所当然。

相关推荐
胡萝卜术1 天前
力扣5. 最长回文子串
前端·javascript·面试
触底反弹1 天前
🔥 从零搭建 RAG 知识库:爬虫→分词→向量化→检索,一步都不能错
javascript·人工智能·面试
এ慕ོ冬℘゜1 天前
JavaScript 数组核心方法实战|新增元素 + 数组转字符串 零基础详解
开发语言·javascript·ecmascript
梦想的旅途21 天前
JavaScript 实现企业微信消息自动发送的技术实践
运维·javascript·自动化·企业微信
就叫飞六吧2 天前
子页面和dialog案例
开发语言·javascript·ecmascript
小月土星2 天前
(实战篇)RAG-demo 深度解析:162行代码中的检索增强生成全景 (含Top-k法)
javascript·数据库·llm
lvchaoq2 天前
this指向面试代码输出题目合集
javascript·面试·职场和发展
你怎么知道我是队长2 天前
JavaScript 函数 this 全解
开发语言·javascript·ecmascript