JS底层解析之Promise:从"画饼"到"吃饼"的异步编程之旅

72 阅读11分钟

JS底层解析之Promise:从"画饼"到"吃饼"的异步编程之旅

探索JavaScript中异步编程的演变历程,揭开Promise的神秘面纱

引子:当代码开始"跳来跳去"

想象一下,你正在一家网红餐厅排队点餐。服务员告诉你:"请稍等,您的餐点需要15分钟准备"。这时你有两个选择:

  1. 傻傻地站在原地等待(同步阻塞)
  2. 先去旁边买杯奶茶,等收到通知再回来取餐(异步非阻塞)

聪明的你肯定会选择第二种方式,对吧?JavaScript的世界也是如此,而Promise就是那个优雅的"取餐通知系统"。

一、同步与异步:代码世界的两种节奏

1.1 同步任务:按部就班的执行者

同步任务就像严谨的德国火车时刻表,任务一个接一个地执行:

console.log("第一步:点餐");
console.log("第二步:付款");
console.log("第三步:取餐");

输出结果永远都是:

第一步:点餐
第二步:付款
第三步:取餐

1.2 异步任务:灵活多变的执行者

但当遇到需要等待的操作时(如网络请求、文件读取),同步方式会阻塞整个程序:

console.log("第一步:点餐");
setTimeout(() => console.log("第二步:准备餐点(耗时操作)"), 1000);
console.log("第三步:取餐");

输出结果:

第一步:点餐
第三步:取餐
第二步:准备餐点(耗时操作) // 等1秒后才出现

代码的执行顺序和编写顺序不一致了!这就是"回调地狱"的开始。

二、为什么我们需要Promise?

2.1 回调地狱:代码的"意大利面"

回调函数是编程中作为参数传递给其他函数并在特定时机被调用的函数,常用于异步操作(如网络请求、定时器)、事件处理(如DOM点击)和数组迭代(如map、filter)等场景。它可通过匿名函数、箭头函数或具名函数引用实现,优点是实现异步编程、代码灵活,缺点是多层嵌套会导致回调地狱和错误处理复杂。回调函数是高阶函数的常见应用,与Promise、async/await等异步模式密切相关,使用时需注意闭包陷阱、上下文丢失等问题,其替代方案包括Promise、async/await等。

当异步操作层层嵌套时,代码会变成这样:

fetchData((data) => {
  processData(data, (result) => {
    saveResult(result, (response) => {
      logResponse(response, () => {
        // 更多嵌套...
      });
    });
  });
});

这种代码:

  • 可读性差(像一团乱麻)
  • 错误处理困难
  • 流程控制复杂

2.2 Promise的诞生:异步编程的救星

Promise 是 JavaScript 中用于处理异步操作的一种对象,它代表一个异步操作的最终完成(或失败)及其结果值。Promise 的出现解决了传统回调函数嵌套过深导致的 “回调地狱” 问题,使异步代码更具可读性和可维护性。

Promise就像一张"取餐凭证"(promise在英文中就是"承诺"的意思):

// 点餐并拿到取餐凭证
const mealPromise = new Promise((resolve) => {
  console.log("厨房开始准备餐点");
  setTimeout(() => {
    resolve("您的餐点准备好了");
  }, 1000);
});

// 拿到餐点后执行的操作
mealPromise.then((message) => {
  console.log(message);
});

三、Promise底层揭秘:状态机的魔法

3.1 Promise的三种状态

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

┌───────────┐     成功回调/resolve()     ┌───────────┐
│           │ ----------------------→ │           │
│  pending  │                         │ fulfilled │
│           │     失败回调/reject()     │           │
└──────┬────┘ ----------------------→ └──────┬────┘
       │                                      │
       │       不可逆转:fulfilled ↔ rejected  │
       │                                      │
       ▼                                      ▼
┌───────────┐     无法转换,状态已固定        ┌───────────┐
│           │ ←───────────────────────────── │           │
│  rejected │                                 │ fulfilled │
│           │                                 │           │
└───────────┘                                 └───────────┘

[图片:Promise状态转换图]

  1. Pending(等待中):初始状态
  2. Fulfilled(已兑现):操作成功完成
  3. Rejected(已拒绝):操作失败

3.2.Promise的核心方法

  1. then() :处理 Promise 成功的结果,返回一个新的 Promise。

    promise.then((value) => { /* 处理成功 */ });
    
  2. catch() :处理 Promise 失败的结果,是 .then(null, errorCallback) 的语法糖。

    promise.catch((error) => { /* 处理失败 */ });
    
  3. finally() :无论 Promise 状态如何都会执行,不接收参数。

    promise.finally(() => { /* 总是执行 */ });
    

3.3.Promise的静态方法

  1. Promise.all(iterable) :并行处理多个 Promise,全部成功时返回包含所有结果的数组,任一失败则立即终止。

    Promise.all([promise1, promise2])
      .then(([result1, result2]) => { /* 处理所有结果 */ })
      .catch((error) => { /* 处理任一错误 */ });
    
  2. Promise.race(iterable) :多个 Promise 竞争,第一个完成(成功或失败)的结果即为最终结果。

    Promise.race([promise1, promise2])
      .then((result) => { /* 处理第一个完成的结果 */ })
      .catch((error) => { /* 处理第一个错误 */ });
    
  3. Promise.resolve(value)  和 Promise.reject(error) :快速创建已解决或已拒绝的 Promise。

3.4.链式调用

Promise 的强大之处在于可以链式调用,避免回调地狱:

asyncOperation()
  .then((result) => processResult(result))
  .then((processedResult) => saveResult(processedResult))
  .catch((error) => handleError(error));

3.5 核心执行流程

const p = new Promise((resolve) => {
  console.log("Promise构造器立即执行!");
  
  // 模拟耗时操作
  setTimeout(() => {
    resolve("操作完成!"); // 改变Promise状态
  }, 1000);
});

console.log("我是同步代码,先执行");

p.then((message) => {
  console.log(message); // 在resolve之后执行
});

执行顺序:

  1. new Promise()中的函数立即执行
  2. 同步代码继续执行
  3. resolve()被调用后,Promise状态改变
  4. .then()中的回调被执行

四、Promise实战:控制执行顺序

4.1 经典问题:让"111"最后输出

const readFilePromise = new Promise((resolve) => {
  // 模拟文件读取
  setTimeout(() => {
    console.log("文件读取完成");
    resolve();
  }, 500);
});

readFilePromise.then(() => {
  console.log("111"); // 保证最后执行
});

4.2 真实场景:GitHub仓库列表获取

<!DOCTYPE html>
<html>
<body>
  <ul id="repos"></ul>
  <button id="btn">加载仓库</button>

  <script>
    document.getElementById('btn').addEventListener('click', async () => {
      try {
        // 等待网络请求完成
        const res = await fetch('https://api.github.com/users/WildBlue58/repos');
        
        // 等待JSON解析完成
        const repos = await res.json();
        
        // 渲染到页面
        document.getElementById('repos').innerHTML = repos
          .map(repo => `<li><a href="${repo.html_url}" target="_blank">${repo.name}</a></li>`)
          .join('');
      } catch (error) {
        console.error("加载失败:", error);
      }
    });
  </script>
</body>
</html>

五、async/await:Promise的语法糖

async函数总是返回一个Promise对象,而await后面通常是一个Promise(如果不是,会被转换为一个立即resolve的Promise)。因此,async/await底层仍然依赖于Promise。

ES2017引入的async/await让异步代码看起来像同步代码:

5.1 基本用法

async function fetchData() {
  console.log("开始请求数据...");
  
  // 等待Promise解决
  const response = await fetch('https://api.example.com/data');
  const data = await response.json();
  
  console.log("数据获取完成:", data);
  return data;
}

fetchData().then(data => {
  console.log("处理数据:", data);
});

5.2 错误处理

async function loadData() {
  try {
    const res = await fetch('https://api.example.com/data');
    if (!res.ok) throw new Error('网络响应异常');
    return await res.json();
  } catch (error) {
    console.error("加载失败:", error);
    return { error: error.message };
  }
}

5.3.async/await与Promise的关系

当我们说"async/await是Promise的语法糖"时,很多人会产生误解,认为它只是简单的语法替换。实际上,async/await是建立在Promise基础上的更高层抽象,它没有取代Promise,而是让Promise的使用变得更加优雅。

// 传统Promise
function fetchData() {
  return fetch('https://api.example.com/data')
    .then(response => response.json())
    .then(data => console.log(data))
    .catch(error => console.error(error));
}

// async/await方式
async function fetchData() {
  try {
    const response = await fetch('https://api.example.com/data');
    const data = await response.json();
    console.log(data);
  } catch (error) {
    console.error(error);
  }
}

无处不在的Promise:async/await的底层支柱

1. async函数始终返回Promise

这是最核心的关联点:任何async函数都会返回一个Promise对象,无论函数内部如何实现。

async function getNumber() {
  return 42;
}

const result = getNumber();
console.log(result instanceof Promise); // true
console.log(result); // Promise {<fulfilled>: 42}

即使函数体中没有await关键字,async函数仍然返回Promise:

async function sayHello() {
  console.log("Hello!");
}

sayHello().then(() => {
  console.log("执行完成");
});

// 输出:
// Hello!
// 执行完成
2. await等待的是Promise的解决

await关键字后面可以接任何值,但它的核心功能是暂停async函数的执行,直到等待的Promise被解决(resolved或rejected)

async function fetchUser() {
  // 等待Promise解决
  const response = await fetch('/api/user');
  
  // 再次等待Promise解决
  const user = await response.json();
  
  return user;
}

当await遇到非Promise值时,JavaScript引擎会将其转换为一个已解决的Promise:

async function example() {
  const num = await 42; // 等价于 await Promise.resolve(42)
  console.log(num); // 42
}
3. 错误处理机制相同

async/await使用try/catch处理错误的方式,底层仍然是Promise的catch机制:

async function fetchData() {
  try {
    const response = await fetch('/api/data');
    return await response.json();
  } catch (error) {
    // 这里捕获的是Promise rejection
    console.error("请求失败:", error);
    return null;
  }
}

这等同于Promise的catch处理:

function fetchData() {
  return fetch('/api/data')
    .then(response => response.json())
    .catch(error => {
      console.error("请求失败:", error);
      return null;
    });
}

5.4.执行流程图解

image.png [流程图:async/await执行流程]

  1. 调用async函数,返回一个待定状态(pending)的Promise
  2. 执行函数体直到遇到第一个await
  3. 暂停函数执行,将控制权交还给事件循环
  4. 等待await后面的Promise解决
  5. Promise解决后恢复函数执行
  6. 将解决的值赋给await表达式左侧的变量
  7. 继续执行直到函数结束或遇到下一个await
  8. 函数最终返回的Promise状态根据函数执行结果确定

5.5.生成器函数的秘密

async/await的实现原理与生成器函数(Generator)密切相关。Babel等转译器会将async函数转换为生成器函数:

// 原始async函数
async function example() {
  const a = await getA();
  const b = await getB();
  return a + b;
}

// 转译为生成器函数
function example() {
  return spawn(function* () {
    const a = yield getA();
    const b = yield getB();
    return a + b;
  });
}

其中spawn函数负责自动执行生成器并处理Promise:

function spawn(genF) {
  return new Promise((resolve, reject) => {
    const gen = genF();
    
    function step(nextF) {
      let next;
      try {
        next = nextF();
      } catch (e) {
        return reject(e);
      }
      
      if (next.done) {
        return resolve(next.value);
      }
      
      Promise.resolve(next.value).then(
        v => step(() => gen.next(v)),
        e => step(() => gen.throw(e))
      );
    }
    
    step(() => gen.next());
  });
}

六、Promise的局限性及解决方案

即使Promise如此强大,仍有需要注意的地方:

  1. 无法取消:一旦创建就会执行

    • 解决方案:使用AbortController
  2. 单一值:只能resolve一个值

    • 解决方案:返回对象或数组
  3. 错误容易被忽略

    • 总是添加.catch()处理
// 使用AbortController取消fetch
const controller = new AbortController();

fetch(url, { signal: controller.signal })
  .then(response => {/* ... */})
  .catch(err => {
    if (err.name === 'AbortError') {
      console.log('请求被取消');
    }
  });

// 取消请求
controller.abort();

七、Promise在现代JavaScript中的应用

Promise已成为现代JavaScript的基石:

  1. Fetch API:替代传统的XMLHttpRequest
  2. 模块加载:ES6模块的import是异步的
  3. Web API:许多浏览器API返回Promise
    • navigator.storage.persist()
    • Blob.text()
    • Notification.requestPermission()

八、介绍一些常见的异步请求

8.1. DOMContentLoaded与 window.onload

  • DOMContentLoaded:结构完工的时刻

DOMContentLoaded事件标志着HTML文档完全加载和解析完成,无需等待样式表、图像和子框架完成加载。

document.addEventListener('DOMContentLoaded', () => {
  console.log('DOM结构准备就绪!');
  // 此时可以安全操作DOM元素
  const button = document.getElementById('myButton');
  button.addEventListener('click', handleClick);
});
为什么是网络请求的最佳时机?
  1. 尽早交互:用户可以在图片加载完成前与页面交互
  2. 性能优化:减少用户等待时间
  3. 资源优先级:核心功能优先加载

🌟 最佳实践:所有需要操作DOM的初始化代码都应放在DOMContentLoaded事件中

  • window.onload:装修完成的庆典

onload事件发生在整个页面(包括图片、样式等所有资源)加载完成后:

window.onload = function() {
  console.log('所有资源加载完毕!');
  // 此时可以安全操作图片尺寸等需要完整资源的操作
  const image = document.getElementById('hero-image');
  console.log(`图片尺寸:${image.width}x${image.height}`);
};
  • 对比表:两大页面事件差异
特性DOMContentLoadedwindow.onload
触发时机HTML解析完成时所有资源加载完成时
执行速度更快较慢
适用场景DOM操作初始化资源依赖型操作
资源依赖不等待图片/样式等待所有资源
事件绑定document.addEventListenerwindow.onload

8.2.事件监听:同步注册,异步执行

事件监听的本质
document.getElementById('btn').addEventListener('click', () => {
  console.log('按钮被点击了!');
});

关键解析:

  1. 同步任务addEventListener()方法本身是同步执行的
  2. 异步触发:回调函数在事件发生时异步执行
  3. 事件队列:点击事件被放入任务队列,等待主线程空闲

JavaScript执行模型详解

console.log('脚本开始'); // 同步任务

// 同步注册事件监听器
document.getElementById('btn').addEventListener('click', () => {
  console.log('按钮点击回调'); // 异步任务
});

setTimeout(() => {
  console.log('定时器回调'); // 异步任务
}, 0);

console.log('脚本结束'); // 同步任务

执行顺序:

  1. 脚本开始
  2. 脚本结束
  3. 定时器回调
  4. (当按钮被点击时) 按钮点击回调
事件循环的优先级

JavaScript事件循环处理任务的优先级:

  1. 同步代码(主线程执行栈)
  2. 微任务(Promise回调、MutationObserver)
  3. 宏任务(setTimeout、setInterval、I/O操作、UI渲染)
  4. UI事件(点击、滚动等)
// 示例:不同任务类型的执行顺序
console.log('开始');

setTimeout(() => console.log('timeout'), 0);

Promise.resolve().then(() => console.log('promise'));

button.addEventListener('click', () => console.log('click'));

console.log('结束');

// 输出顺序:
// 开始
// 结束
// promise
// timeout
// (点击后) click

结语:掌握异步编程的艺术

Promise不仅仅是解决回调地狱的工具,它代表了一种处理异步操作的全新思维方式。从"画饼"(创建Promise)到"吃饼"(处理结果),Promise为我们提供了一套完整的异步编程模型。

[图片:Promise在JavaScript生态系统中的位置]

正如Douglas Crockford所说:"JavaScript是世界上唯一一门人们觉得不需要学习就能使用的语言。" Promise正是那些值得深入学习的概念之一。掌握它,你将在异步编程的世界里游刃有余!

异步的世界不再可怕,Promise就是你的向导。从今天开始,让代码按你期望的顺序优雅执行吧!