缺口一 · 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.
这一页有什么On this page6
- 用 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
这三道是 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.
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.
思路三步:
- 用
useRef拿到自己那个容器的 DOM 节点。 - 在
document上监听mousedown, 用ref.current.contains(e.target)判断点击是否发生在自己内部。 - 清理函数里解绑。
四个细节决定这道题写得好不好:
- 用
mousedown而不是click。click在按下和松开之间如果 DOM 变了 会有诡异行为;而且mousedown更早,关闭反应更快。 contains而不是e.target === ref.current—— 用户点的是内部的按钮,不是容器本身。这和事件委托里closest()是同一个道理。- 依赖
[open],没展开就不绑—— 少一个常驻监听器。 - Escape 也要能关—— 这是可访问性的基本要求, 面试官经常拿它当加分项。
不写清理函数的后果和计时器那道题一样: 每次展开多绑一对监听器;组件卸载后监听器还在, 回调里 setOpen 会对已卸载的组件操作。测试里我直接 spy 了document.addEventListener 和removeEventListener, 断言两边次数相等—— 漏了清理这条会红。
Three steps:
- Use
useRefto get the DOM node of your own container. - Listen for
mousedownondocumentand useref.current.contains(e.target)to decide whether the click happened inside you. - Unbind in the cleanup function.
Four details decide whether this one is written well:
- Use
mousedown, notclick.clickgets weird if the DOM changes between press and release, andmousedownfires earlier, so closing feels faster. contains, note.target === ref.current— the user clicks a button inside, not the container itself. Same reasoning asclosest()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.
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-controls 和aria-labelledby 把 tab 和 panel 关联起来。写了这几行,题目就从「能跑」变成「像个前端写的」。
会追问:「键盘左右箭头切换怎么做?」—— 在 tablist 上监听keydown, 按 ArrowRight / ArrowLeft改 activeId, 并把非激活 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).
星级评分:两个状态叠出一个显示值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
onMouseLeaveon 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. Addaria-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.
动手做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.
四个空。第 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.
hover 预览 + 点击选中 + 再点清零。 检查器会查 ??、onMouseLeave 的位置和无障碍。
Hover to preview, click to pick, click the same star again to reset. The checker looks at ??, where onMouseLeave sits, and accessibility.
- 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
看答案之前,先确认你已经自己动手写过一遍。看懂别人的答案和自己写出来,是两种能力。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.
初学者常见的几种写法错误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.
要用
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.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.
只存一个
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.换一道题也能用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.
- 点外面关掉三要素: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.
- 解绑必须用同一个函数引用,传新箭头函数解不掉且不报错。Removing a listener needs the same function reference; passing a new arrow function removes nothing and reports no error.
- Tabs 只需要一个 activeId,其余全是派生;ARIA 三件套别漏。Tabs needs one activeId and derives everything else; do not leave out the three ARIA attributes.
- 星级评分核心是 shown = hover ?? current;onMouseLeave 挂容器不挂每颗星。The core of a star rating is shown = hover ?? current; put onMouseLeave on the container, not on each star.
- 用 button 而不是 span,天然可聚焦可回车;受控判断用 !== undefined。Use a button rather than a span so it can be focused and used with Enter; test for controlled with !== undefined.