はじめに
JavaScript では、ネットワーク通信やファイル読み込みなど、時間のかかる処理を「非同期」で扱う必要があります。
もしこれらを同期的に実行してしまうと、UI が固まったり、アプリ全体が停止したように見えてしまいます。
この章では、非同期処理の基本である Promise と、現代的な書き方である async / await を、実例を交えながら丁寧に解説します。
非同期処理が必要な理由
JavaScript は「単一スレッド」で動きます。
つまり、1 度に 1 つの処理しか実行できません。
例えば、下記に示すように、外部からなにかしらの値を取り込み、画面に表示する JavaScript があるとします。
この fetch 操作が同期的に実行されると仮定すると…
const res = fetch("/api/user"); // ← ここで数秒止まる
console.log("次の処理");
ネットワーク通信が終わるまで、UI が完全に停止してしまいます。この際、マウスやキーボードへの応答もブロックされてしまう為、ユーザーには、まるでフリーズもしくは、ハングアップしたかのように見えてしまいます。
👉 これを避けるために、JavaScript では 非同期処理 が必須です。
時間のかかる処理をイベントドリブンで処理する事により、「単一スレッド」にもかかわらず、複数の処理を同時に行っているようにみせかけることで、この無反応にみえる状態を防ぎます。
JavaScript は「イベントループ」という仕組みにより、重い処理を待っている間も UI を止めずに他の処理を進められます。
その為の技術が、非同期処理です。
そして、それを実現する為に、当初用いられた方法が、コールバックによる記法です。
コールバックによる非同期処理
非同期で処理を行いたい関数にCallback関数を引数として、渡す記法です。
function getUser(id, callback) {
try {
const user = { id, name: "Kon" };
callback(null, user);
} catch (err) {
callback(err, null);
}
}
function getPosts(userId, callback) {
try {
const posts = [{ id: 1, title: "Post" }];
callback(null, posts);
} catch (err) {
callback(err, null);
}
}
function getComments(postId, callback) {
try {
const comments = [{ id: 1, text: "Nice!" }];
callback(null, comments);
} catch (err) {
callback(err, null);
}
}
getUser(id, (err, user) => {
if (err) {
console.error("getUser failed:", err);
return;
}
getPosts(user.id, (err, posts) => {
if (err) {
console.error("getPosts failed:", err);
return;
}
getComments(posts[0].id, (err, comments) => {
if (err) {
console.error("getComments failed:", err);
return;
}
console.log(comments);
});
});
});
※ これは Node.js で一般的な「エラーファーストコールバック」という書き方で、(err, result) の順で値を返します。
ところが、この記法には、処理が複雑化すると
- ネストが深い
- エラー処理が複雑
- 可読性が低い
といった、俗に「コールバック地獄」と呼ばれる問題が常につきまとっていました。
👉 そこで、この問題を解決する方法として、ES6(ECMAScript 2015)で新たに導入されたのが、Promise です。
Promise による非同期処理
Promise とは、「未来の値」 を表すオブジェクトです。
- まだ値が決まっていない(pending)
- 成功した(fulfilled)
- 失敗した(rejected)
という 3 つの状態を持ちます。
Promise を使った記法では、下記のように、Promise の状態に応じた処理を then / catch を使って書く事が可能です。
下記では、先ほどの CallBack を使った非同期処理を Promise を使った記法で書き直しています。
function getUser(id) {
return new Promise((resolve, reject) => {
try {
const user = { id, name: "Kon" };
resolve(user);
} catch (err) {
reject(err);
}
});
}
function getPosts(userId) {
return new Promise((resolve, reject) => {
try {
const posts = [{ id: 1, title: "Post" }];
resolve(posts);
} catch (err) {
reject(err);
}
});
}
function getComments(postId) {
return new Promise((resolve, reject) => {
try {
const comments = [{ id: 1, text: "Nice!" }];
resolve(comments);
} catch (err) {
reject(err);
}
});
}
getUser(id)
.then(user => getPosts(user.id))
.then(posts => getComments(posts[0].id))
.then(comments => {
console.log(comments);
})
.catch(err => {
console.error(err);
});
👉 ずいぶんとコードがすっきりし、見やすくなった事がわかると思います。
Promise のメリット
- エラー処理は
.catch()1 回で OK - ネストが消えて読みやすい
- コールバック地獄から脱却できる
Promise の限界
thenが増えるとネストが深くなる- エラーがどこで起きたか分かりにくい
- 非同期処理が多くなると
.then()が横に長くなり、可読性が落ちることがある。
async / await を使用した非同期処理
Promise を利用した非同期処理でも、コードの見通しが非常によくなったのですが、ES2017 で導入された async / await の仕組みにより、もっと分かりやすいコードが書けるようになりました。
async / await を使用した記法は、従来の Promise による非同期処理を補強し、その限界を突破する記法として、モダンな非同期処理の記法の中心になっています。
async 関数とは?
async を付けた関数は、必ず Promise を返す関数になります。
async function load() {
return 1;
}
load().then(console.log); // 1
await の本質
await は Promise の完了を待つ構文 です。実際には then の糖衣構文(syntactic sugar)です。
async function load() {
const res = await fetch("/api/user");
const data = await res.json();
console.log(data);
}
では、これらの記法で、どのような変化があるのでしょうか。
下記では、先ほどの Promise を使った非同期処理を async / await を使った記法で書き直しています。
async function getUser(id) {
const user = { id, name: "Kon" };
return user;
}
async function getPosts(userId) {
const posts = [{ id: 1, title: "Post" }];
return posts;
}
async function getComments(postId) {
const comments = [{ id: 1, text: "Nice!" }];
return comments;
}
async function load() {
try {
const user = await getUser(id);
const posts = await getPosts(user.id);
const comments = await getComments(posts[0].id);
console.log(comments);
} catch (err) {
console.error("Error:", err);
}
}
load();
👉 よりコードがシンプルになり、直観的で理解しやすくなった事がわかると思います。
async / await のメリット
- 同期処理のように書ける
- ネストが浅くなる
- エラー処理が
try/catchに統一される
👉 現在の実務では「非同期処理の標準の書き方」となっています。
非同期処理における try / catch の取り扱いについて
下記のような async / await と try / catch を使ったコードにおいて、どこまで try / catch で囲むべきかは、悩むところです。
なお、async 関数内で try / catch を書く必要があるのは「エラーを補足したい場合だけ」で、必須ではありません。
async function load() {
try {
const res = await fetch("/api/user");
const data = await res.json();
console.log(data);
} catch (err) {
console.error("Error:", err);
}
}
👉 このような場合、 try / catch は「エラーが起きる可能性がある最小単位」で囲むことが基本です。
fetch や json は、実行時にエラーを throw する可能性があるからです。例えば、ネットワークエラー、CORS エラー、JSON が壊れている場合などは例外が発生します。
ただし、実務では、UI 更新は別 try / catch に分けることも多いです。API の取得と UI 更新のエラーでは性質が違うからです。
- API 取得のエラー
→ ネットワークやデータの問題 → ユーザーに「読み込み失敗」と伝えるべき
- UI 更新のエラー
→ DOM 操作のバグ → プログラミングミス → API とは関係ない
👉 実行時になんらかの理由で発生するエラーか、バグかの違いです。
並列処理
Promise による非同期処理は、Promise.all() や Promise.allSettled()を利用することで、並列処理が可能になります。
Promise.all とは?
Promise.all() は「複数の Promise を同時に実行し、全部の処理が終わるまで待つ」関数です。
例:
async function load() {
const [user, posts, comments] = await Promise.all([
fetch("/api/user").then(r => r.json()),
fetch("/api/posts").then(r => r.json()),
fetch("/api/comments").then(r => r.json()),
]);
console.log(user, posts, comments);
}
3 つの処理が同時に並列で動作し、全部終わったら結果が配列で返ります。配列の順番は Promise の完了順ではなく、Promise.all() に引き渡した処理の順番です。
1つでも失敗すると全体が reject されます。
Promise.all() では、最初に失敗した Promise のエラーが reject の理由になり、他の Promise は裏で動き続けますが、結果は返りません。
メリット
- 複数処理が同時に走るので高速
- コードが短くて読みやすい
注)1つでも失敗すると全部失敗になります。もし「失敗しても続けたい」なら Promise.allSettled() を使う必要があります。
Promise.allSettled() とは?
Promise.allSettled() も「複数の Promise を同時に実行し、全部の処理が終わるまで待つ」関数です。
例:
const results = await Promise.allSettled([
fetch("/api/user").then(r => r.json()),
fetch("/api/posts").then(r => r.json()),
fetch("/api/comments").then(r => r.json()),
]);
for (const r of results) {
if (r.status === "fulfilled") {
console.log("成功:", r.value);
} else {
console.warn("失敗:", r.reason);
}
}
👉 Promise.all() との最大の違いは、渡した処理の1つが失敗したとしても、全体が reject されないという点です。
返り値は、次のようなオブジェクトの配列です。
[
{ status: "fulfilled", value: ... },
{ status: "rejected", reason: ... },
...
]
成功した場合
{ status: "fulfilled", value: 結果 }
失敗した場合
{ status: "rejected", reason: エラー内容 }
配列の順番は Promise の完了順ではなく、Promise.allSettled() に引き渡した処理の順番です。
メリット
- 1つ失敗しても 他の結果は受け取れる
- UI 表示やログ収集に向いている -「全部やってみて、成功したものだけ使う」という用途に最適
Promise.all() と Promise.allSettled() の違い
| 項目 | Promise.all() |
Promise.allSettled() |
|---|---|---|
| 目的 | 全部成功したら結果を返す | 成功・失敗を含めて全部の結果を返す |
| 成功時の返り値 | 成功した値の配列 | {status: 'fulfilled', value: ...}の配列 |
| 失敗時の返り値 | 1つでも失敗すると全体が reject |
{status: 'rejected', reason: ...}を含む配列 |
| エラー発生時の挙動 | 例外が飛ぶ(try/catch 必須) | 例外は飛ばない(reject しない) |
| 並列実行 | すべて同時に実行 | すべて同時に実行 |
| 用途 | 全部成功しないと意味がない処理 | 失敗しても続けたい処理 |
| 典型例 | 複数 API のデータをまとめて取得 | 成功したものだけ UI に表示したい |
| 結果の形式 | [value1, value2, ...] |
[{status, value/reason}, ...] |
| エラーの扱い | 1つの失敗で全体が止まる | 失敗も「結果」として扱う |
| 使いやすさ | 成功前提の処理に最適 | 失敗や異常が起きても、全体が壊れずに動く処理に最適 |
まとめ
Promiseは「未来の値」を扱う仕組みasync/awaitはPromiseをより読みやすくした構文try/catchでエラー処理が統一される- コールバック地獄を解消できる
fetchなどの非同期処理は、現在ではasync/awaitが標準PromiseはPromise.all()やPromise.allSettled()で、並列処理が可能