setTimeoutのスロットリングとは?止まる理由と回避策

setTimeoutのスロットリングとは?止まる理由と回避策

AIエージェントに、ブラウザ上のフォーム入力をまるごと任せてみました。

対象は React 製の SPA で、ページ内に JavaScript を注入して操作する構成。

途中まではするすると進むのに、あるところから急に「理由の分からない遅さ」に沈む

しかも入れたはずの値が消えたり、選んだはずと違う値が入っていたりする。

原因を切り分けていくと、まったく別々の犯人が3人いました。

React の再レンダー、タイマーのスロットリング、そして DOM 要素のリサイクル。

このうちいちばん見つけにくかったのが、非アクティブタブでのタイマー間引きでした。

同じところで止まっている人のために、症状と原因と対処をまとめておきます。

ハマり①:入力した値がReactに消される

最初にぶつかったのは、値が定着しないという症状。

セレクトやチェックボックスに書き込んだはずの値が、別の項目を触った瞬間にまっさらへ戻ってしまうのです。

不思議なことに、テキスト入力だけは残っていました。

理由は単純で、React の制御コンポーネントは表示の正解を DOM ではなく内部の状態側に持っているから。

DOM を直接書き換えても、次の再レンダーで状態のほうの値に上書きされます。

つまり必要なのは、DOM ではなく React の状態に値を届けること

テキストなら、ネイティブの value セッターを直接呼んでから input と change を発火させると定着しました。

function setText(el, val) {
  const proto = el.tagName === 'TEXTAREA'
    ? HTMLTextAreaElement.prototype
    : HTMLInputElement.prototype;
  Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, val);
  el.dispatchEvent(new Event('input', { bubbles: true }));
  el.dispatchEvent(new Event('change', { bubbles: true }));
}

チェックボックスは小細工をせず、el.click() でそのまま押すのがいちばん確実。

クリックなら React 側の onChange が素直に走ってくれるからですね。

ハマり②:sleepベースの自動化が分単位で止まる

2つ目が、いちばん手こずった症状。

200 ミリ秒ごとに状態を見に行くだけのポーリングが、セレクト1個の設定に30秒以上かかるという異常な遅さに落ちたのです。

しかもデバッグ用に別のタブを見ていたときだけ再現する。

処理を呼び出す側は45秒でタイムアウトするのに、ページ内の非同期処理は生き残って動き続けます。

その結果、後から投げた処理と競合して別の項目に値が入るという二次被害まで起きました。

犯人は非アクティブタブのタイマー間引き

答えは、ブラウザ側の省電力機能でした。

MDN の setTimeout の解説には、バックグラウンドのタブによる負荷を減らすため、ブラウザがアクティブでないタブに最小タイムアウト時間を強制すると明記されています。

デスクトップ版の Chrome と Firefox では最小1秒、条件がそろえばもっと強い段階のスロットル処理も適用されるとのこと。

MDN の公式ドキュメントに仕様として書かれているので、これはバグではなく仕様です。

つまり 200 ミリ秒の sleep を並べた瞬間、非アクティブタブでは何十倍にも間延びする計算になります。

自動化のスクリプトが「なぜか遅い」ときは、まずここを疑う価値があるでしょう。

MDN Web Docs|Window: setTimeout()

NO IMAGE

回避策はsetTimeoutを使わないこと

対処はシンプルで、待ち時間の作り方そのものを変えます。

タイマーではなく、MessageChannelpostMessage を使ってイベントループに制御を返すだけの yield へ置き換える。

// setTimeout は非アクティブタブで間引かれるが、postMessage は間引かれない
const tick = () => new Promise(r => {
  const c = new MessageChannel();
  c.port1.onmessage = () => r();
  c.port2.postMessage(0);
});
const yieldN = async n => { for (let i = 0; i < n; i++) await tick(); };

// 使用例: sleep(200) の代わりに await yieldN(50)

これに差し替えたところ、同じ処理が1回の呼び出しでセレクト12個を連続設定できるところまで完走しました。

待ち時間をミリ秒で数えるのをやめ、「制御を返す回数」で数えるようにした、と言い換えてもいいかもしれません。

ハマり③:クリックしたはずの選択肢と違う値が入る

3つ目は、いちばん気味の悪い症状でした。

一覧から「2017」を探して掴み、その要素をクリックしたのに、入ったのは「2020」。

犯人は要素のリサイクルで、React が再レンダーのたびに選択肢の DOM を作り直さず使い回すため、掴んでおいた参照が別の値にすり替わっていたのです。

対策は2つでした。

ひとつは探すのとクリックするのを同じ処理の中で完結させること、要素の参照を跨いで持ち回らない。

もうひとつは、設定したあとに値を読み返して一致しなければ再試行すること。

for (let attempt = 0; attempt < 3; attempt++) {
  openDropdown(input);
  // 発見とクリックを同一イテレーションで(要素リサイクル対策)
  const opt = visibleOptions().find(o => o.textContent.trim() === val);
  if (opt) clickLikeUser(opt);
  await yieldN(100);
  if (input.value === val) return 'ok'; // 検証して不一致なら再試行
}

書き込んで終わりにせず、検証してから次へ進む形にしたことで誤入力は止まりました

最後に

3つの症状は、どれも「DOM に書いた結果を信じてしまった」ところから生まれています。

React の状態、ブラウザのタイマー、要素の同一性——見えないところで、自分の書き込みは静かに裏切られる。

だからこそ、自動化のコードには書き込み・待機・検証のセットを最初から組み込んでおくのが結局は近道でした。

AI エージェントに任せる場合も同じで、手順を渡すだけでなく検証ループごと渡すと結果が安定します。

ブラウザ自動操作でつまずいた別の3つの壁は、こちらにまとめました。

ブラウザ自動操作で実アプリを触ると詰まる3つの壁

以上です。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

CAPTCHA