Promiseと配列のように、配列を元にした複数の非同期処理を扱う場合のAsync Functionについて見ていきます。
例として、複数のリソースを順番に取得する処理をPromiseで書いていきます。
まずは、Promiseを使った非同期処理を行う関数として、リソースを擬似的に取得するdummyFetch
という関数を実装していきます。
dummyFetch
関数は擬似的にデータ取得する関数で、1000ミリ秒未満のランダムなタイミングでレスポンスを返す非同期的な処理です。
パスが/resource
から始まるリソースを取得した場合は、そのレスポンスをもったResolved状態のPromiseオブジェクトを返します。
それ以外の場合は、リソースの取得に失敗したとしてRejected状態のPromiseオブジェクトを返します。
/**
* 1000ミリ秒未満のランダムなタイミングでレスポンスを擬似的にデータ取得する関数
* 指定した`path`にデータがある場合、成功として**Resolved**状態のPromiseオブジェクトを返す
* 指定した`path`にデータがない場合、失敗として**Rejected**状態のPromiseオブジェクトを返す
*/
function dummyFetch(path) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (path.startsWith("/resource")) {
resolve({ body: `Response body of ${path}` });
} else {
reject(new Error("NOT FOUND"));
}
}, 1000 * Math.random());
});
}
// `then`メソッドで成功時と失敗時に呼ばれるコールバック関数を登録
// /resource/A のリソースは存在するので成功しonFulfilledが呼ばれる
dummyFetch("/resource/A").then((response) => {
console.log(response); // => { body: "Response body of /resource/A" }
}, (error) => {
// この行は実行されません
});
// /not_found のリソースは存在しないのでonRejectedが呼ばれる
dummyFetch("/not_found").then((response) => {
// この行は実行されません
}, (error) => {
console.log(error.message); // => "NOT FOUND"
});
このdymmyFetch
関数を使い、複数のリソースを順番に取得するfetchResources
関数を実装していきます。
fetchResources
関数は、配列で複数のリソースへのパスを受け取り、取得したリソースの中身(body
)を配列として返すことにします。
まずは、Promise APIのみを使ってfetchResources
関数を実装してみましょう。
Promise APIでは、Array#reduce
メソッドを使った逐次処理を実装することで、複数の非同期処理を順番に実行できます。
(詳細はPromiseによる逐次処理を参照)
function dummyFetch(path) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (path.startsWith("/resource")) {
resolve({ body: `Response body of ${path}` });
} else {
reject(new Error("NOT FOUND"));
}
}, 1000 * Math.random());
});
}
// 複数のリソースを取得し、レスポンスボディの配列を返す
function fetchResources(resources) {
const results = [];
// 配列をpromise chainにして順番に処理する
return resources.reduce((promise, resource) => {
return promise.then(() => {
return dummyFetch(resource).then((response) => {
results.push(response.body);
return results;
});
});
}, Promise.resolve());
}
const resources = ["/resource/A", "/resource/B"];
// リソースを取得して出力する
fetchResources(resources).then((results) => {
console.log(results); // => ["Response body of /resource/A", "Response body of /resource/B"]
});
次に、先ほどと同じ処理をするfetchResources
をAsync Functionとawait
式で書いてみます。
Async Functionとして定義したfetchResources
関数では、forループの中でawait
式を使うことで複数の非同期処理を順番に実行できます。
次のコードでは、リソースのパスをforループで順番に、dummyFetch
関数を使ってリソースの中身を取得しています。
forループによる反復処理もawait
式でdummyFetch
関数の完了を待っているため、その非同期処理が終ってから次の反復処理を行います。
すべてのforループの処理が終わると、fetchResources
関数が返したPromiseオブジェクトが変数results
が参照する値でresolveされます。
function dummyFetch(path) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (path.startsWith("/resource")) {
resolve({ body: `Response body of ${path}` });
} else {
reject(new Error("NOT FOUND"));
}
}, 1000 * Math.random());
});
}
// 複数のリソースを取得し、レスポンスボディの配列を返す
async function fetchResources(resources) {
const results = [];
for (let i = 0; i < resources.length; i++) {
const resource = resources[i];
const response = await dummyFetch(resource);
results.push(response.body);
}
return results;
}
const resources = ["/resource/A", "/resource/B"];
// リソースを取得して出力する
fetchResources(resources).then((results) => {
console.log(results); // => ["Response body of /resource/A", "Response body of /resource/B"]
});
Promise APIのみでfetchResources
関数書いた場合は、コールバックの中で処理するためややこしい見た目になりがちです。
一方で、Async Functionとawait
式を使った場合は、非同期処理での取得と追加を順番に行うだけとなりネストがなく見た目はシンプルです。
先ほどのfetchResources
関数ではforループを利用していました。
このとき、配列の反復処理にArray#forEach
メソッドを利用したくなるかもしれません。
しかし、次のようにforループをArray#forEach
に変更するだけでは、構文エラー(Syntax Error)となってしまいます。
async function fetchResources(resources) {
const results = [];
// Syntax Errorとなる例
resources.forEach(function(resources) {
const resource = resources[i];
// Async Functionではないスコープで`await`式を利用しているためSyntax Errorとなる
const response = await dummyFetch(resource);
results.push(response.body);
});
return results;
}
これは、await
式はAsync Functionの直下でのみ利用できるからです。
Async Functionではない通常の関数でawait
式を使うとSyntax Errorとなります。
これは間違ったawait
式の使い方を防止するための仕様です。
function main(){
// Syntax Error
await Promise.resolve();
}
Async Function内でawait
式を使って処理を待っている間も、関数の外側では通常とおり処理が進みます。
次のコードを実行してみると、Async Function内でawait
しても、Async Function外の処理は停止していないことがわかります。
async function asyncMain() {
// 中でawaitしても、Async Functionの外側の処理まで止まるわけではない
await new Promise((resolve) => {
setTimeout(resolve, 16);
});
}
console.log("1. asyncMain関数を呼び出します");
// Async Functionは外から見れば単なるPromiseを返す関数
asyncMain().then(() => {
console.log("3. asyncMain関数が完了しました");
});
// Async Functionの外側の処理はそのまま進む
console.log("2. asyncMain関数外では、次の行が同期的に呼び出される");
このようにawait
式で非同期処理を一時停止しても、Async Function外の処理が停止するわけではありません。
Async Function外の処理も停止できてしまうと、JavaScriptでは基本的にメインスレッドで多くの処理をするため、UIを含めた他の処理が止まってしまいます。
これがawait
式がAsync Functionの外で利用できない理由の一つです。
await
式はAsync Functionの中でのみ利用可能なため、コールバック関数もAsync Functionとして定義しないとawait
式が利用できないことに注意してください。
そのため、fetchResources
関数のArray#forEach
メソッドのコールバック関数に対して、async
キーワードをつけることで構文エラーは発生しなくなります。
この場合は、コールバック関数がAsync Functionとなるため、コールバック関数内でawait
式が利用できます。
しかし、コールバック関数をAsync Functionに修正するだけでは、fetchResources
関数が意図した結果を返しません。
次のようにArray#forEach
メソッドのコールバック関数をAsync Functionにしてみます。
このコードを実行してみると、fetchResources
関数の返したPromiseの結果は空の配列となり、意図した結果にならないことが分かります。
function dummyFetch(path) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (path.startsWith("/resource")) {
resolve({ body: `Response body of ${path}` });
} else {
reject(new Error("NOT FOUND"));
}
}, 1000 * Math.random());
});
}
// リソースを順番に取得する
async function fetchResources(resources) {
const results = [];
resources.forEach(async(resource) => {
const response = await dummyFetch(resource);
results.push(response.body);
});
return results;
}
const resources = ["/resource/A", "/resource/B"];
// リソースを取得して出力する
fetchResources(resources).then((results) => {
// resultsは空になってしまう
console.log(results); // => []
});
forEach
メソッドのコールバック関数としてAsync Functionを渡し、コールバック関数中でawait
式を利用して非同期処理の完了を待っています。
しかし、この非同期処理の完了を待つのはコールバック関数Async Functionの中だけで、外側ではfetchResources
関数の処理が進んでいます。
そのため、コールバック関数でresults
に結果を追加する前に、fetchResources
関数はその時点の変数results
の値でresolveしてしまいます。
次のようにfetchResources
関数にコンソール出力を入れてみると動作が分かりやすいでしょう。
forEach
メソッドのコールバック関数が完了するのは、fetchResources
関数の呼び出しがすべて終わった後になります。
そのためfetchResources
関数はその時点の変数results
の値である空の配列でresolveします。
function dummyFetch(path) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (path.startsWith("/resource")) {
resolve({ body: `Response body of ${path}` });
} else {
reject(new Error("NOT FOUND"));
}
}, 1000 * Math.random());
});
}
// リソースを順番に取得する
async function fetchResources(resources) {
const results = [];
console.log("1. fetchResourcesを開始");
resources.forEach(async(resource) => {
console.log(`2. ${resource}の取得開始`);
const response = await dummyFetch(resource);
console.log(`3. ${resource}の取得完了`);
results.push(response.body);
});
console.log("4. fetchResourcesを終了");
return results;
}
const resources = ["/resource/A", "/resource/B"];
// リソースを取得して出力する
fetchResources(resources).then((results) => {
console.log(results); // => []
});
この問題を解決する方法として、先ほどのようにコールバック関数を使わずにforループを使う方法があります。
また、リソースを順番が重要ではない場合は、Promise.all
メソッドを使い、複数の非同期処理を1つのPromiseとしてまとめる方法があります。
Async Functionとawait
式でも非同期処理を同期処理のような見た目で書けます。
しかし、非同期処理は必ずしも順番に処理することが重要ではない場合があります。
その際に、forループとawait
式で書くと複数の非同期処理を順番に行ってしまい無駄な待ち時間を作ってしまうコードになってしまいます。
先ほどfetchResources
関数ではリソースAを取得し終わってから、リソースBを取得していました。
このとき、取得順が変わっても問題無い場合は、リソースAとリソースBを同時に取得する方が効率的です。
Promise.all
メソッドを使い、リソースAとリソースBを取得する非同期処理を1つのPromise
インスタンスにまとめることができます。
await
式が評価するのはPromise
インスタンスであるため、await
式はPromise.all
メソッドなどPromise
インスタンスを返す処理と組み合わせて利用できます。
そのため、先ほどfetchResources
関数でリソースを同時に取得する場合は、次のように書けます。
Promise.all
メソッドは複数のPromiseを配列で受け取り、それを1つのPromiseとしてまとめたものを返す関数です。
Promise.all
メソッドの返すPromise
インスタンスをawait
することで、非同期処理の結果を配列としてまとめて取得できます。
function dummyFetch(path) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (path.startsWith("/resource")) {
resolve({ body: `Response body of ${path}` });
} else {
reject(new Error("NOT FOUND"));
}
}, 1000 * Math.random());
});
}
// 複数のリソースを取得しレスポンスボディの配列を返す
async function fetchResources(resources) {
// リソースをまとめて取得する
const promises = resources.map((resource) => {
return dummyFetch(resource);
});
// すべてのリソースが取得できるまで待つ
// Promise.allは [ResponseA, ResponseB] のように結果が配列となる
const responses = await Promise.all(promises);
// 取得した結果からレスポンスのボディだけを取り出す
return responses.map((response) => {
return response.body;
});
}
const resources = ["/resource/A", "/resource/B"];
// リソースを取得して出力する
fetchResources(resources).then((results) => {
console.log(results); // => ["Response body of /resource/A", "Response body of /resource/B"]
});
このようにAsync Functionやawait
式は既存のPromise APIと組み合わせて利用できます。
Async Functionも内部的にPromiseの仕組みを利用した構文です。
そのため、Async FunctionとPromiseのAPIを組み合わせて考えることは重要です。