跳到主要内容

JavaScript 异步编程

异步编程是 JavaScript 最核心的特性之一。理解异步机制,是写出高效、可维护代码的基础。

为什么需要异步?

JavaScript 是单线程语言,同一时间只能做一件事。如果所有操作都是同步的,当遇到耗时操作(比如网络请求、文件读取)时,整个程序就会卡住,用户界面也会冻结。

异步编程让 JavaScript 在等待耗时操作完成的同时,继续执行其他代码。这样既保证了程序的响应性,又能处理并发任务。

事件循环

要理解异步编程,首先要理解 JavaScript 的事件循环(Event Loop)机制。这是 JavaScript 处理异步操作的核心。

执行栈与任务队列

JavaScript 运行时有两个关键概念:执行栈和任务队列。

执行栈是 JavaScript 引擎用来跟踪函数调用的数据结构。当调用一个函数时,它被压入栈顶;当函数返回时,它从栈顶弹出。

任务队列(也叫回调队列)是用来存放待执行的异步回调的队列。当异步操作完成时,它的回调函数会被放入任务队列。

事件循环的工作流程

事件循环不断地检查执行栈是否为空。如果执行栈为空,就从任务队列中取出一个任务,放入执行栈执行。这个过程不断重复,形成一个"循环"。

// 执行顺序示例
console.log("1. 同步代码开始");

setTimeout(() => {
console.log("4. 异步回调执行");
}, 0);

console.log("2. 同步代码继续");

// 输出顺序:
// 1. 同步代码开始
// 2. 同步代码继续
// 4. 异步回调执行

这个例子中,即使 setTimeout 的延迟是 0 毫秒,它的回调也不会立即执行。因为 JavaScript 会先执行完所有同步代码,然后再处理任务队列中的回调。

宏任务与微任务

JavaScript 中的任务分为两类:宏任务(Macro Task)和微任务(Micro Task)。

宏任务包括:setTimeoutsetInterval、I/O 操作、UI 渲染等。

微任务包括:Promise.then/catch/finallyprocess.nextTick(Node.js)、MutationObserver 等。

事件循环的执行顺序是:先执行一个宏任务,然后清空所有微任务,再执行下一个宏任务。

console.log("1. 同步代码");

setTimeout(() => {
console.log("4. 宏任务");
}, 0);

Promise.resolve().then(() => {
console.log("3. 微任务");
});

console.log("2. 同步代码");

// 输出顺序:
// 1. 同步代码
// 2. 同步代码
// 3. 微任务
// 4. 宏任务

这个例子清楚地展示了微任务优先于宏任务执行的特性。

回调函数

回调函数是 JavaScript 中最基本的异步处理方式。简单来说,回调函数就是作为参数传递给另一个函数的函数,在某个操作完成后被调用。

基本用法

function fetchData(callback) {
// 模拟网络请求
setTimeout(() => {
const data = { name: "张三", age: 20 };
callback(data); // 数据准备好了,调用回调函数
}, 1000);
}

// 使用回调函数
fetchData((result) => {
console.log("获取到的数据:", result);
});

这个例子中,fetchData 函数接收一个回调函数作为参数。当数据准备好后,它调用这个回调函数,把数据传递出去。

错误优先回调

在 Node.js 中,有一种约定俗成的回调模式:错误优先回调。回调函数的第一个参数是错误对象,第二个参数才是数据。

const fs = require('fs');

fs.readFile('file.txt', 'utf8', (err, data) => {
if (err) {
console.error("读取文件失败:", err);
return;
}
console.log("文件内容:", data);
});

这种模式的好处是,调用者必须先检查错误,再处理数据,避免遗漏错误处理。

回调地狱

当多个异步操作需要按顺序执行时,回调函数会一层层嵌套,形成所谓的"回调地狱"。

getUser(userId, (user) => {
getOrders(user.id, (orders) => {
getOrderDetails(orders[0].id, (details) => {
getShippingInfo(details.shippingId, (shipping) => {
// 嵌套太深,难以阅读和维护
console.log("收货信息:", shipping);
});
});
});
});

回调地狱有几个明显的问题:

  • 代码向右不断延伸,可读性差
  • 错误处理需要在每一层都写,容易遗漏
  • 逻辑分散在多个回调中,难以理解整体流程

这也是为什么后来出现了 Promise 和 async/await 来解决这个问题。

Promise

Promise 是 ES6 引入的异步编程解决方案,它提供了一种更清晰的方式来处理异步操作。

Promise 的三种状态

Promise 对象有三种状态:

pending(待定):初始状态,既没有被兑现,也没有被拒绝。

fulfilled(已兑现):操作成功完成。

rejected(已拒绝):操作失败。

状态一旦改变,就不会再变。从 pending 变成 fulfilled,或者从 pending 变成 rejected。

创建 Promise

使用 new Promise() 构造函数创建 Promise。构造函数接收一个执行器函数,这个函数有两个参数:resolvereject

const promise = new Promise((resolve, reject) => {
// 模拟异步操作
setTimeout(() => {
const success = true;

if (success) {
resolve({ name: "张三", age: 20 }); // 成功时调用
} else {
reject(new Error("操作失败")); // 失败时调用
}
}, 1000);
});

执行器函数会立即执行。当异步操作成功时,调用 resolve 并传入结果;当失败时,调用 reject 并传入错误。

使用 Promise

通过 .then().catch().finally() 方法来处理 Promise 的结果。

const promise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve("操作成功");
}, 1000);
});

promise
.then(result => {
console.log("成功:", result);
})
.catch(error => {
console.error("失败:", error);
})
.finally(() => {
console.log("无论成功或失败都会执行");
});

.then() 接收成功时的回调,.catch() 接收失败时的回调,.finally() 无论成功或失败都会执行。

Promise 链式调用

Promise 的一个强大特性是链式调用。.then() 方法返回一个新的 Promise,可以继续调用 .then()

function fetchUser(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve({ id, name: "张三" });
}, 500);
});
}

function getOrders(userId) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve([{ id: 1, product: "手机" }]);
}, 500);
});
}

// 链式调用
fetchUser(1)
.then(user => {
console.log("用户:", user);
return getOrders(user.id); // 返回新的 Promise
})
.then(orders => {
console.log("订单:", orders);
})
.catch(err => {
console.error("出错了:", err);
});

每个 .then() 都可以返回一个值或新的 Promise。如果返回值,下一个 .then() 会接收到这个值;如果返回 Promise,会等待这个 Promise 完成。

Promise 错误处理

Promise 的错误会沿着链向下传播,直到遇到 .catch()

fetchUser(1)
.then(user => {
return getOrders(user.id);
})
.then(orders => {
throw new Error("处理订单失败"); // 抛出错误
})
.catch(err => {
// 可以捕获到上面任何地方的错误
console.error("捕获到错误:", err);
});

建议在 Promise 链的末尾添加 .catch(),确保不会遗漏错误处理。

Promise 组合方法

Promise 提供了几个静态方法,用于组合多个 Promise。

Promise.all

等待所有 Promise 都成功才成功,任意一个失败就失败。

const p1 = Promise.resolve(1);
const p2 = Promise.resolve(2);
const p3 = Promise.resolve(3);

Promise.all([p1, p2, p3])
.then(results => {
console.log(results); // [1, 2, 3]
});

// 如果有失败
const p4 = Promise.reject("失败");
Promise.all([p1, p4, p3])
.catch(err => {
console.error(err); // "失败"
});

适用场景:需要同时发起多个请求,等待所有请求都完成后再处理。

Promise.allSettled

等待所有 Promise 都完成(无论成功或失败)。

const promises = [
Promise.resolve(1),
Promise.reject("失败"),
Promise.resolve(3)
];

Promise.allSettled(promises).then(results => {
results.forEach((result, index) => {
if (result.status === "fulfilled") {
console.log(`${index} 个成功:`, result.value);
} else {
console.log(`${index} 个失败:`, result.reason);
}
});
});
// 第 0 个成功: 1
// 第 1 个失败: 失败
// 第 2 个成功: 3

适用场景:需要知道每个请求的结果,即使有失败也不影响其他结果。

Promise.race

返回最先完成的 Promise(无论成功或失败)。

const slow = new Promise(resolve => 
setTimeout(() => resolve("慢"), 1000));
const fast = new Promise(resolve =>
setTimeout(() => resolve("快"), 500));

Promise.race([slow, fast])
.then(result => {
console.log(result); // "快"
});

适用场景:设置超时、竞速请求等。

Promise.any

返回最先成功的 Promise。只有全部失败才失败。

const p1 = Promise.reject("失败1");
const p2 = new Promise(resolve =>
setTimeout(() => resolve("成功"), 500));
const p3 = Promise.reject("失败2");

Promise.any([p1, p2, p3])
.then(result => {
console.log(result); // "成功"
});

适用场景:从多个源获取数据,只需要一个成功的结果。

async/await

async/await 是 ES2017 引入的语法,它是 Promise 的语法糖,让异步代码看起来像同步代码。

async 函数

使用 async 关键字声明的函数会自动返回一个 Promise。

async function getData() {
return { name: "张三", age: 20 };
}

// 等同于
function getData() {
return Promise.resolve({ name: "张三", age: 20 });
}

getData().then(data => console.log(data));

即使函数体中直接返回一个值,也会被包装成 Promise。

await 表达式

await 只能在 async 函数中使用。它会暂停函数的执行,等待 Promise 完成,然后返回结果。

function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}

async function example() {
console.log("开始");

await delay(1000); // 暂停,等待 1 秒
console.log("1 秒后");

await delay(1000); // 再等 1 秒
console.log("再过 1 秒");
}

example();

await 会让代码暂停在这里,直到 Promise 完成。这使得异步代码的阅读体验非常接近同步代码。

用 async/await 改写回调地狱

回顾之前的回调地狱,用 async/await 改写后会清晰很多:

async function getUserOrderInfo() {
const user = await fetchUser(1);
console.log("用户:", user);

const orders = await getOrders(user.id);
console.log("订单:", orders);

const details = await getOrderDetails(orders[0].id);
console.log("详情:", details);

const shipping = await getShippingInfo(details.shippingId);
console.log("收货信息:", shipping);

return shipping;
}

代码从上到下阅读,逻辑清晰,每一行都是一个异步操作。

错误处理

使用 try/catch 可以捕获 async/await 中的错误。

async function fetchUserData() {
try {
const user = await fetchUser(1);
const orders = await getOrders(user.id);
return { user, orders };
} catch (error) {
console.error("获取数据失败:", error);
return null;
}
}

也可以在调用时使用 .catch()

const result = await fetchUserData().catch(err => {
console.error(err);
return {};
});

顺序执行与并行执行

async/await 的一个常见误区是,所有 await 都是顺序执行的。如果多个操作之间没有依赖关系,应该并行执行。

// 顺序执行 - 每个请求都要等上一个完成
async function sequential() {
const user = await fetchUser(1); // 等待完成
const orders = await getOrders(1); // 等待完成
const products = await getProducts(); // 等待完成
return { user, orders, products };
}

// 并行执行 - 同时发起所有请求
async function parallel() {
const [user, orders, products] = await Promise.all([
fetchUser(1),
getOrders(1),
getProducts()
]);
return { user, orders, products };
}

并行执行的总时间等于最慢的那个请求的时间,而不是所有请求时间的总和。

循环中的异步处理

在循环中使用 async/await 时,需要注意执行方式。

const ids = [1, 2, 3, 4, 5];

// 顺序处理 - 一个一个来
async function processSequentially() {
const results = [];
for (const id of ids) {
const result = await fetchData(id); // 等待完成再处理下一个
results.push(result);
}
return results;
}

// 并行处理 - 同时处理所有
async function processInParallel() {
const promises = ids.map(id => fetchData(id));
return Promise.all(promises);
}

如果每个操作之间没有依赖,应该使用并行处理。

实际应用示例

发送 HTTP 请求

async function fetchJSON(url) {
try {
const response = await fetch(url);

if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}

const data = await response.json();
return data;
} catch (error) {
console.error("请求失败:", error);
throw error;
}
}

// 使用
async function loadPage() {
try {
const user = await fetchJSON("/api/user");
const posts = await fetchJSON("/api/posts");
console.log("用户:", user, "文章:", posts);
} catch (error) {
console.error("加载页面失败:", error);
}
}

带超时的请求

function withTimeout(promise, ms) {
return Promise.race([
promise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error("请求超时")), ms)
)
]);
}

async function fetchWithTimeout(url, timeout = 5000) {
try {
const response = await withTimeout(fetch(url), timeout);
return await response.json();
} catch (error) {
if (error.message === "请求超时") {
console.log("请求超时,请重试");
}
throw error;
}
}

请求重试

async function fetchWithRetry(url, retries = 3, delay = 1000) {
for (let i = 0; i < retries; i++) {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
if (i === retries - 1) throw error; // 最后一次失败,抛出错误

console.log(`${i + 1} 次重试...`);
await new Promise(r => setTimeout(r, delay * (i + 1))); // 递增延迟
}
}
}

并发限制

当需要发起大量请求时,为了不压垮服务器,可以限制同时进行的请求数量。

async function parallelLimit(tasks, limit) {
const results = [];
const executing = new Set();

for (const task of tasks) {
const promise = task().then(result => {
executing.delete(promise);
return result;
});

executing.add(promise);
results.push(promise);

if (executing.size >= limit) {
await Promise.race(executing);
}
}

return Promise.all(results);
}

// 使用
const urls = ["url1", "url2", "url3", "url4", "url5"];
const tasks = urls.map(url => () => fetch(url).then(r => r.json()));
const results = await parallelLimit(tasks, 2); // 最多同时 2 个请求

常见错误和最佳实践

不要忘记处理错误

// 不好 - 没有错误处理
async function bad() {
const data = await fetchData();
return data;
}

// 好 - 有错误处理
async function good() {
try {
const data = await fetchData();
return data;
} catch (error) {
console.error("获取数据失败:", error);
throw error;
}
}

不要在循环中不必要地使用 await

// 不好 - 顺序执行,浪费时间
async function bad() {
const results = [];
for (const id of ids) {
const result = await fetchData(id); // 每次都要等
results.push(result);
}
return results;
}

// 好 - 并行执行
async function good() {
return Promise.all(ids.map(id => fetchData(id)));
}

使用 Promise.allSettled 处理部分失败

// 不好 - 一个失败就全部失败
async function bad() {
const results = await Promise.all(urls.map(url => fetch(url)));
return results;
}

// 好 - 允许部分失败
async function good() {
const results = await Promise.allSettled(urls.map(url => fetch(url)));
return results
.filter(r => r.status === "fulfilled")
.map(r => r.value);
}

AbortController 取消异步操作

在实际开发中,经常需要取消正在进行的异步操作。AbortController 是浏览器和 Node.js 提供的标准 API,用于取消 fetch 请求和其他支持它的操作。

基本用法

// 创建 AbortController
const controller = new AbortController();
const signal = controller.signal;

// 发起可取消的 fetch 请求
async function fetchWithCancel(url) {
const controller = new AbortController();

// 3 秒后自动取消
setTimeout(() => controller.abort(), 3000);

try {
const response = await fetch(url, { signal: controller.signal });
return await response.json();
} catch (error) {
if (error.name === 'AbortError') {
console.log('请求被取消');
} else {
throw error;
}
}
}

取消多个请求

const controller = new AbortController();

// 同时发起多个请求,共享同一个取消信号
const [users, posts] = await Promise.all([
fetch('/api/users', { signal: controller.signal }).then(r => r.json()),
fetch('/api/posts', { signal: controller.signal }).then(r => r.json()),
]);

// 用户点击取消按钮时
controller.abort();

结合 React 使用

// 在 React 组件中使用
useEffect(() => {
const controller = new AbortController();

async function loadData() {
try {
const response = await fetch('/api/data', {
signal: controller.signal
});
const data = await response.json();
setData(data);
} catch (error) {
if (error.name !== 'AbortError') {
setError(error);
}
}
}

loadData();

// 组件卸载时取消请求
return () => controller.abort();
}, []);

Top-Level await(ES2022)

ES2022 引入了顶层 await,允许在模块顶层直接使用 await,不再需要包裹在 async 函数中。

// config.js - 在模块顶层使用 await
const response = await fetch('/api/config');
export const config = await response.json();

// 动态导入
const module = await import('./utils.js');

// 条件加载
let language;
if (Math.random() > 0.5) {
language = await import('./en.js');
} else {
language = await import('./zh.js');
}

注意:顶层 await 只能在 ES 模块中使用(文件扩展名为 .mjs 或在 package.json 中设置了 "type": "module")。

异步迭代器(Async Iterator)

对于需要逐个处理异步数据源的场景,可以使用异步迭代器。

// 异步生成器
async function* fetchPages(url) {
let page = 1;
while (true) {
const response = await fetch(`${url}?page=${page}`);
const data = await response.json();

if (data.length === 0) break; // 没有更多数据

yield data;
page++;
}
}

// 使用 for await...of 遍历
async function processAllPages() {
for await (const page of fetchPages('/api/items')) {
console.log('处理一页数据:', page.length, '条');
}
}

事件循环的执行顺序总结

理解事件循环的执行顺序对于预测异步代码的行为至关重要:

1. 执行同步代码(主线程任务)
2. 执行所有微任务(Promise.then、queueMicrotask)
3. 执行一个宏任务(setTimeout、setInterval、I/O)
4. 重复步骤 2-3
console.log('1. 同步开始');

setTimeout(() => {
console.log('5. 宏任务 setTimeout');

Promise.resolve().then(() => {
console.log('6. 微任务 Promise (在宏任务中)');
});
}, 0);

Promise.resolve().then(() => {
console.log('3. 微任务 Promise 1');

setTimeout(() => {
console.log('7. 宏任务 setTimeout (在微任务中)');
}, 0);
});

Promise.resolve().then(() => {
console.log('4. 微任务 Promise 2');
});

console.log('2. 同步结束');

// 输出顺序:
// 1. 同步开始
// 2. 同步结束
// 3. 微任务 Promise 1
// 4. 微任务 Promise 2
// 5. 宏任务 setTimeout
// 6. 微任务 Promise (在宏任务中)
// 7. 宏任务 setTimeout (在微任务中)

小结

  1. 事件循环是 JavaScript 处理异步的核心机制,理解它有助于写出正确的异步代码
  2. 回调函数是最基础的异步方式,但容易导致回调地狱
  3. Promise 提供了更好的异步处理方式,支持链式调用和组合
  4. async/await 是 Promise 的语法糖,让异步代码更易读
  5. 并行操作使用 Promise.all,不要在不必要时使用顺序 await
  6. 错误处理很重要,使用 try/catch 或 .catch() 确保错误不会被遗漏
  7. AbortController 用于取消异步操作
  8. 顶层 await 让模块级别的异步代码更简洁

练习

  1. 使用 async/await 重写一个基于回调的函数
  2. 实现一个带超时和重试的 fetch 函数
  3. 使用 Promise.all 实现并行请求多个 API
  4. 实现一个并发限制函数,控制同时进行的请求数量
  5. 理解并演示宏任务和微任务的执行顺序
  6. 使用 AbortController 实现可取消的请求

参考资源