fetch 取数:loading、error 与竞态Fetching data: loading, error and race conditions
题面The problem
先把要求读完,再动手。Read every requirement before you start.
三个 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.
- 三态:loading / error / 成功显示 name 与 emailThree states: loading, error, and success which shows name and email
- fetch 只在网络层失败时 reject —— 404 / 500 要自己查 res.ok,错误文案带 HTTP 和状态码fetch only rejects when the network layer fails. Check res.ok yourself for 404 and 500, and put HTTP plus the status code in the error message
- userId 变了要重新取,并且先回到 loading,不留上一个人的数据在屏幕上When userId changes, fetch again and go back to loading first, so the previous user's data never stays on screen
- 竞态:旧请求晚回来不许覆盖新数据(effect 里立 ignore 开关,清理函数置 true)Race condition: an old response that arrives late must not overwrite newer data. Declare an ignore flag inside the effect and set it to true in the cleanup function
- 切 userId / 卸载时用 AbortController 掐掉在飞的请求;AbortError 不是错误,不给用户看Use AbortController to cancel the in-flight request when userId changes or the component unmounts. An AbortError is not a real failure, so do not show it to the user
预计 35 分钟。这个数字是照「读完题就开始写、不查资料」估的 —— 第一次超时很正常,第二次要压进去。Budget 35 minutes. Overrunning on the first pass is normal; the second pass should fit.
工作区Workspace
这道题要 stub fetch 才能测竞态,而 Sandpack 的测试环境拦不住 fetch(globalThis / window / self 都试过)。测试本身是对的 —— 在本机 vitest 下 9/9 通过。所以这里只给命令。Testing the race condition needs a stubbed fetch, and Sandpack's test environment cannot intercept fetch (globalThis, window and self were all tried). The tests themselves are fine — 9/9 under vitest locally. So you get the commands instead.
跑完自己对一遍期望输出,然后在下面打勾。这里不给假编辑器 —— 装个能跑的样子只会让你以为练过了。Compare the output yourself, then tick it off below. No fake editor here.
展开讲解Walkthrough
下面是《变式三 · fetch 取数:loading、error 与竞态》那一节的原文 —— 和课程里是同一份内容,不是另写的摘要。卡住了再展开。Below is the actual text of the lesson “变式三 · fetch 取数:loading、error 与竞态” — the same content as in the course, not a rewritten summary. Expand it when you stall.
展开《变式三 · fetch 取数:loading、error 与竞态》(6 段 · 约 18 分钟)Expand “变式三 · fetch 取数:loading、error 与竞态” (6 sections · ~18 min)
完整那一节(含练习、常见错误、迁移模式)在The full lesson (with exercises, common mistakes and transfer patterns) is at 变式三 · fetch 取数:loading、error 与竞态。
三态骨架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.
参考答案Reference solution
提示是一级一级放的。四级看完还写不出来,再开答案门。The hints come one level at a time. If all four leave you stuck, open the answer.
这份答案在本机真跑过测试。但先确认你自己动手写过一遍 —— 读懂答案和写出答案是两种能力,考场上考的是后一种。This answer really was run here and its tests passed. But write it yourself first — reading an answer and producing one are two different skills, and the exam tests the second.