题干与适用场景
给定一个搜索 API,设计一个可复用的自动补全组件:用户停止输入 300 毫秒后请求最多 10 条建议,API p95 为 400 毫秒;组件需要在桌面端和移动端支持键盘、鼠标、触摸、屏幕阅读器与 IME 输入,快速连续输入时不得展示旧查询结果。请说明组件 API、状态模型、异步请求、无障碍语义、缓存、错误处理和测试方法。
这些数字是面试假设。基础范围只负责前端组件,服务端搜索排序、拼写纠正、个性化、多选和无限滚动不在本题内。结果可以是文本或自定义渲染行,但每条结果必须有稳定 ID 和可读标签。组件采用可编辑输入框加单选建议列表的组合框模式。
这道题适合前端和全栈岗位,核心考察能力是浏览器 UI、异步状态与无障碍交互,因此归入 frontend。现有 Trie 题只讨论前缀数据结构和 Top-K 追问;本题把搜索 API 当作已知依赖,重点是客户端竞态、焦点语义、IME 与可验证的交互契约。
面试官考察点
第一个信号是能否把“减少请求”和“保证正确”分开。300 毫秒防抖能减少连续输入时的请求数,却不能阻止旧请求比新请求晚返回。强回答会同时使用请求取消和单调递增的请求序号,只有仍对应当前查询的最新请求可以提交结果。
第二个信号是无障碍语义是否形成完整契约。输入框、弹层和选项不能只靠视觉样式关联。DOM 焦点应留在输入框,通过 aria-activedescendant 指向当前选项;aria-expanded、aria-controls、aria-autocomplete、listbox、option 和 aria-selected 必须随状态一致更新。
第三个信号是是否理解文本输入。中文、日文等 IME 会在一次组合会话中产生多次更新;每次中间字符串都发请求,会得到无意义结果并干扰候选词选择。组件需要记录组合状态,在 compositionend 后再针对提交值安排搜索。
第四个信号是状态和 UI 是否可证明一致。普通回答常用一个 loading 布尔值和一个数组,难以表达“查询过短、加载、成功、空结果、失败、已关闭”。强回答会定义状态转移、稳定选项 ID、错误恢复和每次结果替换后的活跃项规则,并用乱序响应、键盘和屏幕阅读器测试这些不变量。
回答前需要澄清的问题
- 选择建议后发生什么? 如果只是回填文本,组件调用
onSelect并关闭列表;如果选择会立即导航,则要明确导航失败和返回页面后的输入恢复。基础方案只回填并回调。 - 输入值由谁控制? 表单需要外部控制时,公开
value与onValueChange;独立搜索框可以提供非受控初值。两种模式不能在组件生命周期中互换。 - 结果是否只含纯文本? 富结果需要
renderItem,但仍必须由getKey和getLabel提供稳定 ID 与可访问名称。渲染任意 HTML 会增加注入风险。 - 最少输入几个字符? 基础方案默认 2 个字符;不足时取消待处理请求、关闭列表并清空活跃项。如果产品要求空查询展示历史记录,那是另一种
aria-autocomplete和缓存策略。 - Tab 是否选择当前建议? 基础方案不把 Tab 改成选择键,Tab 离开控件。若产品坚持“Tab 接受建议”,必须明确告知用户并单独测试,不能悄悄破坏标准焦点移动。
- 错误是否允许保留旧结果? 本题失败时清除旧结果并显示可重试状态,避免把旧查询建议误认成当前结果。离线优先产品可以展示带“可能已过期”标记的缓存,但需要不同契约。
30 秒回答框架
“我会把组件拆成可定制渲染层和无头状态控制器。控制器保存查询、请求状态、列表开关、当前选项 ID、IME 状态和最新请求序号。输入提交后防抖 300 毫秒,取消上一个请求;响应回来时再检查序号和当前查询,旧响应直接丢弃。输入框使用组合框语义,DOM 焦点不离开输入框,方向键只更新 aria-activedescendant,Enter 选择,Escape 关闭。组合输入结束后才搜索,并用独立状态消息播报加载、结果数量和空结果。最后用乱序网络、键盘矩阵、IME、屏幕阅读器和缓存失效测试证明行为。”
分步骤深入解答
第一步:先定义组件边界和公开 API
搜索算法属于服务端,组件只接收查询函数和渲染策略。一个框架无关的接口可以表达为:
Autocomplete<T>({
value,
onValueChange,
fetchSuggestions(query, signal),
getKey(item),
getLabel(item),
renderItem,
onSelect,
minChars = 2,
limit = 10,
debounceMs = 300,
})fetchSuggestions 接收 AbortSignal,让调用方沿着同一取消链终止网络请求。getKey 生成稳定的选项 DOM ID,getLabel 提供回填文本和可访问名称。自定义渲染不能改变键盘、焦点和选择语义。组件公开状态回调时应传结构化原因,例如 input、selection 或 clear,避免使用方从字符串变化猜测用户动作。
第二步:用状态机约束渲染
核心状态包括:
query status = idle | loading | success | empty | error items isOpen activeId selectedItem isComposing latestRequestSeq
query 是输入框当前文本,selectedItem 是已确认选择,两者不能混为一个字段。列表打开必须满足输入达到最小长度、输入框仍在交互上下文中,并且状态允许显示结果或反馈。新查询开始时将 activeId 清空;新结果到达后,只有之前的活跃 ID 仍在新列表中才可保留,否则清空,防止 aria-activedescendant 指向不存在的节点。
empty 与 error 也要有独立状态。空结果是有效响应,错误则可以重试。关闭弹层不必销毁查询或缓存,但必须把 isOpen 和 activeId 复位。
第三步:把防抖、取消和结果提交分成三层
输入变化时先同步更新 query。如果正在组合输入、查询不足 2 个字符或只含空白,就清除定时器、终止当前请求并复位列表。其余查询在 300 毫秒后执行。请求流程可以写成伪代码:
async function search(rawQuery) { const query = normalize(rawQuery) const seq = ++latestRequestSeq
controller?.abort() controller = new AbortController() setStatus("loading")
try { const items = await fetchSuggestions(query, controller.signal) if (seq !== latestRequestSeq || query !== normalize(currentQuery)) return commit(items.slice(0, 10)) } catch (error) { if (isAbort(error)) return if (seq === latestRequestSeq && query === normalize(currentQuery)) { commitError(error) } } }
AbortController 可以终止尚未完成的 Fetch 和响应体读取,减少无用工作;请求序号才是提交结果的正确性门闩。旧请求可能已经完成,调用方也可能使用不完全遵守取消信号的数据层,因此仅调用 abort() 不足以证明旧结果不会覆盖新结果。
300 毫秒防抖加 400 毫秒 API p95,在不计渲染时,从最后一次按键到结果出现的 p95 路径约为 700 毫秒。这个推导说明防抖值是体验与请求量的权衡。如果目标要求更快,优先利用精确查询缓存或缩短防抖,而不是声称 300 毫秒防抖仍能带来 150 毫秒结果。
第四步:正确处理 IME、指针和焦点
compositionstart 把 isComposing 设为真;组合期间的 input 只更新可见文本,不安排搜索;compositionend 把状态设为假,并对最终提交文本安排一次搜索。键盘事件在组合期间仍可能出现,且 isComposing 为真,所以 Enter 不能在此时误选建议。框架封装的事件顺序需要在目标浏览器中实测,不能只依赖桌面英文输入。
鼠标和触摸选择要防止输入框先因 blur 关闭列表,导致点击目标消失。可以在选项的主 pointerdown 阶段保留输入焦点,再在 click 或没有越过移动阈值的 pointerup 中统一完成选择。滚动列表时不能把普通指针移动误判为选择。点击组件外部关闭弹层,但选择回调只运行一次。
焦点始终停留在输入框。弹层选项不进入 Tab 顺序;Tab 按浏览器默认行为离开控件。这样文本编辑键和屏幕阅读器的输入模式都保持稳定。
第五步:实现组合框语义和键盘契约
输入框有可见 label,或使用 aria-labelledby/aria-label 提供名称。它使用 role="combobox"、aria-autocomplete="list"、随弹层变化的 aria-expanded、指向列表的 aria-controls,以及仅在有活跃选项时存在的 aria-activedescendant。
建议容器使用 role="listbox",每项使用 role="option" 和稳定 DOM ID;当前视觉选项同步设置 aria-selected="true"。Down Arrow 把作用中项设为第一项,随后向下移动;Up Arrow 反向移动,DOM 焦点仍留在输入框。基础方案到边界后停住,不循环。Enter 接受当前项,Escape 只关闭弹层并保留输入文本。
不要无条件拦截 Left、Right、Home、End、Backspace 和可打印字符。可编辑组合框应尽量保留浏览器原生单行文本编辑行为。只在实际处理列表移动、选择或关闭时调用 preventDefault()。
结果列表本身不需要因为每次更新就变成高优先级 live region。另设一个 role="status" 区域,节制地播报“正在搜索”“返回 10 条结果”或“没有结果”。状态消息可以在不移动焦点时被辅助技术识别;连续每个按键都播报会使组件过度嘈杂。
第六步:设计有边界的缓存和失败恢复
缓存键至少包含规范化查询、语言、过滤条件和数据版本。只按查询字符串缓存会在切换语言或筛选条件后返回错误结果。使用有容量上限和 TTL 的精确查询缓存;命中时可以立即展示,并按产品新鲜度要求决定是否后台刷新。
缓存响应也必须经过当前查询与请求序号检查。清空输入、选择结果、组件卸载或依赖条件变化时,取消定时器和请求。服务端错误展示轻量重试入口,不把错误文本塞进建议列表当作可选项。重试创建新请求序号,旧错误不能覆盖后续成功。
渲染高亮片段时使用文本节点或经过可信处理的片段,不能把服务端标签直接放进未净化 HTML。查询参数需要正确编码;日志不得记录不必要的完整敏感查询。
第七步:用对抗场景验证不变量
单元测试使用可控时钟验证:299 毫秒不请求,300 毫秒触发一次;继续输入会取消旧定时器;不足 2 个字符时复位。状态测试覆盖成功、空结果、错误、重试、关闭和选择。
竞态测试让查询 a 的慢响应在 ab 的快响应之后到达,最终只能显示 ab 的结果。再分别测试请求可取消、请求已经完成和数据层忽略取消信号三种路径。
交互测试覆盖方向键、Enter、Escape、Tab、点击、触摸、外部点击、组合输入以及结果替换后活跃项消失。每一步都同时断言可见状态、输入值、回调次数、DOM 焦点和 ARIA 属性。
自动化无障碍扫描只能发现部分语义问题。至少要用键盘和目标屏幕阅读器走完输入、加载、结果播报、选择、空结果与错误恢复,并在实际支持的移动端浏览器测试触摸和软键盘。性能测试记录最后按键到首个可交互结果的分布、请求取消率、缓存命中率和旧响应丢弃数。
高质量示范回答
“我先把范围限制为前端单选建议组件,搜索和排序由已有 API 负责。组件接受受控值、查询函数、稳定键、可读标签、自定义行渲染和选择回调。内部把查询文本、已选对象、请求状态、弹层、活跃选项、IME 状态和请求序号分开保存,所以空结果、错误和关闭不会混成一个布尔值。
输入先同步显示。达到 2 个字符并结束组合输入后,我防抖 300 毫秒,取消上一个请求,再启动带序号的新请求。响应只有在序号仍是最新且查询仍匹配输入时才能提交;取消用于节省工作,序号用于保证正确。300 加 400 毫秒意味着无缓存的 p95 路径约 700 毫秒,所以要用真实延迟决定防抖,而不能只追求少发请求。
语义上,输入框是带名称的 combobox,控制 listbox。DOM 焦点留在输入框,方向键改变稳定的 aria-activedescendant,Enter 选择,Escape 关闭,Tab 正常离开。选项用 option 和 aria-selected,独立的 status 区域播报加载、结果数和空结果。IME 期间不搜索也不处理 Enter 选择。
缓存按规范化查询、语言和筛选条件设 TTL 与容量上限。最后我会用慢旧响应覆盖快新响应、IME、键盘与屏幕阅读器、指针失焦、空结果、错误重试和缓存失效来验证;成功标准是任何时刻显示的结果都属于当前查询,焦点与可访问状态也和视觉状态一致。”
常见错误
- 只做防抖 → 防抖减少请求却不规定响应顺序 → 增加取消,并用最新请求序号与当前查询共同守住结果提交。
- 只调用
abort()→ 旧操作可能已经完成或数据层忽略信号 → 把取消当优化,把序号检查当正确性条件。 - 用数组下标作为活跃项 → 结果重排后同一下标可能指向另一条建议 → 使用稳定结果 ID,并在替换列表后验证 ID 仍存在。
- 把 DOM 焦点移进每个选项 → 输入、文本编辑和辅助技术焦点容易失步 → 焦点留在输入框,通过
aria-activedescendant表达活跃项。 - 拦截所有键盘事件 → Home、End、方向键和输入法的原生编辑被破坏 → 只拦截组件确实处理的列表导航、选择与关闭键。
- 组合输入期间逐字搜索 → 未提交的拼音或假名产生错误请求,Enter 还可能误选 → 记录组合会话,结束后再搜索。
- 把结果列表整体设成强提醒 → 每次输入都触发冗长播报 → 用节制的
role="status"播报加载、数量和空结果。 - 缓存只用查询字符串 → 语言或筛选条件变化后命中错误数据 → 把所有影响结果的条件和版本放入缓存键,并设置 TTL 与容量边界。
追问及应对
追问一:如果要展示 1,000 条结果,如何做虚拟列表?
先质疑需求:自动补全通常应限制并排序少量高相关建议。确需浏览大量结果时再做窗口化。当前活跃选项必须保持挂载,或先滚动使其挂载后再更新 aria-activedescendant;不能让属性指向被虚拟化移除的节点。还要同步总数量与位置语义,并用屏幕阅读器验证,不能只看滚动帧率。
追问二:如何支持多个页面共享请求和缓存?
把缓存与请求去重放到页面级数据层,组件仍只消费 fetchSuggestions。共享键必须包含语言、筛选条件、权限范围和数据版本。同一键的并发调用可以共享 Promise,但每个组件仍保留自己的请求序号,因为两个输入框的当前查询和卸载时机不同。
追问三:如何加入本地历史记录和空查询建议?
把数据来源标记为 history 或 remote,并定义删除历史、隐私和跨设备同步规则。空查询时展示历史不属于“根据输入补全”的同一状态,需要单独决定 aria-autocomplete、标题和状态播报。历史项选择仍走同一个稳定 ID 与选择契约,敏感搜索不能默认持久化。
追问四:如何支持服务端渲染与 hydration?
服务端可以渲染标签和空输入框,交互弹层由客户端接管。输入框、列表和选项 ID 必须在服务端与客户端稳定一致;首屏不能用随机 ID 造成 hydration 后 aria-controls 失效。若服务端预载建议,缓存键、查询和数据版本必须一起序列化,客户端只在三者匹配时复用。
追问五:如何把单选改成多选标签输入?
这会改变焦点、删除键和已选项语义,不能只把 selectedItem 改成数组。需要定义输入与标签之间的左右移动、Backspace 删除确认、重复项、最大数量和屏幕阅读器播报。建议列表仍可使用组合框,但已选标签应成为独立可导航区域,并重新完成整套键盘与辅助技术测试。