变式三 · fetch 取数:loading、error 与竞态Variation 3 · fetching data: loading, error, and the race between two requests
三个状态好写,难的是「用户切换很快时,慢的旧请求把新数据覆盖了」。The three states are easy. The hard part is when the user switches quickly and a slow old request overwrites the new data.
这一页有什么On this page9
- 01 三态骨架The three-state skeleton
- 02 fetch 的第一个坑:404 不会 rejectThe first trap in fetch: a 404 does not reject
- 03 真正的考点:竞态What is really being tested: the race between two requests
- 04 AbortController 和 ignore 解决的不是同一件事AbortController and the ignore flag do not solve the same problem
- 05 完整答案The complete answer
- 06 怎么验证How to check it
- 练习 · 动手做Practice
- 常见错误Common mistakes
- 迁移模式Transfer
- 写出 loading / error / data 三态的标准骨架Write the standard skeleton for the three states: loading, error, data
- 知道 fetch 遇到 404 不会 reject,必须自己检查 res.okKnow that fetch does not reject on a 404, so you have to check res.ok yourself
- 解释竞态(race condition)怎么发生,并用清理函数解决Explain how a race condition happens, and fix it with a cleanup function
- 分清 AbortController 和 ignore 标志各解决什么Say what AbortController solves and what an ignore flag solves, and why they are different
原始需求里就写了「API request / loading state / error state」,但源项目里没有任何网络请求,所以前面没讲。这道题补上,而且直接给到「竞态」这一层 —— 只写三态谁都会,竞态才是区分度所在。The original requirements already list API request, loading state, and error state, but the source projects make no network calls, so no earlier lesson covered this. This question fills that gap and goes one level further, to the race between two requests. Anyone can write the three states; the race is what tells answers apart.
三态骨架The three-state skeleton
loading / error / data。顺序和优先级都有讲究。loading, error, data. Both the order and which one wins matter.
标准写法是三个 state 加三个提前返回:
顺序不能乱。先判 loading,再判 error, 最后才渲染数据。如果先判 !user, 第一次渲染时(还在加载)就会闪一下「没有数据」。
loading 初始值必须是 true。写成 false 的话,首帧会先渲染「没有数据」再切成 Loading,界面闪一下。因为 effect 是在渲染之后才跑的。
换 id 时要把 user 清空。否则切到新用户的加载过程中,屏幕上还挂着上一个用户的资料 —— 看起来像「数据错了」。
The standard shape is three pieces of state and three early returns:
The order cannot be shuffled. Check loading, then error, and only then render the data. Check !user first and the very first render — still loading — flashes “no data”.
loading has to start at true. Start it at false and the first frame renders “no data” before switching to Loading, so the UI flickers. The effect runs after the render, that is why.
Clear user when the id changes. Otherwise the previous user’s profile sits on screen while the new one loads — and it looks like the data is simply wrong.
fetch 的第一个坑:404 不会 rejectThe first trap in fetch: a 404 does not reject
这是所有 fetch 题的必考点。Every fetch question checks this one.
fetch 的 promise 只在网络层失败时 reject(断网、DNS 挂了、CORS 被拒)。 服务器返回 404 或 500 时,它是成功的—— 你成功地拿到了一个「失败响应」。
所以不检查 res.ok 的话,res.json() 会去解析错误页的内容, 然后你把它当用户数据渲染出来 —— 轻则显示 undefined,重则整页崩。
这一点和 axios 相反(axios 会对非 2xx 抛错), 所以从 axios 转过来的人特别容易漏。
A fetch promise only rejects when the network layer fails (offline, DNS down, CORS refused). When the server answers 404 or 500 the call succeeded — you successfully got a failure response.
So if you skip the res.ok check, res.json() goes off and parses the error page, and then you render that as user data — mild case, it shows undefined; bad case, the page crashes.
This is the opposite of axios (axios throws on any non-2xx), so people coming over from axios miss it especially often.
真正的考点:竞态What is really being tested: the race between two requests
用户飞快切换 id,两个请求同时在飞,谁后回来谁说话 —— 而后回来的可能是旧的。The user switches id quickly, two requests are in flight, and whichever answers last wins. The one that answers last may be the older one.
场景:用户点了用户 1(这个请求很慢,200ms), 马上又点了用户 2(这个很快,10ms)。
没有防护的话,最终屏幕上显示的是用户 1—— 因为它最后才回来,把用户 2 的数据覆盖了。 URL 上是 2,界面上是 1。
解法是在清理函数里立一个「这次请求作废」的旗子:
ignore 是普通局部变量, 每次 effect 执行都有自己的一份。清理函数通过闭包改的是它那一次的 ignore。 所以旧请求回来时看到的是自己的 ignore === true, 于是什么都不做。
为什么不用 state 存这个旗子?因为它不参与渲染,而且每次 effect 需要独立的一份 —— state 是共享的,会互相干扰。
The scenario: the user clicks user 1 (a slow request, 200ms), then immediately clicks user 2 (a fast one, 10ms).
With no guard, the screen ends up showing user 1 — it came back last and overwrote user 2’s data. The URL says 2, the UI says 1.
The fix is to raise a “this request no longer counts” flag in the cleanup:
ignore is a plain local variable, and every run of the effect gets its own. Through the closure, a cleanup only changes its own ignore. So when the old request comes back it sees its own ignore === true and does nothing.
Why not keep the flag in state? Because it takes no part in rendering, and every run of the effect needs a separate one — state is shared, so the runs would interfere with each other.
AbortController 和 ignore 解决的不是同一件事AbortController and the ignore flag do not solve the same problem
两个都要,各管一头。You want both. Each one covers a different end.
| 解决什么 | 不解决什么 | |
|---|---|---|
ignore 标志 | 旧响应不许写 state(竞态、 以及卸载后 setState) | 网络请求本身还在跑,流量照走 |
AbortController | 真的把在途请求掐掉,省流量和服务器资源 | 不是所有环境都尊重 signal(比如被 mock 掉的 fetch、 某些 polyfill),所以不能只靠它 |
所以生产写法是两个一起用。 另外 abort() 会让 await fetch 抛一个AbortError —— 那是我们自己干的,不能当成错误展示给用户,要在 catch 里过滤掉。
测试里有一条 aborts the in-flight request on unmount: mock 的 fetch 把收到的 signal 存下来, 卸载后断言 signal.aborted === true。
| What it solves | What it does not | |
|---|---|---|
The ignore flag | An old response may not write state (races, plus setState after unmount) | The request itself keeps running and still burns bandwidth |
AbortController | Actually cuts off the in-flight request, saving bandwidth and server work | Not every environment respects the signal (a mocked fetch, some polyfills), so it cannot be your only guard |
So production code uses both. One more thing: abort() makes await fetch throw an AbortError — we did that to ourselves, so it must not be shown to the user as an error. Filter it out in the catch.
One test covers this, aborts the in-flight request on unmount: the mocked fetch stores the signal it received, and after unmount the test asserts signal.aborted === true.
完整答案The complete answer
6 个测试全过,包含竞态和 abort 两条。All 6 tests pass, including one for the race and one for abort.
注意 async 逻辑包在一个立即执行的 async 箭头函数里, 而不是把 effect 本身写成 async —— 因为 effect 的返回值必须是清理函数,async 函数返回的是 Promise,React 会警告。
finally 里也要判 ignore: 否则旧请求回来时会把新请求的 loading 提前关掉, 出现「转圈消失但数据还没到」的空窗。
Notice the async logic sits inside an immediately invoked async arrow function rather than making the effect itself async — the effect’s return value has to be the cleanup, and an async function returns a Promise, which makes React warn.
finally has to check ignore too: otherwise an old request coming back turns off the new request’s loading too early, and you get a gap where the spinner is gone but the data has not arrived.
怎么验证How to check it
竞态这种「偶尔才出现」的 bug,怎么稳定地测出来?答案是自己控制谁先回来。How do you reliably test a bug that only appears now and then? You decide yourself which request answers first.
关键手法是 deferred promise: 造一个 promise,把它的 resolve 抓在手里, 想让哪个请求什么时候回来,就手动调它。 这样「慢的先发、快的后发、慢的最后才回来」这个顺序 是确定的,不靠 setTimeout 赌时间。
vi.stubGlobal("fetch", ...) 把全局fetch 换成假的,按 URL 决定返回哪个 promise。 注意假的响应对象要自己带上 ok /status / json()—— 因为组件用的就是这三个。
最后那条 aborts the in-flight request on unmount用了个小技巧:假 fetch 返回一个永不 settle 的 promise(new Promise(() => )), 把收到的 signal 存下来,卸载后断言signal.aborted === true。
The key move is a deferred promise: build a promise and keep its resolve in your hand, then call it whenever you want that request to come back. That makes the order “slow one sent first, fast one second, slow one resolves last” deterministic, instead of betting on setTimeout.
vi.stubGlobal("fetch", ...) replaces the global fetch with a fake one that picks a promise by URL. The fake response object has to carry ok / status / json() itself — those three are exactly what the component uses.
The last test, aborts the in-flight request on unmount, uses a small trick: the fake fetch returns a promise that never settles (new Promise(() => )), stores the signal it received, and after unmount asserts signal.aborted === true.
动手做Get your hands on it
填空只是过渡。真正掌握的标准,是在没有答案的时候从头写出来 —— 所以做完 L2 之后一定要往 L3、L4 走。Filling blanks is a stepping stone. The real bar is writing it from nothing, so once L2 is comfortable, push on to L3 and L4.
四个空。第 1 和第 4 个合起来解决竞态,第 2 个是 fetch 的经典坑。
Four blanks. The first and the fourth together settle the race. The second is the classic fetch trap.
三个 state 已给好。写出 effect 和三个提前返回。 检查器会查 res.ok、清理函数、竞态防护和 AbortError 过滤。
The three states are given. Write the effect and the three early returns. The checker looks for res.ok, the cleanup function, the race protection and the AbortError filter.
- 从 /api/users/{userId} 取数Fetch from /api/users/{userId}
- 非 2xx 响应要当成错误处理,错误信息形如 HTTP 404Treat any non-2xx response as an error, with a message like HTTP 404
- userId 变化时重新取数,并把上一次的结果作废(竞态防护)Refetch when userId changes, and void the previous result (race protection)
- 用 AbortController 掐掉在途请求,但 AbortError 不展示给用户Use AbortController to cut off the request in flight, but never show AbortError to the user
- 渲染顺序:loading → error → 空数据 → 正常数据Render order: loading, then error, then no data, then the data
- effect 本身不能是 async 函数The effect itself must not be an async function
看答案之前,先确认你已经自己动手写过一遍。看懂别人的答案和自己写出来,是两种能力。Before you open this, make sure you have written it yourself once. Following someone else's answer and producing your own are two different skills.
快速点两个用户,界面最后显示的是先点的那个。 慢一点点就没问题。控制台干净。
Click two users quickly and the screen ends up showing the one you clicked first. Click a little slower and it is fine. The console is clean.
初学者常见的几种写法错误Mistakes beginners actually make
下面每一段都是「能编译、但结果不对」或者「一跑就炸」的真实写法。先自己看出问题在哪,再看解释。Every snippet below either compiles and gives the wrong answer, or blows up on the first run. Spot the problem yourself before reading the explanation.
useEffect must not return anything besides a function, 而且这样根本没法写清理函数。正解是在 effect 内部包一个立即执行的 async 箭头函数。An effect must return a cleanup function or undefined, and an async function returns a
Promise. React warns useEffect must not return anything besides a function, and this way there is no place to put a cleanup function at all.The fix is to define an async arrow function inside the effect and call it immediately.
setUser 又触发渲染 ——无限请求循环。 开发时表现为网络面板疯狂刷屏,接口被打爆。 这是 fetch 题最经典的事故。Every render sends a request, and setUser causes another render — an endless request loop. In development the network panel never stops scrolling and the endpoint is flooded. This is the classic accident in fetch questions.loading 永远是 true, 于是界面卡在「Loading…」,错误信息根本没机会显示 (因为 if (loading) 先返回了)。关 loading 要放在
finally 里。When the request fails, loading stays true forever, so the screen sits on Loading… and the error message never gets a chance to show (because if (loading) returned first).Turn loading off inside
finally.换一道题也能用Works on other problems too
考试不会原题重考。真正能带走的是「看到这种信号 → 伸手去拿这个解法」。The exam will not reuse the same question. What you take away is the reflex: see this signal, reach for that solution.
- 三态骨架:loading 初始为 true,渲染顺序 loading → error → 空 → 数据。The three-state skeleton: loading starts as true, and the render order is loading, then error, then empty, then data.
- fetch 只在网络层失败时 reject,404/500 必须自己检查 res.ok。fetch only rejects when the network layer fails. For 404 and 500 you have to check res.ok yourself.
- 竞态:慢的旧请求后回来会覆盖新数据。解法是每次 effect 一个 ignore 局部变量 + 清理函数置 true。The race: a slow old request answers last and overwrites the new data. The fix is one local ignore variable per effect run, which the cleanup function sets to true.
- AbortController 掐网络,ignore 挡 state 写入 —— 两个都要,AbortError 不算错误。AbortController stops the network call, ignore blocks the state write. You need both, and an AbortError does not count as an error.
- effect 不能是 async;关 loading 放 finally;依赖数组里必须有 id。An effect cannot be async. Turn loading off in finally. The dependency array must contain id.