Promise 的核心实现与破解回调地狱的三大技术

120 阅读8分钟

上一篇我们聊到 Node.js 的回调机制时,看到了它如何通过发布订阅模式管理异步操作 —— 这种模式虽然解决了单线程阻塞问题,但当遇到多层依赖的异步场景时,很容易陷入 "回调地狱":代码横向蔓延、错误处理分散、调试时像在拆俄罗斯套娃。

而 Promise 的出现,正是为了破解这种困境。它用状态管理替代了嵌套调用,用链式调用串联起异步流程,甚至把错误处理也统一收束到了一处。今天我们就从 Promise 最核心的链式调用入手,看看它是如何让异步代码重新变得线性、可控,又是如何成为 async/await 这些现代方案的基础的。来吧!让我们趁热打铁。

Promise 的三大法宝

在 Promise 出现之前,异步操作的处理几乎完全依赖回调函数。但多层嵌套的回调很容易形成 “回调地狱”,不仅代码结构混乱,维护起来也十分棘手。

为什么 Promise 能有效破解回调地狱的困局?这就要从它设计中的三大法宝(核心技术)说起:

  1. 回调函数延迟绑定
    传统回调在发起异步操作时必须同步传入回调,导致嵌套。而 Promise 中,异步操作(如 executor 中的逻辑)启动后,回调函数可通过 then 延迟注册,状态变更时再从队列中取出执行。这种 “注册” 与 “执行” 的解耦,避免了嵌套结构。
  2. 返回值穿透
    then 方法返回的新 Promise 会自动接收前一个回调的返回值:普通值直接传递,Promise 则等待其完成后传递结果。即使 then 未传入有效回调(如 promise.then().then(handler)),值也会通过默认函数(value => value)穿透到下一个 then,确保链条不中断。
  3. 错误冒泡
    任何环节的错误(同步异常或 reject)会自动向后传递:若当前 then 未处理错误,错误会冒泡到下一个 then 的错误回调,直到被 catch 捕获。这使得整个链条只需一个 catch 即可处理所有错误,替代了每层回调单独处理错误的冗余逻辑。

下面就让我们具体来看看是怎么实现的吧!

Promise 的核心原理

Promise 本质上是一个状态机,它有三种状态:

  • pending:初始状态,既不是成功也不是失败
  • fulfilled:操作成功完成
  • rejected:操作失败

一旦状态从 pending 变为 fulfilled 或 rejected,就不能再改变。这种特性保证了 Promise 的结果是可靠的,不会被意外修改。

下面我们通过手写一个简单的 Promise 来深入理解其工作原理。

手写 Promise 实现链式调用

首先,我们来实现一个基本的 Promise 构造函数和核心方法:

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

function MyPromise(executor) {
    // 缓存当前Promise实例
    let self = this;
    self.value = null;
    self.error = null;
    self.status = PENDING;
    self.onFulfilledCallbacks = [];
    self.onRejectedCallbacks = [];

    const resolve = (value) => {
        if (self.status !== PENDING) return;
        
        // 处理thenable对象
        if (value && typeof value.then === 'function') {
            value.then(resolve, reject);
            return;
        }
        
        setTimeout(() => {
            self.status = FULFILLED;
            self.value = value;
            self.onFulfilledCallbacks.forEach(callback => callback(self.value));
        });
    };

    const reject = (error) => {
        if (self.status !== PENDING) return;
        setTimeout(() => {
            self.status = REJECTED;  
            self.error = error;
            self.onRejectedCallbacks.forEach(callback => callback(self.error));
        });
    };

    try {
        executor(resolve, reject);
    } catch (error) {
        reject(error);
    }
}

在这个实现中,我们定义了 Promise 的三种状态,并在构造函数中初始化这些状态。resolve 和 reject 方法用于改变 Promise 的状态,并在状态改变后执行相应的回调函数。记住一点,Promis的状态变化是不可逆的,一旦变化不能改变。注意,我们使用 setTimeout 将回调函数放入微任务队列中执行,这是 Promise 规范的要求。

  1. 为什么用数组?
  • 允许多个 .then() 注册回调:当 Promise 处于 pending 状态时,可能会被多次调用 .then() 或 .catch(),每次调用都会注册一个新的回调函数。数组可以存储这些多个回调,确保它们不会被覆盖,而是在状态变更时按注册顺序依次执行。
  • 实现广播机制:Promise 的设计允许同一个 Promise 实例被多次链式调用(如 promise.then().then())。数组作为容器,能够在 Promise 状态确定后(如从 pending 变为 fulfilled),将结果广播给所有注册的回调函数。
  1. 为什么用 setTimeout
  • 确保回调异步执行:then 方法的回调必须在当前调用栈清空后执行(异步执行)。如果直接同步执行回调(如不使用 setTimeout),可能导致回调在 .then() 方法完成前就被触发,破坏了 Promise 的时序模型。
  • 避免同步异常干扰:同步执行回调会使异常提前抛出,无法被后续的 .catch() 捕获。使用 setTimeout 将回调放入宏任务队列,确保异常能被正确传递到 Promise 链的错误处理函数中。

接下来,实现 then 方法,这是 Promise 链式调用的核心:

MyPromise.prototype.then = function(onFulfilled, onRejected) {
    const self = this;
    
    // 值穿透处理
    // 如果不是函数,就创建一个默认函数将值直接传递
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : value => value;
    onRejected = typeof onRejected === 'function' ? onRejected : error => { throw error };
    
    // 每次调用都返回新Promise,这是链式调用的关键
    return new MyPromise((resolve, reject) => {
        const handleFulfilled = value => {
            try {
                const result = onFulfilled(value);
                // 处理返回Promise的情况
                if (result instanceof MyPromise) {
                    result.then(resolve, reject);
                } else {
                    resolve(result);
                }
            } catch (err) {
                reject(err);
            }
        };
        
        const handleRejected = error => {
            try {
                const result = onRejected(error);
                // 处理返回Promise的情况
                if (result instanceof MyPromise) {
                    result.then(resolve, reject);
                } else {
                    resolve(result);
                }
            } catch (err) {
                reject(err);
            }
        };
        
        if (self.status === FULFILLED) {
            setTimeout(() => handleFulfilled(self.value));
        } else if (self.status === REJECTED) {
            setTimeout(() => handleRejected(self.error));
        } else {
            // 状态为 pending  将回调存入队列,等待状态改变后执行
            self.onFulfilledCallbacks.push(handleFulfilled);
            self.onRejectedCallbacks.push(handleRejected);
        }
    });
};

then 方法的核心是每次调用都返回一个新的 Promise,这是实现链式调用的关键。在 then 方法内部,我们处理了三种状态:已完成(FULFILLED)、已拒绝(REJECTED)和进行中(PENDING)。对于进行中的状态,我们将回调函数存入队列,等待状态改变后再执行。

当 Promise 的状态已经是 FULFILLED 时,说明其结果已经就绪。此时,我们会将成功回调函数放入异步队列中执行。这样做可以确保回调函数在当前调用栈完成后才会被触发,符合 Promise 的异步特性。

若 Promise 的状态为REJECTED ,意味着执行过程中出现了错误。我们会将失败回调函数同样放入异步队列,以异步的方式处理错误,保证错误处理逻辑不会阻塞当前代码的执行。

而当 Promise 处于 PENDING状态时,由于结果尚未确定,暂时无法执行回调函数。这时,我们会将成功和失败的回调函数分别存入对应的队列中(onFulfilledCallbacks 和 onRejectedCallbacks)。等到 Promise 的状态发生改变(变为 FULFILLED 或 REJECTED)时,再从队列中取出这些回调函数并执行,从而确保所有注册的回调都能在合适的时机得到处理。

此外,我们还需要实现 catch 方法,用于捕获 Promise 链中的错误:

MyPromise.prototype.catch = function(onRejected) {
    return this.then(null, onRejected);
};

这里传入 null 是为了显式告诉 then 方法 “此处不处理成功情况”,确保错误回调能正确接收并处理异常,同时保持 Promise 链的连续性。

现在,让我们通过一个实际案例来展示 Promise 链式调用的应用。假设我们需要按顺序读取三个文件,并对它们的内容进行处理:

import { readFile } from 'fs';

// 将 Node.js 的回调风格 API 转换为 Promise
let readFilePromise = (filename) => {
  return new MyPromise((resolve, reject) => {
    readFile(filename, (err, data) => {
      if (!err) {
        resolve(data.toString()); // 将 Buffer 转为字符串
      } else {
        reject(err);
      }
    });
  });
};

// 使用 Promise 链式调用按顺序读取文件
readFilePromise('./001.txt')
  .then(data => {
    console.log(data);
    return readFilePromise('./002.txt');
  })
  .then(data => {
    console.log(data);
    return readFilePromise('./003.txt');
  })
  .then(data => {
    console.log(data);
    return 'done';
  })
  .then(console.log)
  .catch(err => console.error('Error:', err));

经过测试,Promise的链式调用功能正常运行,非常nice

Promise 链式调用的优势

通过手写实现和实际应用,我们可以总结出 Promise 链式调用的几个主要优势:

  1. 避免回调地狱:链式调用让异步代码更加线性,可读性大大提高。
  2. 统一的错误处理catch 方法可以捕获整个 Promise 链中的错误,避免了在每个回调中重复编写错误处理代码。
  3. 值传递:每个 then 方法的返回值会作为下一个 then 方法的参数,实现了值的平滑传递。
  4. 更好的调试体验:Promise 链的调用栈更加清晰,便于调试和定位问题。

总结

Promise 链式调用是 JavaScript 异步编程的重要组成部分,它通过状态机和链式调用的设计,解决了回调地狱的问题,提高了代码的可读性和可维护性。通过手写 Promise 实现,我们深入理解了其工作原理,包括状态管理、回调队同时,Promise 也是 async/await 的基础,掌握 Promise 对于理解和使用 async/await 至关重要。

希望本文能够帮助你深入理解 JS Promise 链式调用,让你在编程的道路上更加得心应手~。