Javascriptで処理を一時停止させるのにハマった話

jsicon.png

 最近になってようやっと(僅かながら)意欲が湧いてきて、またJavascriptでプログラムを組み始めた。
 まあ半端者なりに知識はあるので、全体のデザインが決まったらゴリゴリ書く訳だ。しかし、どうしても sleep() とか wait() とかそういう処理を一時停止する系の関数が欲しくなる場面に行き当たってしまった。

 これはいけない。Javascriptの長所でもあり短所でもある「非同期実行」のため、遅延や一時停止のような処理は非常に書きにくいのだ。
 強引にwhileループで書くことも出来るけど、いやしくも21世紀・令和の時代に生きる人間がそんなことではいけない。もちろん、Javascriptには標準の "setInterval" とか "setTimeout" なんていう関数が標準で使えるのだけれども、これを仕掛けても「非同期実行」の餌食になり、大概思ったとおりには動かない。
 Javascript は(この辺あまり詳しくないけど)「WEBページの動的な仕掛けを記述する」というコンセプトがある訳で、「非同期実行」自体は責められるものじゃない。WEBページは基本イベントドリブンだし、非同期の方が都合が良いところはあるでしょう。
 ただ、今のJavascriptは当初のコンセプトを超えた用途で使われだしていて、そこは改善の余地があるな~とは思う。まあ文句を言い出したらキリが無いけどね。(色々モノ申したい部分は多々あるけどグッと堪えてる)

 まあ本チャンの "Javascripter" の皆さんなら何を今更な内容なんだろうけど、自分のような半端者は日々模索している訳ですよ。
 何だかんだでちょっと面白かったので気付いた事を書き記しておこうという次第。何かのヒントになればもっけの幸いであります。まあお裾分け的な感覚ですな。

---

 この手の処理をしようとなると、これまでは大体setTimeout関数を使ってたのよね。
 setTimeout関数は確かに一定の時間の後に引数として渡した処理を実行してくれる。
 が、setTimeout関数を待ってる間に、その関数の後に書いた処理はタイムアウトを待たずに実行されてしまう。非同期だもんでな。
 これを避けるためにはsetTimout関数に渡した処理内で別の関数や処理を呼び出してやらねばならない。一つや二つならまだしも、下手すれば十重二十重と延々連なる場合だってあろう。これが世に言う「コールバック地獄」であります。
 コードが分かりにくく、読みにくくなるし、当然メンテナンス性も悪くなる。なによりエレガントじゃないね。

 同じような事を思った人は沢山居たらしく、何年か前から同期処理をするための仕掛けがJavascriptの仕様に組み込まれだした。
 ……偉そうに書いてるけど、今回調べて初めて知ったのよね(;^ω^)…… なにしろ知識が古いから……(言い訳)

 それはともかく、その仕掛けというのが "Promise" なんである。
 しかしこのMDNのページは些か分かりづらいんだなあ。それに今やりたいのは一時停止だけなので、ぶっちゃけ小難しい理屈は後回しにしたい(一番成功から遠ざかるパターン...orz)。

 もう少し調べると、async / await なる仕掛けが割りと最近導入されたと分かり、すわ!と飛びついた。
 ググるとそれについて解説するWEBページが幾つも出てくる。ついでに一時停止する処理をこれで書くサンプルコードまで色々出てきた。やっぱり同じような事を考える人は沢山居るのである。

 ありがたく情報を漁らせてもらい、大体こんな感じのコードスニペットを得た(実は大きく勘違いしてた事など知る由もない)。
async function wait () {
let wait_promise = new Promise( resolve => { setTimeout( resolve, 5000 ) } );

await wait_promise;
}
 async と設定した関数内で、setTimeout関数を実行するPromiseを定義し、awaitに食わせてそのPromiseを実行する。結果として5000ミリ秒待機することになる、という理屈だわさ。
 ところがどうも上手くない。
 例えばこんな感じで、HTMLロード後に呼び出してみる。
window.onload = () => {
console.log( '1' );
wait();
console.log( '2' );
}

 実行するとコンソール(WEBブラウザの開発ツール)では、”1" と "2" がすぐさま印字される。
1
2
 しかしこれでは駄目だ。本来の意図としては "1" と "2" の印字には5秒間のタイムラグを生じさせるつもりだったのだから。

 結構悩んで色々こねくり回したけど、よー分からん。が、どうやら突破はできた。それはPromise / async / await についてのドキュメントをゆっくり読んだからなのであった。
 結局のところ、きちんと理解しないまま表層だけの真似事をするから上手くいかんのだ! (まあ今でもきちんと理解してるのかと問われれば甚だ怪しいのですがね)

 では、もの凄~く端折って説明します。
 1)Promiseは、(thenやcatchで)処理を数珠繋ぎ(チェーン)にして順番に処理を実行させる仕組みを提供する。言い方を変えるとチェーン内の各処理は一つ前の処理が終わるのを待って実行される。
 2)async / await は 上記Promiseチェーンと同様の事を、より簡易に書けるようにした仕組みである。

 つまりPromiseチェーンの置き換えとして、async / await があるということは、Promiseチェーンと同じように考えなければちゃんと意図通りに動かせるようにはならないとゆー事。
 さあ、面白くなってまいりましたwww

---

 まずはasync / awaitを使用する上でのポイントを勝手にまとめてみる。
(1)asyncを設定した関数の中でなければawaitは使えない。
(2)awaitはPromiseオブジェクトのみを受け取り、受け取ったPromiseの終了まで待たせる。
(3)async関数内でのみ、(awaitを用いての)同期実行が可能になる。
(4)async関数は(暗黙の)Promiseを返す
(5)awaitより前に記述された処理は非同期実行される。
(6)asyncを定義した関数であっても、asyncを定義している関数のスコープ内でawaitを定義しないと非同期で実行される。
 ※これまたか~な~り端折ってますのでご注意ください。(;^ω^)

 (5)は、まあ当たり前と思われるかもしれないのだけど、例えばsetTimeout関数を仕掛けた後にawait処理が記述された場合、前記setTimeoutが止まったり遅延したりする事はない、というような感じ。
 (6)はちょっとややこしくて、関数自体の実行は非同期なんだけど、関数内部の処理は同期=順番を守って実行されるのね。asyncが定義されてるから。
 これ、ちょっと惑星系っぽいイメージを感じる。ある星系のある惑星上で走ったり止まったりしても、他の惑星の運行やそこに居るの人の運動には影響を与えない、というような。余談ですが。

 これらを踏まえてまずは前述のこいつを意図した通りに動かしたい。
async function wait () {
let wait_promise = new Promise( resolve => { setTimeout( resolve, 5000 ) } );

await wait_promise;
}
 ↓のように書いたら上手く動いてくれなかったのだけど……
// うまくいかない
window.onload = () => {
console.log( '1' );
wait();
console.log( '2' );
}
 当然、普通の関数の形にしても意図した通りには動いてくれない。
// うまくいかない
function test1 () {
console.log('1');
wait();
console.log('2');
}
 ここでポイント(3)を思い出してたもれ。asyncを定義した関数内でないと非同期実行されてしまうのだった!
 なら試しにasyncを付けてみたらどうじゃ……と思ってやってみてもやっぱり上手く行かない。ううむ。
// うまくいかない
async function test2 () {
console.log('1');
wait();
console.log('2');
}
 ポイント(4)を見ると、asyncと定義した関数は(暗黙の)Promiseを返すとある。
 ん?待てよ、ポイント(2)でawaitはPromiseを受け取って終了まで処理を待機させると言ってるではないか。ということは最初に定義したwait関数も暗黙のうちにPromiseを返してる筈だ。
 ならこれでどうだ。
async function test3 () {
console.log('1');
await wait();
console.log('2');
}
 すぐさま実行!(ポチっとな)
1
 むむ、まずは '1' だけが印字された。しかして5秒待つと……
1
2 // 5秒後に印字された!
 キタ━━━━━━━━m9( ゚∀゚)━━━━━━━━!!

 やっと意図通りに動いてくれたではないか。ここに至ってよ~う~や~く async / await の動きがイメージできるようになったのであった。バカは辛いorz

 ここまでくれば話は早い。window.onloadで同じ事をさせるのならば……
window.onload = async () => {
console.log('1');
await wait();
console.log('2');
}
 ↑こうすれば良いのであった! 無名関数でもasyncを付けられるのだ。わはは。

---

 しかし最初のwait関数はいかにも回りくどい。暗黙の動作を期待すると後々分かりづらくなるのではないか、とも思うし。
 ならば、とwait関数を書き直した。明示的にPromiseを返せばよいのだ。
function wait2 () {
return new Promise( resolve => { setTimeout( resolve, 5000 ) } );
}

async function test4 () {
console.log('1');
await wait2();
console.log('2');
}
 はい、バッチリですね。これも意図通りに動きま~す。v( ̄Д ̄)v イエイ

 しかし、これでもまだ回りくどい感じはある。わざわざ関数にする必要も無い場合だってあるでしょうにって思うよね? ね?(押し付けがましい)
 そんな訳で直接Promiseオブジェクトをawaitに喰わせてみた。
async function test5 () {
console.log('1');
await new Promise( resolve => { setTimeout( resolve, 5000 ) } );
console.log('2');
}
 これもバッチリ意図した通りに動いてくれる。まあ当然っちゃ当然ですな。ちょっとしたものだったらこの書き方が一番良いかもしれない。

 ここで最初のwait関数を降り返ると、上記のtest5関数とほとんど変わらないのに気付く。wait関数内にconsole.logが記述されてるようなものだ。とどのつまりは最初の段階では理解が足りていなかったのがよく分かる。wait関数の中身は最初からちゃんと非同期で動いていたのだ。

 まあ最初の段階では一時停止関数を求めていたのよね。単体で汎用的に使える(しかも即効性のある)もの、即ち待たせたいところにちょろっと書き込めばそこで一時停止してくれるツールをね。願望が先に立って目が曇ってた部分はあるな。
 しかし、そのような銀の弾丸の如き便利グッズはJavascriptには存在しないのであった! /(^o^)\ナンテコッタイ

 とは言えこれはこれで一つの成果である。一歩前進した事には変わりない。
 前向きに今後のプログラミングに活かしていこうではないか。

---

 閑話休題。
 ここでもう一度ポイント(3)に立ち戻って見て欲しい。"async関数内でのみ、(awaitを用いての)同期実行が可能になる" とある。
 これは言い換えるとasyncを設定したスコープ内でのみ同期実行が可能になるという事だ。逆に言うとasyncを設定できないスコープでは同期実行はできない。とどのつまりは、
asyncが設定できないスコープ、即ちグローバルスコープではawaitによる同期実行不可!
てことですな。ご注意くだされ。まあエラーが出るから分かるとは思うけど(;^ω^)
 こういう場合は、即時実行関数でラップするとか、Promiseチェーンを使いませう。

 ちなみに今までやった動作("1" を印字し5秒後に "2" を印字する)をPromiseチェーンで表すとこんな感じ。ご参考まで。
function test6 () {
console.log('1');

new Promise( resolve => { setTimeout( resolve, 5000 ) } )
.then( () => console.log('2') );
}
 当然ながらasyncを設定しなくても大丈夫。もちろんasyncでもちゃんと動きますが。

 参考ついでに、厳密に順番通りに動くようにした~い!というワガママな方のために全部awaitできっちり順番通りに動かすバージョン。
async function test7 () {
await new Promise( resolve => resolve( console.log('1') ) );
await new Promise( resolve => setTimeout( resolve, 5000 ) );
await new Promise( resolve => resolve( console.log('2') ) );
}
 全部Promiseにしてやらねばならないので冗長ではある。実際には時間のかかる事が予想される処理を中心にawaitを仕掛けるのが良いのではないかと思う。

 最後にポイント(6)を検証するコードスニペットをば。
async function wait3 () {
console.log("wait3 in");
await new Promise( ( resolve ) => setTimeout( resolve, 5000 ) );
console.log("wait3 out");
}

function test8 () {
console.log('1');
wait3();
console.log('2');
}
 今回はwait3関数内にもconsole.logを仕掛け、test8関数はasync / await を仕掛けない。これを実行すると、
1
wait3 in
2
 最初この三行が一気に出力され、5秒後に
1
wait3 in
2
wait3 out // 5秒後に印字される
 こうなります。もちろん意図した通りの動きであります。

 一応順を追って説明すると、まず '1' の印字の後、wait3関数に処理が移り、'wait3 in'が印字され、続けてPromiseが同期実行されます。Promiseの中身はsetTimeoutなので5秒間の一時停止でやんす。
 しかし、wait3関数の外の、'2' を印字するconsole.logは非同期なので、Promiseの終了を待たずに即刻印字が実行され、wait3関数内でPromiseが5秒後に完了してやっと 'wait3 out' が印字された、という訳ですな。

---

 気付いたらすっごい長くなってまった。他にも何か書くことあった気がするけどこれで一旦オシマイとします。

 実のところ Promise / async / await を理解するため結構実験をしてトライ&エラーを何度も繰り返したんだなあ。正直ここには書けないようなバカバカしい実験も色々やったのよ。それでようやっと分かってきた感じ。
 ただPromiseに関してはまだ分かってないというか活用しきれてない部分もあるのでもうちょっと勉強と実験をしたいところ。だけどそればっかりやってると作りかけのが進まないので、ある程度まで納得できたら後はおいおいやってく形としたい(まあ大体やらないんですけどねorz)
 ただ非同期の中で任意に同期実行できるというのは結構可能性が広がるな~とは思った。俄然面白くなってきたな。

 うん、新しい事が分かるというのはやっぱり楽しい! (・∀・)

 てな感じでした。でわでわ。




※他にも色々あるんですけど忘れてしまいました。ごめんなさい。(m´・ω・`)m

ブログ気持玉

クリックして気持ちを伝えよう!

ログインしてクリックすれば、自分のブログへのリンクが付きます。

→ログインへ

なるほど(納得、参考になった、ヘー)
驚いた
面白い
ナイス
ガッツ(がんばれ!)
かわいい

気持玉数 : 0

この記事へのコメント