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.
这一页有什么On this page5
- 在 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()
这是 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).
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.
cab-booking-context/src/components/Loading/Loading.jsxcab-booking-context/src/test/App.test.jsxfake timer 的用法在 beforeEach / afterEach 里How the fake timer is used, in beforeEach and afterEach
cab-booking-context/src/test/App.test.jsx为什么定时器必须在 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 那边用useCallback 把 onComplete 稳住;② 依赖写 [], 并用一个 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
StrictModerenders twice in development, so one mount starts two; - Each timer fires
onComplete(), sosetCurrentPageruns 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.
cab-booking-context/src/components/Loading/Loading.jsx清理函数在防什么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 秒:
beforeEach里vi.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, andLoadingunmounts; - 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()inbeforeEachreplacessetTimeoutwith 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. Theactwrapper 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()thenuseRealTimers()inafterEachdrain 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.
动手做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.
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.初学者常见的几种写法错误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.
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.换一道题也能用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.
- setTimeout 是副作用,必须在 useEffect 里;写组件体里每渲染一次开一个。setTimeout is a side effect, so it belongs in useEffect. In the component body it starts one more timer on every render.
- 延迟必须是 1000 —— 测试拨的正好是 1000ms。The delay has to be 1000, because the test advances exactly 1000ms.
- 清理函数在这道题里不影响测试结果,但加个「取消」按钮它就是可见 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.
- 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.
- 漏写依赖数组 = 每次渲染都重开定时器,那 1 秒永远数不完。Leaving out the dependency array means the timer starts again on every render, so the 1 second never finishes.