ゲームやアニメーションなどのインタラクティブなコンテンツではしばしば、指定した時間が経過するまで処理を中断する、スリープ処理が必要となるケースがあります。
この記事では、JavaScriptには本来備わっていないスリープ機能を実現する、いくつかの方法を紹介します。
JavaScriptのスリープは擬似的である
JavaScriptには純粋なスリープ機能はありません。そのため、タイマーを駆使して、擬似的なスリープ機能を実装することとなります。
JavaScriptのタイマーはsetTimeout
やsetInterval
を使って記述しますが、これらは非同期のメソッドであるため、スリープ機能を実現するためには、非同期処理の扱い方を理解する必要があります。
setTimeout
は、セットしたタイマーが発動するまでの間も、後続の処理がどんどん実行されます。JavaScriptではAjaxをはじめ、いたるところで非同期処理が登場するため、どのメソッドが非同期であるかを知ったうえで、プログラムを記述する必要があります。
JavaScriptでスリープを実装する方法
タイマー(setTimeout
)を使う方法
定番と言えるのが、タイマーを使って処理を遅らせる方法です。
設定した時間が経過したら目的の処理を実行し、さらに新たなタイマーを設定することで、複数のタイマーを直列に連結することが可能です。
先に述べたように、setTimeout
は非同期のメソッドであるため、待機中にブラウザへ負荷をかけ続けたり、ブラウザの操作を妨げたりということがありません。古くからある方法で、互換性を気にせず使うことができるというのも大きなメリットです。
一方、上のように複数のタイマーを直列に実行する場合、ソースコードのネスト(入れ子)がどんどん深くなってしまうというデメリットがあります。いわゆるコールバック地獄というやつです。
ネストが深くなると、それだけソースコードの可読性が落ちます。「読みやすいプログラムを書く」ことは、あらゆるプログラマ・エンジニアにとって重要な能力ですので、ネストが深くなりすぎないための工夫が必要となります。
setTimeout
の第1引数には、関数をそのまま渡す場合(例:setTimeout(function () { ... }, 1000)
)と、文字列で渡す場合(例:setTimeout('func()', 1000)
がありますが、後者の方法は古く、パフォーマンスも悪いので、関数をそのまま渡すようにしましょう。ジェネレータを使う方法
ジェネレータはECMAScript 2015(ES6)で新たに導入された仕組みで、一時的に関数の処理を中断し、任意のタイミングで再開することができます。この仕組みを利用して、スリープを実装することができます。
関数を定義する際に*
を付けると、その関数はジェネレータ関数となります。ジェネレータ関数の中ではyield
という特別なキーワードを使うことができ、このyield
の箇所で処理が中断し、next()
で復帰して次へ進みます。
setTimeout
のみを使った方法との違いは、タイマーを直列につなげる際に、ネストが深くなっていないということです。yield
で関数の実行を中断し、タイマーが完了してからnext()
で次に進んでいるので、縦に並べて書いたとしても、各タイマーは直列につながっています。
ジェネレータの概念や構文が比較的複雑なため、ソースコードをパッと見て理解するにはそれなりの経験が必要ですが、上手に使いこなすことができれば、簡潔にスリープ機能を記述することができます。
Promise
を使う方法
Promise
は非同期処理を取り扱うための新しい仕組みで、ジェネレータと同様、ECMAScript 2015(ES6)で定義されました。
Promise
をnew
するときに渡すコールバック関数には、引数として2つの関数が自動的に渡されます。resolve
関数はPromise
の解決を意味する関数で、このresolve
関数を実行した瞬間、.then
へと処理が移ります。
今回の例では、setTimeout
が非同期処理に該当します。setTimeout
のタイマーが発動し、目的の処理が終わった後にresolve
関数を実行することで、非同期処理の完了から次の.then
へと処理をつなげています。
一方のreject
関数はPromise
の失敗を意味しており、今回は使用していませんが、例えばAjaxで404
などのエラーが発生した場合に実行して、.catch
へと処理を移行します。
この方法に1つ問題があるとすれば、setTimeout
のみを使う方法と同様、直列につないだときにネストが深くなってしまっていることです。ジェネレータと同様にsleep
関数を作ることができても、直列化した際にネストが深くなってしまっては、意味がありません。
async
とawait
を使う方法
async
とawait
は、Promise
をより発展させた仕組みで、ECMAScript 2017で定義されました。
感覚的にはジェネレータによく似ています。関数を定義する際にasync
を付けることで関数がasync functionとなり、async function内ではawait
というキーワードを使って、Promise
の完了まで待機することができます。
同じPromise
でも、.then
を数珠つなぎにする必要が無く、ジェネレータと同じように縦に並べて書くことができ、ネストが深くなることを防止することができました。
筆者個人としては、async
・await
について、ジェネレータとPromise
の融合版と認識しています。
おまけ:ループ(for
やwhile
)を使う方法(非推奨)
最後に、参考として、ループを使う方法を紹介しておきます。実行中はブラウザが操作できなくなるので、ご注意ください。
条件を満たすまでの間、ループを回し続けることで、次の処理に進むことを抑止しています。
ごく単純な仕組みであり、どのブラウザでも使えますが、CPUの負担が大きい点に加え、処理中はブラウザの描画や操作ができなくなるという大きなデメリットがあるため、この方法は使用するべきではありません。
どの方法でスリープを実装するのがベストか?
ここまで見てきたような、非同期処理を直列につなぐケースでは、setTimeout
のみだとネストが深くなってしまうという大きな問題があるため、やはりジェネレータやasync
・await
を使って実装するのがより良い方法だと思います。
ジェネレータとasync
・await
の違いについてはここでは掘り下げませんが、構文の面では似通っているため、どちらを選択しても、近い書き方でスリープを実装することができます。
一方で、これらの新機能にはIEが対応していないため、特にWebページでジェネレータやasync
・await
を使ってスリープを実装する場合は、BabelやTypeScriptによるトランスパイルが必須となります。
async
・await
のほうがやや後発であるため、Node.jsでトランスパイルをせずに記述する場合は、ジェネレータを選択したほうが安全かもしれません。
IEなどの古いブラウザを相手にする場合でも、手元では積極的に最新の構文を取り入れて書くことができるので、多くのエンジニアが開発環境にトランスパイルを導入しています。