2022年10月3日

スケジューリング: setTimeout と setInterval

関数を今すぐ実行するのではなく、後で特定の時間に実行するように設定することができます。これを「呼び出しのスケジューリング」といいます。

そのためには2つのメソッドがあります。

  • setTimeout を使用すると、時間間隔の後に関数を一度だけ実行できます。
  • setInterval を使用すると、時間間隔の後に関数を繰り返し実行し、その後その間隔で継続的に繰り返し実行できます。

これらのメソッドはJavaScript仕様の一部ではありません。しかし、ほとんどの環境には内部スケジューラがあり、これらのメソッドを提供しています。特に、すべてのブラウザとNode.jsでサポートされています。

setTimeout

構文

let timerId = setTimeout(func|code, [delay], [arg1], [arg2], ...)

パラメータ

func|code
実行する関数またはコードの文字列。通常は関数です。歴史的な理由から、コードの文字列を渡すこともできますが、推奨されません。
delay
実行するまでの遅延(ミリ秒単位、1000ms = 1秒)、デフォルトは0。
arg1, arg2
関数の引数

例えば、このコードは1秒後にsayHi()を呼び出します。

function sayHi() {
  alert('Hello');
}

setTimeout(sayHi, 1000);

引数付き

function sayHi(phrase, who) {
  alert( phrase + ', ' + who );
}

setTimeout(sayHi, 1000, "Hello", "John"); // Hello, John

最初の引数が文字列の場合、JavaScriptはその文字列から関数を作成します。

したがって、これも機能します。

setTimeout("alert('Hello')", 1000);

しかし、文字列の使用は推奨されません。代わりに、このようなアロー関数を使用してください。

setTimeout(() => alert('Hello'), 1000);
関数を渡すが、実行しない

初心者開発者は、関数に括弧()を追加するという間違いを犯すことがあります。

// wrong!
setTimeout(sayHi(), 1000);

これは機能しません。setTimeoutは関数の参照を期待しているためです。ここでsayHi()は関数を実行し、その実行結果がsetTimeoutに渡されます。私たちのケースではsayHi()の結果はundefined(関数は何も返さない)なので、何もスケジュールされません。

clearTimeout でキャンセル

setTimeoutへの呼び出しは、「タイマー識別子」timerIdを返し、これを使用して実行をキャンセルできます。

キャンセルする構文

let timerId = setTimeout(...);
clearTimeout(timerId);

以下のコードでは、関数をスケジュールしてからキャンセルします(考えを変えました)。その結果、何も起こりません。

let timerId = setTimeout(() => alert("never happens"), 1000);
alert(timerId); // timer identifier

clearTimeout(timerId);
alert(timerId); // same identifier (doesn't become null after canceling)

alertの出力からわかるように、ブラウザではタイマー識別子は数値です。他の環境では、これは別のものになる可能性があります。例えば、Node.jsは追加のメソッドを持つタイマーオブジェクトを返します。

繰り返しになりますが、これらのメソッドに関する普遍的な仕様はないため、問題ありません。

ブラウザの場合、タイマーはHTML Living Standardのタイマーセクションで説明されています。

setInterval

setIntervalメソッドは、setTimeoutと同じ構文です。

let timerId = setInterval(func|code, [delay], [arg1], [arg2], ...)

すべての引数は同じ意味を持ちます。しかし、setTimeoutとは異なり、関数を一度だけ実行するのではなく、指定された時間間隔後に定期的に実行します。

以降の呼び出しを停止するには、clearInterval(timerId)を呼び出す必要があります。

次の例では、2秒ごとにメッセージが表示されます。5秒後、出力は停止します。

// repeat with the interval of 2 seconds
let timerId = setInterval(() => alert('tick'), 2000);

// after 5 seconds stop
setTimeout(() => { clearInterval(timerId); alert('stop'); }, 5000);
alertが表示されている間も時間は経過します。

ChromeやFirefoxを含むほとんどのブラウザでは、内部タイマーはalert/confirm/promptを表示している間も「動作し続けます」。

そのため、上記のコードを実行し、しばらくの間alertウィンドウを閉じない場合、alertは閉じるとすぐに次のalertが表示されます。アラート間の実際の間隔は2秒より短くなります。

ネストされたsetTimeout

何かを定期的に実行するには2つの方法があります。

1つはsetIntervalです。もう1つは、次のようなネストされたsetTimeoutです。

/** instead of:
let timerId = setInterval(() => alert('tick'), 2000);
*/

let timerId = setTimeout(function tick() {
  alert('tick');
  timerId = setTimeout(tick, 2000); // (*)
}, 2000);

上記のsetTimeoutは、現在のもの(*)の最後で次の呼び出しをスケジュールします。

ネストされたsetTimeoutは、setIntervalよりも柔軟性の高いメソッドです。このようにして、次の呼び出しは、現在の呼び出しの結果に応じて異なる方法でスケジュールできます。

例えば、データ要求のために5秒ごとにサーバにリクエストを送信するサービスを作成する必要がありますが、サーバが過負荷になっている場合は、間隔を10秒、20秒、40秒…に増やす必要があります。

擬似コードを以下に示します。

let delay = 5000;

let timerId = setTimeout(function request() {
  ...send request...

  if (request failed due to server overload) {
    // increase the interval to the next run
    delay *= 2;
  }

  timerId = setTimeout(request, delay);

}, delay);

そして、スケジュールしている関数がCPU負荷の高いものの場合、実行にかかった時間を測定し、次の呼び出しを早くするか遅くするかを計画することができます。

ネストされたsetTimeoutを使用すると、setIntervalよりも正確に実行間の遅延を設定できます。

2つのコードフラグメントを比較してみましょう。最初のものはsetIntervalを使用しています。

let i = 1;
setInterval(function() {
  func(i++);
}, 100);

2番目はネストされたsetTimeoutを使用しています。

let i = 1;
setTimeout(function run() {
  func(i++);
  setTimeout(run, 100);
}, 100);

setIntervalの場合、内部スケジューラは100msごとにfunc(i++)を実行します。

気づきましたか?

setIntervalfunc呼び出し間の実際の遅延は、コードよりも短くなっています!

これは正常です。なぜなら、funcの実行にかかった時間は、間隔の一部を「消費」しているからです。

funcの実行が予想よりも長く、100ms以上かかる可能性があります。

この場合、エンジンはfuncが完了するのを待ってから、スケジューラをチェックし、時間が来たらすぐに再度実行します。

極端なケースとして、関数が常にdelaymsより長く実行される場合、呼び出しは全く中断せずに発生します。

そして、ネストされたsetTimeoutの図はこちらです。

ネストされたsetTimeoutは、固定遅延(ここでは100ms)を保証します。

これは、新しい呼び出しが前の呼び出しの最後に計画されるためです。

ガベージコレクションとsetInterval/setTimeoutコールバック

関数がsetInterval/setTimeoutで渡されると、内部参照が作成され、スケジューラに保存されます。これにより、関数への他の参照がなくても、関数はガベージコレクションから保護されます。

// the function stays in memory until the scheduler calls it
setTimeout(function() {...}, 100);

setIntervalの場合、関数はclearIntervalが呼び出されるまでメモリに残ります。

副作用があります。関数は外部レキシカル環境を参照するため、関数が存在する限り、外部変数も存在します。これらは関数自体よりもはるかに多くのメモリを消費する可能性があります。そのため、スケジュールされた関数が不要になったら、それが非常に小さい場合でも、キャンセルする方が良いでしょう。

ゼロ遅延setTimeout

特別なユースケースがあります。setTimeout(func, 0)、または単にsetTimeout(func)です。

これは、できるだけ早くfuncの実行をスケジュールします。しかし、スケジューラは、現在実行中のスクリプトが完了した後にのみ、それを呼び出します。

したがって、関数は現在のスクリプトの「直後」に実行されるようにスケジュールされます。

例えば、これは「Hello」を出力し、すぐに「World」を出力します。

setTimeout(() => alert("World"));

alert("Hello");

最初の行は「0ms後に呼び出しをカレンダーに入れます」。しかし、スケジューラは現在のスクリプトが完了した後にのみ「カレンダーをチェックする」ため、「Hello」が先で、「World」はその後です。

ゼロ遅延タイムアウトには、ブラウザ関連の高度なユースケースもあります。これについては、イベントループ:マイクロタスクとマクロタスクの章で説明します。

ゼロ遅延は実際にはゼロではありません(ブラウザの場合)

ブラウザでは、ネストされたタイマーが実行できる頻度に制限があります。HTML Living Standardには、「5つのネストされたタイマーの後、間隔は少なくとも4ミリ秒に強制されます」と記載されています。

以下の例でそれが何を意味するかを示しましょう。その中のsetTimeout呼び出しは、ゼロ遅延で自身を再スケジュールします。各呼び出しは、times配列に前のものからの実際の時刻を記憶します。実際の遅延はどのようになりますか?見てみましょう。

let start = Date.now();
let times = [];

setTimeout(function run() {
  times.push(Date.now() - start); // remember delay from the previous call

  if (start + 100 < Date.now()) alert(times); // show the delays after 100ms
  else setTimeout(run); // else re-schedule
});

// an example of the output:
// 1,1,1,1,9,15,20,24,30,35,40,45,50,55,59,64,70,75,80,85,90,95,100

最初のタイマーはすぐに実行され(仕様に記載されているとおり)、その後9, 15, 20, 24...が表示されます。呼び出し間の4ms以上の必須遅延が有効になります。

setTimeoutの代わりにsetIntervalを使用した場合も同様です。setInterval(f)は、ゼロ遅延でfを数回実行し、その後4ms以上の遅延で実行します。

この制限は古くから存在し、多くのスクリプトがそれに依存しているため、歴史的な理由で存在しています。

サーバサイドJavaScriptの場合、この制限は存在せず、Node.jsのsetImmediateなど、即時非同期ジョブをスケジュールする他の方法があります。したがって、このメモはブラウザ固有です。

まとめ

  • メソッドsetTimeout(func, delay, ...args)setInterval(func, delay, ...args)を使用すると、delayミリ秒後にfuncを1回/定期的に実行できます。
  • 実行をキャンセルするには、setTimeout/setIntervalによって返された値を使用してclearTimeout/clearIntervalを呼び出す必要があります。
  • ネストされたsetTimeout呼び出しは、setIntervalよりも柔軟性の高い代替手段であり、実行間の時間をより正確に設定できます。
  • setTimeout(func, 0)setTimeout(func)と同じ)によるゼロ遅延スケジューリングは、「できるだけ早く、ただし現在のスクリプトが完了した後に」呼び出しをスケジュールするために使用されます。
  • ブラウザは、5回以上のネストされたsetTimeout呼び出し、またはsetInterval(5回目の呼び出し後)の最小遅延を4msに制限しています。これは歴史的な理由によるものです。

すべてのスケジューリングメソッドが正確な遅延を保証するわけではないことに注意してください。

例えば、ブラウザ内のタイマーは多くの理由で遅くなる可能性があります。

  • CPUが過負荷になっている。
  • ブラウザのタブがバックグラウンドモードになっている。
  • ラップトップがバッテリー節約モードになっている。

これらすべてにより、ブラウザとOSレベルのパフォーマンス設定に応じて、最小タイマー解像度(最小遅延)が300msまたは1000msに増加する可能性があります。

課題

重要度:5

fromからtoまで、1秒ごとに数値を出力する関数printNumbers(from, to)を作成してください。

2つのバリエーションのソリューションを作成してください。

  1. setIntervalを使用する。
  2. ネストされたsetTimeoutを使用する。

setIntervalを使用

function printNumbers(from, to) {
  let current = from;

  let timerId = setInterval(function() {
    alert(current);
    if (current == to) {
      clearInterval(timerId);
    }
    current++;
  }, 1000);
}

// usage:
printNumbers(5, 10);

ネストされたsetTimeoutを使用

function printNumbers(from, to) {
  let current = from;

  setTimeout(function go() {
    alert(current);
    if (current < to) {
      setTimeout(go, 1000);
    }
    current++;
  }, 1000);
}

// usage:
printNumbers(5, 10);

どちらのソリューションでも、最初の出力の前に初期遅延があります。関数は最初に1000ms後に呼び出されます。

関数をすぐに実行させたい場合は、別の行に別途呼び出しを追加できます。例:

function printNumbers(from, to) {
  let current = from;

  function go() {
    alert(current);
    if (current == to) {
      clearInterval(timerId);
    }
    current++;
  }

  go();
  let timerId = setInterval(go, 1000);
}

printNumbers(5, 10);
重要度:5

以下のコードでは、setTimeout呼び出しがスケジュールされ、その後、100ms以上かかる重い計算が実行されます。

スケジュールされた関数はいつ実行されますか?

  1. ループの後。
  2. ループの前。
  3. ループの開始時。

alertは何を表示しますか?

let i = 0;

setTimeout(() => alert(i), 100); // ?

// assume that the time to execute this function is >100ms
for(let j = 0; j < 100000000; j++) {
  i++;
}

すべてのsetTimeoutは、現在のコードの実行が完了した後にのみ実行されます。

iは最後の値である100000000になります。

let i = 0;

setTimeout(() => alert(i), 100); // 100000000

// assume that the time to execute this function is >100ms
for(let j = 0; j < 100000000; j++) {
  i++;
}
チュートリアルマップ

コメント

コメントする前にこれを読んでください…
  • 改善点の提案がある場合は、コメントする代わりに、GitHub issueまたはプルリクエストを送信してください。
  • 記事の内容が理解できない場合は、詳しく説明してください。
  • コードを数単語挿入するには<code>タグを使用し、複数行の場合は<pre>タグで囲み、10行を超える場合はサンドボックス(plnkrjsbincodepen…)を使用してください。