为什么异步是前端的必修课
JavaScript 只有一个主线程,界面渲染、事件响应与脚本执行共享它。任何一段同步耗时操作都会直接卡住页面,用户看到的是白屏与「未响应」。异步不是可选的编程风格,而是这个运行时模型下的必然选择:把等待交给宿主环境,主线程腾出来继续处理交互。
理解异步的关键不是记住 API,而是搞清楚「这段代码什么时候执行」——顺序错了,逻辑再对也是错的。
- 定时器
setTimeout把回调排进宏任务队列,最早也要等当前宏任务结束。 - 微任务
Promise.then在每次宏任务结束后立即清空,优先级高于渲染。 - I/O 回调网络与文件读写完成后,由宿主环境推入队列。
事件循环:宏任务与微任务
事件循环每一轮做三件事:取出一个宏任务执行;执行完毕后清空所有微任务;必要时交给浏览器做一次渲染。因此 Promise.then 的回调总是先于 setTimeout(…, 0),这不是偶然,而是两类队列的优先级差异。
console.log("start");
setTimeout(() => console.log("macro"), 0);
Promise.resolve()
.then(() => console.log("micro 1"))
.then(() => console.log("micro 2"));
console.log("end");
// 输出:start → end → micro 1 → micro 2 → macro
把这段代码在心里跑一遍,就能解释很多「诡异」现象:为什么状态更新后立即读取 DOM 拿到的是旧值——渲染发生在微任务之后;为什么连续多次 .then 会在同一轮里全部执行完——微任务队列在每次出队后又会补上新加入的微任务,直到清空为止。
Promise:把回调地狱拍平
Promise 有三种状态:等待、已兑现、已拒绝,状态一旦改变就不再变化。这个不可变性带来两个好处:可以把同一个 Promise 交给多处消费,也可以在任意时机再用 then 挂回调而不会丢失结果。并发控制靠三个静态方法:all 全部成功才算成功,allSettled 无论成败都返回结果数组,race 取最先落地的那一个,常用来做超时。
function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error("请求超时")), ms));
return Promise.race([promise, timeout]);
}
const results = await withTimeout(
Promise.allSettled([fetch("/a.json"), fetch("/b.json")]), 3000);
这里用 allSettled 而不是 all 是有意为之:批量拉取列表时,一个接口失败不应该让整批数据都拿不到,返回结果里会标出每一项的成败,可以在界面上只提示失败的那部分。
async/await 与错误处理
async 函数返回的永远是 Promise,await 只是 then 的语法糖,真正的好处是让异步流程能像同步代码一样用 try/catch 处理错误,也能自然地写循环与条件分支。要注意 await 会暂停当前函数的执行,却不会阻塞主线程——它把后续代码包装成微任务注册到 Promise 上。
- 别用空 catch 吞错捕获后要么重试,要么上报,要么给用户明确提示。
- 并发而非串行互不依赖的请求先
await Promise.all,不要逐个等待。 - 循环里的 await串行循环会成倍放大耗时,需要并发时先收集 Promise 再统一等待。
ESM 模块化与循环依赖
ES Module 的静态结构决定了它在编译期就能分析依赖关系,这也是打包工具能做摇树优化的前提。import 是只读绑定,指向导出方的实时值;export default 适合单一主体,具名导出更适合工具函数集合。两者都能与 import() 动态导入共存,按需加载大模块时用后者能显著降低首屏体积。
// math.mjs
export const add = (a, b) => a + b;
export default function multiply(a, b) { return a * b; }
// main.mjs
import multiply, { add } from "./math.mjs";
const mod = await import("./heavy.mjs"); // 动态导入,按需加载
console.log(add(1, 2), multiply(3, 4));
循环依赖是模块化最常见的坑:两个模块互相引用时,先执行的那一方拿到的是未初始化的绑定。规避办法很朴素——把共享部分抽成第三个模块,或者把「需要时才用」的引用改成函数内部动态调用,让执行时机晚于模块初始化。打包器检测到循环依赖时会给出警告,别把它当成噪声关掉,它往往正是线上「某个导出莫名其妙是 undefined」的根源。
常见的异步陷阱
最隐蔽的一类是忘记等待:调用了一个 async 函数却没写 await,错误不会被 try/catch 捕获,反而变成未处理的 Promise 拒绝,在监控平台上刷出一堆无主异常。另一类是在循环里写 forEach(async …),forEach 不会等待回调完成,循环结束后所有请求都还在飞,依赖结果的后续代码必然拿不到数据。
还有两个与竞态有关的问题。其一是搜索框的输入请求:先发的请求可能后返回,把旧结果覆盖到新关键词上。标准解法是给每次请求打上序号,或者在发起新请求前取消上一个,也可以收窄到使用 AbortController。全局状态与异步 的组合也要小心:同一个缓存被多个并发请求同时写入时,后写的会覆盖先写的,需要做去重或加版本号。
调试异步代码时,善用断点的「异步调用栈」而不是一路 console.log。浏览器会把跨越多个微任务的调用链拼回来,能直接看到是哪一个 await 之后状态出了问题,比手工插日志的还原度更高。
let seq = 0;
async function search(keyword) {
const mine = ++seq;
const data = await fetch(`/api?q=${encodeURIComponent(keyword)}`)
.then((r) => r.json());
if (mine !== seq) return; // 已有更新的请求,丢弃本次结果
render(data);
}
小结与延伸阅读
异步的三层认知依次是:执行顺序(事件循环)、组合方式(Promise)、书写形态(async/await)。顺序理解错了,后两层都只是装饰。
- 页面结构不熟,先读《HTML5 + CSS3 现代布局实战》。
- 要在组件里用好异步,读《React 18 实战:组件化开发》与《Vue 3 组合式 API 开发指南》。
- 服务端同构这套模型,可继续读《全栈开发:Node.js + 数据库实战》。