こんにちはWebエンジニアのmasakichiです。
JavaScriptの非同期を理解するにはMDN Web Docsを読むべき。翻訳しといたよ 1つ目の記事です。
全部で5記事あります。
- Introducing asynchronous JavaScript ←いまここ
- Cooperative asynchronous JavaScript: Timeouts and intervals
- Graceful asynchronous programming with Promises
- Making asynchronous programming easier with async and await
- Choosing the right approach
翻訳箇所について
こちらのページの日本語未翻訳記事です。
なぜ翻訳したか
JavaScriptの非同期処理。特にPromiseというやつが、ぼんやりわかっているけど完全には理解していない状態がずっと続いていました。
そんな中、MDN Web Docsの解説がすごくわかりやすく一気に理解できました。
しかし、これらの記事は日本語に翻訳されていないという問題が。。。
ぜひ非同期処理で悩める同志にも読んでほしい。という想いで翻訳作業をしてみました。
留意点
筆者は英語がそこまで得意というわけではありません。DeepLの力を借りて、翻訳していますので、日本語訳が不自然なところや一部、情報を省略・意訳しています。あらかじめご了承ください。
ライセンスは下記です。
This Article by Mozilla Contributors is licensed under CC-BY-SA 2.5.
非同期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);
});
各操作が処理されている間は、他のことは何もできません - レンダリングは一時停止されます。これは、前回の記事で述べたように、JavaScriptがシングルスレッドであることに起因しています。1つのメインスレッドで一度に発生するのは1つのことだけであり、他のすべては操作が完了するまでブロックされます。
非同期JavaScript
先に説明した理由(ブロッキングに関連するものなど)により、現在では多くのWebAPI機能が非同期コードを使用して実行されています。特に、ネットワークからファイルを取得する、データベースにアクセスしてデータを返す、WebCamからビデオストリームにアクセスする、VRヘッドセットに表示をブロードキャストするなど、外部デバイスから何らかのリソースにアクセスしたり取得したりするものがそうです。
なぜ、非同期コードを使うと難しいのでしょうか?簡単な例を見てみましょう。サーバーから画像を取得する場合、その結果をすぐに返すことはできません。つまり、次のような(擬似)コードは動作しないことになります。
let response = fetch('myImage.png'); // fetch is asynchronous
let blob = response.blob();
// display your image blob in the UI somehow
なぜなら、画像のダウンロードにどれくらい時間がかかるかわからないからです。そのため、2行目を実行しようとすると、まだレスポンスが得られていないため、(断続的に、あるいは毎回)エラーを投げてしまうのです。その代わりに、レスポンスが返ってくるまで待機するコードが必要なのです。
JavaScriptのコードで目にする非同期コードのスタイルには、大きく分けて旧来のコールバック型と新しいプロミス型の2種類があります。以下のセクションでは、それぞれを順番にレビューしていきます。
非同期コールバック
非同期コールバックは、バックグラウンドでコードの実行を開始する関数を呼び出す際に、引数として指定される関数です。バックグラウンドのコードの実行が終了すると、コールバック関数が呼び出され、作業が完了したことを知らせたり、興味深いことが起こったことを知らせたりします。コールバックの使用は、今では少し古くなりましたが、古いけれどもよく使われるAPIでは、まだ使われているのを見かけることができます。
非同期コールバックの例として、addEventListener()メソッドの2番目のパラメータがあります(上のアクションで見たとおりです)。
btn.addEventListener('click', () => {
alert('You clicked me!');
let pElem = document.createElement('p');
pElem.textContent = 'This is a newly-added paragraph.';
document.body.appendChild(pElem);
});
最初のパラメータはリスニングするイベントのタイプで、2番目のパラメータはイベントが発生したときに呼び出されるコールバック関数です。
コールバック関数を他の関数に引数として渡す場合、関数の参照を引数として渡しているだけであり、コールバック関数はすぐには実行されません。コールバック関数を内包する関数に非同期的に「呼び出されます」(だからコールバック)。コールバック関数を実行するのは、コールバック関数を内包する関数なのです。
コールバックを含む独自の関数は簡単に書くことができます。XMLHttpRequestAPIを使ってリソースをロードする例を見てみましょう。
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);
ここでは、オブジェクトの URL として渡された blob を表す displayImage() 関数を作成し、その URL を表示する画像を作成して、ドキュメントの <body>
に追加しています。そして次に、画像を取得する URL ・コンテンツタイプ・コールバックの3つをパラメータとして受け取る loadAsset() 関数を作成します。XMLHttpRequest(しばしば「XHR」と省略されます)を使用して、指定されたURLのリソースを取得し、その応答をコールバックに渡して処理を行います。この場合、コールバックは 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
プロミスは、最近のWeb APIで使われている新しいスタイルの非同期コードです。その良い例が fetch() API で、基本的には XMLHttpRequest の現代的でより効率的なバージョンのようなものです。サーバーからのデータ取得の記事から、簡単な例を見てみましょう。
fetch('products.json').then(function(response) {
return response.json();
}).then(function(json) {
let products = json;
initialize(products);
}).catch(function(err) {
console.log('Fetch problem: ' + err.message);
});
ここでは、fetch()が1つのパラメータ(ネットワークから取り出したいリソースのURL)を受け取り、プロミスを返す様子を示しています。プロミスは、非同期処理の完了または失敗を表すオブジェクトです。いわば、中間状態を表しています。要するに、ブラウザが「できるだけ早く答えを返すことを約束します」という意味で、"promise "という名前になっているのです。
この考え方は、慣れるまでちょっとシュレーディンガーの猫のような感じがします。どちらの結果もまだ起こっていないので、フェッチ操作は現在、将来のある時点でブラウザが操作を完了しようとした結果を待っている状態です。fetch()の末尾には、さらに3つのコードブロックが連結されています。
- 2つのthen()ブロックです。両方とも、直前の操作が成功した場合に実行されるコールバック関数を含んでおり、それぞれのコールバックは直前の成功した操作の結果を入力として受け取るので、前に進んでそれに対して何か他の操作をすることができます。それぞれの .then() ブロックは別のプロミスを返すので、複数の .then() ブロックを互いに連結して、複数の非同期処理を順番に実行させることができることを意味します。
- 最後の catch() ブロックは、 .then() ブロックのいずれかが失敗した場合に実行されます。同期 try...catch ブロックと同様の方法で、エラーオブジェクトが catch() の中で利用可能になり、発生したエラーの種類を報告するために使用することができます。しかし、同期try...catchはプロミスでは動作しませんが、後で学ぶようにasync/awaitでは動作することに注意してください。
イベントキュー
プロミスのような非同期処理はイベントキューに入れられ、メインスレッドが処理を終えた後に実行されるので、後続のJavaScriptコードの実行をブロックすることがありません。キューに入れられた処理は、できるだけ早く完了し、その結果を JavaScript 環境に返します。
プロミス vs コールバック
プロミスは旧来のコールバックと類似している部分があります。プロミスは基本的にreturnされたオブジェクトにコールバック関数に渡すもので、関数の中にコールバックを渡す必要はありません。
しかし、プロミスは非同期処理を行うために特化して作られており、旧来のコールバックと比較して多くの利点があります。
- 複数の.then()を使って複数の非同期処理を連鎖させ、一つの結果を次の処理に入力として渡すことができる。これはコールバックでは非常に難しく、しばしば厄介な「破滅のピラミッド」(コールバック地獄とも呼ばれる)になってしまう。
- Promiseコールバックは、常にイベントキューに入れられた厳密な順番で呼び出されます。
- エラー処理は、「ピラミッド」の各レベルで個別に処理されるのではなく、ブロックの最後にある単一の .catch() ブロックですべてのエラーが処理されます。
- プロミスは、サードパーティライブラリにコールバックを渡すと関数がどのように実行されるかの完全な制御を失う古いスタイルのコールバックとは異なり、制御の逆転を回避することができます。
非同期コードの性質
非同期コードの性質をさらに説明する例として、コードの実行順序を十分に意識しない場合に起こりうること、非同期コードを同期コードのように扱おうとした場合の問題点などを探ってみましょう。次の例は、以前に見たものとかなり似ています。ひとつ違うのは、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() はブロックせずに非同期で実行されるため、プロミス関連のコードの後もコードの実行が続き、最後の console.log() 文 (All done!) に到達してコンソールに出力されることになります。
fetch() ブロックの実行が完全に終了し、その結果を .then() ブロックで配信して初めて、ようやく 2 つ目の console.log() メッセージ (It worked) が表示されるのです。このように、メッセージは期待とは異なる順序で表示されています。
もし、これで混乱したのなら、次のような小さな例を考えてみてください。
console.log("registering click handler");
button.addEventListener('click', () => {
console.log("get click");
});
console.log("all done");
この動作が先程のコードと非常に似ています。1つ目と3つ目のconsole.log()メッセージはすぐに表示されますが、2つ目のメッセージは誰かがマウスボタンをクリックするまで実行されません。前の例も同じように動作しますが、この場合、2つ目のメッセージはクリックではなく、リソースを取得して画面に表示するプロミスチェーンでブロックされます。
この些細なコードの設定例は問題を引き起こす可能性があります。つまり、同期処理にその結果を依存されている非同期コードブロックを設定することはできないのです。ブラウザが同期ブロックを処理する前に非同期関数が返されることを保証できないからです。
結論
JavaScriptは最も基本的な形式として、同期、ブロッキング、シングルスレッド言語であり、一度に一つの処理しか行うことができません。しかし、Webブラウザは関数やAPIを定義しており、同期的に実行されないで、何らかのイベント(時間の経過、ユーザーのマウス操作、ネットワーク上のデータの到着など)が発生したときに非同期に呼び出すべき関数を登録することができる。つまり、メインスレッドを停止したりブロックしたりすることなく、コードに複数の処理を同時に行わせることができるのです。
コードを同期的に走らせるか、非同期的に走らせるかは、何をしようとしているかによって変わってきます。
物事をロードしてすぐに実行させたい場合があります。例えば、ユーザー定義のスタイルをウェブページに適用する場合、できるだけ早くスタイルを適用したいでしょう。
しかし、データベースに問い合わせを行い、その結果をテンプレートに反映させるような時間のかかる処理を行う場合は、スタックから外して非同期にタスクを完了させた方がよいでしょう。時間をかければ、どのような場合に同期ではなく非同期の手法を選択するのがより理にかなっているかがわかるようになります。