アドベントカレンダーの季節ももうすぐ終わりですね。
私は「setTimeoutのコールバックにasync関数を渡して再帰呼び出しを行う場合、clearTimeoutで再帰呼び出しが止まらない話」を書きたいと思います。
※モチベーション元はrequestAnimationFrameの再帰呼び出しですが、止まらなかった原因は同じです
モチベーション
ある時、下記のような再帰処理を、画面の描画更新用に書いていたのですが、ちょっと描画がカクツクなーと思っていました。
let id = 0;
const startRender = () => {
const render = async() => {
...
const hoge = await heavyFunc(someCanvasElement);
...
id = requestAnimationFrame(render);
}
cancelAnimationFrame(id); // 以前に実行したstartRenderで設定されたコールバックは解除したい
id = requestAnimationFrame(render);
}
// 複数回呼ばれることを想定
startRender();
重い計算(await heavyFunc(someCanvasElement)
)してるから多少は仕方がないと放置する日々でしたが、ある修正をしたところ描画が滑らかになりました。
描画のカクツキの原因は、適切に古い再帰処理が止まっておらず、ムダなコールバックが実行され続けていたことにあります。
※Chrome DevToolsのPerformanceタブと睨めっこしていたわけではないので、状況証拠ですが、、、
本日は放置への反省を込め、原因と対策を記事にしておきたいと思います。
デモ
早速ですが、デモを作ってみたので、前述のコードでピンときていない方はご確認ください。
See the Pen stoppable?-settimeout-loop by haradabox (@haradabox) on CodePen.
startを押すとカウントアップが始まり、stopを押すとカウントアップが止まる、シンプルなカウンターが2つ並んでいます。
両方ともsetTimeoutの再帰呼び出しで実装されていますが、一方は確実に止まり、もう一方は止まることもあれば、止まらないこともあります。
動作が不安定な原因
コードを見てみましょう。
止まる方のカウンター
- startボタンクリックでstartメソッドの実行
- (最低)100ミリ秒後に実行されるコールバックを設定し、timeoutIDを保持
- コールバックの最後でstartメソッドを再帰呼び出しする
- stopボタンクリックでclearTimeout
class Counter {
constructor(renderer) {
this.count = 0;
this.timer = null;
this.renderer = renderer;
}
start() {
this.timer = setTimeout(() => {
this.count++;
this.renderer(this.count);
this.start();
}, 100);
}
stop() {
clearTimeout(this.timer);
}
}
const counter = new Counter((count) => {
document.getElementById("count").innerText = count;
});
const $start = document.getElementById("start");
const $stop = document.getElementById("stop");
$start.addEventListener("click", () => {
counter.start();
});
$stop.addEventListener("click", () => {
counter.stop();
});
こちらはsetTimeoutのコールバック関数が同期的に実行されるので、stopボタンクリック時に保持しているtimeoutIDは常に実行前のものとなります。
つまり下記です。
- setTimeout実行
- (最低)100ミリ秒待つ ★ このタイミングでしかstopが実行できないので、コールバック呼び出しが確実に解除できる
- コールバック実行。1へ戻る
止まることもあれば、止まらないこともある方のカウンター
- startボタンクリックでstartメソッドの実行
- (最低)100ミリ秒後に実行される非同期コールバック(途中でawaitを挟み、(最低)50ミリ秒待つ)を設定し、timeoutIDを保持
- 非同期コールバックの最後でstartメソッドを再帰呼び出しする
- stopボタンクリックでclearTimeout
class AsyncCounter {
constructor(renderer) {
this.count = 0;
this.timer = null;
this.renderer = renderer;
}
start() {
this.timer = setTimeout(async() => {
this.count = await new Promise((resolve) => {
setTimeout(() => resolve(this.count + 1), 50);
});
this.renderer(this.count);
this.start();
}, 100);
}
stop() {
clearTimeout(this.timer);
}
}
const counter2 = new AsyncCounter((count) => {
document.getElementById("count2").innerText = count;
});
const $start = document.getElementById("start");
const $stop = document.getElementById("stop");
$start.addEventListener("click", () => {
counter2.start();
});
$stop.addEventListener("click", () => {
counter2.stop();
});
こちらのコールバック関数は非同期であるため、stopボタンクリック時のtimerIDが常に実行前のものとは言えず、stopボタンをクリックするタイミングで結果が異なります。
つまり下記です。
- setTimeout実行
- 約100ミリ秒待つ ★ ここでstopが実行されると、コールバック呼び出しを解除できる
- コールバックのawaitまでを実行
- 約50ミリ秒待つ ★ ここでstopが実行されても、すでにコールバックは呼び出されているため、await以後の再帰呼び出しも実行されてしまう
- コールバックのawait以後を実行。1へ戻る
これではいけませんね。なんとかしましょう。
動作安定版デモ
修正したものがこちらです。
See the Pen stoppable?-settimeout-loop by haradabox (@haradabox) on CodePen.
止まりましたね。
確実に止まるようになったカウンター
- startボタンクリックでstartメソッドの実行 ★startメソッド実行時の時間を保持しておく
- renderメソッドで非同期コールバックを設定したsetTimeoutの実行、timeoutIDを保持
- ★renderメソッド再帰呼び出し前に、保持しておいたstartメソッド実行時の時間を使って、再帰呼び出しすべきかどうかの判定を行う
- 非同期コールバックの最後でrenderメソッドを再帰呼び出しする
class AsyncCounter {
constructor(renderer) {
...
this.startTime = null;
}
start(startTime) {
this.startTime = startTime;
this.render(startTime);
}
render(startTime) {
this.timer = setTimeout(async() => {
this.count = await new Promise((resolve) => {
setTimeout(() => {
resolve(this.count + 1);
}, 50);
});
this.renderer(this.count);
// startやstopが実行されると、
// 古いrender実行コンテキストのstartTimeと、最新のthis.startTimeで差が出るため、
// コールバックの再帰呼び出しが抑制できる
if(this.startTime !== startTime) return;
this.render(startTime);
}, 100);
}
stop() {
this.startTime = null;
...
}
}
...
$start.addEventListener("click", () => {
counter2.start(performance.now());
});
...
つまり、
- setTimeout実行
- (最低)100ミリ秒待つ ★ ここでstopが実行されると、コールバック呼び出しを解除できる
- コールバックのawaitまでを実行
- (最低)50ミリ秒待つ ★ この間にstopが実行されていれば、ガード節により以降の再帰呼び出しはされない
- コールバックのawait以後を実行。1へ戻る
というわけで、確実に止まるようになりました。
むすび
素朴なミスでしたね。
ただ、モチベーションでも書きましたが、「表示重い気がするけど、やむない気もする」系の実装不備は、時と場合で放置されてしまっている場面もあるのかなと思います。
気づいた時にはぜひ、なんで重いのか探ってみましょう。
案外素朴に解決できるかもしれませんよ。