ジェネレータ一覧

JavaScriptでスリープ機能を実装する方法

ゲームやアニメーションなどのインタラクティブなコンテンツではしばしば、指定した時間が経過するまで処理を中断する、スリープ処理が必要となるケースがあります。

この記事では、JavaScriptには本来備わっていないスリープ機能を実現する、いくつかの方法を紹介します。

JavaScriptのスリープは擬似的である

JavaScriptには純粋なスリープ機能はありません。そのため、タイマーを駆使して、擬似的なスリープ機能を実装することとなります。

JavaScriptのタイマーはsetTimeoutsetIntervalを使って記述しますが、これらは非同期のメソッドであるため、スリープ機能を実現するためには、非同期処理の扱い方を理解する必要があります。

JavaScriptにおいて非同期とは、処理の完了を待たずに、次の処理へと進むことを言います。例に挙げた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)で定義されました。

Promisenewするときに渡すコールバック関数には、引数として2つの関数が自動的に渡されます。resolve関数はPromiseの解決を意味する関数で、このresolve関数を実行した瞬間、.thenへと処理が移ります。

今回の例では、setTimeoutが非同期処理に該当します。setTimeoutのタイマーが発動し、目的の処理が終わった後にresolve関数を実行することで、非同期処理の完了から次の.thenへと処理をつなげています。

一方のreject関数はPromiseの失敗を意味しており、今回は使用していませんが、例えばAjaxで404などのエラーが発生した場合に実行して、.catchへと処理を移行します。

この方法に1つ問題があるとすれば、setTimeoutのみを使う方法と同様、直列につないだときにネストが深くなってしまっていることです。ジェネレータと同様にsleep関数を作ることができても、直列化した際にネストが深くなってしまっては、意味がありません。

asyncawaitを使う方法

asyncawaitは、Promiseをより発展させた仕組みで、ECMAScript 2017で定義されました。

感覚的にはジェネレータによく似ています。関数を定義する際にasyncを付けることで関数がasync functionとなり、async function内ではawaitというキーワードを使って、Promiseの完了まで待機することができます。

同じPromiseでも、.thenを数珠つなぎにする必要が無く、ジェネレータと同じように縦に並べて書くことができ、ネストが深くなることを防止することができました。

筆者個人としては、asyncawaitについて、ジェネレータとPromiseの融合版と認識しています。

おまけ:ループ(forwhile)を使う方法(非推奨)

最後に、参考として、ループを使う方法を紹介しておきます。実行中はブラウザが操作できなくなるので、ご注意ください。

条件を満たすまでの間、ループを回し続けることで、次の処理に進むことを抑止しています。

ごく単純な仕組みであり、どのブラウザでも使えますが、CPUの負担が大きい点に加え、処理中はブラウザの描画や操作ができなくなるという大きなデメリットがあるため、この方法は使用するべきではありません

どの方法でスリープを実装するのがベストか?

ここまで見てきたような、非同期処理を直列につなぐケースでは、setTimeoutのみだとネストが深くなってしまうという大きな問題があるため、やはりジェネレータやasyncawaitを使って実装するのがより良い方法だと思います。

ジェネレータとasyncawaitの違いについてはここでは掘り下げませんが、構文の面では似通っているため、どちらを選択しても、近い書き方でスリープを実装することができます。

一方で、これらの新機能にはIEが対応していないため、特にWebページでジェネレータやasyncawaitを使ってスリープを実装する場合は、BabelやTypeScriptによるトランスパイルが必須となります。

asyncawaitのほうがやや後発であるため、Node.jsでトランスパイルをせずに記述する場合は、ジェネレータを選択したほうが安全かもしれません。

JavaScriptでは、最新の構文で書かれたJavaScriptや、JavaScriptに色々な機能を追加した独自言語を使って書かれたソースファイルから、互換性の高いJavaScriptに変換することをトランスパイルと呼びます。
IEなどの古いブラウザを相手にする場合でも、手元では積極的に最新の構文を取り入れて書くことができるので、多くのエンジニアが開発環境にトランスパイルを導入しています。