※2019/03/26: 本質的な内容はそのままですが、記事を全体的に修整しました。
自分が初めて JavaScript の非同期を学ぼうとした時に Promise, async, await が何をしたいのかが全然分からなかったので、中で何をやっているかを何となく分かるようにまとめたいと思います。
「外から見てどうやって使うか」は別記事にしています。
参考「もっと簡単に async, await, Promise - Qiita」
0. まとめ
- コールバック関数を使って非同期処理を記述することができ、実現できることが増える
- ただし、コールバック関数だけで複雑な処理を書こうとするとスマートに記述できず、ソースコードの品質が下がる
- Promise を使うことで非同期の処理をスマートに書くことができるようになる (構文の追加)
- async/await を使うとさらに Promise をスマートに扱うことができるようになる (一部の構文を置き換え)
(コールバック関数は非同期処理以外の目的で使用されることもあります。)
1. 非同期とコールバック関数
1.1. 基本
例えば、「画像が読み込み終わったら何かする」としたいとき、「画像を読み込み開始する」という関数を実行し、そこに「読み込み終わったらこれを実行してね」というコールバック関数を渡しておきます。
これが非同期です。
同期であれば「読み込み終わった?」かを聞いて、まだだったら待つ、という流れになります。
一般的に非同期の方がスマートな方法になると思います (待たずに他の処理ができるので) 。
const something = () => {
console.log('こんにちは!');
};
setTimeout(something, 1000); // 1 秒後に something() してね!
1.2. 問題点 (コールバック地獄など)
非同期を連ねるとコールバック関数が乱立して良く分からなくなるという問題があります。
const main = () => {
console.log('こんにちは!');
setTimeout(dog, 1000);
};
const dog = () => {
console.log('わん!');
setTimeout(cat, 1000);
};
const cat = () => {
console.log('にゃあ!');
setTimeout(rabbit, 1000);
};
const rabbit = () => {
console.log('ぴょん!');
};
main(); // はろー!
ここではコードが短いので分かりやすいですが、それぞれのコールバック関数の中身が長くなってくると実行順序などが分かりにくくなってきます。
以下のように書くと順序は分かりやすいのですが、インデントがどんどん増えていって何だか複雑です…。
console.log('こんにちは!');
setTimeout(() => {
console.log('わん!');
setTimeout(() => {
console.log('にゃあ!');
setTimeout(() => {
console.log('ぴょん!');
}, 1000);
}, 1000);
}, 1000);
また、「画像ファイルを複数個すべて読み込み終わったら」というような処理も書くのが大変です。
2. Promise
Promise を使うことで非同期の処理をスマートに書くことができるようになります。
簡単のためにここでは Promise
, resolve()
, then()
だけ考えます。
(ここでは説明を省きますが、Promise.all()
などもかなり便利な機能です。)
2.1. 実行順序
簡単に言うと、resolve()
が呼ばれると then()
に指定したコールバック関数が呼ばれます。
const promise = new Promise(resolve => {
// ... Promise の中身
// 非同期に何かするメソッド・関数にコールバック関数として resolve(value) をわたす
});
// ... 色々1
promise.then(value => {
// ... then の中身
});
// ... 色々2
時間にそって考えると以下のようになります。
(非同期処理なので、実行順序は変わることがあります。)
- 「Promise の中身」
- 「色々1」
-
then()
(既にresolve()
が呼ばれていれば「then の中身」を実行) - 「色々2」 (
resolve()
が呼ばれたタイミングで「then の中身」を実行)
もっと分かりやすく、具体的な例で言うと、
- 「Promise の中身」に「画像が読み込み終わったら
resolve(image)
を実行する」という処理を書く - 「色々1」で「『読み込み中…』と表示する」
- 「then の中身」に「引数から受け取った画像を表示する」という処理を書く
-
resolve(image)
された・されていたら「then の中身」が実行され、画像が表示される
という流れです。
-
then()
した後にresolve()
されたら「then の中身」を実行 -
then()
する前にresolve()
されても「then の中身」を呼べないので、resolve()
したデータをとっておき、後でthen()
したときに「then の中身」を実行
then()
と resolve()
の両方が呼ばれたら「then の中身」が実行される、と考えてもいいかもしれません。
2.2. Promise チェーン
2.2.1. then()
がやっていること
「then の中身」が値を return
したとき、以下のように実行されます。
- その値が Promise ならば、そのままの Promise を返す (ように見える)
- その値が Promise でなければ、値を
resolve()
する Promise を返す
(Promise でなく Thenable (then
メソッドを持っているオブジェクト) でも同様になります。)
const promise = new Promise(resolve => {
setTimeout(resolve, 1000, 'foo');
});
promise.then(value => {
console.log({value}); // {value: 'foo'}
return new Promise(resolve => { // ★
setTimeout(resolve, 1000, 'bar');
});
}).then(value => {
console.log({value}); // {value: 'bar'}
});
const promise = new Promise(resolve => {
setTimeout(resolve, 1000, 'foo');
});
promise.then(value => {
console.log({value}); // {value: 'foo'}
return 'bar'; // ★
}).then(value => {
console.log({value}); // {value: 'bar'}
});
「then の中身」では以下の 2 つは同じ意味になります。
return 'bar';
return new Promise(resolve => {
resolve('bar');
});
(実際にコードを書くときは、上記のような非同期処理を行わない Promise は意味がないので書きません。)
2.2.2. then()
はコールバック関数の return
値の Promise をそのまま返しているの?
実際には違います。
then()
したときに新しい Promise を作り、「then の中身」が return
したときその返り値を resolve()
しようとしますが、その値が Promise (Thenable) だった場合はその then()
に次の「then の中身」を渡す、というのが分かりやすい説明だと思います。
参考「Promise - JavaScript | MDN」
ですが、実用的な Promise はさらに複雑な動作をしているようです…。
さらに細かい実装に関しては以下が詳しいです。
参考「Promiseの実装をしっかり読んでみたので学習メモ - Qiita」
2.3. Promise を作る関数
例えば、1 秒待つ Promise を作っても、一度使ってしまうと既に resolve()
してしまっているため、使いまわそうとしても次回からはすぐに終了してしまい、待ってくれません。
つまり、Promise は使うたびに new する必要があります。
なので、Promise を使う場合、単に new Promise するのではなく、new Promise する関数として定義するのが一般的です。
const doSomething = () => new Promise(resolve => {
setTimeout(resolve, 1000, 'foo');
});
const promise = doSomething();
promise.then(value => {
console.log({value});
});
これをふまえて MDN ではいきなり関数の戻り値の Promise を扱う説明から入っているのですが、初めて Promise を学ぼうとしたときにいきなり抽象的なところから説明するのは分かりにくい気がします…。
参考「Promiseを使う - JavaScript | MDN」
3. async/await
async/await を使うとさらに Promise をスマートに扱うことができるようになります。
簡単に言ってしまえば、async が Promise の代わりで、await が then の代わりです。
(※すべて置き換えられるわけではありません。詳細は後述。)
3.1. async 関数
アロー関数や function に async を付けると「async 関数」を定義できます。
「async 関数」とは以下のようなことができます。
- (見かけ上) 関数そのものが非同期に実行される (実行開始だけして、いつ終わるかは分からない)
- (実際は) Promise を返す
-
value = await promise;
はpromise.then(value => {});
扱い (※詳細は後述) -
return
はresolve()
扱い (「then の中身」のreturn
と同じ) - 例外等の
throw
はreject()
扱い
-
実際にやっていることは Promise と同じなため、await (後述) が含まれない async 関数は非同期処理をしない Promise と同様に意味がないものになります。その場合は async がない普通の関数と同じ動作をします。
const asyncFunction = async () => {
// ... 中身
// await /* Promise */;
};
// 本当は Promise を返しているが、戻り値が不要な場合は普通の関数のように記述できる
asyncFunction();
3.2. await
await を Promise に付けると、Promise が resolve()
するのを待ってその値を取得する (ように見える) ようになります。
(実際は then()
にあたることをしています。)
(※以下のコードでは省略していますが、実際には await は async 関数内でないと使えません。)
const promise = new Promise(resolve => {
setTimeout(resolve, 1000); // 中で resolve() が呼ばれる
});
await promise; // 1 秒待機
console.log('foo');
const promise = new Promise(resolve => {
setTimeout(resolve, 1000, 'foo');; // 中で resolve('foo') が呼ばれる
});
const value = await promise; // 1 秒待機
console.log(value);
async 関数外で await を使いたい場合は、async 即時関数 を使うのが手っ取り早いです。
(async () => {
// ... (await を使ったコード)
})();
どこでも await が使えるとなると JavaScript が途中で同期のために一時停止するような動作になってしまうので、それはできませんが、async 関数はメインのプログラムに対して非同期に実行される関数なので、その中で (見かけ上) 同期して一時停止していても大丈夫なわけです。
3.3. 通常の関数と async 関数の実行順序
(※以下のコードでは使用していませんが、それぞれ戻り値を使うことができます。)
const normalFunction = () => {
asyncFunction1(); // 実行を開始するが終了を待つことなく次に進む
console.log('normalFunction(): end');
};
const asyncFunction1 = async () => {
await asyncFunction2(); // 実行が終わるのを待つ
console.log('asyncFunction1(): end');
};
const asyncFunction2 = async () => {
await promise; // 実行が終わるのを待つ
console.log('asyncFunction2(): end');
};
const promise = new Promise(resolve => {
setTimeout(resolve, 1000); // 1 秒後に終了
});
normalFunction(); // 実行が終わってから次に進む
- 通常の関数
- 関数内の処理をし、それが終わったら関数呼び出しのところから次の処理をする
- async 関数 (その関数の戻り値に await なし)
- 関数内の処理を開始するが、終わることを待たずに、そのまま関数呼び出しのところから次の処理をする (戻り値の Promise を無視した場合)
- async 関数 (その関数の戻り値に await あり)
- 関数内の処理を開始し、終わることを待って、それが終わったら関数呼び出しのところから次の処理をする
3.4. async/await は 1 対 1 対応?
async 関数の中には await がないと意味がなく、await を使いたいときは async 関数内でないといけませんが、await の後ろには async 関数を実行したものだけでなく Promise も置けるので、「1 対 1」と覚えてしまうと違う気がします…。
つまりこう覚えておくと良いと思います。
「async 関数定義の中に await 、その後ろに async 関数呼び出しまたは promise」
(※以下のコードは実際は async 関数内でないと使えません。)
const promise = new Promise(resolve => {
setTimeout(resolve, 1000, 'foo');
});
const value = await promise; // ★
console.log(value);
const newPromise = value => new Promise(resolve => {
setTimeout(resolve, 1000, value);
});
const value = await newPromise('foo'); // ★
console.log(value);
const sleep = delay => new Promise(resolve => {
setTimeout(resolve, delay);
});
const asyncFunction = async value => {
await sleep(1000); //
return value;
};
const value = await asyncFunction('foo'); // ★
console.log(value);
4. Promise と async は書き換え可能?
async 関数は確かに Promise を返しますが、常に他の Promise の resolve()
を待つことしかできないので、非同期に resolve()
を呼ぶ処理を書いている Promise を async に書き換えることはできません。
(理論上、書き換え可能な状況もありますが、ほぼ無意味でしょう。)
4.1. async → Promise
async を Promise に書き換えることはできますが、メリットはありません。
(※以下のコードは実際は async 関数内でないと使えません。)
const sleep = delay => new Promise(resolve => {
setTimeout(resolve, delay);
});
const asyncFunction = async value => { // async 関数
console.log('sleep start');
await sleep(1000);
console.log('sleep end');
return value;
};
const value = await asyncFunction('foo');
console.log(value);
const sleep = delay => new Promise(resolve => {
setTimeout(resolve, delay);
});
const asyncFunction = value => new Promise(resolve => { // Promise を作る関数
console.log('sleep start');
sleep(1000).then(() => {
console.log('sleep end');
resolve(value);
});
});
asyncFunction('foo').then(value => {
console.log(value);
});
4.2. Promise → async
上記のコードのように、他の Promise に何かするだけの Promise なら async にすることができますが、通常はそのような処理は then()
だけで書けてしまうため、書き換えるとしても await だけで事足りてしまうでしょう。
特に setTimeout()
のように Promise 以外で resolve()
が呼ばれるような処理を書いている Promise は async に書き換えることはできません。