为什么异步是前端的必修课

JavaScript 只有一个主线程,界面渲染、事件响应与脚本执行共享它。任何一段同步耗时操作都会直接卡住页面,用户看到的是白屏与「未响应」。异步不是可选的编程风格,而是这个运行时模型下的必然选择:把等待交给宿主环境,主线程腾出来继续处理交互。

理解异步的关键不是记住 API,而是搞清楚「这段代码什么时候执行」——顺序错了,逻辑再对也是错的。
先分清三种「等待」
  • 定时器setTimeout 把回调排进宏任务队列,最早也要等当前宏任务结束。
  • 微任务Promise.then 在每次宏任务结束后立即清空,优先级高于渲染。
  • I/O 回调网络与文件读写完成后,由宿主环境推入队列。
JavaScript 异步与模块化配图:发光代码块与分流汇合的箭头
分流再汇合,正是异步流程的抽象:并发发起,按序收拢。

事件循环:宏任务与微任务

事件循环每一轮做三件事:取出一个宏任务执行;执行完毕后清空所有微任务;必要时交给浏览器做一次渲染。因此 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」的根源。