DrillLab
第 18 / 25 节LESSON 18 / 25约 22 分钟~22 min

缺口二 · useRef 操作 DOM,与写一个自定义 hookGap 2 · using useRef on the DOM, and writing a custom hook

useRef 的第二种用法(拿 DOM 调命令式 API),以及把 state + effect 打包成可复用的 hook。The second use of useRef — holding a DOM node so you can call methods on it directly — and packing state plus an effect into a hook you can reuse.

1 个练习1 exercises面试 · 第 7 部分Interview · Part 7
这一页有什么On this page7
学完这节你会After this lesson you can
  • 分清 useRef 的两种用途:存不参与渲染的值 vs 拿 DOM 节点Tell the two uses of useRef apart: holding a value that is not rendered, versus holding a DOM node
  • 说明什么时候必须走命令式(ref)而不是声明式Say when you have to call the DOM directly through a ref instead of describing it with state
  • 写出一个带惰性初始化和错误兜底的自定义 hookWrite a custom hook with a lazy initial value and a fallback when something throws
  • 说清「复用逻辑不复用状态」Explain that a hook shares the logic, not the state
这在考试里考什么What the exam does with this

「用 useRef 做一个播放器」考的是你知不知道 React 里有命令式逃逸口 —— 播放、聚焦、滚动、测量这些事没法用 state 表达。自定义 hook 那道是 #340 的动手版,面试官会看你的命名、返回值形状、以及有没有处理异常。Building a player with useRef tests whether you know React leaves you a way out to call the DOM directly — playing, focusing, scrolling and measuring cannot be expressed as state. The custom hook problem is the hands-on version of #340, and the interviewer looks at your naming, the shape of what you return, and whether you handle errors.

§01

useRef 的两种用途The two uses of useRef

很多人只知道第一种。Many people know only the first one.

用途例子
① 存值不参与渲染、 改了也不该触发重渲染的东西定时器 id、上一次的值、次数计数
② 拿节点拿到真实 DOM,调它的命令式 APIfocus()play()scrollIntoView()、 测量尺寸、contains()

共同点:ref.current的改动不会触发重渲染。所以想让界面跟着变,还是得配一个 state—— 播放器里 playing 是 state (按钮文字要变),audioRef 是 ref(只是拿来调方法)。

什么时候必须用命令式:当「要做的事」不能用「界面应该长什么样」表达时。播放、暂停、聚焦、滚动、 选中文本、测量宽高、播放动画—— 这些都是动作,不是状态。React 承认这一点,所以留了 ref 这个口子。

会追问:「为什么不能用 document.querySelector?」—— 能跑,但① 组件多实例时会选错; ② 渲染时机不确定,可能取到 null; ③ 绕过了 React 的抽象, SSR 和 React Native 下直接失效。ref 是 React 给的那条合法通道。

PurposeExamples
1. Hold a valueHold something that does not take part in rendering and should not trigger a re-render when it changestimer id, the previous value, a call counter
2. Hold a nodeReach the real DOM and call its imperative APIfocus(), play(), scrollIntoView(), measuring size, contains()

What they share: changing ref.current never triggers a re-render. So if you want the UI to follow along, you still need a state next to it — in the player, playing is state (the button label changes) and audioRef is a ref (only there to call methods).

When you have to go imperative: when “the thing to do” cannot be phrased as “what the UI should look like”. Play, pause, focus, scroll, select text, measure width and height, run an animation — those are actions, not state. React admits this, which is why it left the ref hatch open.

Follow-up: “Why not document.querySelector?” — it runs, but (1) it picks the wrong one once the component has several instances; (2) the render timing is uncertain, so you may get null; (3) it goes around React’s abstraction and breaks outright under SSR and React Native. ref is the legal channel React gives you.

TSXsrc/components/Player/index.tsx(实测通过)src/components/Player/index.tsx (passes in a real run)已跑通Verified
1import React, { useRef, useState } from "react";
2
3const Player: React.FC<{ src: string }> = ({ src }) => {
4 // useRef 的另一种用法:拿到 DOM 节点,调它的命令式 API
5 const audioRef = useRef<HTMLAudioElement>(null);
6 const [playing, setPlaying] = useState(false);
7 const [time, setTime] = useState(0);
8
9 const toggle = async () => {
10 const el = audioRef.current;
11 if (!el) return;
12 if (playing) {
13 el.pause();
14 setPlaying(false);
15 } else {
16 await el.play(); // play() 返回 Promise,可能被浏览器策略拒绝
17 setPlaying(true);
18 }
19 };
20
21 const stop = () => {
22 const el = audioRef.current;
23 if (!el) return;
24 el.pause();
25 el.currentTime = 0; // 直接改 DOM 属性,不经过 state
26 setPlaying(false);
27 setTime(0);
28 };
29
30 return (
31 <div data-testid="player">
32 <audio
33 ref={audioRef}
34 src={src}
35 onTimeUpdate={(e) => setTime(e.currentTarget.currentTime)}
36 onEnded={() => setPlaying(false)}
37 data-testid="audio"
38 />
39 <button onClick={toggle} data-testid="toggle">
40 {playing ? "Pause" : "Play"}
41 </button>
42 <button onClick={stop} data-testid="stop">Stop</button>
43 <output data-testid="time">{Math.floor(time)}</output>
44 </div>
45 );
46};
47
48export default Player;
1import React, { useRef, useState } from "react";
2
3const Player: React.FC<{ src: string }> = ({ src }) => {
4 // The other use of useRef: hold a DOM node so you can call its imperative API
5 const audioRef = useRef<HTMLAudioElement>(null);
6 const [playing, setPlaying] = useState(false);
7 const [time, setTime] = useState(0);
8
9 const toggle = async () => {
10 const el = audioRef.current;
11 if (!el) return;
12 if (playing) {
13 el.pause();
14 setPlaying(false);
15 } else {
16 await el.play(); // play() returns a Promise, and browser policy may refuse it
17 setPlaying(true);
18 }
19 };
20
21 const stop = () => {
22 const el = audioRef.current;
23 if (!el) return;
24 el.pause();
25 el.currentTime = 0; // Set the DOM property directly, without going through state
26 setPlaying(false);
27 setTime(0);
28 };
29
30 return (
31 <div data-testid="player">
32 <audio
33 ref={audioRef}
34 src={src}
35 onTimeUpdate={(e) => setTime(e.currentTarget.currentTime)}
36 onEnded={() => setPlaying(false)}
37 data-testid="audio"
38 />
39 <button onClick={toggle} data-testid="toggle">
40 {playing ? "Pause" : "Play"}
41 </button>
42 <button onClick={stop} data-testid="stop">Stop</button>
43 <output data-testid="time">{Math.floor(time)}</output>
44 </div>
45 );
46};
47
48export default Player;
§02

播放器的三个细节Three details in the player

play() 返回 Promise 而且可能被拒绝。浏览器的自动播放策略会拒绝「用户没交互过就播放」, 所以严谨的写法要try/catch(面试里说出来就够, 不一定要写)。

currentTime 直接改 DOM, 不经过 state。el.currentTime = 0 是命令式操作; 界面上显示的秒数由onTimeUpdate 事件同步到 state。「事实在 DOM 里,state 只是镜像」—— 这是所有媒体和 canvas 组件的共同模式。

onEnded 要把playing 设回 false。播完了按钮还显示「Pause」是最常见的疏漏。

怎么测(这一条本身是加分知识):jsdom 没有实现媒体播放, 调 play() 会抛Not implemented。 所以要vi.spyOn(HTMLMediaElement.prototype, "play")替掉。这也顺便让你能断言「play 到底被调了几次」, 比检查界面更直接。

(1) play() returns a Promise and it can be rejected. The browser autoplay policy rejects playing before the user has interacted, so a careful version wraps it in try/catch (saying so in the interview is enough, you do not have to write it).

(2) currentTime is written straight to the DOM, not through state. el.currentTime = 0 is an imperative operation; the seconds shown on screen get synced into state by the onTimeUpdate event. “The truth lives in the DOM, state is only a mirror” — the shared pattern behind every media and canvas component.

(3) onEnded has to set playing back to false. A button still reading “Pause” after playback ends is the most common miss.

How to test it (this part is a bonus point by itself): jsdom does not implement media playback, so calling play() throws Not implemented. You have to swap it out with vi.spyOn(HTMLMediaElement.prototype, "play"). That also lets you assert how many times play was really called, which is more direct than inspecting the UI.

TSX怎么测一个播放器How to test a player已跑通Verified
1// jsdom 不实现媒体播放,必须 stub
2beforeEach(() => {
3 play = vi.spyOn(HTMLMediaElement.prototype, "play")
4 .mockImplementation(() => Promise.resolve());
5 pause = vi.spyOn(HTMLMediaElement.prototype, "pause")
6 .mockImplementation(() => {});
7});
8
9test("点 Play 调 audio.play(),再点调 pause()", async () => {
10 render(<Player src="/a.mp3" />);
11 await userEvent.click(screen.getByTestId("toggle"));
12 expect(play).toHaveBeenCalledTimes(1);
13 await userEvent.click(screen.getByTestId("toggle"));
14 expect(pause).toHaveBeenCalledTimes(1);
15});
1// jsdom does not implement media playback, so it has to be stubbed
2beforeEach(() => {
3 play = vi.spyOn(HTMLMediaElement.prototype, "play")
4 .mockImplementation(() => Promise.resolve());
5 pause = vi.spyOn(HTMLMediaElement.prototype, "pause")
6 .mockImplementation(() => {});
7});
8
9test("clicking Play calls audio.play(), clicking again calls pause()", async () => {
10 render(<Player src="/a.mp3" />);
11 await userEvent.click(screen.getByTestId("toggle"));
12 expect(play).toHaveBeenCalledTimes(1);
13 await userEvent.click(screen.getByTestId("toggle"));
14 expect(pause).toHaveBeenCalledTimes(1);
15});
§03

写一个自定义 hookWriting a custom hook

把 state + effect 打包,命名必须 use 开头。Pack state and an effect together; the name has to start with use.

useLocalStorage是最常被要求现场写的一个, 因为它同时考四件事:

  • 惰性初始化——useState(() => …)函数而不是值。 写成 useState(读localStorage())的话,每次渲染都会读一次 localStorage(虽然结果被丢掉, 但同步 I/O 白花了)。
  • 错误兜底—— 隐私模式下localStorage 会抛, 存的脏数据 JSON.parse 会抛。不 try/catch 整个组件就白屏了。
  • 返回值形状——as const 让类型是元组[T, setter] 而不是数组联合, 调用方才能const [a, setA] = …拿到正确类型。
  • 依赖要带上 key—— 否则 key 变了不会重新写入。

最重要的一句:复用逻辑,不复用状态。两个组件各调一次useLocalStorage("theme", …), 得到的是两份独立的 state—— 虽然它们写的是同一个 localStorage 键, 但一边改了另一边的 state 不会更新(要跨组件同步得监听storage 事件,或者上 Context)。测试里专门有一条抓这个, 因为这是最常见的误解。

useLocalStorage is the one people get asked to write on the spot most often, because it tests four things at once:

  • Lazy initialization useState(() => ...) takes a function, not a value. Write useState(readLocalStorage()) and every render reads localStorage once (the result gets thrown away, but the synchronous I/O already happened).
  • Error fallback — in private mode localStorage throws, and dirty stored data makes JSON.parse throw. Without try/catch the whole component goes blank.
  • Shape of the return valueas const makes the type a tuple [T, setter] instead of a union array, so the caller can write const [a, setA] = ... and get the right types.
  • The deps have to include key — otherwise a changed key never gets written again.

The most important sentence: reuse the logic, not the state. Two components each calling useLocalStorage("theme", ...) get two independent states — they write the same localStorage key, but changing one does not update the other’s state (syncing across components needs a storage listener, or Context). One test is there just to catch this, because it is the most common misunderstanding.

TypeScriptsrc/hooks/useLocalStorage.ts(实测通过)src/hooks/useLocalStorage.ts (passes in a real run)已跑通Verified
1import { useEffect, useState } from "react";
2
3/**
4 * 把一个值和 localStorage 绑在一起。
5 * 命名必须 use 开头 —— ESLint 靠这个前缀才会检查 hooks 规则。
6 */
7export function useLocalStorage<T>(key: string, initial: T) {
8 // 惰性初始化:读 localStorage 只在首次渲染做一次,不是每次渲染
9 const [value, setValue] = useState<T>(() => {
10 try {
11 const raw = window.localStorage.getItem(key);
12 return raw === null ? initial : (JSON.parse(raw) as T);
13 } catch {
14 return initial; // 隐私模式 / 脏数据:退回默认值,别让整个组件炸
15 }
16 });
17
18 useEffect(() => {
19 try {
20 window.localStorage.setItem(key, JSON.stringify(value));
21 } catch {
22 /* 写不进去只影响持久化,不影响本次会话 */
23 }
24 }, [key, value]);
25
26 return [value, setValue] as const; // as const 让返回类型是元组而不是数组
27}
1import { useEffect, useState } from "react";
2
3/**
4 * Tie a value to localStorage.
5 * The name has to start with use — ESLint checks the hooks rules only for that prefix.
6 */
7export function useLocalStorage<T>(key: string, initial: T) {
8 // Lazy initialisation: localStorage is read once on the first render, not on every render
9 const [value, setValue] = useState<T>(() => {
10 try {
11 const raw = window.localStorage.getItem(key);
12 return raw === null ? initial : (JSON.parse(raw) as T);
13 } catch {
14 return initial; // Private mode or bad data: fall back to the default instead of breaking the component
15 }
16 });
17
18 useEffect(() => {
19 try {
20 window.localStorage.setItem(key, JSON.stringify(value));
21 } catch {
22 /* A failed write only affects persistence, not this session */
23 }
24 }, [key, value]);
25
26 return [value, setValue] as const; // as const makes the return type a tuple instead of an array
27}
TypeScript三个常见错法Three common wrong versions示意Illustrative
1// ✗ 每次渲染都读一次 localStorage
2const [v, setV] = useState(JSON.parse(localStorage.getItem(key)!));
3
4// ✗ 没有兜底:隐私模式或脏数据直接白屏
5const [v, setV] = useState(() => JSON.parse(localStorage.getItem(key)!));
6
7// ✗ 返回普通数组,类型是 (T | Setter)[],解构后类型全错
8return [value, setValue]; // 少了 as const
1// ✗ reads localStorage on every render
2const [v, setV] = useState(JSON.parse(localStorage.getItem(key)!));
3
4// ✗ no fallback: private mode or bad data leaves a blank screen
5const [v, setV] = useState(() => JSON.parse(localStorage.getItem(key)!));
6
7// ✗ returns a plain array, so the type is (T | Setter)[] and destructuring types are all wrong
8return [value, setValue]; // as const is missing
§04

怎么验证How this was checked

这就是跑出 24 / 24 的那个测试文件(六道题合在一起)。This is the test file that produced 24 / 24, with all six problems in one run.

注意测试自定义 hook 用的是renderHook—— Testing Library 提供的, 不用为了测 hook 专门造一个组件。 改状态要包 act()

Dropdown 那四条里最值得学的是最后一条: 直接 spy document.addEventListenerremoveEventListener, 断言绑了几次就解了几次。这是验证「清理函数写了没有」最直接的办法, 比观察行为可靠。

Note that testing the custom hook uses renderHook — Testing Library provides it, so you do not have to build a component just to test a hook. Changing state has to be wrapped in act().

Of the four Dropdown tests the last one is the one to learn: spy on document.addEventListener and removeEventListener directly and assert that the bind count equals the unbind count. That is the most direct way to check whether the cleanup function got written, and more reliable than watching behavior.

Terminal验证命令The command used to check it已跑通Verified
1$ npx vitest run src/Coding.test.tsx
2
3 Test Files 1 passed (1)
4 Tests 24 passed (24)
TSXsrc/Coding.test.tsx(DrillLab 自出,本机跑过 24/24)src/Coding.test.tsx (written by DrillLab; 24/24 in a real run on this machine)已跑通Verified
1import { act, render, renderHook, screen } from "@testing-library/react";
2import userEvent from "@testing-library/user-event";
3import { afterEach, beforeEach, expect, test, vi } from "vitest";
4import Dropdown from "./components/Dropdown";
5import Tabs from "./components/Tabs";
6import StarRating from "./components/StarRating";
7import Player from "./components/Player";
8import Kanban, { moveCard } from "./components/Kanban";
9import { useLocalStorage } from "./hooks/useLocalStorage";
10import type { Board } from "./types/Card";
11
12/* ---------------- #366 Dropdown ---------------- */
13
14const OPTS = [
15 { id: "a", label: "苹果" },
16 { id: "b", label: "香蕉" },
17];
18
19test("[366] 点触发器展开、选中后收起并显示选中项", async () => {
20 render(<Dropdown options={OPTS} />);
21 expect(screen.queryByTestId("dropdown-list")).toBeNull();
22
23 await userEvent.click(screen.getByTestId("dropdown-trigger"));
24 expect(screen.getByTestId("dropdown-list")).toBeInTheDocument();
25 expect(screen.getByTestId("dropdown-trigger")).toHaveAttribute("aria-expanded", "true");
26
27 await userEvent.click(screen.getByTestId("option-b"));
28 expect(screen.queryByTestId("dropdown-list")).toBeNull();
29 expect(screen.getByTestId("dropdown-trigger")).toHaveTextContent("香蕉");
30});
31
32test("[366] 点外面会关掉,点自己里面不会", async () => {
33 render(
34 <div>
35 <Dropdown options={OPTS} />
36 <button data-testid="outside">外面</button>
37 </div>,
38 );
39 await userEvent.click(screen.getByTestId("dropdown-trigger"));
40
41 // 点自己内部:不该关
42 await userEvent.click(screen.getByTestId("dropdown-list"));
43 expect(screen.getByTestId("dropdown-list")).toBeInTheDocument();
44
45 // 点外面:该关
46 await userEvent.click(screen.getByTestId("outside"));
47 expect(screen.queryByTestId("dropdown-list")).toBeNull();
48});
49
50test("[366] 按 Escape 关闭", async () => {
51 render(<Dropdown options={OPTS} />);
52 await userEvent.click(screen.getByTestId("dropdown-trigger"));
53 await userEvent.keyboard("{Escape}");
54 expect(screen.queryByTestId("dropdown-list")).toBeNull();
55});
56
57test("[366] 卸载后解绑了 document 监听器(清理函数生效)", async () => {
58 const add = vi.spyOn(document, "addEventListener");
59 const remove = vi.spyOn(document, "removeEventListener");
60
61 const { unmount } = render(<Dropdown options={OPTS} />);
62 await userEvent.click(screen.getByTestId("dropdown-trigger")); // 展开才会绑
63 const added = add.mock.calls.filter(([t]) => t === "mousedown" || t === "keydown").length;
64 expect(added).toBe(2);
65
66 unmount();
67 const removed = remove.mock.calls.filter(([t]) => t === "mousedown" || t === "keydown").length;
68 expect(removed).toBe(2); // 少了清理函数这里会是 0
69
70 add.mockRestore();
71 remove.mockRestore();
72});
73
74/* ---------------- #367 Tabs ---------------- */
75
76const TABS = [
77 { id: "one", label: "第一", content: <p>内容一</p> },
78 { id: "two", label: "第二", content: <p>内容二</p> },
79 { id: "three", label: "第三", content: <p>内容三</p> },
80];
81
82test("[367] 默认激活第一个,只渲染激活的面板", () => {
83 render(<Tabs tabs={TABS} />);
84 expect(screen.getByTestId("tab-one")).toHaveAttribute("aria-selected", "true");
85 expect(screen.getByTestId("panel")).toHaveTextContent("内容一");
86 expect(screen.queryByText("内容二")).toBeNull();
87});
88
89test("[367] 点第二个切换,aria-selected 跟着走", async () => {
90 render(<Tabs tabs={TABS} />);
91 await userEvent.click(screen.getByTestId("tab-two"));
92
93 expect(screen.getByTestId("panel")).toHaveTextContent("内容二");
94 expect(screen.getByTestId("tab-two")).toHaveAttribute("aria-selected", "true");
95 expect(screen.getByTestId("tab-one")).toHaveAttribute("aria-selected", "false");
96});
97
98test("[367] initialId 能指定初始激活项", () => {
99 render(<Tabs tabs={TABS} initialId="three" />);
100 expect(screen.getByTestId("panel")).toHaveTextContent("内容三");
101});
102
103/* ---------------- #368 StarRating ---------------- */
104
105test("[368] 点第三颗得 3 分,前三颗填充", async () => {
106 render(<StarRating />);
107 await userEvent.click(screen.getByTestId("star-3"));
108
109 expect(screen.getByTestId("stars-value")).toHaveTextContent("3");
110 expect(screen.getByTestId("star-3")).toHaveAttribute("data-filled", "true");
111 expect(screen.getByTestId("star-4")).toHaveAttribute("data-filled", "false");
112});
113
114test("[368] hover 时预览、移出后回到已选值", async () => {
115 render(<StarRating />);
116 await userEvent.click(screen.getByTestId("star-2"));
117
118 await userEvent.hover(screen.getByTestId("star-5"));
119 expect(screen.getByTestId("star-5")).toHaveAttribute("data-filled", "true"); // 预览
120
121 await userEvent.unhover(screen.getByTestId("star-5"));
122 // 注意 unhover 只离开了那颗星,要真正离开整个容器
123 await userEvent.pointer({ target: document.body });
124 expect(screen.getByTestId("stars")).toHaveAttribute("data-value", "2"); // 已选值没变
125});
126
127test("[368] 再点同一颗清零", async () => {
128 render(<StarRating />);
129 await userEvent.click(screen.getByTestId("star-4"));
130 await userEvent.click(screen.getByTestId("star-4"));
131 expect(screen.getByTestId("stars-value")).toHaveTextContent("0");
132});
133
134test("[368] 受控模式下自己不改值,只调 onChange", async () => {
135 const onChange = vi.fn();
136 render(<StarRating value={1} onChange={onChange} />);
137 await userEvent.click(screen.getByTestId("star-5"));
138
139 expect(onChange).toHaveBeenCalledWith(5);
140 expect(screen.getByTestId("stars")).toHaveAttribute("data-value", "1"); // 仍是父级给的 1
141});
142
143/* ---------------- #373 Player(useRef 操作 DOM) ---------------- */
144
145let play: ReturnType<typeof vi.spyOn>;
146let pause: ReturnType<typeof vi.spyOn>;
147
148beforeEach(() => {
149 // jsdom 没实现媒体播放,play() 会抛 Not implemented —— 所以要 stub
150 play = vi
151 .spyOn(HTMLMediaElement.prototype, "play")
152 .mockImplementation(() => Promise.resolve());
153 pause = vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
154});
155
156afterEach(() => {
157 play.mockRestore();
158 pause.mockRestore();
159});
160
161test("[373] 点 Play 调 audio.play(),再点调 pause()", async () => {
162 render(<Player src="/a.mp3" />);
163 expect(screen.getByTestId("toggle")).toHaveTextContent("Play");
164
165 await userEvent.click(screen.getByTestId("toggle"));
166 expect(play).toHaveBeenCalledTimes(1);
167 expect(screen.getByTestId("toggle")).toHaveTextContent("Pause");
168
169 await userEvent.click(screen.getByTestId("toggle"));
170 expect(pause).toHaveBeenCalledTimes(1);
171 expect(screen.getByTestId("toggle")).toHaveTextContent("Play");
172});
173
174test("[373] Stop 把 currentTime 归零并停下", async () => {
175 render(<Player src="/a.mp3" />);
176 const audio = screen.getByTestId("audio") as HTMLAudioElement;
177
178 await userEvent.click(screen.getByTestId("toggle"));
179 audio.currentTime = 30;
180 await userEvent.click(screen.getByTestId("stop"));
181
182 expect(audio.currentTime).toBe(0); // 直接改的是 DOM 属性
183 expect(screen.getByTestId("toggle")).toHaveTextContent("Play");
184});
185
186/* ---------------- #375 自定义 hook ---------------- */
187
188test("[375] useLocalStorage 首次用默认值,并写进 localStorage", () => {
189 localStorage.clear();
190 const { result } = renderHook(() => useLocalStorage("k", { n: 1 }));
191
192 expect(result.current[0]).toEqual({ n: 1 });
193 expect(JSON.parse(localStorage.getItem("k")!)).toEqual({ n: 1 });
194});
195
196test("[375] 已有值时读出来,而不是用默认值", () => {
197 localStorage.setItem("k", JSON.stringify("存过的"));
198 const { result } = renderHook(() => useLocalStorage("k", "默认"));
199 expect(result.current[0]).toBe("存过的");
200});
201
202test("[375] setValue 会同步写回", () => {
203 localStorage.clear();
204 const { result } = renderHook(() => useLocalStorage("k", 0));
205
206 act(() => result.current[1](42));
207 expect(result.current[0]).toBe(42);
208 expect(localStorage.getItem("k")).toBe("42");
209});
210
211test("[375] 脏数据不会让组件炸,退回默认值", () => {
212 localStorage.setItem("k", "{不是合法 JSON");
213 const { result } = renderHook(() => useLocalStorage("k", "兜底"));
214 expect(result.current[0]).toBe("兜底");
215});
216
217test("[375] 两个组件各调一次 = 两份独立状态(复用逻辑不复用状态)", () => {
218 localStorage.clear();
219 const a = renderHook(() => useLocalStorage("shared", 0));
220 const b = renderHook(() => useLocalStorage("shared", 0));
221
222 act(() => a.result.current[1](5));
223 expect(a.result.current[0]).toBe(5);
224 expect(b.result.current[0]).toBe(0); // b 不会跟着变
225});
226
227/* ---------------- #378 Kanban ---------------- */
228
229const board = (): Board => ({
230 todo: [{ id: 1, title: "写文档" }, { id: 2, title: "改 bug" }],
231 doing: [{ id: 3, title: "评审" }],
232 done: [],
233});
234
235function deepFreeze<T>(o: T): T {
236 Object.freeze(o);
237 Object.values(o as Record<string, unknown>).forEach((v) => {
238 if (v && typeof v === "object" && !Object.isFrozen(v)) deepFreeze(v);
239 });
240 return o;
241}
242
243test("[378] moveCard 把卡移到目标列,且不改原 board", () => {
244 const original = deepFreeze(board());
245 const next = moveCard(original, "todo", "doing", 1);
246
247 expect(next.todo.map((c) => c.id)).toEqual([2]);
248 expect(next.doing.map((c) => c.id)).toEqual([3, 1]);
249 // 原对象一个字节都没动
250 expect(original.todo.map((c) => c.id)).toEqual([1, 2]);
251 expect(original.doing.map((c) => c.id)).toEqual([3]);
252});
253
254test("[378] 没动或找不到卡时原样返回同一个引用", () => {
255 const b = board();
256 expect(moveCard(b, "todo", "todo", 1)).toBe(b);
257 expect(moveCard(b, "todo", "done", 999)).toBe(b);
258});
259
260test("[378] 没被碰到的列复用原数组引用(只重建改动的部分)", () => {
261 const b = board();
262 const next = moveCard(b, "todo", "doing", 1);
263 expect(next.done).toBe(b.done); // done 没动,引用不变
264 expect(next.todo).not.toBe(b.todo); // 动过的必须是新数组
265});
266
267test("[378] 点右移按钮,卡片换列且计数跟着变", async () => {
268 render(<Kanban initial={board()} />);
269 expect(screen.getByTestId("count-todo")).toHaveTextContent("2");
270 expect(screen.getByTestId("card-1")).toHaveAttribute("data-col", "todo");
271
272 await userEvent.click(screen.getByLabelText("把 写文档 右移"));
273
274 expect(screen.getByTestId("card-1")).toHaveAttribute("data-col", "doing");
275 expect(screen.getByTestId("count-todo")).toHaveTextContent("1");
276 expect(screen.getByTestId("count-doing")).toHaveTextContent("2");
277});
278
279test("[378] 第一列没有左移按钮,最后一列没有右移按钮", () => {
280 render(<Kanban initial={board()} />);
281 expect(screen.queryByLabelText("把 写文档 左移")).toBeNull(); // todo 是第一列
282 expect(screen.getByLabelText("把 评审 右移")).toBeInTheDocument();
283});
284
285test("[378] 新增的卡进 todo 列", async () => {
286 render(<Kanban initial={board()} />);
287 await userEvent.type(screen.getByTestId("card-input"), "新任务");
288 await userEvent.click(screen.getByTestId("card-submit"));
289
290 expect(screen.getByTestId("col-todo")).toHaveTextContent("新任务");
291 expect(screen.getByTestId("count-todo")).toHaveTextContent("3");
292});
1import { act, render, renderHook, screen } from "@testing-library/react";
2import userEvent from "@testing-library/user-event";
3import { afterEach, beforeEach, expect, test, vi } from "vitest";
4import Dropdown from "./components/Dropdown";
5import Tabs from "./components/Tabs";
6import StarRating from "./components/StarRating";
7import Player from "./components/Player";
8import Kanban, { moveCard } from "./components/Kanban";
9import { useLocalStorage } from "./hooks/useLocalStorage";
10import type { Board } from "./types/Card";
11
12/* ---------------- #366 Dropdown ---------------- */
13
14const OPTS = [
15 { id: "a", label: "苹果" },
16 { id: "b", label: "香蕉" },
17];
18
19test("[366] the trigger opens it; picking an option closes it and shows the choice", async () => {
20 render(<Dropdown options={OPTS} />);
21 expect(screen.queryByTestId("dropdown-list")).toBeNull();
22
23 await userEvent.click(screen.getByTestId("dropdown-trigger"));
24 expect(screen.getByTestId("dropdown-list")).toBeInTheDocument();
25 expect(screen.getByTestId("dropdown-trigger")).toHaveAttribute("aria-expanded", "true");
26
27 await userEvent.click(screen.getByTestId("option-b"));
28 expect(screen.queryByTestId("dropdown-list")).toBeNull();
29 expect(screen.getByTestId("dropdown-trigger")).toHaveTextContent("香蕉");
30});
31
32test("[366] a click outside closes it, a click inside does not", async () => {
33 render(
34 <div>
35 <Dropdown options={OPTS} />
36 <button data-testid="outside">外面</button>
37 </div>,
38 );
39 await userEvent.click(screen.getByTestId("dropdown-trigger"));
40
41 // Click inside itself: should not close
42 await userEvent.click(screen.getByTestId("dropdown-list"));
43 expect(screen.getByTestId("dropdown-list")).toBeInTheDocument();
44
45 // Click outside: should close
46 await userEvent.click(screen.getByTestId("outside"));
47 expect(screen.queryByTestId("dropdown-list")).toBeNull();
48});
49
50test("[366] Escape closes it", async () => {
51 render(<Dropdown options={OPTS} />);
52 await userEvent.click(screen.getByTestId("dropdown-trigger"));
53 await userEvent.keyboard("{Escape}");
54 expect(screen.queryByTestId("dropdown-list")).toBeNull();
55});
56
57test("[366] the document listeners are removed after unmount (the cleanup works)", async () => {
58 const add = vi.spyOn(document, "addEventListener");
59 const remove = vi.spyOn(document, "removeEventListener");
60
61 const { unmount } = render(<Dropdown options={OPTS} />);
62 await userEvent.click(screen.getByTestId("dropdown-trigger")); // it only binds once open
63 const added = add.mock.calls.filter(([t]) => t === "mousedown" || t === "keydown").length;
64 expect(added).toBe(2);
65
66 unmount();
67 const removed = remove.mock.calls.filter(([t]) => t === "mousedown" || t === "keydown").length;
68 expect(removed).toBe(2); // without the cleanup function this would be 0
69
70 add.mockRestore();
71 remove.mockRestore();
72});
73
74/* ---------------- #367 Tabs ---------------- */
75
76const TABS = [
77 { id: "one", label: "第一", content: <p>内容一</p> },
78 { id: "two", label: "第二", content: <p>内容二</p> },
79 { id: "three", label: "第三", content: <p>内容三</p> },
80];
81
82test("[367] the first tab is active by default, and only the active panel renders", () => {
83 render(<Tabs tabs={TABS} />);
84 expect(screen.getByTestId("tab-one")).toHaveAttribute("aria-selected", "true");
85 expect(screen.getByTestId("panel")).toHaveTextContent("内容一");
86 expect(screen.queryByText("内容二")).toBeNull();
87});
88
89test("[367] clicking the second one switches, and aria-selected follows", async () => {
90 render(<Tabs tabs={TABS} />);
91 await userEvent.click(screen.getByTestId("tab-two"));
92
93 expect(screen.getByTestId("panel")).toHaveTextContent("内容二");
94 expect(screen.getByTestId("tab-two")).toHaveAttribute("aria-selected", "true");
95 expect(screen.getByTestId("tab-one")).toHaveAttribute("aria-selected", "false");
96});
97
98test("[367] initialId sets which tab starts active", () => {
99 render(<Tabs tabs={TABS} initialId="three" />);
100 expect(screen.getByTestId("panel")).toHaveTextContent("内容三");
101});
102
103/* ---------------- #368 StarRating ---------------- */
104
105test("[368] clicking the third star scores 3, and the first three fill in", async () => {
106 render(<StarRating />);
107 await userEvent.click(screen.getByTestId("star-3"));
108
109 expect(screen.getByTestId("stars-value")).toHaveTextContent("3");
110 expect(screen.getByTestId("star-3")).toHaveAttribute("data-filled", "true");
111 expect(screen.getByTestId("star-4")).toHaveAttribute("data-filled", "false");
112});
113
114test("[368] hover previews, and moving out returns to the picked value", async () => {
115 render(<StarRating />);
116 await userEvent.click(screen.getByTestId("star-2"));
117
118 await userEvent.hover(screen.getByTestId("star-5"));
119 expect(screen.getByTestId("star-5")).toHaveAttribute("data-filled", "true"); // the preview
120
121 await userEvent.unhover(screen.getByTestId("star-5"));
122 // note that unhover only left that one star; you have to leave the whole container
123 await userEvent.pointer({ target: document.body });
124 expect(screen.getByTestId("stars")).toHaveAttribute("data-value", "2"); // the picked value did not change
125});
126
127test("[368] clicking the same star again resets to zero", async () => {
128 render(<StarRating />);
129 await userEvent.click(screen.getByTestId("star-4"));
130 await userEvent.click(screen.getByTestId("star-4"));
131 expect(screen.getByTestId("stars-value")).toHaveTextContent("0");
132});
133
134test("[368] in controlled mode it does not change its own value, it only calls onChange", async () => {
135 const onChange = vi.fn();
136 render(<StarRating value={1} onChange={onChange} />);
137 await userEvent.click(screen.getByTestId("star-5"));
138
139 expect(onChange).toHaveBeenCalledWith(5);
140 expect(screen.getByTestId("stars")).toHaveAttribute("data-value", "1"); // still the 1 the parent passed in
141});
142
143/* ---------------- #373 Player (useRef driving the DOM) ---------------- */
144
145let play: ReturnType<typeof vi.spyOn>;
146let pause: ReturnType<typeof vi.spyOn>;
147
148beforeEach(() => {
149 // jsdom does not implement media playback; play() throws Not implemented, so it needs a stub
150 play = vi
151 .spyOn(HTMLMediaElement.prototype, "play")
152 .mockImplementation(() => Promise.resolve());
153 pause = vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
154});
155
156afterEach(() => {
157 play.mockRestore();
158 pause.mockRestore();
159});
160
161test("[373] clicking Play calls audio.play(), clicking again calls pause()", async () => {
162 render(<Player src="/a.mp3" />);
163 expect(screen.getByTestId("toggle")).toHaveTextContent("Play");
164
165 await userEvent.click(screen.getByTestId("toggle"));
166 expect(play).toHaveBeenCalledTimes(1);
167 expect(screen.getByTestId("toggle")).toHaveTextContent("Pause");
168
169 await userEvent.click(screen.getByTestId("toggle"));
170 expect(pause).toHaveBeenCalledTimes(1);
171 expect(screen.getByTestId("toggle")).toHaveTextContent("Play");
172});
173
174test("[373] Stop sets currentTime back to zero and halts playback", async () => {
175 render(<Player src="/a.mp3" />);
176 const audio = screen.getByTestId("audio") as HTMLAudioElement;
177
178 await userEvent.click(screen.getByTestId("toggle"));
179 audio.currentTime = 30;
180 await userEvent.click(screen.getByTestId("stop"));
181
182 expect(audio.currentTime).toBe(0); // what changed is the DOM property itself
183 expect(screen.getByTestId("toggle")).toHaveTextContent("Play");
184});
185
186/* ---------------- #375 a custom hook ---------------- */
187
188test("[375] useLocalStorage uses the default the first time, and writes it to localStorage", () => {
189 localStorage.clear();
190 const { result } = renderHook(() => useLocalStorage("k", { n: 1 }));
191
192 expect(result.current[0]).toEqual({ n: 1 });
193 expect(JSON.parse(localStorage.getItem("k")!)).toEqual({ n: 1 });
194});
195
196test("[375] an existing value is read back instead of using the default", () => {
197 localStorage.setItem("k", JSON.stringify("存过的"));
198 const { result } = renderHook(() => useLocalStorage("k", "默认"));
199 expect(result.current[0]).toBe("存过的");
200});
201
202test("[375] setValue writes back in step", () => {
203 localStorage.clear();
204 const { result } = renderHook(() => useLocalStorage("k", 0));
205
206 act(() => result.current[1](42));
207 expect(result.current[0]).toBe(42);
208 expect(localStorage.getItem("k")).toBe("42");
209});
210
211test("[375] bad data does not break the component; it falls back to the default", () => {
212 localStorage.setItem("k", "{不是合法 JSON");
213 const { result } = renderHook(() => useLocalStorage("k", "兜底"));
214 expect(result.current[0]).toBe("兜底");
215});
216
217test("[375] two components each call it once = two independent states (shared logic, not shared state)", () => {
218 localStorage.clear();
219 const a = renderHook(() => useLocalStorage("shared", 0));
220 const b = renderHook(() => useLocalStorage("shared", 0));
221
222 act(() => a.result.current[1](5));
223 expect(a.result.current[0]).toBe(5);
224 expect(b.result.current[0]).toBe(0); // b does not follow along
225});
226
227/* ---------------- #378 Kanban ---------------- */
228
229const board = (): Board => ({
230 todo: [{ id: 1, title: "写文档" }, { id: 2, title: "改 bug" }],
231 doing: [{ id: 3, title: "评审" }],
232 done: [],
233});
234
235function deepFreeze<T>(o: T): T {
236 Object.freeze(o);
237 Object.values(o as Record<string, unknown>).forEach((v) => {
238 if (v && typeof v === "object" && !Object.isFrozen(v)) deepFreeze(v);
239 });
240 return o;
241}
242
243test("[378] moveCard moves the card to the target column and does not change the original board", () => {
244 const original = deepFreeze(board());
245 const next = moveCard(original, "todo", "doing", 1);
246
247 expect(next.todo.map((c) => c.id)).toEqual([2]);
248 expect(next.doing.map((c) => c.id)).toEqual([3, 1]);
249 // the original object was not touched at all
250 expect(original.todo.map((c) => c.id)).toEqual([1, 2]);
251 expect(original.doing.map((c) => c.id)).toEqual([3]);
252});
253
254test("[378] a no-op move, or a card that is not found, returns the very same reference", () => {
255 const b = board();
256 expect(moveCard(b, "todo", "todo", 1)).toBe(b);
257 expect(moveCard(b, "todo", "done", 999)).toBe(b);
258});
259
260test("[378] untouched columns keep the original array reference (only the changed parts are rebuilt)", () => {
261 const b = board();
262 const next = moveCard(b, "todo", "doing", 1);
263 expect(next.done).toBe(b.done); // done did not move, so the reference is unchanged
264 expect(next.todo).not.toBe(b.todo); // what moved has to be a new array
265});
266
267test("[378] clicking move-right changes the card's column and the counts follow", async () => {
268 render(<Kanban initial={board()} />);
269 expect(screen.getByTestId("count-todo")).toHaveTextContent("2");
270 expect(screen.getByTestId("card-1")).toHaveAttribute("data-col", "todo");
271
272 await userEvent.click(screen.getByLabelText("把 写文档 右移"));
273
274 expect(screen.getByTestId("card-1")).toHaveAttribute("data-col", "doing");
275 expect(screen.getByTestId("count-todo")).toHaveTextContent("1");
276 expect(screen.getByTestId("count-doing")).toHaveTextContent("2");
277});
278
279test("[378] the first column has no move-left button, the last has no move-right", () => {
280 render(<Kanban initial={board()} />);
281 expect(screen.queryByLabelText("把 写文档 左移")).toBeNull(); // todo is the first column
282 expect(screen.getByLabelText("把 评审 右移")).toBeInTheDocument();
283});
284
285test("[378] a newly added card goes into the todo column", async () => {
286 render(<Kanban initial={board()} />);
287 await userEvent.type(screen.getByTestId("card-input"), "新任务");
288 await userEvent.click(screen.getByTestId("card-submit"));
289
290 expect(screen.getByTestId("col-todo")).toHaveTextContent("新任务");
291 expect(screen.getByTestId("count-todo")).toHaveTextContent("3");
292});
练习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.

L3写整块Write a block自己写出 useLocalStorageWrite useLocalStorage yourselfDrillLab 自出Written by DrillLab

四个考点全都会被检查:惰性初始化、try/catch 兜底、 依赖带 key、as const

All four points get checked: lazy initialisation, a try/catch fallback, key in the dependency list, and as const.

要求Requirements
  • 读 localStorage 只在首次渲染发生一次(惰性初始化)Reading localStorage happens once, on the first render (lazy initialisation)
  • 读和写都要有 try/catchBoth the read and the write need a try/catch
  • JSON 序列化 / 反序列化JSON serialising and deserialising
  • effect 的依赖里要有 key 和 valueThe effect's dependency list holds key and value
  • 返回元组,用 as constReturn a tuple, using as const
TypeScriptsrc/hooks/useLocalStorage.ts
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.

错例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// ✗ 用 state 存 DOM 节点
2const [audio, setAudio] = useState<HTMLAudioElement | null>(null);
3<audio ref={setAudio} />
1// ✗ keeping the DOM node in state
2const [audio, setAudio] = useState<HTMLAudioElement | null>(null);
3<audio ref={setAudio} />
能跑,但每次拿到节点都会触发一次重渲染, 而 DOM 节点根本不参与渲染输出。
useRef。 (例外:确实需要「节点出现时触发一次逻辑」时, 回调 ref 是合理的。)
This works, but every time it receives the node it triggers a re-render, and the DOM node is not part of the rendered output at all.
Use useRef. (One exception: a callback ref is reasonable when you really do need to run some logic once, at the moment the node appears.)
TSX示意Illustrative
1// ✗ 忘了 onEnded
2<audio ref={audioRef} src={src} />
3// 播完之后按钮还显示 "Pause"
1// ✗ onEnded was forgotten
2<audio ref={audioRef} src={src} />
3// After playback ends the button still reads "Pause"
媒体播放结束是DOM 自己发生的事, React 不知道。必须监听 onEnded把 state 同步回来。
这是「事实在 DOM 里、state 是镜像」这类组件 的通用注意点。
Playback ending is something the DOM does on its own, and React is not told about it. You have to listen for onEnded and copy the change back into state.
This applies to every component where the DOM holds the real value and state is only a copy of it.
迁移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.

要 focus / play / scroll / 测量尺寸You need to focus, play, scroll or measure a size
useRef 拿节点,走命令式Hold the node with useRef and call it directly
要存定时器 id 或上一次的值You need to keep a timer id or the previous value
useRef 存值,不用 stateKeep the value in useRef, not in state
「DOM 自己变了但界面没同步」"the DOM changed on its own and the screen is out of step"
监听对应事件把 state 同步回来Listen for the matching event and copy the change back into state
同一组 state+effect 写了两遍The same state plus effect is written twice
抽 use 开头的自定义 hookPull it into a custom hook whose name starts with use
初始值需要一次昂贵计算或 I/OThe initial value needs an expensive computation or a read from storage
useState(() => …) 惰性初始化Pass a function to useState so it runs only once
自定义 hook 返回数组类型不对The array returned from a custom hook has the wrong type
加 as constAdd as const
这节的要点What to take away
  1. useRef 两种用途:存不参与渲染的值、拿 DOM 节点调命令式 API;两者都不触发重渲染。Two uses of useRef: holding a value that is not rendered, and holding a DOM node so you can call its methods; neither one triggers a re-render.
  2. 播放/聚焦/滚动/测量是「动作」不是「状态」,这是 React 留 ref 口子的原因。Playing, focusing, scrolling and measuring are actions, not state — which is why React leaves a way out through a ref.
  3. 媒体组件的模式是「事实在 DOM 里,state 只是镜像」,所以要监听 onEnded 之类的事件。In a media component the DOM holds the real value and state is only a copy of it, so you listen for events such as onEnded.
  4. jsdom 不实现媒体播放,测试要 spyOn(HTMLMediaElement.prototype, "play")。jsdom does not implement media playback, so a test needs spyOn(HTMLMediaElement.prototype, "play").
  5. 自定义 hook 四要点:惰性初始化、try/catch 兜底、依赖带 key、as const 返元组。Four points for a custom hook: a lazy initial value, a try/catch fallback, the key in the dependency list, and as const to return a tuple.
  6. 复用逻辑不复用状态 —— 两个组件各调一次就是两份独立 state。A hook shares the logic, not the state — two components each calling it get two separate copies of the state.

接下来What next

  1. 把这一节的练习做掉Do this lesson’s exercises1 个,就在这一页上面 —— 别攒着最后一起做1 of them, further up this page — do not save them for later
    回到练习 ↑Back up to them ↑
  2. 接着看下一节Continue to the next lesson缺口三 · 同一个 Todo 换成 Redux ToolkitGap 3 · the same Todo app, moved to Redux Toolkit
    下一节Next lesson
读完并且做过上面的练习了吗?Read it and worked through the exercises above?
上一节:Previous: 缺口一 · Dropdown、Tabs、星级评分Gap 1 · dropdown, tabs and star rating