1. はじめに
非同期とはなんか裏で重たい処理をいい感じにしてくれてるくらいで非同期が何をしているかあまり理解できてませんでした。
「なんで非同期で書かないといけないの?」と聞かれても、「非同期じゃないと処理が終わるまで画面が表示されなくて、重くなるから」くらいしか答えられません。非同期でなければいけない理由がふわっとしたままなので、実際にコードを書くときも「ここって非同期でいいのかな?」と迷うことがよくありました。
そこでこの記事では、非同期処理が実際に何をしているのかを理解することをゴールにします。
2. 同期処理
まず非同期処理を理解する前に同期処理について説明します。
同期処理とは:
上から順番に1つずつ実行し、前の処理が終わるまで次の行に進まない処理の仕方のこと
console.log('1');
heavyWork(); // これが終わるまで待つ
console.log('2'); // heavyWork が終わってから実行される
同期処理で時間のかかる処理を呼び出すとどうなる?
サーバーからデータを取ってくる通信は、相手の状況や回線の速さによって、いつ終わるか分かりません。なので同期処理で書くと、データが返ってくるまで次の行に進めず、通信が遅いほど長く固まります。
その間は
- ボタンを押しても反応しない
- スクロールできない
- 画面の表示が更新されない
- アニメーションが止まる
といったことが起き、ユーザーから見るとページが固まったように見えます。
公式でも、
一度にできることはひとつだけです。そのため、実行時間の長い同期処理を呼び出してから復帰すると、その間は他のことができなくなります。
じゃあ同期処理っていつ使うの?
実際は、普段書くコードのほとんどは同期処理で書かれてることが多いです。
- 計算:合計金額や税込価格を出す
- 配列の操作:map、filter、sortで一覧を絞り込んだり並べ替えたりする
- 文字列の加工:名前の結合、日付の整形
- 条件分岐:if でログイン済みかどうかを判定する
- 入力チェック(バリデーション):メールアドレスの形式が正しいか確認する
等、時間のかからない処理は同期処理で書かれています。
なので、一瞬で処理が終わる内容までむやみやたらに非同期で書くと、処理が早くなるわけではなくコードが増え複雑化し、バグの原因が増える要因にもなるので、切り分けが必要です。
3. 非同期ってなに?
非同期プログラミングは、長く続く可能性のあるタスクを開始しても、そのタスクが完了するまで待つのではなく、そのタスクの実行中も他のイベントに応答できるようにする技術です。タスクが完了すると、プログラムはその結果を表示します。
図にするとこんな感じ
2章のMDNの引用にもあったとおり、JavaScriptは一度に1つのことしかできません。
これをシングルスレッド:処理の流れが1本だけといいます。
そのため、
サーバーとの通信のように時間のかかる処理を、同期処理で書いてしまうと、処理が終わるまで待たなくてはいけないので、その間は他の作業(ボタンの反応や画面の更新など)が何もできず画面が固まってしまいます
なので、命令を出したあと、結果を待たずに次の処理へ進み、処理が終わったタイミングで通知や結果を受け取る、
これが非同期処理です。
console.log('1');
fetch('/api/data').then(() => {
console.log('3:データが届いた'); // 通信が終わったら実行される
});
console.log('2'); // 通信の完了を待たずに、すぐ実行される
// .then() の中には、通信が終わったあとにやりたい処理を書きます
実行結果は 1 → 2 → 3 の順になり、通信を待っている間もJavaScriptは止まらないので、画面は固まりません。
4. 実際に動かしてみた
3章では通信の例を出しましたが、通信はサーバーがないと試せません。なので、ブラウザだけで試せる setTimeoutを使ってコードを動かしてみます。
setTimeout :指定した時間がたったら処理を実行する関数
console.log('1');
setTimeout(() => {
console.log('2');
}, 5 * 1000); // 5秒後に実行
console.log('3');
2 が最後になりました。
なぜこうなるのか
-
console.log('1')が実行される -
setTimeoutで5秒後にconsole.log('2')をやる、とブラウザに頼む - 5秒を待たずに、すぐ次の
console.log('3')に進む - 5秒たったら、
console.log('2')が実行される
ちなみに非同期処理で裏で動いてる部分がブラウザにあたるんですが、イメージがわかなかったので図にしてます
0秒にしたらどうなる?
ちなみに、待ち時間を 0秒 にしたらどうなるでしょう。
console.log('1');
setTimeout(() => {
console.log('2');
}, 0); // 0秒後に実行
console.log('3');
0秒にしても 2 は最後に処理されました。
setTimeout に渡した処理は、時間が0秒でも非同期処理として処理されます。そのため、いま実行している同期処理(console.log('3') まで)が全部終わってから実行されます。
なので時間がかからない処理を非同期で書いてしまうと、データを取ってきたのに空の状態で表示されるバグが発生するので注意です。
バクの一例
let data;
fetch('/api/data')
.then((res) => res.json())
.then((json) => {
data = json; // 通信が終わったら、ここで値が入る
});
console.log(data); // 通信を待たずに先に実行されるのでundefined
直し方としては
- 結果を使う処理を非同期処理の中に書く
-
awaitで受け取るまで待つこと
awaitについては次の章で紹介します。
5. Promise と async/await について
ここまでで、非同期処理は命令を出したあと、結果を待たずに次の処理へ進み、処理が終わったタイミングで通知や結果を受け取る方式だと分かりました。次に処理の結果の受け取り方についてです。
4章でも結果が届く前に値を使うと空のままになるバグの一例を出しましたが、届いた結果を安全に使うための書き方が、
Promise と async/await です。
Promiseとは
MDNでは、Promiseを次のように説明しています。
プロミスは非同期関数から返されるオブジェクトで、操作の現在の状態を表します。プロミスが呼び出し元に返される時点では、操作が完了していないことが多いのですが、プロミスオブジェクトは最終的な操作の成功や失敗を処理するためのメソッドを提供しています。
この内容だけ見ると未経験にはわかりにくい…。
MDNのPromiseについてもう少しわかりやすい説明があったので「プロミスの使い方」を読んでもしっくりこない方はこちらがおすすめです。
わかりやすくPromiseとは言うと
Promiseは、非同期処理の結果(成功か失敗か、その値)を、あとから受け取るためのオブジェクトです
Promiseには3つの状態があります。
| 状態 | 意味 |
|---|---|
| 待機(pending) | まだ結果が出ていない |
| 履行(fulfilled) | 成功した → .then() が呼ばれる |
| 拒否(rejected) | 失敗した → .catch() が呼ばれる |
.then()は、Promiseが「成功」になったときに呼ばれる処理を登録するメソッドでした。
// コードに Promise と書いていないので分かりにくいけど、
// fetch() を呼ぶと、Promiseが返ってくる
console.log('1');
fetch('/api/data').then(() => { // fetch がPromiseを返す。これがPromise
console.log('3:データが届いた'); // 通信が終わったら実行される
});
console.log('2'); // 通信の完了を待たずに、すぐ実行される
// .then() の中には、通信が終わったあとにやりたい処理を書きます
ちなみに
new Promise(...) と書けば、自分でPromiseを作ることもできます。ただ、普段の業務では fetch のように最初からPromiseを返してくれる関数を使う側になることがほとんどです。
次に別の処理の受け取り方についてasync/awaitを紹介します。
async/awaitとは
.then() をつなげていくと、コードが読みにくくなりがちです。そこで使うのが async/await です。
asyncキーワードにより、非同期プロミスコードをよりシンプルに動作させることができます。(中略)これにより、非同期関数を使用しながらも、一見すると同期コードのように見えるコードを書くことができます。
-
async:関数の前に付けると、その関数が非同期関数になる -
await:Promiseの前に付けると、結果が出るまでその場で待って、中身を受け取れる
async function getData() {
const response = await fetch('/api/data'); // 結果が出るまでここで待つ
console.log('データが届いた');
}
.then() を使わずに、同期処理と同じように上から読めるようになりました。
でも、await で待つと聞くと、同期処理と同じで画面が固まるのでは?と思い確認してみました。
0.5秒ごとにカウントを表示しながら、await で5秒待つ場合と、同期処理で5秒待つ場合を比べてみます。
待っている間もカウントが出続ければ止まっていない、カウントが止まれば固まっている、ということになります。
// 0.5秒ごとにカウントを表示(7秒で止まる)
let count = 0;
const timer = setInterval(() => {
count++;
console.log(`カウント ${count}`);
if (count >= 14) clearInterval(timer);
}, 500);
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function getData() {
console.log('2:待機を開始');
await wait(5 * 1000);
console.log('4:5秒たった');
}
console.log('1');
getData();
console.log('3');
実行結果は
1
2:待機を開始
3 // 5秒を待たずにすぐ表示される
4:5秒たった // 5秒後に表示される
2:待機を開始 のあと、5秒待っている間も 3 やカウントが表示され続けています。
await で止まっているのは getData の中だけで、JavaScript自体は先に進んでいるからです。
同期処理で書いた場合
// 0.5秒ごとにカウントを表示(7秒で止まる)
let count = 0;
const timer = setInterval(() => {
count++;
console.log(`カウント ${count}`);
if (count >= 14) clearInterval(timer);
}, 500);
function waitSync(ms) {
const end = Date.now() + ms;
while (Date.now() < end) {}
}
function getDataSync() {
console.log('2:待機を開始');
waitSync(5 * 1000);
console.log('4:5秒たった');
}
console.log('1');
getDataSync();
console.log('3');
結果は 1 → 2 →(5秒固まる)→ 4 → 3 です。
2:待機を開始 のあと、5秒間は何も表示されず、カウントも止まりました。
JavaScriptが5秒間ずっと waitSync にかかりきりで、ほかのことが何もできない状態です。
これが、Webアプリケーションだとしたらユーザー側は、画面が固まって何もできない状態になるんだなと思いました。
6. 結局、同期と非同期どっちで書けばいいの?
- 計算・配列の操作・条件分岐など、すぐ終わる処理:同期処理
- 通信など、結果が返ってくるまで待ち時間がある処理:非同期処理
7. まとめ
- JavaScriptは一度に1つのことしかできない(シングルスレッド)
- 通信のように時間のかかる処理を同期処理でかくと、その間は画面が固まる
- 非同期処理は、時間のかかる処理をブラウザに頼んでおいて、待っている間に止まらないための仕組み
参考文献
https://developer.mozilla.org/ja/docs/Learn_web_development/Extensions/Async_JS/Introducing
https://developer.mozilla.org/ja/docs/Learn_web_development/Extensions/Async_JS/Promises
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Promise
https://developer.mozilla.org/ja/docs/Learn_web_development/Extensions/Async_JS/Promises#async_%E3%81%A8_await





