DrillLab

星级评分(hover 预览 + 受控双模式)Star rating (hover preview, controlled and uncontrolled)

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

题面The problem

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

hover 预览 + 点击选中 + 再点清零。 检查器会查 ??onMouseLeave 的位置和无障碍。

Hover to preview, click to pick, click the same star again to reset. The checker looks at ??, where onMouseLeave sits, and accessibility.

验收标准Acceptance criteria
  • hover 到第 n 颗时前 n 颗显示为亮 —— 这只是预览,不改已选值Hovering star n lights up the first n stars. This is only a preview, so the selected value must not change
  • 鼠标移出整个组件后回到已选值When the mouse leaves the whole component, the display goes back to the selected value
  • 点第 n 颗设为 n 分;再点同一颗清零Clicking star n sets the rating to n. Clicking that same star again clears it to 0
  • 每颗星是 button,aria-label 是 `${n} star`,aria-pressed 标出选中那颗Every star is a button, aria-label is `${n} star`, and aria-pressed marks the selected one
  • 只有两个 state(已选值 + hover),要显示几颗亮是派生的There are only two pieces of state (the selected value and the hovered one). How many stars light up is derived
  • 传了 value 就是受控:内部不留自己的值,点击只调 onChangePassing value makes the component controlled: keep no value of your own inside, and a click only calls onChange
  • max 决定画几颗星max decides how many stars are drawn

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

展开讲解Walkthrough

下面是《缺口一 · Dropdown、Tabs、星级评分》那一节的原文 —— 和课程里是同一份内容,不是另写的摘要。卡住了再展开。Below is the actual text of the lesson “缺口一 · Dropdown、Tabs、星级评分” — the same content as in the course, not a rewritten summary. Expand it when you stall.

展开《缺口一 · Dropdown、Tabs、星级评分》(3 段 · 约 24 分钟)Expand “缺口一 · Dropdown、Tabs、星级评分” (3 sections · ~24 min)

完整那一节(含练习、常见错误、迁移模式)在The full lesson (with exercises, common mistakes and transfer patterns) is at 缺口一 · Dropdown、Tabs、星级评分

§01

Dropdown:点外面要关掉Dropdown: a click outside has to close it

这道题唯一的难点就是「怎么知道用户点的不是我」。The only hard part here is telling that the click was not inside my own element.

思路三步:

  1. useRef 拿到自己那个容器的 DOM 节点。
  2. document 上监听mousedown, 用 ref.current.contains(e.target)判断点击是否发生在自己内部。
  3. 清理函数里解绑

四个细节决定这道题写得好不好:

  • mousedown 而不是clickclick 在按下和松开之间如果 DOM 变了 会有诡异行为;而且 mousedown更早,关闭反应更快。
  • contains 而不是e.target === ref.current—— 用户点的是内部的按钮,不是容器本身。这和事件委托里closest() 是同一个道理。
  • 依赖 [open],没展开就不绑—— 少一个常驻监听器。
  • Escape 也要能关—— 这是可访问性的基本要求, 面试官经常拿它当加分项。

不写清理函数的后果和计时器那道题一样: 每次展开多绑一对监听器;组件卸载后监听器还在, 回调里 setOpen 会对已卸载的组件操作。测试里我直接 spy 了document.addEventListenerremoveEventListener, 断言两边次数相等—— 漏了清理这条会红。

Three steps:

  1. Use useRef to get the DOM node of your own container.
  2. Listen for mousedown on document and use ref.current.contains(e.target) to decide whether the click happened inside you.
  3. Unbind in the cleanup function.

Four details decide whether this one is written well:

  • Use mousedown, not click. click gets weird if the DOM changes between press and release, and mousedown fires earlier, so closing feels faster.
  • contains, not e.target === ref.current — the user clicks a button inside, not the container itself. Same reasoning as closest() in event delegation.
  • Depend on [open], do not bind while closed — one less permanent listener.
  • Escape has to close it too — a baseline accessibility requirement, and interviewers often use it as the bonus point.

Skipping the cleanup function costs you the same as in the timer question: every open binds one more pair of listeners, and after the component unmounts the listener is still there, so the callback calls setOpen on an unmounted component. The test spies on document.addEventListener and removeEventListener directly and asserts the two counts match — miss the cleanup and it goes red.

TSXsrc/components/Dropdown/index.tsx(实测通过)src/components/Dropdown/index.tsx (passes in a real run)已跑通Verified
1import React, { useEffect, useRef, useState } from "react";
2
3export interface Option { id: string; label: string }
4
5interface Props {
6 options: Option[];
7 onSelect?: (id: string) => void;
8}
9
10const Dropdown: React.FC<Props> = ({ options, onSelect }) => {
11 const [open, setOpen] = useState(false);
12 const [picked, setPicked] = useState<Option | null>(null);
13 // useRef 存 DOM 节点:用来判断「这次点击是不是发生在我身上」
14 const boxRef = useRef<HTMLDivElement>(null);
15
16 useEffect(() => {
17 if (!open) return; // 没展开就不用监听,省一个监听器
18
19 const onDocClick = (e: MouseEvent) => {
20 // e.target 是真正被点的那个节点;contains 判断它在不在我这棵子树里
21 if (boxRef.current && !boxRef.current.contains(e.target as Node)) {
22 setOpen(false);
23 }
24 };
25 const onKey = (e: KeyboardEvent) => {
26 if (e.key === "Escape") setOpen(false);
27 };
28
29 document.addEventListener("mousedown", onDocClick);
30 document.addEventListener("keydown", onKey);
31 // 清理:不解绑的话每次展开都多一对监听器,卸载后还会对已卸载组件 setState
32 return () => {
33 document.removeEventListener("mousedown", onDocClick);
34 document.removeEventListener("keydown", onKey);
35 };
36 }, [open]);
37
38 const choose = (o: Option) => {
39 setPicked(o);
40 setOpen(false);
41 onSelect?.(o.id);
42 };
43
44 return (
45 <div ref={boxRef} data-testid="dropdown">
46 <button
47 onClick={() => setOpen((v) => !v)}
48 aria-haspopup="listbox"
49 aria-expanded={open}
50 data-testid="dropdown-trigger"
51 >
52 {picked ? picked.label : "请选择"}
53 </button>
54
55 {open && (
56 <ul role="listbox" data-testid="dropdown-list">
57 {options.map((o) => (
58 <li key={o.id}>
59 <button
60 role="option"
61 aria-selected={picked?.id === o.id}
62 onClick={() => choose(o)}
63 data-testid={`option-${o.id}`}
64 >
65 {o.label}
66 </button>
67 </li>
68 ))}
69 </ul>
70 )}
71 </div>
72 );
73};
74
75export default Dropdown;
1import React, { useEffect, useRef, useState } from "react";
2
3export interface Option { id: string; label: string }
4
5interface Props {
6 options: Option[];
7 onSelect?: (id: string) => void;
8}
9
10const Dropdown: React.FC<Props> = ({ options, onSelect }) => {
11 const [open, setOpen] = useState(false);
12 const [picked, setPicked] = useState<Option | null>(null);
13 // useRef holds the DOM node: used to tell whether a click landed on me
14 const boxRef = useRef<HTMLDivElement>(null);
15
16 useEffect(() => {
17 if (!open) return; // Closed, so no listener needed — that saves one
18
19 const onDocClick = (e: MouseEvent) => {
20 // e.target is the node that was really clicked; contains asks whether it is inside my subtree
21 if (boxRef.current && !boxRef.current.contains(e.target as Node)) {
22 setOpen(false);
23 }
24 };
25 const onKey = (e: KeyboardEvent) => {
26 if (e.key === "Escape") setOpen(false);
27 };
28
29 document.addEventListener("mousedown", onDocClick);
30 document.addEventListener("keydown", onKey);
31 // Cleanup: without it, every open adds another pair of listeners, and after unmount they would setState on an unmounted component
32 return () => {
33 document.removeEventListener("mousedown", onDocClick);
34 document.removeEventListener("keydown", onKey);
35 };
36 }, [open]);
37
38 const choose = (o: Option) => {
39 setPicked(o);
40 setOpen(false);
41 onSelect?.(o.id);
42 };
43
44 return (
45 <div ref={boxRef} data-testid="dropdown">
46 <button
47 onClick={() => setOpen((v) => !v)}
48 aria-haspopup="listbox"
49 aria-expanded={open}
50 data-testid="dropdown-trigger"
51 >
52 {picked ? picked.label : "Select…"}
53 </button>
54
55 {open && (
56 <ul role="listbox" data-testid="dropdown-list">
57 {options.map((o) => (
58 <li key={o.id}>
59 <button
60 role="option"
61 aria-selected={picked?.id === o.id}
62 onClick={() => choose(o)}
63 data-testid={`option-${o.id}`}
64 >
65 {o.label}
66 </button>
67 </li>
68 ))}
69 </ul>
70 )}
71 </div>
72 );
73};
74
75export default Dropdown;
§02

Tabs:只需要一个 stateTabs: one piece of state is enough

很多人会给每个 tab 存一个 isActive,那是多余的。Many people keep an isActive flag on every tab. That is not needed.

唯一需要记住的事实是「哪个 id 是激活的」。当前该渲染哪个面板、哪个按钮高亮,全都能从这一个值算出来—— 典型的派生数据。

两个实现选择要想清楚:

  • 只渲染激活的面板(本实现)—— 简单,但切回来时面板内部的 state 会丢。
  • 全部渲染、用 CSS 隐藏—— 能保住面板内部状态, 但一上来就要渲染所有内容。面试里主动说出这个取舍会加分。

ARIA 三件套别漏role="tablist" /role="tab" +aria-selected /role="tabpanel", 再用 aria-controlsaria-labelledby 把 tab 和 panel 关联起来。写了这几行,题目就从「能跑」变成「像个前端写的」。

会追问:「键盘左右箭头切换怎么做?」—— 在 tablist 上监听keydown, 按 ArrowRight / ArrowLeftactiveId, 并把非激活 tab 的tabIndex 设成 -1(让 Tab 键只进入激活项)。

The only fact you need to keep is which id is active. Which panel to render and which button to highlight can all be computed from that one value — textbook derived data.

Two implementation choices to think through:

  • Render only the active panel (what this one does) — simple, but state inside a panel is lost when you switch back.
  • Render them all and hide with CSS — keeps the panel state, but everything renders up front. Bringing this trade-off up yourself scores points.

Do not drop the three ARIA pieces: role="tablist" / role="tab" + aria-selected / role="tabpanel", then tie tab and panel together with aria-controls and aria-labelledby. Those few lines turn the answer from “it runs” into “a front-end person wrote this”.

Follow-up: “How do the left and right arrow keys switch tabs?” — listen for keydown on the tablist, change activeId on ArrowRight / ArrowLeft, and set tabIndex to -1 on the inactive tabs (so Tab only enters the active one).

TSXsrc/components/Tabs/index.tsx(实测通过)src/components/Tabs/index.tsx (passes in a real run)已跑通Verified
1import React, { useState } from "react";
2
3export interface Tab { id: string; label: string; content: React.ReactNode }
4
5const Tabs: React.FC<{ tabs: Tab[]; initialId?: string }> = ({ tabs, initialId }) => {
6 // 只存「哪个是激活的」,当前面板是派生出来的
7 const [activeId, setActiveId] = useState(initialId ?? tabs[0]?.id);
8
9 const active = tabs.find((t) => t.id === activeId) ?? tabs[0];
10
11 return (
12 <div data-testid="tabs">
13 <div role="tablist">
14 {tabs.map((t) => (
15 <button
16 key={t.id}
17 role="tab"
18 aria-selected={t.id === active.id}
19 aria-controls={`panel-${t.id}`}
20 id={`tab-${t.id}`}
21 onClick={() => setActiveId(t.id)}
22 data-testid={`tab-${t.id}`}
23 >
24 {t.label}
25 </button>
26 ))}
27 </div>
28
29 {/* 只渲染激活的那个面板 */}
30 <div
31 role="tabpanel"
32 id={`panel-${active.id}`}
33 aria-labelledby={`tab-${active.id}`}
34 data-testid="panel"
35 >
36 {active.content}
37 </div>
38 </div>
39 );
40};
41
42export default Tabs;
1import React, { useState } from "react";
2
3export interface Tab { id: string; label: string; content: React.ReactNode }
4
5const Tabs: React.FC<{ tabs: Tab[]; initialId?: string }> = ({ tabs, initialId }) => {
6 // Only "which one is active" is stored; the current panel is derived from it
7 const [activeId, setActiveId] = useState(initialId ?? tabs[0]?.id);
8
9 const active = tabs.find((t) => t.id === activeId) ?? tabs[0];
10
11 return (
12 <div data-testid="tabs">
13 <div role="tablist">
14 {tabs.map((t) => (
15 <button
16 key={t.id}
17 role="tab"
18 aria-selected={t.id === active.id}
19 aria-controls={`panel-${t.id}`}
20 id={`tab-${t.id}`}
21 onClick={() => setActiveId(t.id)}
22 data-testid={`tab-${t.id}`}
23 >
24 {t.label}
25 </button>
26 ))}
27 </div>
28
29 {/* Only the active panel is rendered */}
30 <div
31 role="tabpanel"
32 id={`panel-${active.id}`}
33 aria-labelledby={`tab-${active.id}`}
34 data-testid="panel"
35 >
36 {active.content}
37 </div>
38 </div>
39 );
40};
41
42export default Tabs;
§03

星级评分:两个状态叠出一个显示值Star rating: two pieces of state produce one displayed value

已选值 + hover 预览,显示的是「有 hover 就用 hover」。A chosen value plus a hover preview: show the hover value whenever there is one.

核心一行:const shown = hover ?? current

为什么用 ?? 而不是||—— 这里正好是 #281 讲过的坑:hover 可能是 0(虽然本实现从 1 开始,但如果你支持 0 星就会踩),|| 会把 0 当成「没 hover」。?? 才对, 而且 hover 的类型是number | null 也正是为了配它。

三个细节:

  • onMouseLeave 放在容器上, 不是每颗星上 —— 否则在星之间移动会不停触发。
  • 再点同一颗清零—— 常见需求,一行三元。
  • <button>而不是 <span>—— 天然可聚焦、可回车触发。 配上 aria-label="3 星"读屏也能用。

受控 / 非受控双模式是这道题的进阶分: 传了 value 就以父级为准、 自己不存;没传就用内部 state。 判断方式是value !== undefined——注意不能用 value != null, 否则父级传 null 表示「清空」时会被当成非受控。

The core line: const shown = hover ?? current.

Why ?? and not || — this is exactly the trap from #281: hover can be 0 (this implementation starts at 1, but you hit it the moment you support 0 stars), and || reads 0 as “no hover”. Use ??, and note that hover is typed number | null precisely to pair with it.

Three details:

  • Put onMouseLeave on the container, not on each star — otherwise moving between stars fires it over and over.
  • Clicking the same star again clears it — a common requirement, one ternary.
  • Use <button>, not <span> — focusable and Enter-triggerable already. Add aria-label="3 stars" and a screen reader can use it too.

Controlled and uncontrolled in one component is the advanced point here: if value is passed the parent wins and you store nothing; if not, use internal state. The check is value !== undefined do not use value != null, or a parent passing null to mean “clear” gets treated as uncontrolled.

TSXsrc/components/StarRating/index.tsx(实测通过)src/components/StarRating/index.tsx (passes in a real run)已跑通Verified
1import React, { useState } from "react";
2
3interface Props {
4 max?: number;
5 value?: number; // 传了就是受控
6 onChange?: (v: number) => void;
7}
8
9const StarRating: React.FC<Props> = ({ max = 5, value, onChange }) => {
10 const [inner, setInner] = useState(0);
11 const [hover, setHover] = useState<number | null>(null);
12
13 const isControlled = value !== undefined;
14 const current = isControlled ? value : inner;
15
16 // 有 hover 就显示 hover 的,否则显示已选的 —— 这是派生数据
17 const shown = hover ?? current;
18
19 const set = (v: number) => {
20 if (!isControlled) setInner(v);
21 onChange?.(v);
22 };
23
24 return (
25 <div
26 data-testid="stars"
27 data-value={current}
28 onMouseLeave={() => setHover(null)}
29 >
30 {Array.from({ length: max }, (_, i) => i + 1).map((n) => (
31 <button
32 key={n}
33 type="button"
34 aria-label={`${n} star`}
35 aria-pressed={n === current}
36 data-filled={n <= shown}
37 onMouseEnter={() => setHover(n)}
38 onClick={() => set(n === current ? 0 : n)} // 再点同一颗就清零
39 data-testid={`star-${n}`}
40 >
41 {n <= shown ? "★" : "☆"}
42 </button>
43 ))}
44 <output data-testid="stars-value">{current}</output>
45 </div>
46 );
47};
48
49export default StarRating;
1import React, { useState } from "react";
2
3interface Props {
4 max?: number;
5 value?: number; // Pass it and the component is controlled
6 onChange?: (v: number) => void;
7}
8
9const StarRating: React.FC<Props> = ({ max = 5, value, onChange }) => {
10 const [inner, setInner] = useState(0);
11 const [hover, setHover] = useState<number | null>(null);
12
13 const isControlled = value !== undefined;
14 const current = isControlled ? value : inner;
15
16 // Show the hovered star if there is a hover, otherwise the picked one — this is derived data
17 const shown = hover ?? current;
18
19 const set = (v: number) => {
20 if (!isControlled) setInner(v);
21 onChange?.(v);
22 };
23
24 return (
25 <div
26 data-testid="stars"
27 data-value={current}
28 onMouseLeave={() => setHover(null)}
29 >
30 {Array.from({ length: max }, (_, i) => i + 1).map((n) => (
31 <button
32 key={n}
33 type="button"
34 aria-label={`${n} star`}
35 aria-pressed={n === current}
36 data-filled={n <= shown}
37 onMouseEnter={() => setHover(n)}
38 onClick={() => set(n === current ? 0 : n)} // Clicking the same star again resets to zero
39 data-testid={`star-${n}`}
40 >
41 {n <= shown ? "★" : "☆"}
42 </button>
43 ))}
44 <output data-testid="stars-value">{current}</output>
45 </div>
46 );
47};
48
49export default StarRating;
TSX三种常见错法Three common wrong versions示意Illustrative
1// ✗ 用 || :支持 0 星时会出错
2const shown = hover || current;
3
4// ✗ onMouseLeave 放每颗星上:星之间移动会闪
5<button onMouseEnter={...} onMouseLeave={() => setHover(null)} />
6
7// ✗ 用 span:键盘用不了
8<span onClick={...}></span>
1// ✗ using || : this goes wrong once 0 stars is allowed
2const shown = hover || current;
3
4// ✗ onMouseLeave on every star: moving between stars makes it flicker
5<button onMouseEnter={...} onMouseLeave={() => setHover(null)} />
6
7// ✗ using span: the keyboard cannot reach it
8<span onClick={...}></span>
§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.