计时器(useEffect 清理函数)Timer (useEffect cleanup)
题面The problem
先把要求读完,再动手。Read every requirement before you start.
两个 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(seconds),初始 00:00;按钮在 Start / Pause 之间切Show format(seconds), starting at 00:00. The button switches between Start and Pause
- 跑起来每秒 +1While it is running, the count goes up by 1 every second
- 必须用函数式更新 setSeconds(s => s + 1) —— 否则过期闭包会让秒数卡在 1You must use the updater form setSeconds(s => s + 1). Otherwise a stale closure keeps the count stuck at 1
- effect 必须返回 clearInterval 的清理函数:否则重启会叠 interval、卸载后还在跑The effect must return a cleanup function that calls clearInterval. Without it, restarting stacks up intervals and the timer keeps ticking after unmount
- 没在跑的时候不该有定时器No interval should exist while the timer is not running
- Reset 归零并且停下Reset sets the count back to zero and stops the timer
预计 25 分钟。这个数字是照「读完题就开始写、不查资料」估的 —— 第一次超时很正常,第二次要压进去。Budget 25 minutes. Overrunning on the first pass is normal; the second pass should fit.
工作区Workspace
工作区是一个真的浏览器沙箱:左边写代码,右边实时预览,下面一个「跑测试」按钮。测试和本机那套是同一批断言,转写成了浏览器里能跑的写法。The workspace is a real in-browser sandbox: edit on the left, live preview on the right, one Run button below. The assertions are the same ones that pass on a real machine, rewritten for the browser runner.
需要联网。Requires an internet connection. 打包器和 npm 依赖都在 CodeSandbox 的远程服务上(评估过程见 docs/sandpack-evaluation.md),断网这块就起不来 —— 那就照下面的命令在本机跑。The bundler and the npm packages come from CodeSandbox's remote service, so this panel needs network access.
展开讲解Walkthrough
下面是《变式二 · 计时器:useEffect 的清理函数》那一节的原文 —— 和课程里是同一份内容,不是另写的摘要。卡住了再展开。Below is the actual text of the lesson “变式二 · 计时器:useEffect 的清理函数” — the same content as in the course, not a rewritten summary. Expand it when you stall.
展开《变式二 · 计时器:useEffect 的清理函数》(5 段 · 约 16 分钟)Expand “变式二 · 计时器:useEffect 的清理函数” (5 sections · ~16 min)
完整那一节(含练习、常见错误、迁移模式)在The full lesson (with exercises, common mistakes and transfer patterns) is at 变式二 · 计时器:useEffect 的清理函数。
清理函数: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.
参考答案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.