最近身の回りにJavaScriptを使う人が増えています。フロントエンドエンジニア(自称)としては嬉しい限りです。そんな中、非同期処理について聞かれることが多いので、初学者にもなるべくわかりやすく説明してみたいと思います。
インターネットでJavaScriptの非同期処理について検索すると、コールバック、Promise、async/await...というように、難しい話がたくさん出てきて混乱すると思います。
非同期処理についてはコールバック関数、Promise、async/awaitの順番で学んでいくのが普通ですが、コールバック関数、Promiseは少し難しいので、以下の順番で説明していこうと思います。
- async/await
- コールバック関数
- Promise
- 非同期処理tips
async/await
まず先に非同期処理の概念について説明したいところですが、後輩がとてもわかりやすく説明してくれているのでここでは割愛します。
「非同期処理とは何か」から知りたい方は『2021/9 新人が知らない技術を身に着けるまで(Node.js非同期処理編)』を先に読んでみてください。
それではいよいよ説明に入りますが、まずはコールバック関数、Promiseのことは一旦忘れてください。 JavaScriptの非同期処理はasync/awaitだけだと思いこみましょう。
async/awaitは以下のようにして使います。
async function getUserAccount() {
...
// 非同期処理
const response = await fetch('https://example.com/user');
...
}
ここで出てくるfetch
はサーバにリクエストを送信する非同期関数です。そのため、普通はfetch
を実行してもレスポンスが返ってくるのを待たずに次の行を実行します。
fetch
の前にawait
をつけることで、fetch
の処理が最後まで完了してから次の行を実行させることができます。つまり、await
をつけると上から下へ一行ずつ処理を終わらせてから進んでいきます。非同期処理は処理の順番がややこしくなりますが、その順番を意識しなくていいので楽ですね。
await
したいときは今書いている関数にasync
をつけてあげる必要がありますので、async/awaitはセットで使います。
どのタイミングでasync/awaitを使うかがわからないという人もいると思います。とりあえず、非同期関数が出てきたらawait
を使う、await
を使いたいからasync
をつける、と覚えておきましょう。
そして、async
がついた関数も非同期になります。そのため、以下のlog
関数は期待通りに動作しません。
async function getUserAccount() {
...
// 非同期処理
const response = await fetch('https://example.com/user/1');
if (!response.ok) {
return 'ユーザアカウント情報を取得できませんでした';
}
return 'ユーザアカウント情報を取得できました';
}
function log() {
// ユーザアカウント情報を取得
const message = getUserAccount();
console.log(message);
}
log();
// Promise?{<pending>}
getUserAccount
はasync
をつけたことによって非同期処理となっているので、getUserAccount()
を実行する際もawait
をしないと処理が終わる前に次の行のconsole.log
を実行してしまいます。
getUserAccount
の処理が完了してから次を実行してほしいため、log
関数を以下の様に修正します。
// getUserAccountをawaitしたいのでasyncを前につける
async function log() {
// getUserAccountは非同期関数のため、awaitする
const message = await getUserAccount();
console.log(message);
}
log()
// ユーザアカウント情報を取得できませんでした
// もしくは
// ユーザアカウント情報を取得できました
繰り返しますが、非同期関数が出てきたらasync/awaitをつける、asyncをつけた関数も非同期関数になると覚えておきましょう。
コールバック関数
async/awaitは新しい書き方ということもあり見た目もスッキリしていて実装も簡単ですが、非同期処理の歴史はコールバック関数から始まりました。
コールバック関数とは、以下のような関数に渡す関数のことです。
function hello(callbackFunction) {
console.log('hello');
callbackFunction(); // 引数で受け取った関数(コールバック関数)の実行
}
function world() {
console.log('world');
}
hello(world); // 関数helloの引数として関数worldを渡している。
関数に関数を渡すことに違和感を覚える人もいると思います。JavaScriptではオブジェクトや配列と同様に、関数にもFunction型というデータ型があります。つまり、関数も「値」として扱われるので問題がないということになります。
ところで、コールバックという名前の由来はあまり気にしないほうがいいかもしれません。僕は調べてもあまりイメージが掴めなかったので、「呼ぶ(call)のが後になる(back)関数」と勝手に解釈しています。
先ほどのサンプルコードでは、コールバック関数をworld
として定義していましたが、普通は以下のように事前に定義せずに書くことが多いです。
function hello(callbackFunction) {
console.log('hello');
callbackFunction();
}
hello(() => {
console.log('world');
});
関数hello
を呼び出すときに実行してほしい関数を定義する方法です。これは定義した関数に名前をつけないので無名関数と呼ばれます。また、無名関数の中でも関数定義に矢印のような=>
を使っている関数はアロー関数といいます。
無名関数や関数定義については本題ではないので詳しい説明は省略しますが、とても一般的な書き方なので慣れましょう。
これ以降のサンプルコードで出てくるコールバック関数はこちらのやり方で定義していきます。
上のほうでコールバック関数について紹介したサンプルコードには非同期処理がありませんが、コールバック関数は多くの場合、非同期処理と一緒に使われます。
そのため、非同期処理について調べると必ずと言っていいほどコールバック関数の話が出てきますが、これによって非同期処理とコールバック関数を混同してしまいやすくなります。
そのため、まずは非同期処理を切り離してしっかりコールバック関数を理解するようにしましょう。
コールバック関数と非同期処理
今のようにasync/awaitという便利な機能はなかったので、昔は非同期処理が完了してから実行したい処理をコールバック関数として渡していました。
例えば、コールバック関数を使う非同期処理で有名なものの一つに、setTimeout
があります。
setTimeout(コールバック関数, ミリ秒);
第二引数で指定した時間の経過後、第一引数に指定したコールバック関数を実行(正確にはキューへ登録)します。
以下はログ出力のサンプルコードです。「2」はsetTimeout
を使って5秒後に出力するようにしています。
console.log('1');
setTimeout(() => {
console.log('2');
}, 5000);
console.log('3');
もしsetTimeout
が非同期処理ではなかった場合、以下の結果になるでしょう
1
(5秒後...)
2
3
ですが、実際はsetTimeout
は非同期処理なので以下の結果になります。
1
3
(5秒後...)
2
もう一つ別の例を紹介します。以下はNode.jsのfs.readFile
という関数で、指定のファイルを読み込む非同期処理です。読み込み完了後、第二引数に渡したコールバック関数を実行します。
const result = fs.readFile("./example.txt", (error, data) => {
if (error) {
return 'error';
} else {
return 'success';
}
});
console.log(result);
// undefined
ファイル読み込みの結果によってログの出力内容を切り替えるコードです。ファイルの読み込みが失敗したら「error」の文字列、成功したら「success」の文字列をresult
に代入しようとしています。しかし、fs.readFile
は非同期処理なので処理完了前にconsole.log(result)
を実行してしまい、期待通りの動きにはなりません。
この例のように、非同期処理の結果がその後の処理に影響するケースが、コールバック関数を使った非同期処理の難しいポイントではないかなと個人的に考えています。
僕が初学者だった頃はこの問題をどう解決すればいいのかわかりませんでした。
わかる人にとっては当たり前なことですが、この例のように非同期処理の結果に依存する処理はすべてコールバック関数内に記述してあげる必要があります。
fs.readFile("./example.txt", (error, data) => {
if (error) {
console.log('error');
} else {
console.log('success');
}
});
// error もしくは success
コールバック関数と非同期処理についてここまで説明してきましたが、イメージは掴めたでしょうか。改めて要点をまとめておきます。
- まずは非同期処理と切り離してコールバック関数を理解する
- 非同期処理の実行順序は同期処理とは異なるので注意
- 非同期処理の結果に依存する処理はコールバック関数の中に記述する
Promise
コールバック型の非同期処理では処理結果に依存する処理はコールバック関数の中に記述します。もし、そのコールバック関数の中に非同期処理が含まれていて、さらにそのコールバック関数にも非同期処理が...といった状況だとどうなるでしょう。
fs.readFile('example1.txt', (error, data1) => {
fs.readFile('example2.txt', (error, data2) => {
fs.readFile('example3.txt', (error, data3) => {
fs.readFile('example4.txt', (error, data4) => {
// do something
})
})
})
})
いわゆるコールバック地獄と呼ばれる問題です。これは極端な例かもしれませんが、ネストが深くて可読性が悪い上、メンテナンスも大変です。このコールバック型非同期処理の問題を解決するべく登場したのがPromiseです。
概要
ただでさえ非同期処理は難しいのに、Promiseという聞き慣れない概念が出てくると余計にわからなくなりますね。Promiseは和訳すると「約束」ですが、僕が初学者だった頃、非同期処理と約束がどう結びつくの?と混乱していました。
ここに違和感を覚えたままだとPromiseを理解しにくいので、僕は
「時間がかかっても必ず処理を終わらせることを約束してくれる」ものだと解釈しています。
Promiseは以下のようにPromiseオブジェクトを生成して使います。
new Promise(コールバック関数)
コールバック関数の中に非同期で実行したい処理を書きます。このコールバック関数には引数が決まっていて、第一引数にresolve
、第二引数にreject
を持ちます。
new Promise((resolve, reject) => {
// do something
...
})
ここで出てくるresolve
はコールバック関数が正常に終了する際に実行する関数であり、reject
は失敗したときに実行する関数です。
new Promise()
で生成したPromiseオブジェクトは、コールバック関数の実行状況によってpending
、fulfilled
、rejected
の3つの状態を持ちます。
- pending:処理が終わるのを待っている状態
- fulfilled:処理が正常に終了した状態
- rejected:処理が失敗してしまった状態
new Promise()
でPromiseオブジェクトを生成した時点ではpending
の状態になっています。
その後、コールバック関数内でresolve()
が実行されればfulfilled
に、reject
が実行されればrejected
に変わります。
then と catch
Promiseは非同期処理なので、以下の例のようにpending
状態のPromiseオブジェクトがfulfilled
もしくはrejected
になるのを待ちません。
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve();
}, 5000);
});
console.log(promise); // Promise {<pending>}
then
非同期処理が正常に終了してから実行してほしい処理は関数にし、Promiseオブジェクトが持つthen
メソッドにコールバック関数として渡してあげます。then
メソッドは非同期処理が正常に終了した場合(pending → fulfilled)に実行されます。
new Promise((resolve, reject) => {
setTimeout(() => {
resolve();
}, 5000);
}).then(() => {
console.log('5秒経過');
})
resolve
に引数を渡した場合は、以下のようにthen
メソッドのコールバック関数に渡されます。
new Promise((resolve, reject) => {
setTimeout(() => {
resolve('5秒経過');
}, 5000);
}).then((message) => { // messageには「5秒経過」が入っている
console.log(message);
})
catch
new Promise()
に渡したコールバック関数内でreject()
が実行されたり、Exceptionが発生した場合、catch
メソッドでキャッチすることができます。
new Promise((resolve, reject) => {
reject(new Error('failed'));
}).catch((err) => {
console.log(err.message);
});
// failed
コールバック地獄の回避
Promiseを使った非同期処理の基本的な書き方については説明が終わったので、ここで一度コールバック地獄の問題を思い出してください。Promiseはこの問題を解決すべく登場した概念です。
Promiseを使った非同期処理はthen
メソッドをつなげることができます。そのため、先ほど説明したファイル読み込み処理のコールバック地獄は以下のように修正することができます。
// before
fs.readFile('example1.txt', (error, data1) => {
fs.readFile('example2.txt', (error, data2) => {
fs.readFile('example3.txt', (error, data3) => {
fs.readFile('example4.txt', (error, data4) => {
// do something
})
})
})
})
// after
new Promise((resolve, reject) => {
fs.readFile('example1.txt', (error, data1) => {
resolve();
}
})
.then(() => {
return new Promise((resolve, reject) => {
fs.readFile('example2.txt', (error, data1) => {
resolve();
}
});
})
.then(() => {
return new Promise((resolve, reject) => {
fs.readFile('example3.txt', (error, data1) => {
resolve();
}
});
})
.then(() => {
// do something
})
.catch(err => {
// error handling
})
コールバック型の非同期処理を使っていてネストが深くなるようなら、Promiseを検討したほうがよいでしょう。
async/await と Promise
ここで最初に説明したasync/awaitについて補足しておきますが、実は裏ではPromiseが動いています。Promiseで非同期処理を書くと少し複雑になるため、ES2017でasync/awaitという新しい書き方が登場しました。
書き方の違いを見るために、async/awaitで書いたコードをPromiseで書き直してみましょう。
// async version
async function getUserAccount() {
const response = await fetch('https://example.com/user/1');
if (!response.ok) {
return 'ユーザアカウント情報を取得できませんでした';
}
return 'ユーザアカウント情報を取得できました';
}
// Promise version
function getUserAccount() {
const promise = new Promise((resolve, reject) => {
fetch('https://example.com/user/1')
.then(response => {
if (!response.ok) {
resolve('ユーザアカウント情報を取得できませんでした');
return;
}
resolve('ユーザアカウント情報を取得できました');
});
});
return promise;
}
Promiseと比べると、async/awaitのほうがとても見やすくてスッキリしています。また、Promiseを使った書き方では最後にPromiseオブジェクトを返しています。つまり、async関数の戻り値もPromiseです。
const message = getUserAccount();
console.log(message);
// Promise?{<pending>}
async/awaitの最初の説明でawait
をつけると処理の完了を待つと言っていたのはつまり、このPromiseオブジェクトがpending
からfulfilled
に変わるのを待っているということです。
そのため、await
の右側にはPromiseオブジェクトを置くこともできます。
async function hello() {
const promise = new Promise((resolve, reject) => {
setTimeout(()=>{
resolve('hello');
}, 5000);
});
const message = await promise;
}
非同期処理 tips
コールバック型の非同期処理をawaitする
コールバック型の非同期関数は戻り値がなかったり、Promiseオブジェクトも返さないのでawait
はできません。await
したい場合はPromiseでラップしてあげましょう。
// before
async function sayHelloAfter5Seconds() {
await setTimeout(() => { // awaitできない
console.log('hello');
}, 5000);
console.log('world');
}
// after
async function sayHelloAfter5Seconds() {
// await できる
await new Promise((resolve, reject) => {
setTimeout(() => {
console.log('hello');
resolve();
}, 5000);
});
console.log('world');
}
ループ中にawait
よく言われることですが、forEachの中ではawait
できません。ループ中にawait
したいときは代わりにfor
を使いましょう。
// before
items.forEach(async item => {
await sendItem(item); // awaitできない
});
// after
for (const item of items) {
await sendItem(item); // awaitできる
}
まとめて非同期処理を実行する
ループを繰り返すごとにawait
をしているとパフォーマンスが悪くなります。前のループが終わってからでないと次に進めない場合以外ではPromise.all
を使うとパフォーマンスが良くなります。
// before
for (const item of items) {
await sendItem(item);
}
// after
const promises = [];
for (const item of items) {
const promise = sendItem(item);
promises.push(promise);
}
Promise.all(promises).then(results => {
// promisesがすべてfulfilledになれば実行される
// resultsにはsendItem()の実行結果が配列になっている
})
ただし、promises
のいずれかがrejected
になった場合はその時点で処理が中断されます。
一部が失敗しても最後まで処理を完了させたい場合は2通りの方法があります。
①実行結果の成否を返すようにする
const promises = [];
for (const item of items) {
const promise = sendItem(item)
.then(() => { return true})
.catch(() => { return false});
promises.push(promise);
}
Promise.all(promises).then(results => {
// resultsにはbooleanの配列
})
②Promise.allSettled
を使う
const promises = [];
for (const item of items) {
const promise = sendItem(item)
promises.push(promise);
}
Promise.allSettled(promises).then(results => {
// resultsの中身はpromiseの結果(以下の形式)の配列
// 成功 { status: fulfilled, value: 実行結果 }
// 失敗 { status: rejected, reason: エラー内容 }
})
ただし、Promise.allSettledはES2020で登場したので、プロジェクトによっては使えない場合もあります。
最後に
非同期処理を最初から完璧に理解するのは難しいと思います。僕も非同期処理をここまで理解するのに1年以上かかりました。まずはasync/awaitから少しずつ使えるようになっていきましょう。