設問とスコープ
検索APIが与えられたとき、再利用可能なオートコンプリートコンポーネントを設計してください。ユーザーが入力を停止してから300ミリ秒後に最大10件のサジェストをリクエストする必要があり、APIのp95レイテンシは400ミリ秒です。デスクトップとモバイルにおいて、キーボード、マウス、タッチ、スクリーンリーダー、IME入力をサポートし、素早いタイピング中に古いクエリの結果が表示されてはなりません。コンポーネントAPI、状態モデル、非同期リクエスト、アクセシビリティセマンティクス、キャッシング、エラーハンドリング、テストについて説明してください。
これらの数値は面接における前提条件です。基本スコープはフロントエンドコンポーネントです。サーバー側のランキング、スペル修正、パーソナライズ、複数選択、無限スクロールはスコープ外とします。結果はプレーンテキストまたはカスタムレンダリングされた行のいずれでも構いませんが、すべての結果は安定したIDと読み上げ可能なラベルを持つ必要があります。コンポーネントは、単一選択のサジェストリストを備えた編集可能なcomboboxパターンに従います。
この質問はフロントエンドおよびフルスタックの職種に適しています。そのコアスキルはブラウザUI、非同期状態、アクセシブルなインタラクションであるため、カテゴリは frontend です。既存のTrieの記事ではプレフィックス検索をデータ構造の問題として扱い、Top Kは発展課題としてのみ言及されています。ここでは検索APIは所与の依存関係であり、独立した課題はクライアント側の競合、フォーカスセマンティクス、IMEの挙動、そして検証可能なインタラクション規約です。
面接官が評価するポイント
第1の評価基準は、候補者がリクエスト削減と正当性(correctness)を区別しているかどうかです。300ミリ秒のデバウンスは連続入力中のリクエストを削減しますが、新しいリクエストの後に古いリクエストが返ってくることを防ぐことはできません。優れた回答では、キャンセル処理と単調増加するリクエストシーケンスを組み合わせ、現在のクエリに対する最新のリクエストのみが結果をコミットできるようにします。
第2の評価基準は、アクセシビリティセマンティクスが完全な規約を形成しているかどうかです。視覚的なスタイリングだけでは、input、ポップアップ、optionを関連付けることはできません。DOMフォーカスはinputに残しつつ、aria-activedescendant でアクティブなoptionを特定する必要があります。aria-expanded、aria-controls、aria-autocomplete、listbox、option、aria-selected は、可視状態と一貫して変化しなければなりません。
第3の評価基準は、テキスト入力の正確なモデル化です。中国語、日本語、その他のIMEは、1回の変換セッション中に複数の更新を発生させます。すべての途中文字列で検索を行うと無関係なリクエストが発生し、変換候補の選択を妨げる可能性があります。コンポーネントは変換状態を追跡し、compositionend の後に確定された値に対して検索をスケジュールする必要があります。
第4の評価基準は、状態とUIの一貫性が証明可能かどうかです。単一の loading ブール値と配列だけでは、短いクエリ、読み込み中、成功、空の結果、失敗、ポップアップが閉じている状態を明確に表現できません。優れた回答では、状態遷移、安定したoption ID、復帰挙動、結果変更後のアクティブなoptionのルールを定義し、それらの不変条件を順序が入れ替わったレスポンス、キーボード、スクリーンリーダーを用いてテストします。
回答前の確認事項
- サジェストが選択された後は何が起こるか? inputに値を入力するだけであれば、
onSelectを呼び出してリストを閉じます。即座に画面遷移する場合は、遷移失敗時や戻ったときのinputの復元に関する独自の規約が必要です。基本設計ではinputに値を入力し、コールバックを発火させます。 - inputの値を制御するのは誰か? フォームでは
valueとonValueChangeが必要な場合があります。独立した検索ボックスでは非制御の初期値をサポートすることもあります。コンポーネントはライフタイム中にモードを切り替えてはなりません。 - 結果はプレーンテキストか? リッチな結果には
renderItemが必要ですが、getKeyとgetLabelは依然として安定したIDとアクセシブルな名前を提供しなければなりません。任意のHTMLを受け入れるとインジェクションのリスクが高まります。 - 検索を開始する文字数はいくつか? 基本のデフォルトは2文字です。その閾値未満の場合は、保留中の処理をキャンセルし、リストを閉じ、アクティブなoptionをクリアします。空クエリで履歴を表示する場合は、異なる
aria-autocompleteとキャッシュポリシーが必要になります。 - Tabキーでアクティブなサジェストを選択するか? 基本設計では選択しません。Tabキーはウィジェットから抜けます。プロダクト側がTabキーでサジェストを確定することを強く求める場合、通常のフォーカス移動を暗黙的に上書きするのではなく、明示的なユーザーへの伝達と個別のテストが必要です。
- エラー発生後も古い結果を保持すべきか? この設計では結果をクリアし、再試行可能なエラーを表示します。これにより、ユーザーが古いクエリのサジェストを現在のクエリのものと誤認するのを防ぎます。オフラインファーストのプロダクトでは、古くなっている可能性があるとマークしたキャッシュエントリを表示することもありますが、それは別の規約となります。
30秒の回答フレームワーク
「カスタマイズ可能なレンダリングとヘッドレスな状態コントローラーを分離します。コントローラーはクエリ、リクエストステータス、ポップアップの状態、アクティブなoption ID、IME状態、最新のリクエストシーケンスを保持します。入力が確定された後、300ミリ秒のデバウンスを行い、直前のリクエストをキャンセルします。レスポンスがコミットされるには、最新のシーケンスと現在のクエリの両方に一致していなければなりません。inputはcomboboxセマンティクスを使用し、DOMフォーカスを維持します。矢印キーで aria-activedescendant を更新し、Enterで選択し、Escapeで閉じます。検索は変換の終了を待ち、個別のステータスメッセージで読み込み中、結果件数、結果なしをアナウンスします。これらをネットワークレスポンスの順序入れ替え、キーボードマトリクス、IME入力、スクリーンリーダー、キャッシュ無効化を用いて検証します。」
ステップごとの詳細解説
ステップ1: 境界と公開APIの定義
検索アルゴリズムはサーバーに属します。コンポーネントはクエリ関数とレンダリングポリシーを受け取ります。フレームワークに依存しないインターフェースは次のようになります:
Autocomplete<T>({
value,
onValueChange,
fetchSuggestions(query, signal),
getKey(item),
getLabel(item),
renderItem,
onSelect,
minChars = 2,
limit = 10,
debounceMs = 300,
})fetchSuggestions は AbortSignal を受け取るため、呼び出し元は1つのキャンセルチェーンをネットワークまで伝播させることができます。getKey は安定したoption DOM IDを提供し、getLabel は補完テキストとアクセシブルな名前の両方を提供します。カスタムレンダリングによってキーボード、フォーカス、または選択セマンティクスが損なわれてはなりません。コンポーネントが値変更のコールバックを公開する場合は、利用側が文字列の変更からユーザーの意図を推測しなくて済むよう、input、selection、clear などの構造化された理由を含めます。
ステップ2: 状態マシンによるレンダリングの制約
中核となる状態は以下のとおりです:
query status = idle | loading | success | empty | error items isOpen activeId selectedItem isComposing latestRequestSeq
query は現在の入力テキストであり、selectedItem は確定された選択項目です。これらは異なる事実であり、1つのフィールドを共有すべきではありません。リストが開くことができるのは、クエリが最小文字数を満たし、inputがまだインタラクティブなコンテキストにあり、ステータスに表示すべき結果またはフィードバックがある場合のみです。新しいクエリが開始されたときは activeId をクリアします。新しい結果が届いたときは、古いアクティブIDがまだ存在する場合にのみそれを保持し、それ以外の場合はクリアして、aria-activedescendant が存在しないノードを参照しないようにします。
empty と error は異なる状態です。空のレスポンスは有効な状態であり、エラーは再試行が可能です。ポップアップを閉じてもクエリやキャッシュを破棄する必要はありませんが、isOpen と activeId はリセットしなければなりません。
ステップ3: デバウンス、キャンセル、結果コミットの分離
入力イベントはまず同期的に 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ミリ秒のデバウンスとAPIの400ミリ秒のp95レイテンシにより、最後のキー押下から結果が表示されるまでのp95パスは、レンダリング前でおよそ700ミリ秒になります。この計算によりトレードオフが明確になります。体験を高速化する必要がある場合は、完全一致クエリのキャッシュを使用するか、リクエストコストを測定した上でデバウンス時間を短縮します。300ミリ秒のデバウンスでキャッシュなしの150ミリ秒のレスポンスが得られるなどと主張してはなりません。
ステップ4: IME、ポインタ、フォーカスの適切な処理
compositionstart は isComposing を true に設定します。変換中、input は表示テキストを更新しますが、検索のスケジュールは行いません。compositionend はフラグをクリアし、最終的に確定されたテキストに対して1回の検索をスケジュールします。変換中もキーボードイベントが発生して isComposing が報告されることがあるため、その間はEnterキーでサジェストを選択してはなりません。デスクトップの英語入力があらゆるシーケンスを代表していると思い込まず、対象ブラウザでフレームワークのイベントラッパーをテストしてください。
マウスおよびタッチによる選択では、clickイベントが実行される前にinputのblurが発生し、クリックされたoptionがDOMから削除されてしまう可能性を考慮しなければなりません。主ボタンの pointerdown でinputのフォーカスを維持しつつ、click または移動閾値内に収まった pointerup によって、1つの共通パスを通じて選択を完了させます。リストのスクロールによって通常のポインタ移動が選択として扱われてはなりません。外側のクリックでポップアップを閉じつつ、選択コールバックは厳密に1回だけ実行されるようにします。
DOMフォーカスはinputに残します。ポップアップのoptionはTabキーのフォーカス遷移から除外され、Tabキーはブラウザのデフォルトに従ってウィジェットから抜けます。これにより、ネイティブのテキスト編集挙動と支援技術の入力モードが安定した状態に保たれます。
ステップ5: comboboxとキーボード規約の実装
inputには視覚的な label を持たせるか、aria-labelledby または aria-label 経由で名前を付与します。inputは role="combobox"、aria-autocomplete="list"、ポップアップと同期する aria-expanded、リストを指す aria-controls、およびアクティブなoptionが存在する場合にのみ aria-activedescendant を使用します。
サジェストコンテナには role="listbox" を使用します。各項目には role="option" と安定したDOM IDを付与し、視覚的にアクティブな項目にはさらに aria-selected="true" を付与します。下矢印キーで最初のoptionをアクティブにし、その後下方向に移動します。上矢印キーは逆方向に移動し、その間DOMフォーカスはinputに残ります。基本設計では端でループさせずに停止します。Enterキーはアクティブなoptionを確定します。Escapeキーはポップアップを閉じますが、入力テキストは保持します。
Left、Right、Home、End、Backspace、または印刷可能文字を無条件にインターセプトしてはなりません。編集可能なcomboboxは、ブラウザのネイティブな単一行編集挙動を維持すべきです。コンポーネントがリストの移動、選択、または破棄を実際に処理する場合にのみ preventDefault() を呼び出します。
結果リストは、更新のたびに高優先度のライブリージョン(live region)にする必要はありません。個別の role="status" 要素を使用して、フォーカスを移動させずに「検索中」「10件の結果」「結果なし」をアナウンスします。キー押下のたびにアナウンスを行うと、ウィジェットが過剰におしゃべりになってしまいます。
ステップ6: キャッシュの制限と障害復旧
キャッシュキーには、少なくとも正規化されたクエリ、ロケール、フィルター、データバージョンを含めます。クエリのみでキャッシュすると、ロケールやフィルターの変更後に誤ったデータが返されます。TTLと容量制限の両方を備えた完全一致クエリキャッシュを使用します。キャッシュヒット時は即座にレンダリングし、プロダクトの鮮度ポリシーで必要な場合にのみバックグラウンドでリフレッシュを行います。
キャッシュされたレスポンスであっても、現在のクエリおよびリクエストシーケンスのチェックを通過させる必要があります。inputのクリア、結果の選択、コンポーネントのアンマウント、依存関係の変更が発生した場合は、タイマーとリクエストをキャンセルします。サーバーエラー時は小さな再試行アクションを表示し、サジェストリストの選択可能な行にはしません。再試行により新しいリクエストシーケンスが生成されるため、古いエラーがその後の成功を上書きすることはありません。
マッチ箇所をハイライトする際は、サニタイズされていないサーバーのマークアップではなく、テキストノードまたは信頼できる構造化フラグメントを使用します。クエリパラメータを正しくエンコードし、本当に必要な場合を除き機密性の高いクエリ全体をログに記録しないようにします。
ステップ7: 敵対的シナリオによる不変条件の検証
クロックを制御したユニットテストにより、299ミリ秒ではリクエストが発生せず、300ミリ秒で1回のリクエストが発生し、入力を続けると直前のタイマーがキャンセルされ、2文字未満のクエリでは状態がリセットされることを証明します。状態テストでは成功、空の結果、エラー、再試行、閉じる、選択を網羅します。
競合テストでは、a に対する遅いレスポンスが ab に対する速いレスポンスの後に届く状況をシミュレートし、最終的なリストに ab の結果のみが含まれることを確認します。アボート可能なリクエスト、すでに完了したリクエスト、キャンセルシグナルを無視するデータレイヤーの3つのパスを個別にテストします。
インタラクションテストでは、矢印キー、Enter、Escape、Tab、クリック、タッチ、外側クリック、IME変換、および結果変更後にアクティブなoptionが消失するケースをカバーします。各ステップで、可視状態、inputの値、コールバックの発火回数、DOMフォーカス、ARIA属性をまとめてアサートします。
自動化されたアクセシビリティスキャンで捕捉できるのは規約の一部にすぎません。キーボードと対象のスクリーンリーダーを使用して、入力、読み込み、結果のアナウンス、選択、空の結果、エラー復旧を完遂します。サポート対象のモバイルブラウザでタッチ操作とソフトウェアキーボードをテストします。パフォーマンステストでは、最後のキー押下から最初のインタラクティブな結果までの分布、リクエストキャンセル率、キャッシュヒット率、破棄された古いレスポンスの数を記録します。
質の高い模範解答
「これをフロントエンドの単一選択サジェストコンポーネントとしてスコープを定めます。検索とランキングは既存のAPIが担当します。コンポーネントは制御された値、クエリ関数、安定したキー、読み上げ可能なラベル、カスタム行レンダラー、選択コールバックを受け取ります。内部的には、クエリテキスト、選択されたオブジェクト、リクエストステータス、ポップアップの状態、アクティブなoption、IME状態、リクエストシーケンスを個別に保持し、空の結果、エラー、閉じたポップアップが単一のブール値にまとめられないようにします。
inputは即座に更新されます。2文字が入力され変換が終了すると、300ミリ秒のデバウンスを行い、直前のリクエストをキャンセルして、シーケンス番号付きのリクエストを開始します。レスポンスがコミットできるのは、そのシーケンスが依然として最新であり、そのクエリが現在のinputと一致している場合のみです。キャンセルは処理を節約し、シーケンシングは正当性を保証します。300ミリ秒と400ミリ秒という前提では、キャッシュなしのp95パスは約700ミリ秒になるため、実際のレイテンシとリクエストコストに基づいてデバウンス時間を決定すべきです。
セマンティクスとしては、名前付きのinputはlistboxを制御するcomboboxとなります。DOMフォーカスはinputに残し、矢印キーで安定した aria-activedescendant を変更し、Enterで選択し、Escapeで閉じ、Tabは通常通り抜けます。optionは option と aria-selected を使用し、個別のステータス領域で読み込み中、結果件数、結果なしをアナウンスします。検索とEnterによる選択は、いずれも変換中は一時停止されます。
キャッシュはTTLと容量で制限され、正規化されたクエリ、ロケール、フィルターをキーとします。古い低速レスポンスと新しい高速レスポンスの競合、IME、キーボードおよびスクリーンリーダーの挙動、ポインタのblur、空の結果、再試行、キャッシュ無効化を検証します。成功の基準は、表示されるすべての結果が現在のクエリに属しており、すべての遷移においてフォーカスとアクセシビリティ状態が視覚状態と一致していることです。」
よくある落とし穴
- デバウンスしか行わない → デバウンスはリクエストを減らしますが、レスポンスの順序までは保証しません → キャンセル処理を追加し、最新シーケンスと現在のクエリの両方に基づいてコミットを制御します。
abort()の呼び出ししか行わない → 古い処理がすでに完了している可能性や、データレイヤーがシグナルを無視する可能性があります → キャンセルは最適化として扱い、シーケンスチェックを正当性の条件とします。- 配列のインデックスをアクティブな項目として使用する → 並べ替え後に同じインデックスが別の結果を指す可能性があります → 安定した結果IDを使用し、リスト置換後もそれが存在することを確認します。
- DOMフォーカスを各optionに移動させる → input、テキスト編集、支援技術のフォーカスが乖離する可能性があります → フォーカスはinputに維持し、
aria-activedescendantを通じてアクティブな項目を公開します。 - すべてのキーボードイベントをインターセプトする → ネイティブのHome、End、矢印キー、IMEの編集挙動が壊れます → コンポーネントがリストのナビゲーション、選択、破棄のために実際に使用するキーのみをインターセプトします。
- IMEの更新ごとに検索を実行する → 未確定の仮名テキストで無駄なリクエストが発生し、Enterキーで誤ってサジェストを選択してしまう可能性があります → 変換セッションを追跡し、変換が終了した後にのみ検索を実行します。
- 結果リスト全体を緊急アナウンスにする → 入力更新のたびに冗長な音声読み上げが発生します → 読み込み中、件数、空の結果には控えめな
role="status"メッセージを使用します。 - クエリ単体でキャッシュする → ロケールやフィルターの変更時に不正なデータにヒットする可能性があります → 結果に影響を与えるすべての条件とバージョンをキーに含め、TTLと容量制限を設定します。
発展課題と対応方法
発展課題1: リストに1,000件の結果を表示する必要がある場合は何が変わるか?
まず要件に疑問を投げかけます。オートコンプリートは通常、関連性の高い少数のサジェストをランク付けして制限すべきです。大規模なセットの閲覧が必須である場合は、ウィンドウイング(仮想スクロール)を追加します。アクティブなoptionはマウントされた状態を維持するか、aria-activedescendant を更新する前にマウントされたウィンドウ内にスクロールする必要があります。この属性は仮想化によって消去されたノードを参照することはできません。意味のある位置情報と総サイズセマンティクスを公開し、フレームレートの測定だけでなくスクリーンリーダーで検証します。
発展課題2: 複数のページでリクエストとキャッシュエントリを共有するにはどうすればよいか?
コンポーネントは fetchSuggestions のみを消費する状態を保ちつつ、キャッシュストレージとリクエストの重複排除をページレベルのデータレイヤーに移動します。共有キーにはロケール、フィルター、認証スコープ、データバージョンを含めます。同一キーへの呼び出しは同一のPromiseを共有できますが、2つのinputで現在のクエリやアンマウントのタイミングが異なる可能性があるため、各コンポーネントは独自のリクエストシーケンスを保持します。
発展課題3: ローカル履歴と空クエリサジェストをどのように追加するか?
各ソースを history または remote としてマークし、削除、プライバシー、デバイス間同期を定義します。空クエリでの履歴は、入力されたテキストの補完とは異なる状態であるため、明示的な aria-autocomplete、見出し、アナウンスポリシーが必要です。履歴の選択も安定したID規約に従う必要があり、機密性の高い検索内容はデフォルトで永続化してはなりません。
発展課題4: サーバーレンダリングとハイドレーションではどのように動作するか?
サーバーはlabelと空のinputをレンダリングし、クライアントがインタラクティブなポップアップを担当します。input、リスト、optionのIDはサーバーとクライアント間で安定していなければなりません。初回レンダリング時のランダムなIDは、ハイドレーション後に aria-controls を破壊する可能性があります。サーバーがサジェストを事前ロードする場合は、キャッシュキー、クエリ、データバージョンを一緒にシリアライズし、3つすべてが一致している場合にのみペイロードを再利用します。
発展課題5: 複数選択チップをサポートするためにコンポーネントをどのように変更するか?
これはフォーカス、削除、選択された項目のセマンティクスを変更します。selectedItem を配列に置き換えるだけでは不十分です。inputとチップ間の左右の移動、Backspaceによる確認、重複、最大件数、スクリーンリーダーのアナウンスを定義します。サジェストリストはcomboboxのままで構いませんが、選択されたチップには独自のナビゲーション可能領域と、新たなキーボードおよび支援技術のテストパスが必要です。