从「为什么需要异步」讲起,一路到事件循环和竞态处理。基础同学可以顺着读下来,有经验的同学可以直接跳到后半段的坑和进阶。
一、为什么需要异步?
JavaScript 只有一个线程——同一时刻只能干一件事。这带来一个大麻烦:如果一件事很慢(比如向服务器要数据,可能要等好几秒),而你「站在原地等它做完」,那这几秒里整个页面都会卡死:点按钮没反应、动画停住、什么都动不了。
用厨房打个比方。厨房里只有一个厨师。他要煮一锅汤,汤要炖 10 分钟。有两种做法:
- 同步(傻等):厨师站在锅前盯着,10 分钟里什么别的都不做。这期间来了客人点菜,他也不理——厨房「卡死」了。
- 异步(聪明):厨师把汤放上灶,转头去切菜、摆盘、招呼客人。汤好了灶台会「叮」一声通知他,他再回来处理。
异步就是第二种做法:遇到慢的事情,不干等,先登记一个「做完了叫我」的回调,转头去干别的。那锅汤(网络请求、定时器、读文件)是由浏览器底层帮你在后台完成的,不占用你这唯一的厨师。
所以在 JS 里有一条铁律:永远不要让主线程傻等。异步不是什么高级技巧,而是单线程环境下的生存必需。
二、Promise 是什么?
既然是「做完了叫我」,那就需要一个东西来代表这件还没做完的事。这个东西就是 Promise(承诺)。
你可以把 Promise 理解成一张取餐小票:你点了餐,还没做好,但店家先给你一张票。这张票有三种状态:
- pending(等待中):餐还在做。
- fulfilled(成功):餐做好了,你拿到了食物(一个结果值)。
- rejected(失败):出问题了,比如食材没了(一个错误)。
小票一旦从「等待中」变成「成功」或「失败」,就定下来了,不会再变。
拿到一张 Promise 小票后,用 .then 登记「成功了做什么」,用 .catch 登记「失败了做什么」:
const 小票 = 取餐(); // 返回一个 Promise
小票
.then((食物) => {
console.log("拿到了", 食物); // 成功时执行
})
.catch((错误) => {
console.log("出问题了", 错误); // 失败时执行
});
console.log("我先去找个座位"); // 这行不会等餐,立刻执行
注意最后一行:登记完回调后,代码立刻往下走,不会卡在这里等餐。这就是异步——「登记回调,转头做别的」。
三、async / await:把异步写得像同步
.then 链一多就容易套很多层,读起来累。于是有了 async / await,让异步代码看起来像从上到下的同步代码。
规则很简单:
- 在函数前加
async,它就变成一个「异步函数」(它一定返回一个 Promise)。 - 在函数里用
await等一个 Promise,代码会在这里暂停,等结果出来再继续往下。
同一件事,两种写法对比:
// 写法 A:.then 链
function 取餐流程() {
取餐()
.then((食物) => 加热(食物))
.then((热餐) => 上桌(热餐))
.catch((错误) => console.log("失败", 错误));
}
// 写法 B:async / await(更好读)
async function 取餐流程() {
try {
const 食物 = await 取餐(); // 暂停,等取餐完成
const 热餐 = await 加热(食物); // 再暂停,等加热完成
上桌(热餐);
} catch (错误) {
console.log("失败", 错误);
}
}
写法 B 读起来就是「取餐 → 加热 → 上桌」,一目了然。
关键提醒:await 的「暂停」只是暂停这个 async 函数自己,并不会卡死整个页面——它把「后面的代码」登记成一个回调,控制权先还给浏览器去干别的,等结果回来再接着跑。这跟第一节说的「厨师转头做别的」是同一件事,只是语法上看起来像在「等」。
四、用 fetch 取数据(以及三个必踩的坑)
真实项目里,异步几乎都是在向服务器要数据。浏览器内置的 fetch 就是干这个的,它返回一个 Promise:
async function 加载文档() {
const res = await fetch("/api/docs"); // 等服务器响应
const data = await res.json(); // 把响应内容解析成 JS 对象
console.log(data);
}
看起来简单,但有三个坑,不知道就一定会踩:
坑一:fetch 对 4xx / 5xx 不会失败(最容易踩)
你以为服务器返回 500 错误,await fetch() 就会抛错进 catch?不会。 fetch 只有在网络层挂了(断网、域名解析失败)时才 reject。服务器返回 404、500 这种「请求到了、但结果是错误」,fetch 照样算「成功」。
所以你必须手动检查 res.ok(只有 2xx 状态码它才是 true):
const res = await fetch("/api/docs");
if (!res.ok) {
throw new Error(`服务器返回 ${res.status}`); // 自己抛出来
}
const data = await res.json();
漏了这一步,页面就会拿着错误响应当正常数据用,出现莫名其妙的 bug。
坑二:fetch 是「两段式」
拿数据要 await 两次,很多人只写一次就懵了。原因是:
- 第一个
await fetch(...)只等到了响应头(服务器说「我要开始发数据了」)。 - 数据本身(body)还在网络上传输,要再
await res.json()才能拿到并解析。
而且 res.json() 自己也可能抛错(如果服务器发来的不是合法 JSON)。
坑三:fetch 没有「超时」功能
如果服务器一直不回应,fetch 会一直等下去,不会自动放弃。想要「等超过 3 秒就放弃」,得自己用 AbortController 做一个:
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 3000); // 3 秒后按下"取消键"
try {
const res = await fetch("/api/docs", { signal: controller.signal });
// ...
} finally {
clearTimeout(timer); // 不管成功失败都要清掉定时器
}
controller 就像一个「取消按钮」,controller.signal 是连到 fetch 的那根线。谁按下 abort(),正在进行的 fetch 就会立刻失败(抛一个 AbortError)。
有意思的是:「超时」本质上就是「程序自己按下的取消」——用 setTimeout 在 3 秒后自动按一下取消键而已。所以「超时」和「用户手动点取消」用的是同一套机制。
五、用 try / catch 兜住错误
异步操作很容易失败(网断了、服务器挂了、数据格式错了)。用 try / catch 把 await 包起来,失败时给用户一个明确的提示,而不是让页面默默卡住或白屏:
async function 加载文档() {
try {
const res = await fetch("/api/docs");
if (!res.ok) throw new Error(`服务器出错 ${res.status}`);
const data = await res.json();
显示文档(data);
} catch (错误) {
显示提示("加载失败:" + 错误.message); // 让用户知道发生了什么
}
}
两个原则:
- 永远不要写空的 catch(
catch {})——它会把错误悄悄吞掉,让 bug 无迹可寻。 - 给用户看得懂的话,给开发者留完整的错误对象。
六、异步界面不是「加载中 / 完成」两种状态
学异步最容易犯的错,是用一个 isLoading 布尔值糊弄所有情况。实际上,一次请求是一个小状态机,可能走向好几种结局:
idle(初始)
└→ loading(加载中)
├→ success(成功·有数据)
├→ success(成功·但没有数据) ← 不是错误!应引导用户"去上传"
├→ error(网络失败 / 服务器出错)
├→ timeout(超时) ← 应鼓励"重试"
└→ aborted(用户主动取消) ← 不是错误!别弹红色警告
三个最容易搞错的辨析:
- 「没有数据」不是错误:请求成功了,只是结果是个空列表。应该显示「还没有文档,去上传第一个吧」,而不是报错。
- 「用户取消」不是错误:这是用户自己的选择,安静地回到初始状态就好,弹红色警告反而莫名其妙。
- 「超时」不等于「网络断了」:网络没断,只是我们等得不耐烦主动放弃了,文案应该鼓励重试。
想要给这几种情况不同的反馈,就得先能区分它们。做法是给错误打一个「种类」标签:
class ApiError extends Error {
constructor(kind, message, status = null) {
super(message);
this.kind = kind; // "network"|"http"|"timeout"|"aborted"
this.status = status; // HTTP 状态码,比如 500
}
}
这样界面就能照着 kind 决定:是弹红条、还是显示引导、还是安静返回。用一个 phase 字段(idle/loading/success/error…)代替一堆布尔值,能保证任意时刻只处于一个状态,不会出现「又在加载又出错了」这种自相矛盾。这也正是后面学 React 状态管理的思维雏形。
七、(进阶)事件循环:宏任务与微任务
前面说「await 会把后面的代码登记成回调,转头做别的」。那这些回调按什么顺序被执行?这就是事件循环在管的事。它维护两张待办清单:
- 宏任务(macrotask):
setTimeout、DOM 事件回调、<script>本体。一轮只取一个来做。 - 微任务(microtask):
Promise.then/catch/finally、await之后的续体、queueMicrotask。一轮会全部清空。
一条铁律:
取一个宏任务 → 执行到底 → 清空全部微任务 →(渲染画面)→ 取下一个宏任务
记法:凡是跟 Promise 沾边的(含 async/await)都算微任务,定时器和用户事件算宏任务;微任务优先级更高。
来看一个经典例子,猜猜输出顺序:
console.log("1");
setTimeout(() => console.log("2"), 0); // 宏任务
Promise.resolve().then(() => console.log("3")); // 微任务
console.log("4");
// 输出:1 4 3 2
1、4是同步代码,先一口气跑完(<script>本身就是当前这个宏任务)。- 当前宏任务结束后,先清空微任务 → 打印
3。 - 微任务清空了,才去取下一个宏任务 → 打印
2。
注意 setTimeout(…, 0) 的 0 不代表「马上执行」,只代表「尽快排进宏任务队列」——而微任务永远插在下一个宏任务前面,所以 3 比 2 早。
再看一个进阶的,理解「微任务能插到已经排好队的宏任务前面」:
setTimeout(() => {
console.log("A");
Promise.resolve().then(() => console.log("B"));
}, 0);
setTimeout(() => console.log("C"), 0);
// 输出:A B C
- 两个
setTimeout先排进宏任务队列:[第一个, 打印C的]。 - 取出第一个执行:打印
A,并产生一个微任务(打印B)。 - 第一个宏任务结束——铁律要求先清空微任务,所以
B先打印。 - 微任务清空了,才轮到早就在队列里等着的「打印 C」。
B 插到了 C 前面,尽管「打印 C」这个宏任务排队更早。这就是「微任务优先」的威力。
八、(进阶)单线程也躲不掉的一种竞态
你可能会想:只有一个线程、代码一句句执行,那总不会有「并发 bug」了吧?还真有一种——陈旧响应竞态。它藏在 await 里:
async function 加载文档(筛选条件) {
const res = await fetch(`/api/docs?filter=${筛选条件}`); // ← 暂停点
const data = await res.json(); // ← 暂停点
渲染(data); // 跑到这里时,用户可能早就切换成别的筛选了
}
设想用户先点「全部」,紧接着点「失败」,于是发出了两个请求。如果「全部」那个请求更慢、后回来,它就会把界面又覆盖回旧的「全部」数据——用户明明选的是「失败」,却看到了「全部」。
关键在于理解 await 是一个暂停点:暂停期间,别的代码(比如用户的第二次点击)会插进来跑。所以「发请求」和「渲染结果」这两个动作之间,世界已经变了。
解决办法不是加锁(这里没有内存被同时改写的问题),而是发新请求前,取消掉上一个还没回来的:
if (inFlight) inFlight.abort(); // 取消上一个还在飞的请求
const controller = new AbortController();
inFlight = controller;
// ...用 controller.signal 发起新请求,旧的结果回来时会被丢弃
好消息是:后面学 React 时,useEffect 的清理函数、以及 TanStack Query 这类库会自动帮你做这件事。但先手写一遍,你才能真正明白框架在背后替你解决了什么。
总结
| 概念 | 一句话 |
|---|---|
| 为什么要异步 | 单线程,傻等会卡死整个页面,所以「登记回调,转头做别的」 |
| Promise | 代表一件还没做完的事,有 pending/成功/失败三种状态 |
| async / await | 把异步写得像同步;await 只暂停当前函数,不卡死页面 |
| fetch 坑一 | 4xx/5xx 不会失败,必须自己查 res.ok |
| fetch 坑二 | 两段式,拿数据要 await 两次(响应头 + body) |
| fetch 坑三 | 没有原生超时,靠 AbortController + setTimeout 自己做 |
| try / catch | 兜住失败,给用户明确提示,别写空 catch |
| 异步状态机 | 不是一个布尔值,是 idle/loading/success/空/error/timeout/取消 |
| 事件循环 | 一个宏任务 → 清空所有微任务 → 渲染 → 下一个宏任务 |
| 陈旧响应竞态 | await 是暂停点,跨过它世界会变;发新请求前取消旧的 |
异步是前端的分水岭。搞懂「单线程 + 登记回调」这个核心模型,再往上,Promise、async/await、fetch 都只是这套模型上的具体工具。后面学 React 的数据加载、加载状态、错误处理,全都建立在今天这些概念之上。

没有回应