clearTimeoutで止まらない話

アドベントカレンダーの季節ももうすぐ終わりですね。

私は「**[setTimeout](https://developer.mozilla.org/ja/docs/Web/API/setTimeout)のコールバックに[async](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Statements/async_function)関数を渡して再帰呼び出しを行う場合、[clearTimeout](https://developer.mozilla.org/ja/docs/Web/API/clearTimeout)で再帰呼び出しが止まらない話**」を書きたいと思います。
※モチベーション元は[requestAnimationFrame](https://developer.mozilla.org/ja/docs/Web/API/Window/requestAnimationFrame)の再帰呼び出しですが、止まらなかった原因は同じです

# モチベーション
ある時、下記のような再帰処理を、画面の描画更新用に書いていたのですが、ちょっと描画がカクツクなーと思っていました。

```js
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タブ](https://developer.chrome.com/docs/devtools/evaluate-performance/reference/)と睨めっこしていたわけではないので、状況証拠ですが、、、

本日は放置への反省を込め、原因と対策を記事にしておきたいと思います。

# デモ
早速ですが、デモを作ってみたので、前述のコードでピンときていない方はご確認ください。

<p class="codepen" data-height="300" data-default-tab="html,result" data-slug-hash="ZEXyMKY" data-user="haradabox" style="height: 300px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/haradabox/pen/ZEXyMKY">
  stoppable?-settimeout-loop</a> by haradabox (<a href="https://codepen.io/haradabox">@haradabox</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

startを押すとカウントアップが始まり、stopを押すとカウントアップが止まる、シンプルなカウンターが2つ並んでいます。

両方ともsetTimeoutの再帰呼び出しで実装されていますが、一方は確実に止まり、もう一方は**止まることもあれば、止まらないこともあります。**

## 動作が不安定な原因
コードを見てみましょう。

### 止まる方のカウンター
1. startボタンクリックでstartメソッドの実行
1. （最低）100ミリ秒後に実行されるコールバックを設定し、timeoutIDを保持
1. コールバックの最後でstartメソッドを再帰呼び出しする
1. stopボタンクリックでclearTimeout

```js
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は常に実行前のものとなります。

つまり下記です。

1. setTimeout実行
1. （最低）100ミリ秒待つ **★ このタイミングでしかstopが実行できないので、コールバック呼び出しが確実に解除できる**
1. コールバック実行。1へ戻る


### 止まることもあれば、止まらないこともある方のカウンター
1. startボタンクリックでstartメソッドの実行
1. （最低）100ミリ秒後に実行される非同期コールバック（**途中でawaitを挟み、（最低）50ミリ秒待つ**）を設定し、timeoutIDを保持
1. 非同期コールバックの最後でstartメソッドを再帰呼び出しする
1. stopボタンクリックでclearTimeout

```js
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ボタンをクリックするタイミングで結果が異なります**。

つまり下記です。

1. setTimeout実行
1. 約100ミリ秒待つ　**★ ここでstopが実行されると、コールバック呼び出しを解除できる**
1. コールバックのawaitまでを実行
1. 約50ミリ秒待つ　**★ ここでstopが実行されても、すでにコールバックは呼び出されているため、await以後の再帰呼び出しも実行されてしまう** 
1. コールバックのawait以後を実行。1へ戻る

これではいけませんね。なんとかしましょう。

# 動作安定版デモ
修正したものがこちらです。

<p class="codepen" data-height="300" data-default-tab="html,result" data-slug-hash="NWavpxB" data-user="haradabox" style="height: 300px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/haradabox/pen/NWavpxB">
  stoppable?-settimeout-loop</a> by haradabox (<a href="https://codepen.io/haradabox">@haradabox</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

止まりましたね。

## 確実に止まるようになったカウンター
1. startボタンクリックでstartメソッドの実行　**★startメソッド実行時の時間を保持しておく**
1. renderメソッドで非同期コールバックを設定したsetTimeoutの実行、timeoutIDを保持
1. **★renderメソッド再帰呼び出し前に、保持しておいたstartメソッド実行時の時間を使って、再帰呼び出しすべきかどうかの判定を行う**
1. 非同期コールバックの最後でrenderメソッドを再帰呼び出しする

```js
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());
});
...
```

つまり、

1. setTimeout実行
1. （最低）100ミリ秒待つ　**★ ここでstopが実行されると、コールバック呼び出しを解除できる**
1. コールバックのawaitまでを実行
1. （最低）50ミリ秒待つ　**★ この間にstopが実行されていれば、ガード節により以降の再帰呼び出しはされない**
1. コールバックのawait以後を実行。1へ戻る

というわけで、確実に止まるようになりました。

# むすび
素朴なミスでしたね。

ただ、モチベーションでも書きましたが、「**表示重い気がするけど、やむない気もする**」系の実装不備は、時と場合で放置されてしまっている場面もあるのかなと思います。

気づいた時にはぜひ、なんで重いのか探ってみましょう。

案外素朴に解決できるかもしれませんよ。