变式二 · 计时器:useEffect 的清理函数Variation 2 · a timer: the useEffect cleanup function
这道题真正的考点只有一个 —— 你会不会写 return () => clearInterval(id)。This question really tests one thing: can you write return () => clearInterval(id).
这一页有什么On this page8
- 01 清理函数:effect 的另一半The cleanup function: the other half of an effect
- 02 为什么必须用 setSeconds(s => s + 1)Why setSeconds(s => s + 1) is required
- 03 忘了清理会怎样:两种后果,都实测过What a missing cleanup does: two results, both measured
- 04 完整答案The complete answer
- 05 怎么验证How to check it
- 练习 · 动手做Practice
- 常见错误Common mistakes
- 迁移模式Transfer
- 说清 useEffect 的清理函数什么时候跑、为什么必须有Explain when the useEffect cleanup function runs, and why it has to be there
- 解释「过期闭包」为什么让 setSeconds(seconds + 1) 卡在 1Explain why a stale closure, a callback holding an old value, freezes setSeconds(seconds + 1) at 1
- 独立实现 start / pause / reset 的计时器Build a timer with start / pause / reset on your own
- 看懂「忘了清理」造成的两种后果:越跳越快、卸载后泄漏Recognise the two results of a missing cleanup: the count speeds up, and the timer keeps running after the component is gone
源项目里没有任何定时器,所以前面的课没讲过清理函数 —— 但它是 useEffect 的另一半,同类考试(计时器、轮询、订阅、事件监听、WebSocket)几乎必考。这道题是这个知识点最短的载体。The source projects contain no timers, so no earlier lesson covered the cleanup function. It is the other half of useEffect, and exams of this kind almost always ask for it: timers, polling, subscriptions, event listeners, WebSocket. This question is the shortest way to carry that one idea.
清理函数:effect 的另一半The cleanup function: the other half of an effect
useEffect 里 return 出去的那个函数,React 会在「下一次执行之前」和「卸载时」调用它。React calls the function you return from useEffect twice over: before the next run, and when the component is removed.
前面讲 useEffect 时只讲了「什么时候跑」。 完整的规则是四句话:
- 首次渲染后,执行 effect。
- 依赖变化时,先执行上一次的清理函数,再执行新的 effect。
- 组件卸载时,执行最后一次的清理函数。
- 没有 return,就没有清理这一步。
所以清理函数的职责很明确:把这一次 effect 建立起来的东西拆掉。建了定时器就清定时器,加了事件监听就移除监听, 开了订阅就取消订阅,发了请求就中止请求。
判别口诀:effect 里只要出现了setInterval / setTimeout /addEventListener / subscribe /new WebSocket / fetch, 就一定要有 return。
Earlier lessons on useEffect only covered when it runs. The full rule is four sentences:
- After the first render, run the effect.
- When a dependency changes, run the previous cleanup first, then run the new effect.
- When the component unmounts, run the last cleanup.
- No return means no cleanup step at all.
So the cleanup has a precise job: tear down whatever this run of the effect set up. Started an interval, clear the interval; added a listener, remove the listener; opened a subscription, cancel it; fired a request, abort it.
Quick test: if an effect contains setInterval / setTimeout / addEventListener / subscribe / new WebSocket / fetch, it needs a return.
为什么必须用 setSeconds(s => s + 1)Why setSeconds(s => s + 1) is required
写成 setSeconds(seconds + 1) 会卡在 1 不动。这个坑叫「过期闭包」。Write setSeconds(seconds + 1) and the display freezes at 1. The name for this is a stale closure: the callback still holds an old value.
setInterval 的回调是在某一次渲染里创建的。它通过闭包捕获了那一次渲染的 seconds。
依赖是 [running],所以 seconds 变化不会重建 effect,那个回调也就永远不会被替换。 于是它每一秒都在算「当时那个 seconds + 1」:
函数式更新绕开了这个问题:setSeconds(s => s + 1) 里的 s是 React 在调用时交给你的最新值,不来自闭包。
另一条常见但更差的解法是把seconds 加进依赖数组。那样每一秒都会 销毁定时器再建一个新的 —— 能跑,但计时会因为反复重建而漂移, 而且完全没必要。
The setInterval callback is created inside one particular render. Through its closure it captured that render’s seconds.
The dependency list is [running], so a change to seconds does not rebuild the effect, and that callback never gets replaced. Every second it computes “that old seconds + 1”:
The updater form sidesteps the whole thing: the s in setSeconds(s => s + 1) is the latest value React hands you at call time, not something out of the closure.
The other common but worse fix is adding seconds to the dependency array. Then every second destroys the interval and builds a new one — it runs, but the clock drifts from all that rebuilding, and there is no reason for it.
忘了清理会怎样:两种后果,都实测过What a missing cleanup does: two results, both measured
我把 clearInterval 那行删掉真跑了一遍,8 个测试挂了 4 个。I deleted the clearInterval line and ran the suite for real: 4 of the 8 tests failed.
后果一:秒数越跳越快。每次 running 变成 true 就新建一个 interval,旧的没被清掉还在跑。start / pause 来回四次之后, 同时有 4 个 interval 在给同一个 state 加一。
测试里那条「start/pause 四次,每次 1 秒,应该正好 4 秒」 就是专门抓它的 —— 漏了清理会得到1 + 2 + 3 + 4 = 10 秒。这是实测输出:
后果二:组件卸载后定时器还在跑。这是内存泄漏,而且回调还会对已经卸载的组件调用 setState。 测试用 vi.getTimerCount() 直接查: 卸载后应该是 0,漏了清理就是 1。
Consequence one: the seconds speed up. Every time running flips to true a new interval is created, and the old one was never cleared, so it is still running. After four start / pause rounds, four intervals are adding one to the same state.
The test that says “start/pause four times, one second each, should land on exactly 4 seconds” is there to catch this — miss the cleanup and you get 1 + 2 + 3 + 4 = 10 seconds. This is the real output:
Consequence two: the interval keeps running after unmount. That is a memory leak, and the callback also calls setState on a component that is already gone. The test checks it directly with vi.getTimerCount(): it should be 0 after unmount, and it is 1 when the cleanup is missing.
完整答案The complete answer
8 个测试全过,其中两条专门验证清理生效。All 8 tests pass, and two of them exist only to prove the cleanup ran.
format 单独导出成纯函数,方便直接单测 —— 「把能纯化的逻辑抽出来」在 assessment 里是加分项。
reset 同时把 running 设回 false —— 否则清零之后它会立刻从 0 继续跑,不符合「重置」的预期。
format is exported on its own as a pure function so it can be unit-tested directly — “pull out the logic that can be pure” scores points in an assessment.
reset also sets running back to false — otherwise it zeroes the clock and immediately starts counting from 0 again, which is not what “reset” means.
怎么验证How to check it
定时器怎么测?把时间也 mock 掉。How do you test a timer? Replace the clock with a fake one you control.
测计时器不能真等 3 秒。vi.useFakeTimers()把 setInterval 换成假的,vi.advanceTimersByTime(3000)一瞬间把时钟推 3 秒 —— 测试跑得快,而且结果稳定。
三个关键写法值得单独记:
- 推时间必须包在
act()里, 否则 React 的 state 更新还没落到 DOM,断言会读到旧值。 vi.getTimerCount()直接查「现在还有几个定时器活着」 —— 这是验证清理函数最直接的手段,比看秒数更硬。afterEach(() => vi.useRealTimers())必须有,否则假时钟会漏到别的测试文件里。
start/pause many times does not speed up 那一条 就是上面「1+2+3+4 = 10」的来源。
You cannot really wait 3 seconds in a test. vi.useFakeTimers() swaps setInterval for a fake one, and vi.advanceTimersByTime(3000) pushes the clock forward 3 seconds in an instant — fast tests, stable results.
Three details worth memorising on their own:
- Advancing time must be wrapped in
act(), or React’s state update has not landed in the DOM yet and the assertion reads the old value. vi.getTimerCount()answers “how many timers are alive right now” — the most direct way to verify a cleanup, harder evidence than reading the seconds.afterEach(() => vi.useRealTimers())is mandatory, otherwise the fake clock leaks into other test files.
start/pause many times does not speed up is where the “1+2+3+4 = 10” above comes from.
动手做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.
三个空,全在这九行里。第 2 个空漏了会「越跳越快」, 第 3 个空写错会「卡在 1 不动」。
Three blanks, all within these nine lines. Miss the second and the clock speeds up with every start. Get the third wrong and the display freezes at 1.
两个 state、一个 effect、一个 reset、一个 mm:ss 格式化。 检查器会专门查清理函数和函数式更新。
Two states, one effect, one reset, and one mm:ss formatter. The checker looks specifically for the cleanup function and the updater form.
- format(65) 要返回 "01:05",个位数补零format(65) has to return "01:05", padding single digits with a zero
- 点 Start 开始每秒加一,点 Pause 停下并保留当前值Start begins adding one per second; Pause stops and keeps the current value
- Reset 停下来并清零(按钮文字回到 Start)Reset stops and goes back to zero (the button text returns to Start)
- effect 必须返回清理函数清掉定时器The effect has to return a cleanup function that clears the interval
- 必须用函数式更新,避免过期闭包Use the updater form, so there is no stale closure
- 按钮文字:跑着显示 Pause,停着显示 StartButton text: Pause while running, Start while stopped
看答案之前,先确认你已经自己动手写过一遍。看懂别人的答案和自己写出来,是两种能力。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.
点了几次 Start / Pause 之后,秒数开始一次跳好几秒。 下面是真实的测试输出。
After a few clicks of Start and Pause, the seconds start jumping several at a time. Below is the real test output.
初学者常见的几种写法错误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.
seconds 是 effect 创建那一刻的值。 依赖里没有 seconds,effect 不会重建, 所以它每秒都在算 0 + 1。改成 setSeconds(s => s + 1)。The callback captured the value seconds had at the moment the effect was created. seconds is not in the dependency list, so the effect is never rebuilt, and every second it computes 0 + 1 again. Change it to setSeconds(s => s + 1).running 还是 true,定时器还在跑 —— 清零之后立刻又从 0 开始涨。用户点「重置」的预期是停下来并归零,两件事都要做。running is still true and the interval is still going, so the count starts climbing again from 0 right away. When a user presses reset they expect it to stop and go back to zero. Both things have to happen.定时器 id 不是渲染要用的数据,本来就不该是 state (真要存也该用
useRef)。让 effect + 清理函数管,代码更短也更难写错。This works, but it takes the lifetime of the side effect out of React’s hands and into yours, and then it is easy to forget the cleanup when the component is removed.The timer id is not data the render needs, so it should not be state in the first place (if you must keep it, use
useRef). Let the effect and its cleanup function handle it: less code, and fewer ways to get it wrong.换一道题也能用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.
- 清理函数在「依赖变化前」和「卸载时」执行 —— 它负责拆掉这次 effect 建立的东西。The cleanup function runs before the dependencies change and when the component is removed. Its job is to take down whatever this effect set up.
- effect 里出现定时器/监听器/订阅/连接/请求,就一定要 return。If an effect starts a timer, a listener, a subscription, a connection, or a request, it must return a cleanup function.
- 定时器回调必须用函数式更新,否则闭包里的 state 永远是旧的。A timer callback has to use the updater function form, or the state it captured stays old forever.
- 漏掉 clearInterval 的实测后果:start/pause 四次得到 10 秒而不是 4 秒,卸载后定时器还活着。Measured result of a missing clearInterval: four start/pause cycles give 10 seconds instead of 4, and the timer is still alive after the component is gone.
- reset 要同时停表和清零;定时器 id 不该放 state。reset has to stop the clock and zero it. The timer id does not belong in state.