代表的な面接トピック

フロントエンド面接:DebounceとThrottleはどのように実装するか?

フロントエンド普通
Offer.cc 編集チーム公開日 更新日

質問

debounce(fn, wait, options)およびthrottle(fn, wait, options)を実装してください。leadingおよびtrailing呼び出しをサポートし、最新の引数とthisの値を保持し、cancel()およびflush()を公開してください。連続入力、maxWait、タイマー遅延、コンポーネントのクリーンアップ、および決定論的テストが正しさにどのように影響するかを説明してください。

問題とスコープ

ブラウザ向けコード用のdebounce(fn, wait, options)throttle(fn, wait, options)を実装します。返される関数は、最新の引数とthisを保持し、最新の呼び出し結果を返し、cancel()flush()を公開する必要があります。オプションはleadingtrailing、およびdebounce用のmaxWaitです。

デフォルトのdebounceはtrailingエッジでのみ実行されます。leading呼び出しはバースト(連続呼び出し)の開始時に実行されます。leadingとtrailingの両方が有効になっている場合、単発の呼び出しはleadingエッジで1回実行されます。待機期間内の2回目の呼び出しは、最新の引数を使用してtrailing呼び出しを生成します。maxWaitは、連続するイベントストリームによって処理が無期限に延期されるのを防ぎます。Throttleは、waitサイズのインターバルあたり最大1回の呼び出しを許可し、leadingおよびtrailingの動作をサポートします。

この規約はブラウザの入力、スクロール、リサイズ、レンダリング、およびコンポーネントのライフサイクルによって駆動されるため、これはfrontendの質問です。同じユーティリティはNode.jsでも実行できますが、ランタイムを変更しても面接で評価される中核スキルは変わりません。それは、UIのタイミングセマンティクスを小さな状態マシンに変換する能力です。実装にはJavaScriptとO(1)の補助状態を使用します。

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

最初のシグナルは、候補者がタイマーを書く前に規約を定義しているかどうかです。「debounceは待ち、throttleは制限する」という説明だけでは、leadingの動作、単独のleading呼び出しがtrailingも実行するかどうか、どの引数が優先されるか、クリーンアップが何を意味するかは決まりません。優れた回答は、呼び出しタイムラインを作成し、これらの選択を明示します。

2番目のシグナルは状態の推論力です。maxWait、クロックの変動、戻り値、flush()が登場すると、単一のタイマー変数だけでは不十分になります。実装には、最後の呼び出し時刻、最後の実際の実行時刻、保留中の引数とレシーバ、タイマーハンドル、最新の結果が必要です。各要素は規約のルールに対応している必要があります。

3番目のシグナルはブラウザに関する判断力です。タイマーは最小遅延を提供するものであり、正確な締め切りではありません。ロングタスクやバックグラウンドでのスロットリングにより、コールバックの実行が遅れることがあります。requestAnimationFrame()を使用してスクロール処理をスロットルすると処理が描画と同期しますが、それ自体がイベント発生頻度を下げるわけではありません。場合によっては、scrollendIntersectionObserver、またはフレームワークのクリーンアップフックにより、汎用タイマーユーティリティが不要になることもあります。

最後に、面接官は視覚的な直感ではなくテストを確認します。実時間のスリープ(sleep)を使用すると、テストが遅く不安定(flaky)になります。優秀な候補者は、時間とタイマーを注入または差し替え、仮想クロックを進め、境界値、キャンセル、フラッシュ、連続入力に対する正確な呼び出しシーケンスをアサートします。

回答前に確認すべき質問

  • デフォルト値は何ですか? この回答では、debounceに{ leading: false, trailing: true }を、throttleに{ leading: true, trailing: true }を使用します。デフォルト値が異なると、単独呼び出しの動作やテストが変わります。
  • 両方のエッジが有効な場合はどうなりますか? 単一の呼び出しはleadingエッジでのみ実行されます。trailing呼び出しは、待機期間中に別の呼び出しが届いた場合にのみ発生します。これにより、単独のアクションの重複実行が回避されます。
  • 遅延呼び出しはどの引数とレシーバを使用しますか? 最後に保留された呼び出しの引数とthisを使用します。最初のイベントをキャプチャすると、オートコンプリートやリサイズ処理が古いデータで実行されてしまいます。
  • 入力は無限に続く可能性がありますか? 続く場合、trailingのみのdebounceは永久に実行されない可能性があります。maxWaitは、前回の実際の実行または現在のバーストの開始からの最大遅延を設定します。
  • cancel()flush()は何をすべきですか? Cancelは保留中の処理を削除し、バースト状態をリセットします。Flushは対象となる保留中のtrailing呼び出しを即座に実行してその結果を返します。後で重複して呼び出されてはなりません。
  • 正確な経過時間は必要ですか? ブラウザのタイマーは正確なスケジューリングを保証しません。規約は実行可能になる最も早いタイミングと順序を制御し、テストではスケジューラのノイズを排除するために仮想クロックを使用します。

30秒での回答

「まず、タイムラインを使用してleadingとtrailingのセマンティクスを定義します。Debounceはバーストをグループ化し、通常は追加の呼び出しがない状態でwaitミリ秒経過した後に呼び出されます。Throttleはバースト中の定期的な進行を保証します。最新の引数とレシーバ、最後の呼び出し時刻、最後の実際の実行時刻、1つのタイマー、最新の結果を保持します。

各呼び出し時に、今すぐ実行可能かどうかを判断します。そうでない場合は残りの待機時間をスケジュールします。後の呼び出しによってtrailingの締め切りが移動した可能性があるため、タイマーは実行可否を再確認します。maxWaitは枯渇(starvation)を防ぎます。throttleはmaxWaitwaitに固定した同じ状態マシンです。cancel()は保留中の状態をクリアし、flush()は保留中のtrailing呼び出しを1回実行します。境界値での呼び出し、leading+trailing、連続入力、キャンセル、フラッシュ、アンマウント時のクリーンアップを中心に、仮想クロックでテストします。」

ステップバイステップの詳細解説

ステップ1: 言葉をタイムラインに変換する

wait = 100ミリ秒とし、呼び出しがA@0B@40C@90D@220で発生すると仮定します。trailingのみのdebounceはC@190D@320を生成します。各呼び出しによって静止期間の締め切りが移動し、最新の引数が優先されます。leadingとtrailingの両方が有効なdebounceはA@0C@190D@220を生成します。Dは単独であるため、320で再実行されることはありません。

leadingとtrailingが有効なthrottleは、最終的な保留値を保持しながら、100ミリ秒のウィンドウあたり最大1回の呼び出しを生成します。最初のバーストでは、即座にAが実行され、trailing境界で最新の保留値であるCが実行されます。実際のブラウザにおける正確なタイムスタンプは概念上の境界より遅くなることがありますが、早くなることはありません。

このタイムラインから状態遷移が明らかになります。アイドル時の呼び出しがバーストを開始し、以降の呼び出しが保留データを置き換え、タイマーが実行または再スケジュールを行い、実行によって保留データはクリアされますが結果は保持されます。これらの遷移を最初に書き出すことで、ほとんどのオフバイワンエラーやtrailingの重複バグを防ぐことができます。

ステップ2: 明示的な状態マシンを1つ実装する

以下の実装は記載された規約に従っています。shouldInvoke()は最初の呼び出し、静止期間の境界、クロックの逆行、およびmaxWaitを処理します。remainingWait()はtrailingと最大待機の締め切りのうち、早い方を選択します。

js
function debounce(fn, wait, options = {}) {
  wait = Math.max(0, Number(wait) || 0);

  const leading = options.leading === true;
  const trailing = options.trailing !== false;
  const hasMaxWait = Number.isFinite(options.maxWait);
  const maxWait = hasMaxWait
    ? Math.max(wait, options.maxWait)
    : 0;

  let timerId;
  let lastArgs;
  let lastThis;
  let lastCallTime;
  let lastInvokeTime = 0;
  let result;

  function invoke(time) {
    const args = lastArgs;
    const receiver = lastThis;

    lastArgs = undefined;
    lastThis = undefined;
    lastInvokeTime = time;
    result = fn.apply(receiver, args);
    return result;
  }

  function shouldInvoke(time) {
    const sinceCall = time - lastCallTime;
    const sinceInvoke = time - lastInvokeTime;

    return lastCallTime === undefined
      || sinceCall >= wait
      || sinceCall < 0
      || (hasMaxWait && sinceInvoke >= maxWait);
  }

  function remainingWait(time) {
    const sinceCall = time - lastCallTime;
    const trailingWait = wait - sinceCall;

    if (!hasMaxWait) return trailingWait;

    const sinceInvoke = time - lastInvokeTime;
    return Math.min(trailingWait, maxWait - sinceInvoke);
  }

  function trailingEdge(time) {
    timerId = undefined;

    if (trailing && lastArgs) return invoke(time);

    lastArgs = undefined;
    lastThis = undefined;
    return result;
  }

  function timerExpired() {
    const time = Date.now();

    if (shouldInvoke(time)) return trailingEdge(time);

    timerId = setTimeout(timerExpired, remainingWait(time));
  }

  function leadingEdge(time) {
    lastInvokeTime = time;
    timerId = setTimeout(timerExpired, wait);
    return leading ? invoke(time) : result;
  }

  function cancel() {
    if (timerId !== undefined) clearTimeout(timerId);

    timerId = undefined;
    lastArgs = undefined;
    lastThis = undefined;
    lastCallTime = undefined;
    lastInvokeTime = 0;
  }

  function flush() {
    if (timerId === undefined) return result;

    clearTimeout(timerId);
    return trailingEdge(Date.now());
  }

  function debounced(...args) {
    const time = Date.now();
    const invokeNow = shouldInvoke(time);

    lastArgs = args;
    lastThis = this;
    lastCallTime = time;

    if (invokeNow) {
      if (timerId === undefined) return leadingEdge(time);

      if (hasMaxWait) {
        clearTimeout(timerId);
        timerId = setTimeout(timerExpired, wait);
        return invoke(time);
      }
    }

    if (timerId === undefined) {
      timerId = setTimeout(timerExpired, wait);
    }

    return result;
  }

  debounced.cancel = cancel;
  debounced.flush = flush;
  return debounced;
}

function throttle(fn, wait, options = {}) {
  return debounce(fn, wait, {
    leading: options.leading !== false,
    trailing: options.trailing !== false,
    maxWait: wait,
  });
}

状態はO(1)であり、ラッパーの各呼び出しはO(1)の処理を行います。コールバック自体のコストはユーティリティの計算量には含まれません。本番コードではメンテナンスされている実装をインポートすることもできますが、面接での価値はその規約を説明しテストできることにあります。

ステップ3: タイマーが再確認する理由を説明する

最初の呼び出しが100ミリ秒のタイマーをスケジュールし、その後90ミリ秒の時点で2回目の呼び出しが届いたとします。元のタイマーが無条件に100で実行された場合、静止期間はわずか10ミリ秒になってしまいます。そのため、timerExpired()sinceCallを再計算し、100ミリ秒の静止期間が経過していないことを確認して、残りの90ミリ秒をスケジュールします。

イベントごとにタイマーをクリアして再作成する方法も、trailingのみのより単純な実装としては有効です。再確認を行う状態マシンは、leading呼び出し、maxWait、戻り値、throttleセマンティクスもサポートするためにその複雑さを正当化しています。基本的なtrailing debounceのみが求められている場合は、より小さな実装を使用し、より多機能な規約にはより多くの状態が必要になると述べてください。

ステップ4: maxWaitで枯渇を防ぐ

オートコンプリートのクエリは通常、入力の中断を待つべきです。一方、テレメトリのバッファリングや自動保存は、入力が続いている間ずっと待機し続けるわけにはいきません。wait = 300ミリ秒とmaxWait = 1000ミリ秒の設定では、100ミリ秒ごとに呼び出しが繰り返されても、ランタイムのスケジューリング遅延の影響を受けつつ、約1000ミリ秒の最大境界ごとに少なくとも1回トリガーされます。

maxWaitはthrottleへの架け橋でもあります。これをwaitと同じ値に設定すると、連続呼び出しが発生しても1インターバルを超えて実行が延期されることはありません。実装を1つにまとめることで、エッジケースの動作において2つのタイマー状態マシンが乖離するのを防ぎます。この導出は実装上の選択であり、throttleの唯一の有効な定義ではありません。規約とテストが常に決定基準となります。

ステップ5: ライフサイクルと副作用を処理する

遅延された処理は、それをスケジュールしたUIよりも長く存続する可能性があります。コンポーネントはクリーンアップ時にcancel()を呼び出し、古いコールバックがアンマウント後の状態を更新したり、古いpropsを使用したり、ページ遷移後にリクエストを送信したりしないようにする必要があります。破棄前に保留中のテキストを確定する必要があるプロダクトの場合は、意図的にflush()を呼び出してからクリーンアップを行ってください。アンマウント時に毎回無言でデータを送信するような設計にはしないでください。

非同期検索をデバウンスすることはリクエストの生成頻度を制御するものであり、レスポンスの順序を制御するものではありません。リクエストが開始された後、より遅い古いレスポンスが新しい結果を上書きしてしまう可能性があります。debounceに加えて、AbortController、リクエスト世代の管理、または最新レスポンスのチェックを併用してください。レート制限と古いレスポンス対策は、それぞれ異なる障害モードを解決するものです。

ステップ6: 実際の用途に応じてブラウザプリミティブを選択する

入力停止後のバリデーションのように、確定した最終値のみが重要な場合はdebounceを使用します。ポインタやスクロール状態の定期的なサンプリングのように、途中の進行状況が重要な場合はthrottleを使用します。描画処理と視覚的な更新を同期させるにはrequestAnimationFrame()を使用しますが、これ自体がスクロールイベントの発生頻度を自動的に下げるわけではないことに留意してください。しきい値ベースの可視性判定にはIntersectionObserverを、特にスクロールの終了イベントが必要な場合はscrollendを使用します。

判断基準は動作に基づきます。中間状態を破棄するのか、定期的な進行を維持するのか、描画と同期させるのか、ブラウザ定義のしきい値を監視するのかです。習慣だけでプリミティブを選択すると、不要な処理が発生したり、ユーザーに見えるべき更新が隠れたりする可能性があります。

ステップ7: 仮想時間でテストする

Date.nowsetTimeoutclearTimeoutをフェイククロックに置き換えるか、テストランナーのフェイクタイマーを使用します。値と仮想タイムスタンプを記録し、各締め切りの直前および正確な締め切りの時刻まで進めます。実際の100ミリ秒のタイムアウトが正確に100ミリ秒で発火することをアサートしてはいけません。

最小限のテストマトリクスには、trailingのみのバースト、leadingのみのバースト、1回および複数回呼び出しにおけるleading+trailing、連続入力時のmaxWaitwaitちょうどでの呼び出し、最新の引数とレシーバ、結果の再利用、締め切り前のcancel、締め切り前のflush、連続したflush、wait=0、および別のラッパー呼び出しをスケジュールするコールバックが含まれます。UI統合の場合は、クリーンアップと古いネットワークレスポンスのテストも行ってください。

評価の高い模範回答

「コーディングの前にタイムラインを定義します。100ミリ秒のtrailing debounceの場合、0、40、90での呼び出しは、最後の引数を使用して190以降の最初の実行可能タイミングで1回の呼び出しを生成します。leadingとtrailingの両方を有効にしたバージョンは0と190で呼び出されますが、単独のleading呼び出しがtrailingエッジで重複することはありません。

保持する状態は、1つのタイマー、保留中の引数とレシーバ、最後の呼び出し時刻、最後の実際の実行時刻、および最新の結果です。タイマーは自身がまだ実行可能であると決めつけず、新しい呼び出しによって静止期間の締め切りが移動している可能性があるため再確認します。maxWaitは2つ目の締め切りを追加し、連続入力によってコールバックが枯渇しないようにします。ThrottleはmaxWaitwaitと等しく設定して同じ状態マシンを再利用します。

thisを保持し、前回の実行結果を返し、cancel()で保留中の状態をクリアし、flush()が最大1回のtrailing呼び出しを実行するようにします。コンポーネント内ではクリーンアップ時にcancelを呼び出します。検索処理では、古いレスポンスの遅延到着をdebounceでは防げないため、リクエストの中断やバージョニングを別途行います。ブラウザのタイマーは遅れる可能性があるため、これらすべてを仮想時間と正確な呼び出しシーケンスで検証します。」

よくある間違い

  • エッジの動作を定義する前にコーディングする → 妥当に見える異なる実装がそれぞれ別のテストで失敗する → 最初にデフォルト値とタイムスタンプ付きの呼び出しシーケンスを記述する。
  • 最初の引数を保持し続ける → 遅延されたコールバックが古い入力に対して動作する → 呼び出しごとに保留中の引数とレシーバを置き換える。
  • leading呼び出しの後に常にtrailing呼び出しを発火させる → 1回のクリックで2回のアクションが発生する → インターバル中に別の呼び出しが保留された場合にのみtrailingを実行する。
  • maxWaitなしで無限にリセットする → 連続ストリームによって自動保存やバッチ処理が枯渇する → 定期的な進行が必要な場合は最大締め切りを追加する。
  • タイマーの遅延を厳密な時間として扱う → ロングタスクやランタイムのスロットリングによりタイムスタンプのアサーションが失敗する → 最も早い実行可能タイミングとして扱い、仮想時間でテストする。
  • requestAnimationFrame()を汎用throttleとして使用する → スクロールイベントと同じ頻度で実行される可能性がある → 描画の同期に使用し、実行頻度の低減が必要な場合は別のインターバルを計測する。
  • クリーンアップを忘れる → 画面遷移やアンマウント後に遅延処理が実行される → ライフサイクルのクリーンアップ時にcancelを呼ぶか、プロダクトの仕様上必要な場合は明示的にflushする。
  • debounceが古い検索結果を防ぐと思い込む → すでに開始されたリクエストは順不同で解決される可能性がある → リクエストのキャンセルや最新リクエストの検証と組み合わせる。
  • 基本的な要求に対して完全な状態マシンを構築する → 不要なコードはバグの温床となる → 指定された最小限の規約を実装し、その後に拡張機能について説明する。

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

フォローアップ 1: wait境界ちょうどで呼び出しが届いた場合はどうなりますか?

順序モデルを定義します。決定論的なテストにおいて、同じ仮想タイムスタンプで古いタイマータスクが新しい呼び出しタスクより先に実行される場合、古いバーストがtrailingを実行し、新しい呼び出しが別のバーストを開始することがあります。新しい呼び出しが先に処理された場合、タイマーが再確認する前に保留状態を更新できます。ブラウザのタスクキューは、同時に発生した外部イベントを魔法のようにアトミックにするわけではありません。テストでは特定の順序をスケジュールし、実装は内部的に一貫している必要があります。

フォローアップ 2: なぜsetIntervalでthrottleを実装しないのですか?

インターバルは、追加の状態によって抑制しない限り、保留中の処理がない場合でも刻み続けます。また、leading、最終的なtrailing値、キャンセル、アイドル後の再開についての推論が難しくなります。実際の要求に基づいてスケジュールされるワンショットタイマーを使用することで、次の境界を明示的に制御できます。setIntervalも正確な規約があれば機能しますが、必要なすべてのセマンティクスを含めると決して単純にはなりません。

フォローアップ 3: trailingが無効な場合でもflushは実行されるべきですか?

実行対象となる保留中のtrailing処理が存在しないため、flush()fnを呼び出さずに最新の呼び出し結果を返します。これはタイマーの期限切れと同じtrailingゲートに従います。プロダクトが「オプションに関係なく強制実行」を必要とする場合は、それは別のAPIであり、異なる名前とテストを与える必要があります。

フォローアップ 4: クロックの巻き戻りはどのようにテストしますか?

クロックを注入し、その値をlastCallTimeより小さく変更します。sinceCall < 0の分岐は、莫大または負の残り時間をスケジュールするのではなく、その状態を実行可能として扱います。利用可能であれば単調増加クロック(monotonic clock)が望ましいですが、ブラウザのユーティリティ規約では間接的にランタイムクロックが使われることが多いため、この防御的な分岐によってラッパーの停止を防ぎます。

フォローアップ 5: プロジェクトにおいてこの実装の代わりにLodashを使用すべきなのはどのような場合ですか?

ドキュメント化されたセマンティクス、バンドル戦略、およびプロジェクトの依存関係ポリシーが合致している場合は、メンテナンスされているライブラリを使用してください。自作のユーティリティには独自の互換性テスト、レビュー、および所有権(保守責任)が必要です。面接でこれを実装することは思考力を証明するものであり、成熟した依存関係を再作成することが本番環境における最善の決定であることを証明するものではありません。

公開情報ソース

関連する質問