DrillLab

写一个自定义 hook:useLocalStorageWrite a custom hook: useLocalStorage

React中等 · Medium约 25 分钟~25 min浏览器里能跑Runs in the browser
§01

题面The problem

先把要求读完,再动手。Read every requirement before you start.

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

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

验收标准Acceptance criteria
  • 名字必须 use 开头 —— ESLint 靠这个前缀才会检查 hooks 规则The name must start with use. ESLint only applies the hooks rules to functions with that prefix
  • 惰性初始化:读 localStorage 只在首次渲染做一次,不是每次渲染都读Lazy initialization: read localStorage once on the first render, not on every render
  • key 不存在时用 initialWhen the key is not there, fall back to initial
  • 存的是 JSON,取出来 JSON.parse —— 对象和数组要能原样回来What you store is JSON, and you read it back with JSON.parse, so objects and arrays come back unchanged
  • 脏数据 parse 不了、或隐私模式下 setItem 抛错,都要吞掉,不许让组件炸Two failures must be caught so the component keeps working: data that JSON.parse cannot read, and setItem throwing in private mode
  • 值变了写回 localStorageWhen the value changes, write it back to localStorage
  • 返回 [value, setValue] 元组,setValue 支持函数式更新Return a [value, setValue] tuple, and setValue must accept the updater form

预计 25 分钟。这个数字是照「读完题就开始写、不查资料」估的 —— 第一次超时很正常,第二次要压进去。Budget 25 minutes. Overrunning on the first pass is normal; the second pass should fit.

§02

工作区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.

2 个起始文件 · 目标 7 passed2 starter files · target 7 passed
自己写出来、测试全绿之后再打勾。看懂答案不算。Tick this only after you wrote it yourself and the tests went green.
§03

展开讲解Walkthrough

下面是《缺口二 · useRef 操作 DOM,与写一个自定义 hook》那一节的原文 —— 和课程里是同一份内容,不是另写的摘要。卡住了再展开。Below is the actual text of the lesson “缺口二 · useRef 操作 DOM,与写一个自定义 hook” — the same content as in the course, not a rewritten summary. Expand it when you stall.

展开《缺口二 · useRef 操作 DOM,与写一个自定义 hook》(4 段 · 约 22 分钟)Expand “缺口二 · useRef 操作 DOM,与写一个自定义 hook” (4 sections · ~22 min)

完整那一节(含练习、常见错误、迁移模式)在The full lesson (with exercises, common mistakes and transfer patterns) is at 缺口二 · useRef 操作 DOM,与写一个自定义 hook

§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});
§04

参考答案Reference solution

提示是一级一级放的。四级看完还写不出来,再开答案门。The hints come one level at a time. If all four leave you stuck, open the answer.

提示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.

这份答案在本机真跑过测试。但先确认你自己动手写过一遍 —— 读懂答案和写出答案是两种能力,考场上考的是后一种。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.