DrillLab
第 05 / 08 节LESSON 05 / 08约 14 分钟~14 min

Loading:一秒之后自己跳走Loading: it moves to the next page by itself after one second

useEffect 里一个 setTimeout,return 里一个 clearTimeout。少了后者会出真问题。One setTimeout inside useEffect, one clearTimeout in the return. Leave the second one out and you get a real problem.

2 个练习2 exercisesCab Booking · 第 2 部分Cab Booking · Part 2
这一页有什么On this page5
学完这节你会After this lesson you can
  • 在 useEffect 里写 setTimeout 并正确清理Write a setTimeout inside useEffect and clear it correctly
  • 说清清理函数在防什么,以及不清理的真实症状Explain what the cleanup function prevents, and what really goes wrong without it
  • 看懂测试为什么要 vi.useFakeTimers() + advanceTimersByTime(1000)See why the test needs vi.useFakeTimers() together with advanceTimersByTime(1000)
  • 知道 act() 包住时间推进的原因Know why the time advance is wrapped in act()
这在考试里考什么What the exam does with this

这是 effect 清理的标准考法,也是本站 React 变式二「计时器」的同一个考点。测试用 fake timer 把 1 秒变成一行代码,所以延迟数字必须正好是 1000 —— 写 900 或 1200,advanceTimersByTime(1000) 之后页面状态就不对了。This is the standard way effect cleanup gets examined, and the Timer (useEffect cleanup) task on this site tests the same point. The test uses a fake timer to turn the 1 second into a single line, so the delay has to be exactly 1000. Write 900 or 1200 and the page is in the wrong state after advanceTimersByTime(1000).

这节课要看的真实文件Real files this lesson looks at高亮行 = 需要你动手改的文件Highlighted rows are the files you edit
cab-booking-context/src/components/Loading/Loading.jsxsetTimeout + clearTimeout

提醒:源项目在磁盘上是做完的版本 —— 下面就是答案。想自己先写一遍的话,现在关上。Heads up: on disk this project is the finished version — what follows is the answer. Close this if you want to write it yourself first.

JSXLoading.jsx源项目From source
1import { useEffect } from "react";
2
3const Loading = ({ onComplete }) => {
4 useEffect(() => {
5 // 模拟 1 秒延迟加载
6 const timer = setTimeout(() => {
7 if (onComplete) onComplete();
8 }, 1000);
9
10 return () => clearTimeout(timer);
11 }, [onComplete]);
12
13 return (
14 <main data-testid="loading" className="loading-container">
15 <div className="spinner" aria-hidden="true" />
16 <h1>Loading...</h1>
17 <p>We are working on your cab booking. Thanks for your patience.</p>
18 </main>
19 );
20};
21
22export default Loading;
Source: cab-booking-context/src/components/Loading/Loading.jsx
cab-booking-context/src/test/App.test.jsxfake timer 的用法在 beforeEach / afterEach 里How the fake timer is used, in beforeEach and afterEach
JSXApp.test.jsx源项目From source
1import { act, fireEvent, render, screen } from "@testing-library/react";
2import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3import App from "../App";
4import { CabProvider } from "../context/CabContext";
5
6const renderApp = () =>
7 render(
8 <CabProvider>
9 <App />
10 </CabProvider>,
11 );
12
13describe("React: Cab Booking", () => {
14 beforeEach(() => {
15 vi.useFakeTimers();
16 });
17
18 afterEach(() => {
19 vi.runOnlyPendingTimers();
20 vi.useRealTimers();
21 });
22
23 it("renders the home page and empty ride history", () => {
24 renderApp();
25
26 expect(
27 screen.getByText("Book a Safe Ride with HackerRide"),
28 ).toBeInTheDocument();
29 expect(screen.getByTestId("book-button")).toBeInTheDocument();
30 expect(screen.getByTestId("no-ride-title")).toHaveTextContent(
31 "No ride history yet.",
32 );
33 });
34
35 it("shows grouped cab options with all required card fields", () => {
36 renderApp();
37
38 fireEvent.click(screen.getByTestId("book-button"));
39
40 expect(screen.getByTestId("all-cabs-section")).toBeInTheDocument();
41 expect(screen.getAllByTestId("car-type-heading").map((node) => node.textContent))
42 .toEqual(["Sedan", "SUV", "Luxury"]);
43 expect(screen.getAllByTestId("cab-card-img")).toHaveLength(6);
44 expect(screen.getAllByTestId("cab-card-name")).toHaveLength(6);
45 expect(screen.getAllByTestId("cab-card-type")).toHaveLength(6);
46 expect(screen.getAllByTestId("cab-card-price")).toHaveLength(6);
47 expect(screen.getAllByTestId("cab-card-select-button")).toHaveLength(6);
48 });
49
50 it("completes a booking and adds it to ride history", () => {
51 renderApp();
52
53 fireEvent.click(screen.getByTestId("book-button"));
54 fireEvent.click(screen.getAllByTestId("cab-card-select-button")[0]);
55
56 expect(screen.getByTestId("loading")).toBeInTheDocument();
57
58 act(() => {
59 vi.advanceTimersByTime(1000);
60 });
61
62 expect(screen.getByTestId("confirm-message")).toHaveTextContent(
63 "Ford Fusion is on the way and will arrive shortly.",
64 );
65
66 fireEvent.click(screen.getByTestId("confirm-button"));
67
68 expect(screen.getByTestId("history-cabs")).toHaveTextContent(
69 "Ford Fusion",
70 );
71 expect(screen.getByTestId("history-cabs")).toHaveTextContent("$20");
72 });
73
74 it("keeps only the newest three rides", () => {
75 renderApp();
76
77 const selectCabByIndex = (index) => {
78 fireEvent.click(screen.getByTestId("book-button"));
79 fireEvent.click(screen.getAllByTestId("cab-card-select-button")[index]);
80 act(() => {
81 vi.advanceTimersByTime(1000);
82 });
83 fireEvent.click(screen.getByTestId("confirm-button"));
84 };
85
86 selectCabByIndex(0);
87 selectCabByIndex(1);
88 selectCabByIndex(2);
89 selectCabByIndex(3);
90
91 const rides = screen.getAllByTestId("history-cabs");
92 expect(rides).toHaveLength(3);
93 expect(rides[0]).toHaveTextContent("Ford Explorer");
94 expect(rides[1]).toHaveTextContent("Toyota Highlander");
95 expect(rides[2]).toHaveTextContent("Honda Accord");
96 expect(screen.queryByText(/Ford Fusion/)).not.toBeInTheDocument();
97 });
98});
Source: cab-booking-context/src/test/App.test.jsx
§01

为什么定时器必须在 useEffect 里Why the timer has to be inside useEffect

写在组件体里,每次渲染都会开一个新的Put it in the component body and every render starts another one

一句话:setTimeout副作用 —— 它改变了组件外面的东西(浏览器的定时器表)。 副作用必须放进 useEffect

写在组件体里会怎样:

  • 组件每渲染一次就多开一个定时器, 而且没人记得它们的 id,清不掉;
  • React 18 的 StrictMode开发模式下渲染两次,一次挂载就开两个
  • 每个定时器到期都会调一次 onComplete(), 于是 setCurrentPage 被调多次 —— 功能上看起来没事,因为设成同一个值,但这是运气

依赖数组写什么:源项目写的是 [onComplete]

这是诚实的写法 —— effect 里用到了 onComplete,就该声明它。
但它有个后果:App 传下来的是() => setCurrentPage("cab-confirmation")每次 App 重渲染都是一个新函数, 所以 onComplete 变了、effect 会重跑 (先 clearTimeout 再重新计时)。
这个应用里撞不到问题,因为Loading 显示期间 App 不会重渲染 —— 没有别的 state 在变。 但换个场景(比如页头有个每秒刷新的时钟),这个定时器会被无限重置,永远跳不到确认页

会追问:「那怎么办?」—— 两条路: App 那边用useCallbackonComplete 稳住; 依赖写 [], 并用一个 ref 存住最新的 onComplete。 ① 更常见,② 更彻底。面试时能说出「新函数身份导致 effect 重跑」 这个因果链,比背出答案更重要。

In one line: setTimeout is a side effect — it changes something outside the component (the browser’s timer table). Side effects belong in useEffect.

What happens if you put it in the component body:

  • Every render starts one more timer, and nobody remembers their ids, so they cannot be cleared;
  • React 18’s StrictMode renders twice in development, so one mount starts two;
  • Each timer fires onComplete(), so setCurrentPage runs several times — which happens to look fine because it sets the same value, but that is luck.

What goes in the dependency array: the source project writes [onComplete].

That is the honest version — the effect uses onComplete, so it declares it.
It has a consequence, though: App passes down () => setCurrentPage("cab-confirmation"), and every App render creates a new function, so onComplete changed and the effect re-runs (clearing the timeout and starting over).
Nothing bites in this app, because App does not re-render while Loading is on screen — no other state is moving. Change the scene, though — say a clock in the header ticking every second — and the timer is reset forever and the confirmation page never arrives.

Follow-up: “So what do you do?” — two routes: (1) stabilise onComplete with useCallback over in App; (2) use [] as the dependency array and keep the latest onComplete in a ref. (1) is more common, (2) is more thorough. Being able to state the causal chain — new function identity, so the effect re-runs — matters more than reciting either fix.

JSXsrc/components/Loading/Loading.jsx源项目From source
1import { useEffect } from "react";
2
3const Loading = ({ onComplete }) => {
4 useEffect(() => {
5 // 模拟 1 秒延迟加载
6 const timer = setTimeout(() => {
7 if (onComplete) onComplete();
8 }, 1000);
9
10 return () => clearTimeout(timer);
11 }, [onComplete]);
12
13 return (
14 <main data-testid="loading" className="loading-container">
15 <div className="spinner" aria-hidden="true" />
16 <h1>Loading...</h1>
17 <p>We are working on your cab booking. Thanks for your patience.</p>
18 </main>
19 );
20};
21
22export default Loading;
Source: cab-booking-context/src/components/Loading/Loading.jsx
§02

清理函数在防什么What the cleanup function prevents

组件已经不在了,定时器还在替它调 setStateThe component is already gone, and the timer still calls setState for it

一句话:return () => clearTimeout(timer)保证「组件走了,它开的定时器也走了」。

不清理的真实症状:这道题里 Loading 只活 1 秒、也没有别的路能提前离开, 所以四个测试全都不会因为少了清理而失败
这正是要警惕的地方 ——「测试通过 ≠ 做对了」在这里又出现了一次。

什么时候会真的炸:只要加一个「取消」按钮让用户在 loading 期间返回首页 ——

  • 用户 0.3 秒时点了取消,setCurrentPage("home")Loading 卸载;
  • 1 秒时定时器到期,照样调 onComplete()
  • 于是 setCurrentPage("cab-confirmation") ——用户明明已经回首页了,页面自己跳到了确认页。

注意这不是「内存泄漏」那么抽象的东西, 它是一个用户能看见的 bug。
会追问:「React 不是会警告Can't perform a React state update on an unmounted component 吗?」——React 18 起那条警告被移除了, 因为它误报太多。所以现在不清理不会有任何提示,只会有诡异行为

测试怎么控制这 1 秒:

  • beforeEachvi.useFakeTimers() —— 把 setTimeout 换成假的,时间不会自己走
  • act(() => { vi.advanceTimersByTime(1000) }) —— 手动把表拨快 1 秒,定时器立刻到期。act 是因为到期会触发 setState, 不套的话 React 会警告更新发生在 act 外面, 而且断言可能在重渲染之前就跑了;
  • afterEach vi.runOnlyPendingTimers() useRealTimers() ——把没到期的定时器清干净再还原, 不然会漏到下一个测试里。

所以延迟必须正好 1000。写 1200 的话,advanceTimersByTime(1000)之后定时器还没到期, 页面还停在 loading,getByTestId("confirm-message") 找不到 —— 测试 3 和 4 全红。

In one line: return () => clearTimeout(timer) guarantees that when the component leaves, its timer leaves with it.

What actually breaks without it: in this question Loading lives for one second and there is no other way out, so none of the four tests fail if you omit the cleanup.
That is exactly what to be suspicious of — “tests pass” is not “you got it right”, once again.

When a request really does hang: add one Cancel button that lets the user go home during loading —

  • At 0.3s the user cancels, setCurrentPage("home") runs, and Loading unmounts;
  • At 1s the timer fires and calls onComplete() anyway;
  • So setCurrentPage("cab-confirmation") runs — the user is sitting on the home page and it jumps to the confirmation page by itself.

This is not something abstract like “a memory leak”; it is a bug the user can see.
Follow-up: “Doesn’t React warn Can't perform a React state update on an unmounted component?” — that warning was removed in React 18 because it fired too often on correct code. So today skipping the cleanup gives you no warning at all, only strange behaviour.

How the test controls that second:

  • vi.useFakeTimers() in beforeEach replaces setTimeout with a fake one, so time does not move on its own;
  • act(() => { vi.advanceTimersByTime(1000) }) winds the clock forward one second and the timer fires immediately. The act wrapper is there because firing triggers a setState; without it React warns about an update outside act, and the assertion may run before the re-render;
  • vi.runOnlyPendingTimers() then useRealTimers() in afterEach drain any pending timers before restoring, so nothing leaks into the next test.

Which is why the delay has to be exactly 1000. Write 1200 and after advanceTimersByTime(1000) the timer has not fired, the page is still loading, and getByTestId("confirm-message") finds nothing — tests 3 and 4 both go red.

JSXfake timer 三步(摘自源项目测试,加注释)Three steps with fake timers (from the source project tests, annotated)示意Illustrative
1// 测试里控制时间的三步(源项目 App.test.jsx 的用法)
2beforeEach(() => {
3 vi.useFakeTimers(); // ① 时间冻住
4});
5
6afterEach(() => {
7 vi.runOnlyPendingTimers(); // ③ 清掉没到期的
8 vi.useRealTimers(); // 还原真时钟
9});
10
11it("completes a booking …", () => {
12 // …点选一辆车…
13 expect(screen.getByTestId("loading")).toBeInTheDocument();
14
15 act(() => { vi.advanceTimersByTime(1000); }); // ② 手动拨 1 秒
16
17 expect(screen.getByTestId("confirm-message")).toHaveTextContent(
18 "Ford Fusion is on the way and will arrive shortly.",
19 );
20});
1// the three steps that control time in the tests (as used in App.test.jsx)
2beforeEach(() => {
3 vi.useFakeTimers(); // ① freeze the clock
4});
5
6afterEach(() => {
7 vi.runOnlyPendingTimers(); // ③ drain the timers that have not fired
8 vi.useRealTimers(); // restore the real clock
9});
10
11it("completes a booking …", () => {
12 // …select a cab…
13 expect(screen.getByTestId("loading")).toBeInTheDocument();
14
15 act(() => { vi.advanceTimersByTime(1000); }); // ② move the clock 1 second by hand
16
17 expect(screen.getByTestId("confirm-message")).toHaveTextContent(
18 "Ford Fusion is on the way and will arrive shortly.",
19 );
20});
练习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补齐 Loading 的四个空Fill in the four blanks of Loading
第 3 个空是这道题的送分点,第 4 个空是这道题的良心。Blank 3 is the one that earns the marks. Blank 4 is the one that is simply the right thing to do.
JSXsrc/components/Loading/Loading.jsx4 个空4 blanks
1import { useEffect } from "react";
2
3const Loading = ({ onComplete }) => {
4 (() => {
5 const timer = (() => {
6 if (onComplete) onComplete();
7 }, );
8
9 return () => ;
10 }, [onComplete]);
11
12 return (
13 <main data-testid="loading" className="loading-container">
14 <div className="spinner" aria-hidden="true" />
15 <h1>Loading...</h1>
16 <p>We are working on your cab booking. Thanks for your patience.</p>
17 </main>
18 );
19};
把 4 个空都填上才能检查(还差 4 个)Fill all 4 blanks to check (4 to go)
L2Debug LabDebug LabDebug Lab:定时器永远不到期Debug Lab: the timer never fires
测试 3 报「找不到 confirm-message」, 而 DOM 快照显示页面还停在 loading。 先读报错,再看下面那个 Loading 组件 ——它和源项目差一个东西Test 3 reports that it cannot find confirm-message, and the DOM snapshot shows the page still sitting on loading. Read the error first, then look at the Loading component below — one thing in it differs from the source project.
第 1 步 · 先看现象和代码,别急着改Step 1 · read the symptom and the code before you touch anything
FAIL src/test/App.test.jsx > React: Cab Booking > completes a booking and adds it to ride history TestingLibraryElementError: Unable to find an element by: [data-testid="confirm-message"] Ignored nodes: comments, script, style <body> <div> <div class="App"> <header …>…</header> <main class="loading-container" data-testid="loading"> <div class="spinner" aria-hidden="true" /> <h1>Loading...</h1> </main> </div> </div> </body> ❯ src/test/App.test.jsx:52:17
JSXsrc/components/Loading/Loading.jsx(有问题的版本)src/components/Loading/Loading.jsx (the broken version)示意Illustrative
1const Loading = ({ onComplete }) => {
2 useEffect(() => {
3 const timer = setTimeout(() => {
4 if (onComplete) onComplete();
5 }, 1000);
6
7 return () => clearTimeout(timer);
8 }); // ← 依赖数组呢?
9
10 return (
11 <main data-testid="loading" className="loading-container">
12 <div className="spinner" aria-hidden="true" />
13 <h1>Loading...</h1>
14 </main>
15 );
16};
1const Loading = ({ onComplete }) => {
2 useEffect(() => {
3 const timer = setTimeout(() => {
4 if (onComplete) onComplete();
5 }, 1000);
6
7 return () => clearTimeout(timer);
8 }); // ← where is the dependency array?
9
10 return (
11 <main data-testid="loading" className="loading-container">
12 <div className="spinner" aria-hidden="true" />
13 <h1>Loading...</h1>
14 </main>
15 );
16};
第 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.

JSXsetInterval + 无清理setInterval with no cleanup示意Illustrative
1// ✕ 用 setInterval 而且忘了清理
2useEffect(() => {
3 setInterval(() => {
4 if (onComplete) onComplete();
5 }, 1000);
6}, [onComplete]);
7
8// 症状:跳到确认页之后,每隔 1 秒又调一次
9// setCurrentPage("cab-confirmation")
10// 用户点了 Okay 想回首页 —— 1 秒后被拽回确认页
11// 而且这个定时器永远不会停
1// ✕ using setInterval, and forgetting the cleanup
2useEffect(() => {
3 setInterval(() => {
4 if (onComplete) onComplete();
5 }, 1000);
6}, [onComplete]);
7
8// symptom: after the jump to the confirmation page, once a second it calls
9// setCurrentPage("cab-confirmation") again
10// the user presses Okay to go home — one second later they are dragged back
11// and this timer never stops
两个错叠在一起。setInterval 会反复触发, 而没有清理函数意味着它连组件卸载都不停
有意思的是测试 3 还是会过 —— 它只查确认页出现了,不查后来有没有再跳。测试 4 会挂:它连订四辆, 第一轮遗留的 interval 会在后面几轮里把页面拽回确认页, 第二轮找 book-button 就找不到了。
「只跑一次」用 setTimeout, 「反复跑」才用 setInterval, 两者都必须清理。
Two mistakes on top of each other. setInterval fires again and again, and with no cleanup function it does not even stop when the component is removed.
The interesting part is that test 3 still passes — it only checks that the confirmation page appeared, not whether the page changes again later. Test 4 fails: it books four cabs in a row, and the interval left over from the first round pulls the page back to the confirmation page during the later rounds, so the second round cannot find book-button any more.
Use setTimeout for something that runs once and setInterval only for something that repeats. Both of them have to be cleared.
迁移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.

组件里要开定时器 / 订阅 / 加监听A component starts a timer, a subscription or a listener
放进 useEffect,并在 return 里成对清掉Put it in useEffect and clear it in the return, one for one
「本该自动跳转但一直不跳」"It should move on by itself, but it never does"
先看 effect 的依赖数组 —— 漏了就每次渲染都重置Look at the dependency array of the effect first; without it every render starts over
测试要控制一段延迟A test needs to control a delay
vi.useFakeTimers() + act(() => vi.advanceTimersByTime(n))
afterEach 里要不要清定时器Whether afterEach has to clear the timers
要 —— runOnlyPendingTimers 再 useRealTimers,否则漏到下个测试Yes: runOnlyPendingTimers and then useRealTimers, or they leak into the next test
这节的要点What to take away
  1. setTimeout 是副作用,必须在 useEffect 里;写组件体里每渲染一次开一个。setTimeout is a side effect, so it belongs in useEffect. In the component body it starts one more timer on every render.
  2. 延迟必须是 1000 —— 测试拨的正好是 1000ms。The delay has to be 1000, because the test advances exactly 1000ms.
  3. 清理函数在这道题里不影响测试结果,但加个「取消」按钮它就是可见 bug。The cleanup function does not change the test result in this task, but add a cancel button and its absence becomes a visible bug.
  4. React 18 起不再警告「在已卸载组件上 setState」,所以漏清理毫无提示。Since React 18 there is no warning about calling setState on a component that is already removed, so a missing cleanup gives you no hint at all.
  5. 漏写依赖数组 = 每次渲染都重开定时器,那 1 秒永远数不完。Leaving out the dependency array means the timer starts again on every render, so the 1 second never finishes.

接下来What next

  1. 把这一节的练习做掉Do this lesson’s exercises2 个,就在这一页上面 —— 别攒着最后一起做2 of them, further up this page — do not save them for later
    回到练习 ↑Back up to them ↑
  2. 接着看下一节Continue to the next lesson历史与确认页:两个小而致命的细节The history and confirmation pages: two small details that decide pass or fail
    下一节Next lesson
读完并且做过上面的练习了吗?Read it and worked through the exercises above?
上一节:Previous: 按类型分组渲染六张卡Rendering the six cards grouped by type