こんにちはWebエンジニアのmasakichiです。
JavaScriptの非同期を理解するにはMDN Web Docsを読むべき。翻訳しといたよ 5つ目の記事です。
全部で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の基本をそれなりに理解していること。
目的
様々な非同期プログラミング技術を使用するタイミングを適切に選択できるようになる。
非同期コールバック
一般に古いスタイルのAPIでは、ある関数が別の関数にパラメータとして渡され、その関数が非同期処理の完了時に呼び出され、コールバックがその結果に対して何かを行うというものである。これはプロミスの前身となるもので、効率や柔軟性に欠ける。必要なときだけ使ってください.
コード例
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);
落とし穴
- コールバックが入れ子になっていると、煩雑で読みにくい(=「コールバック地獄」)。
- コールバック関数のエラーで起きた場合はネストの各レベルで一度呼ばれる必要がありますが、プロミスではチェーン全体のエラーを処理するために .catch() ブロックをひとつ使うだけで済みます。
- 非同期コールバックはあまり優雅ではありません。
- プロミスコールバックはイベントキューに入れられた厳密な順序で常に呼び出されます; 非同期コールバックはそうではありません。
- 非同期コールバックは、サードパーティライブラリに渡されたときに、関数がどのように実行されるかの完全な制御を失います。
ブラウザの互換性
APIにおけるコールバックの正確なサポートは、特定のAPIに依存します。より具体的なサポート情報については、使用しているAPIのリファレンスドキュメントを参照してください。
setTimeout()
setTimeout()は、任意の時間が経過した後に関数を実行することができるメソッドです。
コード例
ここでは、ブラウザは匿名関数を実行する前に2秒間待機し、その後アラートメッセージを表示します。
let myGreeting = setTimeout(function() {
alert('Hello, Mr. Universe!');
}, 2000)
落とし穴
再帰的なsetTimeout()呼び出しは、setInterval()と同様に、次のようなコードで、関数を繰り返し実行することができます。
let i = 1;
setTimeout(function run() {
console.log(i);
i++;
setTimeout(run, 100);
}, 100);
再帰的なsetTimeout()とsetInterval()には違いがあります。
- 再帰的な setTimeout() は、少なくとも指定した時間 (この例では 100 ミリ秒) の実行間隔を保証します。この間隔は、コードの実行時間に関係なく同じになります。
- setInterval() では、選択した間隔に、実行したいコードの実行にかかる時間が含まれます。例えば、コードの実行に 40 ミリ秒かかるとすると、間隔を 60 ミリ秒に設定するだけです。
コードの実行時間が割り当てた時間間隔よりも長くなる可能性がある場合は、再帰的な setTimeout() を使用したほうがよいでしょう - これならコードの実行時間に関係なく実行間隔を一定に保つことができ、エラーになることもありません。
setInterval()
setInterval()は、関数の実行間隔を設定して繰り返し実行することができるメソッドです。requestAnimationFrame()ほど効率的ではありませんが、実行速度/フレームレートを選択することができます。
コード例
次の関数は、新しい Date() オブジェクトを作成し、toLocaleTimeString() を使ってそこから時間文字列を取り出し、UIに表示します。そして、setInterval()を使って1秒に1回実行し、1秒に1回更新されるデジタル時計の効果を作り出しています。
function displayTime() {
let date = new Date();
let time = date.toLocaleTimeString();
document.querySelector('.clock').textContent = time;
}
displayTime();
const createClock = setInterval(displayTime, 1000);
落とし穴
フレームレートはアニメーションが実行されているシステムに最適化されておらず、やや非効率的である可能性があります。特定の (遅い) フレームレートを選択する必要がない限り、一般的には requestAnimationFrame() を使用したほうがよいでしょう。
requestAnimationFrame()
requestAnimationFrame() は、現在のブラウザ/システムで利用可能な最高のフレームレートで、関数を繰り返し効率的に実行できるようにするメソッドです。特定のフレームレートが必要な場合を除き、可能な限り setInterval()/recursive setTimeout() の代わりにこのメソッドを使用する必要があります。
コード例
シンプルなアニメーションのスピナーです。
const spinner = document.querySelector('div');
let rotateCount = 0;
let startTime = null;
let rAF;
function draw(timestamp) {
if(!startTime) {
startTime = timestamp;
}
rotateCount = (timestamp - startTime) / 3;
if(rotateCount > 359) {
rotateCount %= 360;
}
spinner.style.transform = 'rotate(' + rotateCount + 'deg)';
rAF = requestAnimationFrame(draw);
}
draw();
落とし穴
requestAnimationFrame()では、特定のフレームレートを選択することができません。より遅いフレームレートでアニメーションを実行する必要がある場合は、 setInterval() や再帰的な setTimeout() を使用する必要があります。
Promises
プロミスは、非同期処理を実行し、それが確実に完了するまで待ってから、その結果に基づいて別の処理を実行することができるJavaScriptの機能である。プロミスは現代の非同期JavaScriptのバックボーンとなっています。
コード例
次のコードは、サーバーから画像を取得し、<img>
要素内に表示するものです。
fetch('coffee.jpg')
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
} else {
return response.blob();
}
})
.then(myBlob => {
let objectURL = URL.createObjectURL(myBlob);
let image = document.createElement('img');
image.src = objectURL;
document.body.appendChild(image);
})
.catch(e => {
console.log('There has been a problem with your fetch operation: ' + e.message);
});
落とし穴
プロミスチェーンは複雑で、解析が困難な場合があります。いくつものプロミスをネストすると、コールバック地獄と似たようなトラブルに見舞われることがあります。例えば
remotedb.allDocs({
include_docs: true,
attachments: true
}).then(function (result) {
let docs = result.rows;
docs.forEach(function(element) {
localdb.put(element.doc).then(function(response) {
alert("Pulled doc with id " + element.doc._id + " and added to local db.");
}).catch(function (err) {
if (err.name == 'conflict') {
localdb.get(element.doc._id).then(function (resp) {
localdb.remove(resp._id, resp._rev).then(function (resp) {
// et cetera...
プロミスの連鎖力を利用して、よりフラットでパースしやすい構造で行く方が良いのです。
remotedb.allDocs(...).then(function (resultOfAllDocs) {
return localdb.put(...);
}).then(function (resultOfPut) {
return localdb.get(...);
}).then(function (resultOfGet) {
return localdb.put(...);
}).catch(function (err) {
console.log(err);
});
もしくは、こうも書けます。
remotedb.allDocs(...)
.then(resultOfAllDocs => {
return localdb.put(...);
})
.then(resultOfPut => {
return localdb.get(...);
})
.then(resultOfGet => {
return localdb.put(...);
})
.catch(err => console.log(err));
これで基本的なことはかなりカバーできたと思います。もっと完全な扱いについては、Nolan Lawsonによる優れたWe have a problem with promises をご覧ください。
Promise.all()
複数のプロミスが完了するのを待ってから、他のすべてのプロミスの結果に基づいてさらに処理を実行することができるJavaScriptの機能です。
コード例
次の例では、サーバーから複数のリソースを取得し、Promise.all()を使ってそれらのリソースがすべて利用可能になるのを待ってから、すべてのリソースを表示します。
function fetchAndDecode(url, type) {
return fetch(url).then(response => {
if(type === 'blob') {
return response.blob();
} else if(type === 'text') {
return response.text();
}
})
.catch(e => {
console.log(`There has been a problem with your fetch operation for resource "${url}": ` + e.message);
});
}
let coffee = fetchAndDecode('coffee.jpg', 'blob');
let tea = fetchAndDecode('tea.jpg', 'blob');
let description = fetchAndDecode('description.txt', 'text');
Promise.all([coffee, tea, description]).then(values => {
console.log(values);
let objectURL1 = URL.createObjectURL(values[0]);
let objectURL2 = URL.createObjectURL(values[1]);
let descText = values[2];
let image1 = document.createElement('img');
let image2 = document.createElement('img');
image1.src = objectURL1;
image2.src = objectURL2;
document.body.appendChild(image1);
document.body.appendChild(image2);
let para = document.createElement('p');
para.textContent = descText;
document.body.appendChild(para);
});
落とし穴
Promise.all()が拒否する場合、その配列パラメータに入力されている1つ以上のプロミスが拒否しているか、プロミスを全く返さない可能性があります。あるいは、プロミスを返さないかもしれません。
Async/await
プロミスの上に構築された糖衣構文で、同期のコールバックコードを書くような構文で非同期処理を実行することができます。
コード例
以下の例は、以前見た画像を取得して表示するシンプルなプロミスの例をリファクタリングして、async/awaitを使って書いたものです。
async function myFetch() {
let response = await fetch('coffee.jpg');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
} else {
let myBlob = await response.blob();
let objectURL = URL.createObjectURL(myBlob);
let image = document.createElement('img');
image.src = objectURL;
document.body.appendChild(image);
}
}
myFetch()
.catch(e => {
console.log('There has been a problem with your fetch operation: ' + e.message);
});
落とし穴
- await 演算子は、非同期関数の内部や、コードのトップレベルのコンテキストで使用することはできません。このため、ラッパーの作成が必要になることがあります。これは、状況によっては少しイライラするかもしれませんが、たいていの場合、それだけの価値があります。
- ブラウザの async/await のサポートは promises のサポートほど良くはありません。もしあなたがasync/awaitを使いたいが、古いブラウザのサポートが気になるなら、BabelJSライブラリの使用を検討することができます - これは、最新のJavaScriptを使ってアプリケーションを書き、ユーザーのブラウザに必要な変更があれば、Babelがそれを判断することを可能にします。