异步编程核心

从回调到 Promise,理解 JavaScript 的异步世界

🎯 学习目标

学完本章,你将能够:

  • 能够解释 JavaScript 事件循环的执行顺序,预测宏任务和微任务的输出
  • 能够使用 Promise 处理异步操作,理解其三态(pending、fulfilled、rejected)
  • 能够使用 then/catch 链式调用处理 Promise 的成功和失败
  • 能够使用 Promise.all/race/allSettled/any 组合多个异步任务
  • 能够将回调函数风格的 API 转换为 Promise 风格(promisify)

一个烧脑的问题

下面这段代码,输出顺序是什么?

console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve().then(() => console.log("C"));
console.log("D");

如果你的答案是 A B C D,那你并不孤单——大多数人第一次都会猜错。

实际输出是 A D C B。setTimeout 延迟明明是 0,为什么反而最后才执行?Promise 又凭什么插队?

要回答这个问题,需要理解 JavaScript 最核心的机制:事件循环。这一章,我们把异步编程从里到外彻底搞清楚。

Part 1: 同步与异步

单线程也能同时处理很多事

JavaScript 是单线程的,一次只能做一件事。那网络请求、文件读写这些耗时操作怎么办?如果等它完成再往下走,整个页面就冻住了。答案就是异步:先发起请求,不等着,等结果回来了再处理。这一部分先把"同步"和"异步"的区别讲透,为后面的事件循环和 Promise 打好基础。

单线程的 JS

为什么一个线程能同时处理那么多请求?

单线程意味着 JavaScript 只有一个调用栈,代码一行一行执行,前面的事没做完,后面就得等。

用生活场景说:你是一个餐厅服务员,一次只能端一盘菜。如果客人点了 10 道菜,你得一道一道上,中间不能分身。

这个比喻只是为了方便理解。实际上 JS 引擎背后有浏览器的 Web API 线程池和 Node.js 的 libuv 线程池来处理耗时任务,并不是真的"一个人干所有活"。

// 同步——按顺序,阻塞
console.log("1");
console.log("2");
console.log("3"); // 输出: 1 2 3

// 异步——不阻塞后续代码
console.log("1");
setTimeout(() => console.log("2"), 0);
console.log("3"); // 输出: 1 3 2

为什么需要异步

不异步,你的程序就"卡住了"

如果所有操作都是同步的,一个网络请求要等 3 秒,整个页面就冻结 3 秒——按钮点不了,动画不动,用户体验灾难。

网络请求

fetch API、AJAX 调用

文件读写

Node.js 的 fs 模块

定时器

setTimeout / setInterval

用户事件

click / scroll / input

Part 2: 事件循环

JavaScript 异步的核心引擎

回到开场那道题:setTimeout 延迟为 0 为什么最后执行?Promise.then 凭什么能插队?答案藏在事件循环里。它是 JavaScript 调度任务的核心机制:先跑完同步代码,再清空微任务队列,最后才取宏任务。理解了这个"同步→微任务→宏任务"的循环,任何执行顺序题都能一步步推导出来。

事件循环全景

一张图看懂 JS 如何调度任务

图:JavaScript 事件循环全景流程图

循环规则很简单:同步代码 → 清空微任务 → 取一个宏任务 → 清空微任务 → 取下一个宏任务。如此反复。

记住这个顺序,后面所有代码题都能迎刃而解。

宏任务与微任务

两种任务,两种优先级

宏任务 Macrotask微任务 Microtask
setTimeout / setIntervalPromise.then / catch / finally
I/O 操作queueMicrotask()
UI 渲染MutationObserver
setImmediate (Node.js)process.nextTick (Node.js)
requestAnimationFrame

关键区别:微任务在当前宏任务结束后、下一个宏任务开始前全部执行完。所以微任务优先级高于宏任务。

执行顺序详解

用流程图跟踪开场那道题

图:A D C B 执行顺序追踪流程图
// 完整执行过程
// 1. 同步: A → D
// 2. 微任务: C (Promise.then)
// 3. 宏任务: B (setTimeout)
// 输出: A D C B

复杂示例

多轮循环的完整追踪

console.log("script start");

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

Promise.resolve()
    .then(() => console.log("promise 1"))
    .then(() => console.log("promise 2"));

queueMicrotask(() => console.log("microtask"));

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

console.log("script end");

输出顺序:

script start → script end → promise 1 → microtask → promise 2 → timeout 1 → timeout 2
易错点:第二个 .then(() => console.log("promise 2")) 是在第一个 then 回调执行后才注册的,但它依然是微任务,会在当前微任务清空阶段被执行,不会等到宏任务之后。

Part 3: 回调地狱

代码写着写着就变成了一座金字塔

最早的异步方式是回调函数——把"完成后要做的事"以参数传进去。能用,但如果一个异步操作依赖上一个的结果呢?回调套回调,代码缩进越来越深,最终变成一座"金字塔"。错误处理分散在每一层,根本没法用 try/catch。先看看这个痛点有多严重,才明白后面 Promise 为什么是救星。

回调地狱

嵌套越来越深,代码越来越乱

// 连续依赖的异步操作——"金字塔"
getUser(userId, (err, user) => {
    if (err) { handleError(err); return; }
    getOrders(user.id, (err, orders) => {
        if (err) { handleError(err); return; }
        getOrderDetails(orders[0].id, (err, details) => {
            if (err) { handleError(err); return; }
            getProduct(details.productId, (err, product) => {
                if (err) { handleError(err); return; }
                console.log(product);
            });
        });
    });
});

回调的五大痛点:

  • 嵌套过深——代码呈金字塔状,难以阅读
  • 错误处理复杂——每层都要 if (err)
  • 无法 return/throw——异步回调中的 return 只是退出回调
  • 信任问题——回调可能被调用多次或不调用
  • 无法 try/catch——异步错误捕获不到

Part 4: Promise

一个"未来的值"——要么成功,要么失败

回调地狱的解药就是 Promise。它把嵌套的回调拉平成链式的 .then().then().catch(),每一步返回一个新的 Promise,错误可以统一捕获。这一部分从 Promise 的三种状态讲起,到链式调用的机制,帮你彻底搞懂"一个值怎么代表未来"。

Promise 三态

pending → fulfilled 或 rejected,不可逆

Promise 是一个对象,代表一个异步操作的最终结果。它有三种状态:

图:Promise 三态转换图
let p = new Promise((resolve, reject) => {
    resolve("成功");
    resolve("再次成功"); // 被忽略,状态已确定
});

// 快捷方法
let p1 = Promise.resolve(42);
let p2 = Promise.reject(new Error("出错了"));

then/catch

链式调用,告别金字塔

Promise 的核心能力是链式调用:每次 .then() 返回一个新的 Promise,可以一直链下去。

fetchUser(1)
    .then(user => fetchOrders(user.id))
    .then(orders => fetchOrderDetail(orders[0].id))
    .then(detail => console.log(detail))
    .catch(err => console.error("任一环节出错:", err))
    .finally(() => console.log("请求结束"));

then 的返回值

返回普通值还是 Promise,区别在哪?

Promise.resolve(1)
    .then(val => val + 1)
    // 返回普通值 → 自动包装为 resolved Promise
    .then(val => Promise.resolve(val + 1))
    // 显式返回 Promise
    .then(val => { throw new Error("oops"); })
    // 抛出错误 → 变为 rejected
    .catch(err => {
        console.log(err.message); // "oops"
        return "recovered"; // catch 中返回值,链继续
    })
    .then(val => console.log(val)); // "recovered"
注意:.catch() 会"吞掉"错误并返回一个 resolved 的 Promise。如果你希望错误继续传播,需要在 catch 中重新 throw。

Part 5: 组合方法

多个 Promise 同时运行,怎么管理?

页面加载时需要同时请求用户信息、配置和权限——三个请求互不依赖,串行发太慢了。Promise.all 可以让它们并行跑,全部完成后再继续。但如果有一个失败就全盘放弃?还是想等所有结果不管成败?Promise.race、Promise.any、Promise.allSettled 四个方法各有适用场景,这一部分逐一讲清楚。

Promise.all

全部成功才成功,一个失败就失败

let p1 = fetch("/api/users");
let p2 = fetch("/api/posts");
let p3 = fetch("/api/comments");

Promise.all([p1, p2, p3])
    .then(([users, posts, comments]) => {
        console.log("全部加载完成");
    })
    .catch(err => console.log("有一个失败了:", err));

使用场景:页面需要多个接口数据才能渲染,必须全部到齐才行。

四种组合

all / allSettled / race / any 各有用途

方法成功条件失败条件
Promise.all全部成功一个失败就失败
Promise.allSettled永远成功永远不会失败
Promise.race最先完成的最先完成的(不论成败)
Promise.any最先成功的全部失败才失败
// race 实现请求超时
Promise.race([
    fetch("/api/data"),
    new Promise((_, reject) =>
        setTimeout(() => reject(new Error("超时")), 5000)
    )
]);

Part 6: Promise 化

把回调风格的函数"包装"成 Promise

Node.js 早期 API 都是回调风格 fs.readFile(path, (err, data) => {}),没法用 .then() 链式调用。手动包装一个 new Promise 可以解决,但每个函数都写一遍太繁琐。Node.js 提供了 util.promisify 一键转换,理解它的原理后,你也能写出自己的 promisify 工具。

手动 promisify

把回调函数包装成 Promise

// 包装 delay
function delay(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}
delay(1000).then(() => console.log("1秒后"));

// 包装 Node.js 回调函数
function readFilePromise(path, encoding) {
    return new Promise((resolve, reject) => {
        fs.readFile(path, encoding, (err, data) => {
            if (err) reject(err);
            else resolve(data);
        });
    });
}

util.promisify

Node.js 内置的转换工具

$ node
const util = require('util');
const fs = require('fs');

const readFile = util.promisify(fs.readFile);
readFile('data.txt', 'utf-8')
    .then(data => console.log(data))
    .catch(err => console.error(err));
// 通用 promisify 实现
function promisify(fn) {
    return function(...args) {
        return new Promise((resolve, reject) => {
            fn(...args, (err, ...result) => {
                if (err) reject(err);
                else resolve(result.length > 1 ? result : result[0]);
            });
        });
    };
}

事件循环实战

用 Mermaid 追踪完整执行流程

图:多轮事件循环完整执行追踪图

常见异步错误

不要在异步代码中凭直觉猜执行顺序。 用事件循环的规则一步步推导:先同步,再微任务,最后宏任务。

问题一:Promise 链中断

原因:忘记 return 内部的 Promise。

// 错误
.then(user => {
    fetchOrders(user.id); // 忘记 return!
})
.then(orders => console.log(orders)); // undefined

// 正确
.then(user => {
    return fetchOrders(user.id); // 加上 return
})

问题二:forEach 中的 async

原因:forEach 不会等待 async 回调完成。

// 错误:不会等待
items.forEach(async (item) => {
    await process(item);
});

// 正确:用 for...of
for (let item of items) {
    await process(item);
}

取消请求

用 AbortController 主动终止异步操作

AbortController 是浏览器和 Node.js 提供的取消机制。它创建一个 AbortSignal 对象,可以传递给 fetch 等异步操作来取消它们。

const controller = new AbortController();
const signal = controller.signal;

// 取消 fetch 请求
fetch('/api/data', { signal })
    .then(res => res.json())
    .then(data => console.log(data))
    .catch(err => {
        if (err.name === 'AbortError') {
            console.log('请求已被取消');
        }
    });

// 5 秒后自动取消
setTimeout(() => controller.abort(), 5000);
// 给 Promise 加超时
function fetchWithTimeout(url, ms) {
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), ms);

    return fetch(url, { signal: controller.signal })
        .then(res => {
            clearTimeout(timeout);
            return res.json();
        })
        .catch(err => {
            clearTimeout(timeout);
            if (err.name === 'AbortError') {
                throw new Error(`请求超时(${ms}ms)`);
            }
            throw err;
        });
}
易错点:controller.abort() 只能调用一次,调用后 signal.aborted 变为 true,所有使用该 signal 的请求都会被取消。

微任务陷阱

await 和 .then 的微妙时机

await 后面的代码不在同步栈中执行,而是被包装成微任务。这意味着:

async function foo() {
    console.log("A");
    await Promise.resolve();
    console.log("B"); // 这行在微任务中执行!
}
foo();
console.log("C");
// 输出: A C B(不是 A B C)

多个 .then 的注册时机也容易让人困惑:

const p = Promise.resolve();

p.then(() => console.log("A"));
p.then(() => console.log("B"));
p.then(() => console.log("C"));
console.log("D");
// 输出: D A B C
// 三个 .then 各自独立注册,按注册顺序排入微任务队列
挑战题:预测下面的输出顺序。
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve()
    .then(() => console.log("3"))
    .then(() => setTimeout(() => console.log("4"), 0))
    .then(() => console.log("5"));
(async () => {
    console.log("6");
    await Promise.resolve();
    console.log("7");
})();
console.log("8");

答案:1 6 8 3 7 5 2 4。推导关键:async 函数中 await 前的代码是同步执行的;await 后的代码是微任务;第二个 .then 注册的 setTimeout 要到宏任务阶段才执行。

试一试

练习一

任务:预测下面代码的输出顺序。

console.log("start");
setTimeout(() => console.log("timeout"), 0);
Promise.resolve()
    .then(() => console.log("promise 1"))
    .then(() => console.log("promise 2"));
console.log("end");

参考答案

start → end → promise 1 → promise 2 → timeout

练习二

任务:用 Promise 封装一个 loadImage(url) 函数,图片加载成功就 resolve,失败就 reject。

// 提示:用 new Image() 和 onload/onerror
// 你的代码:

参考答案

function loadImage(url) {
    return new Promise((resolve, reject) => {
        const img = new Image();
        img.onload = () => resolve(img);
        img.onerror = () => reject(new Error(`加载失败: ${url}`));
        img.src = url;
    });
}

练习三

任务:实现一个 retry(fn, retries, delay) 函数,在请求失败时自动重试指定次数,每次重试间隔 delay 毫秒。

// retry(fn, retries, delay)
// fn: 返回 Promise 的函数
// retries: 最大重试次数
// delay: 每次重试间隔(毫秒)
// 你的代码:

参考答案

function retry(fn, retries, delay) {
    return fn().catch(err => {
        if (retries <= 0) throw err;
        console.log(`失败,${delay}ms 后重试(剩余 ${retries} 次)`);
        return new Promise(resolve => {
            setTimeout(resolve, delay);
        }).then(() => retry(fn, retries - 1, delay));
    });
}

// 使用示例
retry(
    () => fetch('https://api.example.com/data').then(r => r.json()),
    3,
    1000
);

📝 要点总结

本章核心知识点

事件循环

单线程通过事件循环处理异步任务

宏任务与微任务

setTimeout 是宏任务,Promise.then 是微任务

Promise 三态

pending、fulfilled、rejected,不可逆

链式调用

then 返回新 Promise,支持链式操作

组合方法

all、race、allSettled、any 处理并发

下一章

Promise 让异步代码不再嵌套,但链式调用仍然有"视觉噪声"。下一章我们学习 async/await——用同步的写法写异步代码。

1 / X