DrillLab
第 17 / 21 节LESSON 17 / 21约 18 分钟~18 min

变式三 · 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.

3 个练习3 exercisesReact · 第 5 部分React · Part 5
这一页有什么On this page9
学完这节你会After this lesson you can
  • 写出 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
这在考试里考什么What the exam does with this

原始需求里就写了「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.

§01

三态骨架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.

TSX三态与渲染优先级The three states and the order they are checked in已跑通Verified
1const [user, setUser] = useState<User | null>(null);
2const [loading, setLoading] = useState(true); // 初始就是 true
3const [error, setError] = useState<string | null>(null);
4
5// ...effect...
6
7if (loading) return <p data-testid="loading">Loading</p>;
8if (error) return <p data-testid="error">出错了:{error}</p>;
9if (!user) return <p data-testid="empty">没有数据</p>;
10return <article data-testid="user"></article>;
1const [user, setUser] = useState<User | null>(null);
2const [loading, setLoading] = useState(true); // true from the start
3const [error, setError] = useState<string | null>(null);
4
5// ...effect...
6
7if (loading) return <p data-testid="loading">Loading</p>;
8if (error) return <p data-testid="error">出错了:{error}</p>;
9if (!user) return <p data-testid="empty">没有数据</p>;
10return <article data-testid="user"></article>;
§02

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.

TSX必须自己检查 res.okYou have to check res.ok yourself已跑通Verified
1const res = await fetch(`/api/users/${userId}`);
2
3// fetch 只在网络层失败时 reject;404/500 是「成功拿到一个失败响应」
4if (!res.ok) throw new Error(`HTTP ${res.status}`);
5
6const data: User = await res.json();
1const res = await fetch(`/api/users/${userId}`);
2
3// fetch only rejects when the network layer fails; a 404 or 500 is a failure response you received successfully
4if (!res.ok) throw new Error(`HTTP ${res.status}`);
5
6const data: User = await res.json();
测试里专门有一条 treats a 404 as an error:mock 一个 { ok: false, status: 404 },断言界面显示 HTTP 404 而不是崩掉。One test exists for exactly this, treats a 404 as an error: it mocks { ok: false, status: 404 } and asserts that the screen shows HTTP 404 instead of crashing.
§03

真正的考点:竞态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.

Text竞态的时间线The timeline of the race示意Illustrative
1t=0 用户点了 1 -> effect#1 发请求(要 200ms)
2t=20 用户点了 2 -> effect#1 的清理函数跑:ignore#1 = true
3 -> effect#2 发请求(要 10ms)
4t=30 用户 2 的响应回来 -> ignore#2 是 false -> setUser(用户2) ✓
5t=200 用户 1 的响应回来 -> ignore#1 是 true -> 直接丢掉 ✓
6
7# 没有 ignore 的话,t=200 那一刻会 setUser(用户1),
8# 界面变回用户 1,而 URL 上还是 2。
1t=0 user clicks 1 -> effect#1 sends a request (takes 200ms)
2t=20 user clicks 2 -> effect#1 cleanup function runs: ignore#1 = true
3 -> effect#2 sends a request (takes 10ms)
4t=30 response for user 2 arrives -> ignore#2 is false -> setUser(user 2) ✓
5t=200 response for user 1 arrives -> ignore#1 is true -> thrown away ✓
6
7# Without ignore, at t=200 it would call setUser(user 1),
8# the screen goes back to user 1 while the URL still says 2.
TSX竞态的解法How the race is settled已跑通Verified
1useEffect(() => {
2 let ignore = false; // 每次 effect 自己的一份
3
4 (async () => {
5 const res = await fetch(`/api/users/${userId}`);
6 if (!res.ok) throw new Error(`HTTP ${res.status}`);
7 const data = await res.json();
8 if (!ignore) setUser(data); // ← 作废了就什么都不做
9 })();
10
11 return () => { ignore = true; }; // 清理函数只做这一件事
12}, [userId]);
1useEffect(() => {
2 let ignore = false; // one of these per run of the effect
3
4 (async () => {
5 const res = await fetch(`/api/users/${userId}`);
6 if (!res.ok) throw new Error(`HTTP ${res.status}`);
7 const data = await res.json();
8 if (!ignore) setUser(data); // ← if this run no longer counts, do nothing
9 })();
10
11 return () => { ignore = true; }; // the cleanup does only this
12}, [userId]);
§04

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 solvesWhat it does not
The ignore flagAn old response may not write state (races, plus setState after unmount)The request itself keeps running and still burns bandwidth
AbortControllerActually cuts off the in-flight request, saving bandwidth and server workNot 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.

TSX两者配合The two working together已跑通Verified
1const controller = new AbortController();
2
3const res = await fetch(url, { signal: controller.signal });
4// ...
5} catch (e) {
6 const err = e as Error;
7 // 主动取消不是错误,别展示给用户
8 if (!ignore && err.name !== "AbortError") setError(err.message);
9}
10
11return () => {
12 ignore = true;
13 controller.abort();
14};
1const controller = new AbortController();
2
3const res = await fetch(url, { signal: controller.signal });
4// ...
5} catch (e) {
6 const err = e as Error;
7 // Cancelling on purpose is not an error, so do not show it to the user
8 if (!ignore && err.name !== "AbortError") setError(err.message);
9}
10
11return () => {
12 ignore = true;
13 controller.abort();
14};
§05

完整答案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.

TSXsrc/components/UserCard/index.tsx(实测 6/6 通过)src/components/UserCard/index.tsx (6 of 6 pass here)已跑通Verified
1import React, { useEffect, useState } from "react";
2import type { User } from "../../types/User";
3
4const UserCard: React.FC<{ userId: number }> = ({ userId }) => {
5 const [user, setUser] = useState<User | null>(null);
6 const [loading, setLoading] = useState(true);
7 const [error, setError] = useState<string | null>(null);
8
9 useEffect(() => {
10 // ignore 是「本次请求还算不算数」的开关。
11 // userId 一变,旧 effect 的清理函数先把它置 true,
12 // 于是旧请求即使晚回来也不会覆盖新数据 —— 这就是竞态的解法。
13 let ignore = false;
14 const controller = new AbortController();
15
16 setLoading(true);
17 setError(null);
18 setUser(null);
19
20 (async () => {
21 try {
22 const res = await fetch(`/api/users/${userId}`, { signal: controller.signal });
23
24 // fetch 只在网络层失败时 reject。
25 // 404 / 500 是「成功拿到一个失败响应」,必须自己检查 res.ok,
26 // 否则会把错误页当数据用。
27 if (!res.ok) throw new Error(`HTTP ${res.status}`);
28
29 const data: User = await res.json();
30 if (!ignore) setUser(data);
31 } catch (e) {
32 // 主动取消不是错误,别展示给用户
33 const err = e as Error;
34 if (!ignore && err.name !== "AbortError") setError(err.message);
35 } finally {
36 if (!ignore) setLoading(false);
37 }
38 })();
39
40 return () => {
41 ignore = true;
42 controller.abort(); // 顺手掐掉在途请求,省流量
43 };
44 }, [userId]);
45
46 if (loading) return <p data-testid="loading">Loading</p>;
47 if (error) return <p data-testid="error">出错了:{error}</p>;
48 if (!user) return <p data-testid="empty">没有数据</p>;
49
50 return (
51 <article data-testid="user">
52 <h2 data-testid="user-name">{user.name}</h2>
53 <p data-testid="user-email">{user.email}</p>
54 </article>
55 );
56};
57
58export default UserCard;
1import React, { useEffect, useState } from "react";
2import type { User } from "../../types/User";
3
4const UserCard: React.FC<{ userId: number }> = ({ userId }) => {
5 const [user, setUser] = useState<User | null>(null);
6 const [loading, setLoading] = useState(true);
7 const [error, setError] = useState<string | null>(null);
8
9 useEffect(() => {
10 // ignore is the switch for whether this request still counts.
11 // When userId changes, the cleanup of the old effect sets it to true first,
12 // so a late old response cannot overwrite new data. That settles the race.
13 let ignore = false;
14 const controller = new AbortController();
15
16 setLoading(true);
17 setError(null);
18 setUser(null);
19
20 (async () => {
21 try {
22 const res = await fetch(`/api/users/${userId}`, { signal: controller.signal });
23
24 // fetch only rejects when the network layer fails.
25 // A 404 or 500 is a failure response received successfully, so check
26 // res.ok yourself or you will use the error page as data.
27 if (!res.ok) throw new Error(`HTTP ${res.status}`);
28
29 const data: User = await res.json();
30 if (!ignore) setUser(data);
31 } catch (e) {
32 // Cancelling on purpose is not an error, so do not show it to the user
33 const err = e as Error;
34 if (!ignore && err.name !== "AbortError") setError(err.message);
35 } finally {
36 if (!ignore) setLoading(false);
37 }
38 })();
39
40 return () => {
41 ignore = true;
42 controller.abort(); // also cut off the request in flight and save bandwidth
43 };
44 }, [userId]);
45
46 if (loading) return <p data-testid="loading">Loading</p>;
47 if (error) return <p data-testid="error">出错了:{error}</p>;
48 if (!user) return <p data-testid="empty">没有数据</p>;
49
50 return (
51 <article data-testid="user">
52 <h2 data-testid="user-name">{user.name}</h2>
53 <p data-testid="user-email">{user.email}</p>
54 </article>
55 );
56};
57
58export default UserCard;
§06

怎么验证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 的 promisenew 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.

Terminal验证命令The command that verifies it已跑通Verified
1npx vitest run src/UserCard.test.tsx # 6 passed
TSXsrc/UserCard.test.tsx(DrillLab 自出,本机跑过)src/UserCard.test.tsx (written for DrillLab, run here)已跑通Verified
1import { render, screen, waitFor } from "@testing-library/react";
2import { afterEach, expect, test, vi } from "vitest";
3import UserCard from "./components/UserCard";
4
5type Deferred<T> = { promise: Promise<T>; resolve: (v: T) => void; reject: (e: unknown) => void };
6function deferred<T>(): Deferred<T> {
7 let resolve!: (v: T) => void;
8 let reject!: (e: unknown) => void;
9 const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
10 return { promise, resolve, reject };
11}
12
13const okRes = (body: unknown) => ({ ok: true, status: 200, json: async () => body });
14
15afterEach(() => { vi.unstubAllGlobals(); });
16
17test("shows loading first, then the data", async () => {
18 const d = deferred<unknown>();
19 vi.stubGlobal("fetch", vi.fn(() => d.promise));
20
21 render(<UserCard userId={1} />);
22 expect(screen.getByTestId("loading")).toBeInTheDocument();
23
24 d.resolve(okRes({ id: 1, name: "张三", email: "z@example.com" }));
25 expect(await screen.findByTestId("user-name")).toHaveTextContent("张三");
26 expect(screen.getByTestId("user-email")).toHaveTextContent("z@example.com");
27 expect(screen.queryByTestId("loading")).toBeNull();
28});
29
30test("treats a 404 as an error(fetch 不会因为 404 而 reject)", async () => {
31 vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false, status: 404, json: async () => ({}) })));
32
33 render(<UserCard userId={9} />);
34 expect(await screen.findByTestId("error")).toHaveTextContent("HTTP 404");
35});
36
37test("shows an error when the network fails", async () => {
38 vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("Failed to fetch"); }));
39
40 render(<UserCard userId={1} />);
41 expect(await screen.findByTestId("error")).toHaveTextContent("Failed to fetch");
42});
43
44test("refetches when userId changes", async () => {
45 const spy = vi.fn(async (url: string) =>
46 okRes({ id: Number(url.split("/").pop()), name: `用户${url.split("/").pop()}`, email: "x@y.z" }),
47 );
48 vi.stubGlobal("fetch", spy);
49
50 const { rerender } = render(<UserCard userId={1} />);
51 expect(await screen.findByTestId("user-name")).toHaveTextContent("用户1");
52
53 rerender(<UserCard userId={2} />);
54 expect(await screen.findByTestId("user-name")).toHaveTextContent("用户2");
55 expect(spy).toHaveBeenCalledTimes(2);
56});
57
58test("a slow stale response must not overwrite the newer one(竞态)", async () => {
59 const slow = deferred<unknown>(); // userId 1,很慢
60 const fast = deferred<unknown>(); // userId 2,很快
61 vi.stubGlobal(
62 "fetch",
63 vi.fn((url: string) => (url.endsWith("/1") ? slow.promise : fast.promise)),
64 );
65
66 const { rerender } = render(<UserCard userId={1} />);
67 rerender(<UserCard userId={2} />); // 用户飞快切到了 2
68
69 fast.resolve(okRes({ id: 2, name: "用户2", email: "b@x.z" }));
70 expect(await screen.findByTestId("user-name")).toHaveTextContent("用户2");
71
72 // 现在旧请求才回来 —— 它必须被忽略
73 slow.resolve(okRes({ id: 1, name: "用户1", email: "a@x.z" }));
74 await waitFor(() => expect(screen.getByTestId("user-name")).toHaveTextContent("用户2"));
75 expect(screen.getByTestId("user-name")).not.toHaveTextContent("用户1");
76});
77
78test("aborts the in-flight request on unmount", async () => {
79 const signals: AbortSignal[] = [];
80 vi.stubGlobal("fetch", vi.fn((_url: string, init: RequestInit) => {
81 signals.push(init.signal as AbortSignal);
82 return new Promise(() => {}); // 永不 settle
83 }));
84
85 const { unmount } = render(<UserCard userId={1} />);
86 expect(signals[0].aborted).toBe(false);
87 unmount();
88 expect(signals[0].aborted).toBe(true);
89});
1import { render, screen, waitFor } from "@testing-library/react";
2import { afterEach, expect, test, vi } from "vitest";
3import UserCard from "./components/UserCard";
4
5type Deferred<T> = { promise: Promise<T>; resolve: (v: T) => void; reject: (e: unknown) => void };
6function deferred<T>(): Deferred<T> {
7 let resolve!: (v: T) => void;
8 let reject!: (e: unknown) => void;
9 const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
10 return { promise, resolve, reject };
11}
12
13const okRes = (body: unknown) => ({ ok: true, status: 200, json: async () => body });
14
15afterEach(() => { vi.unstubAllGlobals(); });
16
17test("shows loading first, then the data", async () => {
18 const d = deferred<unknown>();
19 vi.stubGlobal("fetch", vi.fn(() => d.promise));
20
21 render(<UserCard userId={1} />);
22 expect(screen.getByTestId("loading")).toBeInTheDocument();
23
24 d.resolve(okRes({ id: 1, name: "张三", email: "z@example.com" }));
25 expect(await screen.findByTestId("user-name")).toHaveTextContent("张三");
26 expect(screen.getByTestId("user-email")).toHaveTextContent("z@example.com");
27 expect(screen.queryByTestId("loading")).toBeNull();
28});
29
30test("treats a 404 as an error(fetch 不会因为 404 而 reject)", async () => {
31 vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false, status: 404, json: async () => ({}) })));
32
33 render(<UserCard userId={9} />);
34 expect(await screen.findByTestId("error")).toHaveTextContent("HTTP 404");
35});
36
37test("shows an error when the network fails", async () => {
38 vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("Failed to fetch"); }));
39
40 render(<UserCard userId={1} />);
41 expect(await screen.findByTestId("error")).toHaveTextContent("Failed to fetch");
42});
43
44test("refetches when userId changes", async () => {
45 const spy = vi.fn(async (url: string) =>
46 okRes({ id: Number(url.split("/").pop()), name: `用户${url.split("/").pop()}`, email: "x@y.z" }),
47 );
48 vi.stubGlobal("fetch", spy);
49
50 const { rerender } = render(<UserCard userId={1} />);
51 expect(await screen.findByTestId("user-name")).toHaveTextContent("用户1");
52
53 rerender(<UserCard userId={2} />);
54 expect(await screen.findByTestId("user-name")).toHaveTextContent("用户2");
55 expect(spy).toHaveBeenCalledTimes(2);
56});
57
58test("a slow stale response must not overwrite the newer one(竞态)", async () => {
59 const slow = deferred<unknown>(); // userId 1, slow
60 const fast = deferred<unknown>(); // userId 2, fast
61 vi.stubGlobal(
62 "fetch",
63 vi.fn((url: string) => (url.endsWith("/1") ? slow.promise : fast.promise)),
64 );
65
66 const { rerender } = render(<UserCard userId={1} />);
67 rerender(<UserCard userId={2} />); // the user switched to 2 at once
68
69 fast.resolve(okRes({ id: 2, name: "用户2", email: "b@x.z" }));
70 expect(await screen.findByTestId("user-name")).toHaveTextContent("用户2");
71
72 // only now does the old request come back, and it has to be ignored
73 slow.resolve(okRes({ id: 1, name: "用户1", email: "a@x.z" }));
74 await waitFor(() => expect(screen.getByTestId("user-name")).toHaveTextContent("用户2"));
75 expect(screen.getByTestId("user-name")).not.toHaveTextContent("用户1");
76});
77
78test("aborts the in-flight request on unmount", async () => {
79 const signals: AbortSignal[] = [];
80 vi.stubGlobal("fetch", vi.fn((_url: string, init: RequestInit) => {
81 signals.push(init.signal as AbortSignal);
82 return new Promise(() => {}); // never settles
83 }));
84
85 const { unmount } = render(<UserCard userId={1} />);
86 expect(signals[0].aborted).toBe(false);
87 unmount();
88 expect(signals[0].aborted).toBe(true);
89});
练习Practice

动手做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.

L2填空Fill the blanks补全取数 effect 的四个关键位置Fill in the four key spots of the fetching effectDrillLab 自出Written by DrillLab

四个空。第 1 和第 4 个合起来解决竞态,第 2 个是 fetch 的经典坑。

Four blanks. The first and the fourth together settle the race. The second is the classic fetch trap.

TSXsrc/components/UserCard/index.tsx4 个空4 blanks
1useEffect(() => {
2 let = false;
3
4 setLoading(true);
5 setError(null);
6 setUser(null);
7
8 (async () => {
9 try {
10 const res = await fetch(`/api/users/${userId}`);
11 if (!res.) throw new Error(`HTTP ${res.status}`);
12 const data: User = await res.json();
13 if (!ignore) setUser(data);
14 } catch (e) {
15 if (!ignore) setError((e as Error).message);
16 } finally {
17 if (!ignore) setLoading(false);
18 }
19 })();
20
21 return () => { ignore = ; };
22}, []);
把 4 个空都填上才能检查(还差 4 个)Fill all 4 blanks to check (4 to go)
L3写整块Write a block自己写出带竞态防护的取数 effectWrite the fetching effect with race protection yourselfDrillLab 自出Written by DrillLab

三个 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.

要求Requirements
  • 从 /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
TSXsrc/components/UserCard/index.tsx
This check is textual: it looks for the right constructs, it does not run your code
提示Hints共 4 级,已看 0 级4 levels, 0 opened
先自己想两分钟。想不出来再点右上角 —— 提示是一级一级放的,不会一次给完。Think for two minutes first. Then use the button above — hints come one level at a time, never all at once.

看答案之前,先确认你已经自己动手写过一遍。看懂别人的答案和自己写出来,是两种能力。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.

L3Debug LabDebug LabDebug Lab · URL 上是用户 2,界面显示用户 1Debug Lab · the URL says user 2 and the screen shows user 1DrillLab 自出Written by DrillLab

快速点两个用户,界面最后显示的是先点的那个。 慢一点点就没问题。控制台干净。

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.

第 1 步 · 先看现象和代码,别急着改Step 1 · read the symptom and the code before you touch anything
# 没有任何报错。 $ npx vitest run src/UserCard.test.tsx ✕ a slow stale response must not overwrite the newer one(竞态) Expected element to have text content: 用户2 Received: 用户1 Tests 1 failed | 5 passed (6) # 手动复现: # 1. 点用户 1(这个接口慢,200ms) # 2. 立刻点用户 2(这个快,10ms) # 3. 先看到用户 2 —— 对的 # 4. 200ms 后界面自己变成了用户 1 ← 错的,URL 上还是 2# No error at all. $ npx vitest run src/UserCard.test.tsx ✕ a slow stale response must not overwrite the newer one(竞态) Expected element to have text content: 用户2 Received: 用户1 Tests 1 failed | 5 passed (6) # Manual repro: # 1. Click user 1 (that request is slow, 200ms) # 2. Click user 2 right away (that one is fast, 10ms) # 3. User 2 shows up first — correct # 4. 200ms later the view switches itself to user 1 ← wrong, the URL still says 2
TSXsrc/components/UserCard/index.tsx示意Illustrative
1useEffect(() => {
2 setLoading(true);
3 setError(null);
4
5 (async () => {
6 try {
7 const res = await fetch(`/api/users/${userId}`);
8 if (!res.ok) throw new Error(`HTTP ${res.status}`);
9 setUser(await res.json());
10 } catch (e) {
11 setError((e as Error).message);
12 } finally {
13 setLoading(false);
14 }
15 })();
16}, [userId]);
第 2 步 · 这是什么类型的错误Step 2 · what kind of error is this
错例Wrong

初学者常见的几种写法错误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.

TSX示意Illustrative
1// ✗ effect 本身写成 async
2useEffect(async () => {
3 const res = await fetch(url);
4 setUser(await res.json());
5}, [userId]);
1// ✗ Making the effect itself async
2useEffect(async () => {
3 const res = await fetch(url);
4 setUser(await res.json());
5}, [userId]);
effect 的返回值必须是清理函数或 undefined, 而 async 函数返回 Promise。React 会警告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.
TSX示意Illustrative
1// ✗ 忘了依赖数组
2useEffect(() => {
3 fetch(url).then((r) => r.json()).then(setUser);
4});
1// ✗ Forgetting the dependency array
2useEffect(() => {
3 fetch(url).then((r) => r.json()).then(setUser);
4});
每次渲染都发一次请求,而 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.
TSX示意Illustrative
1// ✗ 只在成功路径关 loading
2try {
3 const data = await res.json();
4 setUser(data);
5 setLoading(false);
6} catch (e) {
7 setError((e as Error).message);
8}
1// ✗ Turning loading off only on the success path
2try {
3 const data = await res.json();
4 setUser(data);
5 setLoading(false);
6} catch (e) {
7 setError((e as Error).message);
8}
出错时 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.
迁移Transfer

换一道题也能用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.

「按 id 取数并展示」Fetch by id and show the result
三态 + effect 依赖 [id]The three states, plus an effect with [id] as its dependency
用了 fetchThe code uses fetch
必须检查 res.ok,404 不会 rejectCheck res.ok; a 404 does not reject
「切换很快时数据错乱」The data comes out wrong when you switch quickly
竞态,用 ignore 标志 + 清理函数A race. Use an ignore flag plus a cleanup function
「卸载后 setState 警告」A setState warning after the component is removed
同一套 ignore 写法就解决了The same ignore pattern fixes it
「网络面板疯狂刷屏」The network panel never stops scrolling
effect 漏了依赖数组The effect is missing its dependency array
「出错后卡在 Loading」It sticks on Loading after an error
setLoading(false) 要放 finallysetLoading(false) belongs in finally
这节的要点What to take away
  1. 三态骨架:loading 初始为 true,渲染顺序 loading → error → 空 → 数据。The three-state skeleton: loading starts as true, and the render order is loading, then error, then empty, then data.
  2. 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.
  3. 竞态:慢的旧请求后回来会覆盖新数据。解法是每次 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.
  4. 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.
  5. effect 不能是 async;关 loading 放 finally;依赖数组里必须有 id。An effect cannot be async. Turn loading off in finally. The dependency array must contain id.

接下来What next

  1. 把这一节的练习做掉Do this lesson’s exercises3 个,就在这一页上面 —— 别攒着最后一起做3 of them, further up this page — do not save them for later
    回到练习 ↑Back up to them ↑
  2. 接着看下一节Continue to the next lesson变式四 · 递归读取评论的评论Variation 4 · reading replies to replies with recursion
    下一节Next lesson
读完并且做过上面的练习了吗?Read it and worked through the exercises above?
上一节:Previous: 变式二 · 计时器:useEffect 的清理函数Variation 2 · a timer: the useEffect cleanup function