代表的な面接トピック

フロントエンド面接:古い検索レスポンスが最新の結果を上書きするのを防ぐにはどうすればよいか?

フロントエンド難しい
Offer.cc 編集チーム公開日 更新日

質問

ユーザーが「hello」と素早く入力すると、h、he、hel、hell、helloのリクエストがトリガーされ、これらは任意の順序で完了する可能性があります。キャンセル処理、キャッシュ、ローディング表示、エラー、アクセシブルなフィードバックを扱いながら、古いレスポンスが最新の結果を置き換えてしまうのをどのように防ぎますか?

プロンプトとコンテキスト

検索ボックスは、入力が変更されるたびにサジェストをリクエストします。ユーザーが hello と素早く入力すると、ブラウザは hhehelhellhello のリクエストを送信しますが、ネットワークはレスポンスがその順序で到着することを保証しません。古いリクエストが最後に完了し、hello の結果を hell で置き換えてしまう可能性があります。また、ユーザーが入力をクリアしたり、ネットワークを切り替えたり、ページから離脱したりすることもあります。

リクエストのライフサイクル、結果のコミットルール、キャンセル戦略、キャッシュと鮮度ポリシー、ローディングおよびエラー状態、アクセシブルなフィードバック、検証計画を設計してください。単に「デバウンス(debounce)を追加する」と言うだけでなく、表示されている結果がどのクエリを表しているかを説明してください。

これはシニアフロントエンド、React、およびUIインフラストラクチャの面接に適しています。Reactの公式ドキュメントでは、素早いタイピングを用いてデータ取得の競合状態(race condition)を説明し、Effectのクリーンアップ中に古いレスポンスを無視することを推奨しています。MDNでは、AbortController.abort() がfetch、レスポンスボディの消費、またはストリームを終了できると記載されています。web.devのstale-while-revalidateのガイダンスでは、許容可能な古い値を提供しながらバックグラウンドで更新するというキャッシュのトレードオフが追加されています。これらのソースはこのトピックの技術的代表性を裏付けるものですが、特定の企業の固定された質問や面接の頻度を確定するものではありません。中心となるスキルがブラウザ側の非同期状態、レンダリングの一貫性、インタラクションのフィードバックであるため、カテゴリは frontend です。

面接官が評価するポイント

第一に、候補者が「リクエストが完了すること」「リクエストがまだコミット対象として適格であること」を区別できるかどうかです。Promiseが最初に完了したからといって、それが現在のクエリを表しているとは限りません。すべてのリクエストには安定した識別子が必要であり、その識別子またはクエリキーがコミット時にも一致している必要があります。

第二に、キャンセルがリソース管理であることを理解しているかどうかです。AbortController は無駄な処理を削減できますが、サーバーがリクエストを処理した後にキャンセルが発生することもあります。アボートはビジネス的なロールバックではなく、古い結果に対する保護を代替することはできません。

第三に、完全なステートマシンをモデル化できるかどうかです。空の入力、初回読み込み、既存結果を表示した状態での更新、成功、空の結果、リトライ可能なエラー、古いキャッシュ、アンマウントのすべてに明示的なルールが必要です。新しいクエリの読み込み中に古い結果を保持するか、スケルトンを表示するかは、クエリのセマンティクスとユーザーに誤解を与えるリスクに依存します。

最後に、順序通りに到着する成功レスポンスのみをテストするのではなく、レスポンスの順序を制御し、高速入力、クリア、リトライ、キャッシュヒット、アンマウント、アクセシブルなアナウンスを網羅して動作を検証できるかどうかです。

最初に確認すべき明確化の質問

  • キーストロークごとに必ずリクエストをトリガーする必要がありますか? プロダクトが入力を一時停止するのを待つ場合、デバウンスは有用ですが、リクエスト数を減らすだけであり、すでに送信されたリクエスト間の競合は解決しません。
  • 古い結果を表示したままでもよいですか? サジェストであれば、前のクエリに属していることが明示されていれば更新中も残すことがよくあります。一方、財務の見積もりや権限の結果などは、誤った選択を防ぐためにクリアする必要がある場合があります。
  • 何がキャッシュキーを無効化しますか? クエリ文字列、フィルター、言語、アカウントスコープ、データバージョンがすべて影響する可能性があります。ページのURLだけでキャッシュすると、異なる権限やフィルターが混ざってしまう可能性があります。
  • サーバーはキャンセルや重複排除をサポートしていますか? クライアント側には依然として正確性を保つガードが必要です。サーバー側のキャンセルはリソース使用率を改善しますが、古いリクエストが影響を与えなかったことを証明するものではありません。
  • 入力欄にはスクリーンリーダー向けのリアルタイムフィードバックが必要ですか? 結果件数やローディング状態は、既存の status 領域をpoliteに使用できますが、1文字ごとにユーザーの読み上げを中断すべきではありません。

30秒の回答フレームワーク

「各クエリをリクエストキーで表現し、そのキーが現在のクエリと一致している場合にのみ、レスポンスによる結果の更新を許可します。クリーンアップ時には AbortController.abort() を呼び出してネットワークとボディ読み取りの処理を解放しますが、キャンセルをロールバックとしては扱いません。アボートが失敗した場合でも、識別子のチェックによって古いレスポンスは破棄されます。ユーザーの一時停止後にデバウンスをかけることでノイズは減りますが、競合保護の代わりにはなりません。状態としては、空、ローディング中、古い結果の更新中、成功、空の結果、リトライ可能なエラーを区別します。キャッシュには完全なクエリキーと明示的な鮮度ウィンドウを使用します。テストでは古いリクエストが新しいリクエストの後に返るように強制し、クリア、アンマウント、リトライ、キャッシュ、スクリーンリーダーへのフィードバックを網羅します。」

深掘り回答

1. 結果コミットの不変条件(Invariant)を定義する

currentKeystatusvisibleData、およびオプションのキャッシュを保持します。currentKey には、正規化されたクエリと、結果を変更するすべてのフィルター、言語、アカウントスコープが含まれます。各ネットワークリクエストには一意の requestId が割り当てられ、そのクロージャが自身のキーとコントローラーを保持します。

結果をコミットする前に、リクエストが無効化されていないこと、およびそのキーが現在のキーと一致していることを確認します。その後でのみ、表示データ、エラー、または成功状態を書き込むことができます。この不変条件は、ネットワークが順序を保証しないため、『最後のリクエストが通常最後に完了する』と想定するよりも安全です。

UIは、現在のクエリキー、最新の使用可能なキャッシュエントリ、アクティブなリクエスト、およびエラーの純粋な射影として扱います。複数のEffectが独立した loadingdataerror の値を同期させることは避けてください。そうしないと、クエリのクリアや素早い置換によって、古いエラーが新しい状態に書き込まれてしまう可能性があります。

2. デバウンス、スロットル、競合保護を切り離す

デバウンスは素早い入力を1つのリクエストにまとめ、サジェストに役立ちます。スロットルは時間枠内のリクエストを制限し、スクロールやモニタリングに役立ちます。どちらもリクエストの開始タイミングを制御するものであり、すでに送信されたリクエストが遅れて到着するのを防ぐものではありません。

250ミリ秒のデバウンスを設定していても、リクエストの通信中にユーザーが再度入力する可能性があります。そのため識別子のチェックを維持します。Reactの公式の例では、Effectのクリーンアップで ignore フラグを設定し、古いレスポンスが setResults を呼び出さないようにしています。連番のインクリメント、クエリキーの比較、または明示的なステートマシンでも同じルールを実装できます。

3. キャンセルを正確性の証明ではなく最適化として扱う

アクティブな各リクエストに固有の AbortController を付与します。クエリの変更、入力のクリア、コンポーネントのアンマウント、または代替リクエストの開始時には、abort() を呼び出します。AbortError は予期されたキャンセルとして処理し、ネットワーク障害として表示したり、新しいクエリの状態を上書きさせたりしないようにします。

シグナルがサーバーの処理を停止するには遅すぎる場合や、ブラウザ側の読み取りを停止するだけの場合もあります。「古いリクエストをキャンセルした」と「古いリクエストがサーバー側に何の影響も与えなかった」は異なる主張です。検索のGETは通常書き込みの副作用を持ちませんが、それでもrequestIdによるガードが必要です。不可逆な操作には明示的な冪等性(Idempotency)の契約が必要です。

4. 古い結果、スケルトン、キャッシュの選択

初回のクエリでデータがない場合は、ローディングプレースホルダーとアクセシブルなステータステキストを表示します。更新中は、安全であれば古い結果を保持し、それが属するクエリのラベルを付け、控えめな更新インジケーターを表示します。古いデータが誤った選択を引き起こす恐れがある場合は、クリアするか操作不能にします。

キャッシュキーには完全なクエリ入力を含める必要があります。キャッシュヒット時は即座にレンダリングし、バックグラウンドで再検証(revalidate)することができますが、古いデータや権限が変更されたデータが最新の事実として提示されないよう、時間、ソース、エラーを記録します。stale-while-revalidateは、まず許容可能な古い値を提供し、非同期で新しい値を取得します。プロダクト側で許容可能な鮮度ウィンドウを定義する必要があります。

5. エラー、空の結果、リトライの処理

空の結果は成功した状態であり、ネットワークエラーではありません。エラーは現在のキーにバインドし、古いクエリからのエラーは破棄します。リトライ可能なエラーは、クエリとバックオフ情報を保持します。リトライ時は、すでに古いとマークされたPromiseを再利用するのではなく、新しいrequestIdを作成します。

サーバーが401、権限スコープの変更、または無効なクエリ条件を返した場合は、無限にリトライするのではなくキャッシュをクリアまたは再検証します。ネットワーク回復時は現在のキーのみをリクエストし、回復イベントによってユーザーがすでにクリアしたクエリが再入力されないようにします。

6. キーボードと支援技術のフィードバックを維持する

結果が更新されても入力フォーカスを移動させてはいけません。古い結果がアンマウントされてもスクリーンリーダーがリスト全体を再読み込みしないよう、安定したリスト識別子を使用します。ローディング、結果件数、エラーは既存のpoliteな status 領域に配置し、意味のある状態変化のときのみ更新します。

キーボードユーザーは、ローディング中も入力の継続、キャンセル、または結果の選択ができる必要があります。結果が古い場合は、選択を適用する前にクエリキーが一致していることを確認します。色だけをローディングやエラーのシグナルにしてはならず、エラーテキストを入力欄やリストに関連付けます。

7. 制御された順序でステートマシンをテストする

トランスポートのテストダブルは、各リクエストを一時停止し、レスポンスを手動で解放できるようにすべきです。新しいリクエストの後に古いリクエストが成功するケース、新しい成功の後に古い失敗が届くケース、クリア後の古いレスポンス、キャッシュヒット後のバックグラウンド更新失敗、アンマウント後のレスポンス、AbortError、リトライ中の再入力などを網羅します。

各イベントの後に、現在のキー、表示結果、ステータス、キャッシュのタイムスタンプ、アナウンスをアサートします。同一のペイロードが異なる順序で到着しても、レンダリングの重複やフォーカスの喪失が発生しないことを検証します。リクエスト数、キャンセル率、破棄された古いレスポンス、表示結果のレイテンシ、リトライ率を追跡しますが、誤った結果を隠すためにリクエスト数を減らすようなことはしません。

質の高い模範回答

「まず、テキスト、フィルター、言語、権限スコープを含むキーに完全なクエリを正規化します。実際のリクエストごとにrequestIdとAbortControllerを発行し、現在のキーを記録します。レスポンスが成功、空の結果、またはエラーをコミットする前に、『requestIdがまだアクティブであること』と『キーがまだ一致していること』の両方のチェックを通過する必要があります。入力変更時には古いコントローラーをアボートし、AbortErrorを静かに処理しますが、アボートによってサーバーの処理がロールバックされたとは見なしません。

ノイズを減らすため、250ミリ秒の一時停止後にリクエストを送信します。初回の読み込みではプレースホルダーを表示します。更新中は、安全な場合にのみ古い結果を保持し、前のクエリに属していることをラベル付けし、それ以外の場合はクリアします。キャッシュエントリは完全なキーを使用し、バックグラウンドでの検証前に短時間レンダリングされる場合があります。鮮度ウィンドウを超えたエントリはローディングのみを表示します。

ステートマシンは、空の入力、ローディング中、古いデータの更新中、成功、空の結果、リトライ可能なエラーを区別します。エラーはキーに関連付けられているため、遅れて届いた古いエラーは破棄されます。フォーカスは入力欄に留まり、結果の識別性は安定して保たれ、既存のpolite領域が1文字ごとではなく意味のある状態変化をアナウンスします。

そして、hellhello の後に返るケース、クリア後に古いレスポンスが届くケース、キャンセルが失敗するケース、キャッシュ更新が失敗するケース、アンマウント後に届くケース、リトライ中に再度入力するケースをテストで強制的に再現します。最終的なUIは、現在のキーによってのみ説明可能でなければなりません。」

よくある間違い

  • デバウンスのみに頼る → すでに送信されたリクエストが競合する可能性がある → requestIdまたはキーによるガードを維持する。
  • 最後に完了したレスポンスを表示する → ネットワークの完了順序はクエリの順序ではない → 現在のキーのみがコミットを許可される。
  • アボートをロールバックとして扱う → サーバーはすでに処理を完了している可能性がある → クリーンアップにはキャンセルを、正確性には識別子/バージョンのルールを使用する。
  • すべてのクエリで単一のキャッシュエントリを使用する → フィルター、言語、スコープが結果間で漏洩する可能性がある → 結果に関係するすべての入力をキーに含める。
  • 古いエラーに新しいクエリを上書きさせる → ユーザーに過去の失敗が表示されてしまう → エラーをrequestIdとキーにバインドする。
  • 読み込みごとにUIを真っ白にする → ユーザーが有用なコンテキストを失う → 誤解を招くデータのリスクに基づいて、古い結果を残すかスケルトンにするかを選択する。
  • キーストロークごとにアナウンスする → スクリーンリーダーが頻繁に中断される → ローディング、結果、エラーの意味のある変更のみをアナウンスする。
  • 順序通りの成功のみをテストする → 実際の競合状態が再現されない → レスポンス、キャンセル、クリア、アンマウント、リトライの順序を制御する。

フォローアップの質問と回答

ページネーションや無限スクロールにはキーだけで十分ですか?

フィルター、ソート、カーソル、またはページ番号をリクエストキーに含め、各ページをクエリのバージョンにバインドします。新しいクエリは古いページを破棄します。同じクエリに対して別のページを読み込む場合は前のページを表示したままでもかまいませんが、重複するカーソルや古いページが確定済みのリストを上書きしてはなりません。クライアント側のページ番号から順序を推測するのではなく、サーバーから提供された次のカーソルを使用します。

ユーザーがオフラインで入力し、再接続した場合は何が変わりますか?

検索では通常、過去のすべてのリクエストを永続化する必要はありません。現在の入力と最後に許容されたキャッシュを保持します。再接続時は現在のキーのみをリクエストし、オフライン時の古い処理は無視します。オフラインサジェストが製品要件である場合は、オフラインの結果がリアルタイムなものとして提示されないよう、キャッシュの経過時間、データのスコープ、鮮度を明示する必要があります。

React QueryやSWRだけでこれを解決できますか?

キャッシュライブラリは重複排除、キャッシュ、無効化、ライフサイクル管理を提供できますが、クエリキー、許容可能な鮮度、リトライ、インタラクションの状態を定義するのは依然としてプロダクト側です。ライブラリのキャンセル機能やデフォルトのstale timeだけでは、権限スコープ、誤認リスク、アクセシブルなフィードバックを決定できません。ライブラリの競合セマンティクスを検証し、クエリキーとUI状態にルールを組み込んでください。

検索が監査ログ付きのPOSTになった場合、キャンセルは安全ですか?

安全であると決めつけてはいけません。POSTはサーバー側の副作用を持つ可能性があり、クライアント側のアボートはトランザクションをロールバックしません。冪等性キー(Idempotency key)、明示的な送信ステータス、ステータス問い合わせエンドポイントを使用し、確定的な確認が得られた後にのみ成功を表示します。副作用のない複雑なクエリにPOSTを使用することは依然として合理的ですが、そのリトライおよびキャンセルの契約は明示的である必要があります。

公開情報ソース

関連する質問