DrillLab
第 17 / 25 节LESSON 17 / 25约 24 分钟~24 min

缺口一 · Dropdown、Tabs、星级评分Gap 1 · dropdown, tabs and star rating

三个小组件,考的是「组件自己的交互状态怎么管」—— 之前五道变式题一个都没覆盖到。Three small components. They test how a component manages its own interaction state, which none of the five earlier variant tasks covered.

2 个练习2 exercises面试 · 第 7 部分Interview · Part 7
这一页有什么On this page6
学完这节你会After this lesson you can
  • 用 useRef + document 监听实现「点外面关掉」,并正确清理Close on a click outside using useRef plus a document listener, and clean the listener up correctly
  • 说清 Tabs 为什么只需要一个 stateExplain why Tabs needs only one piece of state
  • 把「hover 预览」和「已选值」叠成一个显示值Combine a hover preview and a chosen value into one displayed value
  • 实现同时支持受控和非受控的组件Build a component that works both controlled and uncontrolled
这在考试里考什么What the exam does with this

这三道是 Easy / Medium 里出现频率最高的。它们代码量小,所以面试官会盯细节:点外面关不关、Escape 关不关、监听器解绑没有、ARIA 有没有、hover 移出后回不回到已选值。写得出来是及格,这些细节全中才是好。These three come up most often among the easy and medium problems. There is little code, so the interviewer watches the details: does a click outside close it, does Escape close it, is the listener removed, are the ARIA attributes there, does it go back to the chosen value when the pointer leaves. Getting it to work is a pass; getting every detail right is a good answer.

§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>
练习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.

L2填空Fill the blanks补全「点外面关掉」Fill in "click outside closes it"DrillLab 自出Written by DrillLab

四个空。第 2 个用错会导致「点自己内部也关掉」, 第 4 个漏了会泄漏监听器。

Four blanks. Get the 2nd one wrong and a click inside the dropdown closes it too; miss the 4th one and you leak a listener.

TSXsrc/components/Dropdown/index.tsx4 个空4 blanks
1const boxRef = <HTMLDivElement>(null);
2
3useEffect(() => {
4 if (!open) return;
5
6 const onDocClick = (e: MouseEvent) => {
7 if (boxRef.current && !boxRef.current.(e.target as Node)) {
8 setOpen(false);
9 }
10 };
11
12 document.addEventListener("", onDocClick);
13 return () => document.("", onDocClick);
14}, [open]);
把 4 个空都填上才能检查(还差 4 个)Fill all 4 blanks to check (4 to go)
L3写整块Write a block自己写出星级评分Write the star rating yourselfDrillLab 自出Written by DrillLab

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

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

要求Requirements
  • hover 到第 n 颗时前 n 颗显示为选中样式(预览)Hovering star n shows the first n stars in the filled style (a preview)
  • 鼠标移出整个组件后回到已选值Moving the mouse out of the whole component goes back to the picked value
  • 点第 n 颗设为 n 分;再点同一颗清零Clicking star n sets the score to n; clicking the same star again resets to zero
  • 每颗星是 button,带 aria-label,键盘可用Every star is a button with an aria-label, and works from the keyboard
  • 显示值必须是派生的,不许再开第三个 stateThe shown value has to be derived; a third piece of state is not allowed
TSXsrc/components/StarRating/index.tsx
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// ✗ 用 e.target === ref.current 判断
2const onDocClick = (e: MouseEvent) => {
3 if (e.target !== boxRef.current) setOpen(false);
4};
1// ✗ testing with e.target === ref.current
2const onDocClick = (e: MouseEvent) => {
3 if (e.target !== boxRef.current) setOpen(false);
4};
点内部的选项也会被判成「点了外面」, 于是刚展开就关,或者选不中任何东西
要用 contains(), 它会检查整棵子树。
Clicking an option inside also counts as a click outside, so it closes the moment it opens, or you can never select anything.
Use contains() — it checks the whole subtree.
TSX示意Illustrative
1// ✗ 解绑时传了一个新函数
2useEffect(() => {
3 document.addEventListener("mousedown", (e) => handle(e));
4 return () => document.removeEventListener("mousedown", (e) => handle(e));
5}, [open]);
1// ✗ a brand new function is passed when removing
2useEffect(() => {
3 document.addEventListener("mousedown", (e) => handle(e));
4 return () => document.removeEventListener("mousedown", (e) => handle(e));
5}, [open]);
两个箭头函数是不同的引用removeEventListener一个也解不掉—— 而且不会报错,你只会发现监听器越来越多。
必须先存成一个具名函数,绑和解都用它。
The two arrow functions are different references, so removeEventListener removes nothing at all — and it reports no error, you only notice that the listeners keep piling up.
Store one named function first, and use that same function to add and to remove.
TSX示意Illustrative
1// ✗ 给每个 tab 存一个 isActive
2const [tabs, setTabs] = useState(
3 raw.map((t, i) => ({ ...t, isActive: i === 0 })),
4);
1// ✗ storing an isActive flag on every tab
2const [tabs, setTabs] = useState(
3 raw.map((t, i) => ({ ...t, isActive: i === 0 })),
4);
同一个事实存了 n 份, 切换时要遍历全部改一遍,而且很容易出现「两个都激活」或「一个都不激活」
只存一个 activeId,其余全算出来。
One fact is now stored n times, switching means walking all of them and rewriting each one, and it is easy to end up with two active tabs, or none at all.
Keep a single activeId and compute the rest from 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.

「点外面要关掉」"a click outside should close it"
useRef + document mousedown + contains + 清理useRef, a mousedown listener on document, contains, plus cleanup
解绑监听器没生效Removing the listener has no effect
绑和解必须是同一个函数引用Adding and removing must use the same function reference
「弹层要能按 Escape 关」"Escape should close the panel"
同一个 effect 里再加 keydownAdd a keydown listener in the same effect
「哪一项被选中」"which item is selected"
只存一个 id,其余派生Store one id only, and derive the rest
hover 预览 + 已选值A hover preview together with a chosen value
shown = hover ?? current,别用 ||shown = hover ?? current; do not use ||
要同时支持受控和非受控It has to work both controlled and uncontrolled
判断 prop !== undefinedCheck whether the prop !== undefined
这节的要点What to take away
  1. 点外面关掉三要素:useRef 拿节点、document 上 mousedown、contains 判断,外加清理。Three parts to closing on an outside click: useRef for the node, mousedown on document, contains for the test — plus the cleanup.
  2. 解绑必须用同一个函数引用,传新箭头函数解不掉且不报错。Removing a listener needs the same function reference; passing a new arrow function removes nothing and reports no error.
  3. Tabs 只需要一个 activeId,其余全是派生;ARIA 三件套别漏。Tabs needs one activeId and derives everything else; do not leave out the three ARIA attributes.
  4. 星级评分核心是 shown = hover ?? current;onMouseLeave 挂容器不挂每颗星。The core of a star rating is shown = hover ?? current; put onMouseLeave on the container, not on each star.
  5. 用 button 而不是 span,天然可聚焦可回车;受控判断用 !== undefined。Use a button rather than a span so it can be focused and used with Enter; test for controlled with !== undefined.

接下来What next

  1. 把这一节的练习做掉Do this lesson’s exercises2 个,就在这一页上面 —— 别攒着最后一起做2 of them, further up this page — do not save them for later
    回到练习 ↑Back up to them ↑
  2. 接着看下一节Continue to the next lesson缺口二 · useRef 操作 DOM,与写一个自定义 hookGap 2 · using useRef on the DOM, and writing a custom hook
    下一节Next lesson
读完并且做过上面的练习了吗?Read it and worked through the exercises above?
上一节:Previous: 16 道题逐题对照The 16 problems, compared one by one