JavaScript では、「データを読み込んでから表示する」「画像がそろってから並べる」のように、ある処理が終わるのを待って次の処理をしたい場面がよくあります。通信やファイルの読み込みは、終わるまで待たずに次の行へ進む「非同期処理」なので、書き方を知らないと、まだ届いていないデータを使おうとしてしまいます。ここでは、待ち方の書き方がどう移り変わってきたかと、今のおすすめの書き方を説明します。
DOM の読み込みを待つ
まず、ページの HTML の読み込みを待つ方法です。head 要素の中でスクリプトを読み込むと、その時点ではまだ body の要素ができていないため、要素を探しても見つかりません。
jQuery では $(function () { ... }) と書いて、DOM の準備ができるのを待つのが定番でした。今は、script 要素に defer 属性を付けるのがいちばん簡単です。
<!-- HTML の解析が終わってから、書いた順に実行される -->
<script src="main.js" defer></script>
defer を付けたスクリプトは、HTML の解析を止めずに読み込まれ、解析が終わってから実行されます。type="module" のスクリプトは、何も付けなくても同じ扱いです。スクリプトの中で待ちたい場合は、DOMContentLoaded イベントを使います。
document.addEventListener("DOMContentLoaded", () => {
// ここでは、HTML の要素がすべてそろっている
});
画像なども含めて、ページのすべてが読み込まれるのを待つなら load イベントですが、多くの場合は DOMContentLoaded で足ります。
コールバックの時代
非同期処理の終わりを知る昔ながらの方法は、終わったときに呼んでほしい関数 (コールバック) を渡すことです。
loadData("/api/user.json", (user) => {
loadData("/api/posts.json?user=" + user.id, (posts) => {
loadData("/api/comments.json?post=" + posts[0].id, (comments) => {
// 処理が続くほど、入れ子が深くなる
});
});
});
順番に処理を続けるほど入れ子が深くなり、エラーの扱いもそれぞれの段階で書く必要があります。この読みにくさは「コールバック地獄」と呼ばれました。
jQuery の Deferred から Promise へ
jQuery の $.ajax() などは、Deferred というオブジェクトを返します。.done() で成功したときの処理、.fail() で失敗したときの処理を、あとからつなげて書けます。複数の処理は $.when() でまとめて待てました。
この考え方は、のちに JavaScript の標準の Promise (処理の結果を、あとで受け取る約束を表すオブジェクト) として取り入れられました。jQuery も 3.0 から、Deferred が標準の Promise と同じ規則で動くように改められています。今の fetch() は Promise を返します。
fetch("/api/user.json")
.then((response) => response.json())
.then((user) => {
console.log(user.name);
})
.catch((error) => {
console.error("読み込みに失敗しました", error);
});
async/await で、上から順に書く
async を付けた関数の中では、await で Promise の結果が出るまで待てます。非同期処理を、ふつうの処理と同じように上から順に書けるので、今はこの書き方が中心です。エラーは try...catch で受け取ります。
async function showUserPosts() {
try {
const user = await (await fetch("/api/user.json")).json();
const posts = await (await fetch("/api/posts.json?user=" + user.id)).json();
console.log(user.name, posts.length);
} catch (error) {
console.error("読み込みに失敗しました", error);
}
}
例では短くするために省いていますが、実際には response.ok で HTTP のエラー (404 など) も確かめます。await は、async を付けた関数の中か、type="module" のスクリプトのいちばん外側でだけ使えます。
複数の処理をまとめて待つ (Promise.all)
前の結果を使わない処理どうしは、1 つずつ await すると、そのぶん待ち時間が積み重なります。同時に始めて、すべての終わりを待つなら Promise.all() を使います。
const [news, events] = await Promise.all([
fetch("/api/news.json").then((r) => r.json()),
fetch("/api/events.json").then((r) => r.json()),
]);
結果の配列は、終わった順ではなく、渡した順に並びます。1 つでも失敗すると全体が失敗になるので、一部が失敗しても残りを使いたいときは Promise.allSettled() を使います。この性質を使って、画像を決まった順番で並べる例を「XML に書かれた順番どおりに画像を並べる」で紹介しています。
setTimeout で「だいたい待つ」のは誤り
「読み込みに 1 秒くらいかかるから、1 秒後に次の処理をする」という書き方は、やめましょう。
// 誤り: 読み込みが 1 秒以内に終わるとは限らない
loadData();
setTimeout(showData, 1000);
通信にかかる時間は、回線や端末、サーバーの混み具合で大きく変わります。遅い環境では間に合わずに失敗し、速い環境では無駄に待たされます。手元では動くのに、利用者の環境でだけ失敗する、という見つけにくい不具合になります。処理の終わりは、推測せず、イベントや Promise で受け取るのが正しい方法です。
なお、入力が止まってから検索する、といった「わざと時間を置く」処理に setTimeout() を使うのは問題ありません。
まとめ
- DOM の読み込みを待つなら、
script要素のdefer属性かDOMContentLoadedを使う - 非同期処理の待ち方は、コールバック → jQuery の Deferred → Promise → async/await と移り変わってきた
- 今は async/await で上から順に書き、エラーは
try...catchで受け取る - 複数の処理を同時に待つなら
Promise.all()。結果は渡した順に並ぶ - 終わる時間を推測して
setTimeout()で待たない