Dropdown:点外面要关掉Dropdown: close it on an outside click
题面The problem
先把要求读完,再动手。Read every requirement before you start.
四个空。第 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.
- 点触发器展开,选中后收起并把触发器文字换成选中项的 labelClicking the trigger opens the list. Picking an option closes it and puts that option's label on the trigger
- 点组件外面任何地方都要关掉;点组件内部不能关(useRef + node.contains)A click anywhere outside the component closes it. A click inside the component must not close it (useRef plus node.contains)
- 按 Escape 关闭,别的键不关Escape closes it. No other key closes it
- 卸载时必须解绑 document 上的监听器;没展开时不该挂监听器Remove the listener on document when the component unmounts, and do not attach one while the list is closed
- 触发器 aria-haspopup="listbox" + aria-expanded;列表 role="listbox",每项 role="option" + aria-selectedThe trigger has aria-haspopup="listbox" and aria-expanded. The list has role="listbox", and every item has role="option" plus aria-selected
预计 25 分钟。这个数字是照「读完题就开始写、不查资料」估的 —— 第一次超时很正常,第二次要压进去。Budget 25 minutes. Overrunning on the first pass is normal; the second pass should fit.
工作区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.
展开讲解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、星级评分。
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.
参考答案Reference solution
这道题没有配套的分级提示 —— 卡住了先看上面的讲解那一节。This problem has no graded hints. If you are stuck, read the walkthrough above first.
这份答案在本机真跑过测试。但先确认你自己动手写过一遍 —— 读懂答案和写出答案是两种能力,考场上考的是后一种。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.