題幹與適用情境
給定一個搜尋 API,設計一個可重用的自動完成元件:使用者停止輸入 300 毫秒後請求最多 10 筆建議,API p95 為 400 毫秒;元件需要在桌面與行動裝置支援鍵盤、滑鼠、觸控、螢幕閱讀器與輸入法組字,快速連續輸入時不得顯示舊查詢結果。請說明元件 API、狀態模型、非同步請求、無障礙語意、快取、錯誤處理和測試方法。
這些數字是面試假設。基本範圍只負責前端元件,伺服器端搜尋排序、拼字修正、個人化、多選和無限捲動不在本題內。結果可以是文字或自訂渲染列,但每筆結果都必須有穩定 ID 和可讀標籤。元件採用可編輯輸入框加單選建議清單的組合方塊模式。
這道題適合前端和全端職缺,核心考察能力是瀏覽器 UI、非同步狀態與無障礙互動,因此歸入 frontend。現有 Trie 題只討論前綴資料結構和 Top-K 追問;本題把搜尋 API 視為已知相依項目,重點是用戶端競態、焦點語意、輸入法組字與可驗證的互動契約。
面試官考察重點
第一個訊號是能否把「減少請求」和「保證正確」分開。300 毫秒 debounce 能減少連續輸入時的請求數,卻不能阻止舊請求比新請求晚回來。強回答會同時使用請求取消和單調遞增的請求序號,只有仍對應目前查詢的最新請求可以提交結果。
第二個訊號是無障礙語意是否形成完整契約。輸入框、浮層和選項不能只靠視覺樣式關聯。DOM 焦點應留在輸入框,透過 aria-activedescendant 指向目前選項;aria-expanded、aria-controls、aria-autocomplete、listbox、option 和 aria-selected 必須隨狀態一致更新。
第三個訊號是是否理解文字輸入。中文、日文等輸入法會在一次組字工作階段中產生多次更新;每個中間字串都發請求,會得到無意義結果並干擾候選字選擇。元件需要記錄組字狀態,在 compositionend 後才針對已提交的值安排搜尋。
第四個訊號是狀態和 UI 是否能證明一致。普通回答常用一個 loading 布林值和一個陣列,難以表達「查詢太短、載入、成功、空結果、失敗、已關閉」。強回答會定義狀態轉移、穩定選項 ID、錯誤復原和每次結果替換後的作用中項目規則,並用亂序回應、鍵盤和螢幕閱讀器測試這些不變條件。
回答前需要釐清的問題
- 選擇建議後會發生什麼? 如果只是回填文字,元件呼叫
onSelect並關閉清單;如果選擇會立即導頁,就要明確定義導頁失敗和返回頁面後的輸入復原。基本方案只回填並回呼。 - 輸入值由誰控制? 表單需要外部控制時,公開
value與onValueChange;獨立搜尋框可以提供非受控初始值。兩種模式不能在元件生命週期中互換。 - 結果是否只含純文字? 豐富結果需要
renderItem,但仍須由getKey和getLabel提供穩定 ID 與無障礙名稱。渲染任意 HTML 會增加注入風險。 - 至少輸入幾個字元? 基本方案預設 2 個字元;不足時取消待處理請求、關閉清單並清除作用中項目。如果產品要求空查詢顯示歷史紀錄,那是另一種
aria-autocomplete和快取策略。 - Tab 是否選取目前建議? 基本方案不把 Tab 改成選擇鍵,Tab 會離開元件。若產品堅持「Tab 接受建議」,必須明確告知使用者並單獨測試,不能悄悄破壞標準焦點移動。
- 錯誤時是否保留舊結果? 本題在失敗時清除舊結果並顯示可重試狀態,避免把舊查詢建議誤認成目前結果。離線優先產品可以顯示標記為「可能已過期」的快取,但需要不同契約。
30 秒回答框架
「我會把元件拆成可自訂的渲染層和無頭狀態控制器。控制器保存查詢、請求狀態、清單開關、目前選項 ID、輸入法組字狀態和最新請求序號。輸入提交後 debounce 300 毫秒,取消上一個請求;回應返回時再檢查序號和目前查詢,舊回應直接丟棄。輸入框使用組合方塊語意,DOM 焦點不離開輸入框,方向鍵只更新 aria-activedescendant,Enter 選取,Escape 關閉。組字結束後才搜尋,並用獨立狀態訊息播報載入、結果數量和空結果。最後用亂序網路、鍵盤矩陣、輸入法、螢幕閱讀器和快取失效測試證明行為。」
分步深入解答
第一步:先定義元件邊界和公開 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。
第三步:把 debounce、取消和結果提交分成三層
輸入變更時先同步更新 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 毫秒 debounce 加上 400 毫秒 API p95,在不計渲染時,從最後一次按鍵到結果出現的 p95 路徑約為 700 毫秒。這個推導說明 debounce 值是體驗與請求量的取捨。如果目標要求更快,優先利用精確查詢快取或縮短 debounce,而不是宣稱 300 毫秒 debounce 仍能帶來 150 毫秒結果。
第四步:正確處理輸入法、指標與焦點
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 負責。元件接受受控值、查詢函式、穩定鍵、可讀標籤、自訂列渲染和選擇回呼。內部把查詢文字、已選物件、請求狀態、浮層、作用中選項、輸入法組字狀態和請求序號分開保存,所以空結果、錯誤和關閉不會混成一個布林值。
輸入先同步顯示。達到 2 個字元並結束組字後,我 debounce 300 毫秒,取消上一個請求,再啟動帶序號的新請求。回應只有在序號仍是最新且查詢仍符合輸入時才能提交;取消用來節省工作,序號用來保證正確。300 加 400 毫秒表示無快取的 p95 路徑約 700 毫秒,所以要用真實延遲決定 debounce,不能只追求少發請求。
語意上,輸入框是有名稱的 combobox,控制 listbox。DOM 焦點留在輸入框,方向鍵改變穩定的 aria-activedescendant,Enter 選擇,Escape 關閉,Tab 正常離開。選項使用 option 和 aria-selected,獨立的 status 區域播報載入、結果數和空結果。輸入法組字期間不搜尋,也不處理 Enter 選擇。
快取依正規化查詢、語言和篩選條件設定 TTL 與容量上限。最後我會用慢舊回應覆蓋快新回應、輸入法、鍵盤與螢幕閱讀器、指標失焦、空結果、錯誤重試和快取失效來驗證;成功標準是任何時候顯示的結果都屬於目前查詢,焦點和無障礙狀態也與視覺狀態一致。」
常見錯誤
- 只做 debounce → debounce 減少請求卻不規定回應順序 → 增加取消,並用最新請求序號與目前查詢共同守住結果提交。
- 只呼叫
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 刪除確認、重複項目、最大數量和螢幕閱讀器播報。建議清單仍可使用組合方塊,但已選標籤應成為獨立可導覽區域,並重新完成整套鍵盤與輔助技術測試。