MDNに非同期 JavaScriptという学習項目があるのですが、目次以外は日本語がありません。
ということで以下はIntroducing asynchronous JavaScript項目の日本語訳です。
Introducing asynchronous JavaScript
この記事では、同期JavaScriptにまつわる問題点を簡潔に要約します。
そして非同期JavaScriptのテクニックの幾つかを紹介し、それぞれが問題点の解決にどのように役立つかを示します。
前提条件:基礎的なコンピュータリテラシー、JavaScriptの基礎をある程度理解していること。
目的:非同期JavaScriptとは何か、同期JavaScriptとは何が違うのか、どのようなユースケースが存在するか、ということを理解する。
同期JavaScript
非同期JavaScriptが何であるのかを理解するためには、まず同期JavaScriptが何であるかを理解することから始めなければなりません。
このセクションでは、前回の記事で現れた情報の一部を要約します。
これまでに学習してきた多くの機能は、そのほとんどが同期です。
幾つかのコードを実行してみると、ブラウザはそれを実行すると同時に結果を返します。
簡単な例を見てみましょう。
実際の動作はこちらで、ソースはこちらで見ることができます。
const btn = document.querySelector('button');
btn.addEventListener('click', () => {
alert('You clicked me!');
let pElem = document.createElement('p');
pElem.textContent = 'This is a newly-added paragraph.';
document.body.appendChild(pElem);
});
このブロックは、処理が上から順番に実行されます。
- 利用可能な要素をDOMから探し出す。
- クリックしたときに発火するclickイベントリスナーを設定する。
- alert()でメッセージボックスを出す。
- アラートが消えたら<p>要素を生成する。
- そこにテキストを登録する。
- 最後にドキュメントボディに要素を追加する。
それぞれの処理が行われている間、他には何も起こりません。
レンダリングは一時停止されます。
これは、前回の記事で述べたように、JavaScriptがシングルスレッドであるためです。
ひとつのスレッドで一度に発生させることができるイベントはひとつだけであり、他のすべてはイベントが終了するまでブロックされます。
従って上記の例では、ボタンをクリックすると、その後アラートでOKボタンを押すまで、次の段落は表示されません。
実際に以下で試してみることができます。
https://codepen.io/pen/?&editable=true
https://jsfiddle.net/api/mdn/
注意:alert()は同期によるブロッキングを示すためのデモンストレーションとして使うのには最適ですが、実アプリで使用するととても残念なことになるので注意が必要です。
非同期JavaScript
前述のブロッキングといった問題を回避するため、多くのWeb APIは非同期コードを使って実行されるようになりました。
特に何らかの外部デバイスにアクセスしたりフェッチしたりするもの、たとえばネットワーク経由でファイルを取得する、データベースにアクセスしてデータを返す、Webカメラからビデオストリームにアクセスする、VRヘッドセットに出力をブロードキャストする、といったものです。
同期JavaScriptでこれらを実現するのが難しいのは何故でしょうか。
以下に簡単な例を見てみましょう。
サーバから画像を取得する場合、即座に結果を得ることはできません。
すなわち、以下の擬似コードはおそらく動作しないだろう、ということです。
var response = fetch('myImage.png');
var blob = response.blob();
// 画像を何処かのUIに表示する
理由は、画像のダウンロードにかかる時間がわからないためです。
時間がかかった場合、2行目に辿り着いた時点でresponse
はまだ使用できません。
従って、時々あるいは毎回、2行目でエラーが発生することでしょう。
これを回避するため、response
を使用するときはresponse
が使用可能になるまで待機しておく必要があります。
JavaScriptで扱える非同期コードのスタイルは主に2種類が存在します。
古いスタイルのコールバック形式と、Promise
を使った新しい形式のコードです。
以下のセクションでは、それぞれを順に解説します。
非同期コールバック
非同期コールバックは、バックグラウンドで動作するコードを呼び出すときにパラメータとして指定する関数です。
バックグラウンドコードは、実行が終了するとコールバック関数を呼び出して、自分が終了したことを知らせます。
あるいは特に注意すべきことが発生した際に通知します。
非同期コールバックは少々時代遅れになりつつありますが、いま一般に使われている歴史の長いAPIではまだまだ使用されています。
以下は非同期コールバック関数をaddEventListener()の第二引数に与える例です。
btn.addEventListener('click', () => {
alert('You clicked me!');
let pElem = document.createElement('p');
pElem.textContent = 'This is a newly-added paragraph.';
document.body.appendChild(pElem);
});
第一引数には監視するイベントのタイプを指定し、第二引数がイベントが発生したときに呼び出されるコールバック関数です。
コールバック関数をパラメータとして別の関数に渡す際、別の関数を実行した時点でコールバック関数まで実行されることはありません。
別の関数内部の何処かで非同期的にコールバックされます(これが名前の由来です)。
別の関数は、対象のイベントが発生したときにコールバック関数を実行します。
コールバック関数を含む関数は独自に実装することが簡単にできます。
ここではXMLHttpRequest APIを使った例を見てみましょう。
実際の動作はこちらで、ソースはこちらで見ることができます。
function loadAsset(url, type, callback) {
let xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.responseType = type;
xhr.onload = function() {
callback(xhr.response);
};
xhr.send();
}
function displayImage(blob) {
let objectURL = URL.createObjectURL(blob);
let image = document.createElement('img');
image.src = objectURL;
document.body.appendChild(image);
}
loadAsset('coffee.jpg', 'blob', displayImage);
引数のobject URL
を表示するimg
タグを作ってドキュメントのbody
に追加する関数displayImage()
を作りました。
次いで、URLとコンテンツタイプ、そしてコールバックを引数として受け取るloadAsset()
関数を作ります。
この関数は、XMLHttpRequest
(よくXHR
と略される)を使って指定されたURLのリソースをフェッチし、返ってきたレスポンスをコールバック関数に渡します。
loadAsset()
関数にコールバックとして渡されたdisplayImage()
関数はすぐに動作するのではなく、XHR
によるリソースのダウンロードが完了するまで待機します。
待機はonload
イベントハンドラで実現されています。
コールバックは汎用性があります。
関数の実行順序や関数間で渡すデータを制御できるだけではなく、状況に応じて異なる関数を呼び出すこともできます。
レスポンスの内容によってprocessJSON()
関数を実行したり、displayText()
関数を実行したりといった様々なアクションを持たせることができます。
全てのコールバックが非同期であるわけではないことに注意してください。
以下はArray.prototype.forEach()を用いて配列項目をループする例です。
実際の動作はこちらで、ソースはこちらで見ることができます。
const gods = ['Apollo', 'Artemis', 'Ares', 'Zeus'];
gods.forEach(function (eachName, index){
console.log(index + '. ' + eachName);
});
この例は、ギリシャの神々の配列をループし、インデックスと値をコンソールに出力するものです。
foreach()
が期待するパラメータは配列インデックスと値の2値を引数として持つコールバック関数です。
ただし、このコールバック関数は一切待機せずに即座に実行されます。
Promises
Promiseは、モダンなWebAPIで使用される、新しいスタイルの非同期コードです。
よくある例はfetch() APIで、これは要するにXMLHttpRequestの現代版スタイルのようなものです。
サーバからのデータ取得から簡単な例を見てみましょう。
fetch('products.json').then(function(response) {
return response.json();
}).then(function(json) {
products = json;
initialize();
}).catch(function(err) {
console.log('Fetch problem: ' + err.message);
});
fetch()
は取得したいリソースのURLひとつだけを引数に取り、そしてPromiseオブジェクトを返します。
Promiseは最終的に非同期操作が完了したかもしくは失敗したかのステータスを持つオブジェクトで、それまではどちらでもない中間状態になっています。
『わかり次第すぐに結果を返すことを約束する』という概念であるため、"promise"という名前が付いています。
この概念に慣れるのは少し練習が必要です。
これはシュレディンガーの猫に似ていると言えるかもしれません。
成功失敗どちらの結果もまだ発生していない状態では、fetch()
はまだ実行を完了せず、次の操作を保留します。
結果がわかったときに、fetch()
の次にある3つのコードブロックが呼び出されます。
・ふたつあるthen()ブロックは、いずれもその前の操作が成功した場合に呼び出されるコールバック関数です。
各コールバックは、その前の操作が成功した場合に返した結果を入力として受け取ります。
各then()
ブロックはそれぞれ別のpromise
を返すため、then()
ブロックをいくつも連ねて、複数の非同期操作を順番に実行することができます。
・catch()ブロックは、どこかのthen()
ブロックが失敗したときに実行されます。
同期のtry...catchブロックと似たようなもので、catch()
ブロックはエラーオブジェクトを受け取ります。
これは発生したエラーの種類を判断するために使用できます。
後ほど解説しますが、同期try...catch
はpromise
内で使用することはできません。
async/awaitを使っている場合は使用可能です。
注:promise
については後から詳しく解説するので、まだ完全に理解できてなくても大丈夫です。
The event queue
Promiseなどの非同期操作はイベントキューに入れられ、メインスレッドの処理が完了した後で実行されるため、後続のJavaScriptコードの実行をブロックしません。
キューに入れられた処理は、なるべく早めに処理が行われ、結果がJavaScriptに返されます。
Promises versus callbacks
Pormiseは、古い形式のコールバックと多少の類似点があります。
これらはコールバック関数を呼び出すオブジェクトであって、コールバックそのものを関数に渡す必要はありません。
しかしながら、Promiseは非同期処理を容易にするために設計されており、古い形式のコールバックよりも多くの利点が存在します。
・複数の非同期操作を.then()
を使って連結し、最初の操作の返り値を次の操作の入力に渡すことができます。
これをコールバックで行うことは困難で、しばしばコールバック地獄と呼ばれる厄介なピラミッドになります。
・Promiseのコールバックは、必ずイベントキューに積まれた順番に処理されます。
・エラー処理は旧形式コールバックより遙かに優れています。ピラミッドの各レベルに個別のエラー処理を書く必要がなく、どのレベルでエラーが発生しても最後にあるひとつの.catch()
で処理を受け取ることができます。
・コールバックを渡した時点で制御する権利を失う旧形式コールバックと異なり、Promiseは制御の反転を避けることができます。
The nature of asynchronous code
非同期コードの性質をさらに詳しく見てみましょう。
非同期コードの挙動を理解しないまま、非同期コードを同期コードと同じように記述した場合にどのような問題が起こるかを確認します。
以下のコードは、上で出てきたものと似ています。
異なるところは、コードの実行される順番を調べるためにいくつかのconsole.log()
が埋め込まれていることです。
実際の動作はこちらで、ソースはこちらで見ることができます。
console.log ('Starting');
let image;
fetch('coffee.jpg').then((response) => {
console.log('It worked :)')
return response.blob();
}).then((myBlob) => {
let objectURL = URL.createObjectURL(myBlob);
image = document.createElement('img');
image.src = objectURL;
document.body.appendChild(image);
}).catch((error) => {
console.log('There has been a problem with your fetch operation: ' + error.message);
});
console.log ('All done!');
ブラウザがコードを実行すると、まず最初のconsole.log()
でStarting
を表示し、次にimage
変数をセットします。
その後は次の行に進んでfetch()
ブロックの実行をはじめますが、fetch()
は非同期実行されるため、メイン処理はPromise関連のコードより先に一番最後のconsole.log()
まで辿り着き、All done!
を出力します。
fetch()
ブロックはファイルのフェッチ処理が終了すると、次の.then()
ブロックに進み、It worked :)
をconsole.log()
に出力します。
そのため、このメッセージはあなたが思っていたのと異なる順番で表示されることでしょう。
・Starting
・All done!
・It worked :)
よくわからない場合は、次の小さな例を考えてみてください。
console.log("registering click handler");
button.addEventListener('click', () => {
console.log("get click");
});
console.log("all done");
これは、先ほどの例とよく似た動作をします。
最初と最後のconsole.log()
メッセージは即座に表示されますが、2番目のget click
メッセージは、マウスのボタンをクリックするまで表示されません。
Promiseの例では、クリックされるのを待つかわりにリソースの取得が終わるまで待つということになります。
これらのトリビアルな例が示すように、非同期コードは処理順を認識しておかなければ問題を引き起こす可能性があります。
非同期コードブロックは、後で同期コードで使用するために結果を返すようなことはできません。
ブラウザが同期ブロックを処理するより前に、非同期コードが値を返すことを保証しません。
この動作を確認するために、最初の例の3番目のconsole.log()
に返り値を入れてみましょう。
console.log ('All done! ' + image.src + 'displayed.');
コンソールには、メッセージではなくエラーが表示されるはずです。
TypeError: image is undefined; can't access its "src" property
ブラウザが3個目のconsole.log()
を実行しようとした時点では、まだfetch()
ブロックの動作が完了していないため、image
がまだ定義されていないからです。
注:セキュリティ上の理由から、ローカルのファイルに対してfetch()
、もしくは類似の操作を行うことはできません。
上記の例をローカルで再現するためには、ローカルWebサーバを介して実行する必要があります。
Active learning: make it all async!
問題のあるfetch()
の例を修正し、3個のconsole.log()
が想定したとおりに実行されるためには、3番目のconsole.log()
も非同期に実行する必要があります。
これは、2番目のブロックが終わった後に実行されるもうひとつの.then()
ブロックを追加するか、あるいは単純に2つめの.then()
ブロックの末尾に移動すれば修正できます。
今すぐ試してみてください。
注:行き詰まった場合は、こちらで答えを確認することができます。
あるいはライブに確認できます。
また、後のほうで出てくるGraceful asynchronous programming with Promisesにおいて、Promiseに関するより詳しい情報を知ることができます。
Conclusion
最も基礎的な部分では、JavaScriptは同期・ブロッキング・シングルスレッド言語であり、一度にひとつの動作しか実行することができません。
しかし、Webブラウザは同期実行されない関数を登録する関数およびAPIを提供しています。
そのかわり、これらは何らかのイベントが発生したとき(一定時間経過、ユーザ操作やマウス入力、ネットワーク経由のデータ到着など)に、非同期で呼び出す必要があります。
これらを使うことで、メインスレッドを停止したりブロッキングしたりすることなく、複数の処理を同時に実行させることができるようになります。
コードを同期的に実行するか非同期的に実行するかは、その目的によって決定すべきものです。
対象をすぐにロードして実行したい場合があります。
たとえばユーザ定義スタイルをWebページに適用したい場合、できるかぎり早くスタイルを取り込む必要があるでしょう。
しかし、データベースへのクエリやその結果を使ったテンプレートの作成など、時間のかかる操作を実行する場合は、これをメインスレッドから分離して、非同期にタスクを実行した方がよいでしょう。
時が経つにつれ、この場合は同期ではなく非同期を選択した方が理に叶っていたということがわかります。
In this module
一般的な非同期プログラミングの概念
非同期JavaScriptの紹介
協調型非同期JavaScript:タイムアウトとインターバル
Promiseによる洗練された非同期プログラミング
async/awaitによる簡単な非同期プログラミング
適切な技術を選択する
感想
MDNは、有象無象の氾濫するフロントエンド界隈において、最も信頼できる情報源のひとつです。
わけのわからない変なブログを見るくらいなら、MDNを見た方がよっぽど役に立つことが多いです。
まあ『最も』とか言っちゃうとソースやらW3Cやら見るのが最も適切だって話になるのですが、あんなもの一般人は読んでられませんしね。
あと、たまにMSDN(自動翻訳の混沌)と間違える。
本当はこの記事もMDNにコミットするつもりだったんだけど、構文の変換が想像以上に面倒だったので諦めました。
誰かかわりに送ってやって。
それにしても、ほぼ公式みたいなMDNが未だに訳されてないというのはびっくりですね。