はじめに
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は「未来の値」を扱う仕組みasync/awaitはPromiseをより読みやすくした構文try/catchでエラー処理が統一される- コールバック地獄を解消できる
fetchなどの非同期処理は、現在ではasync/awaitが標準