JavaScriptで頻出する、async/awaitを使った非同期処理。
WebAPIアクセス、データベースアクセス、登場する場面は多岐に渡ります。
async/awaitを使った実装は、とてもシンプル。
しかしその背後にある仕掛けは、意外と複雑です。正しく理解していないと思わぬ落とし穴にはまります。
実例で試しながら、非同期処理で何が起こるのか、正しく理解しましょう。
非同期処理って何がうれしいの?
まず、そもそも思う疑問がこれ。
非同期処理って、なんのメリットがあるの?
これを解決していきます。
非同期処理の代表、フェッチAPI
JavaScriptで、非同期処理をもっともよく使うのは、おそらくフェッチAPIでしょう。
フェッチAPIは、HTTPリクエストとレスポンスを処理するためのAPIです。WebAPIでデータを取得するときに使います。
ごく簡単に、フェッチAPIを使った処理を、実装してみましょう。(注:一部エラー処理など省略しています)
async function handleData() {
console.log('handleData開始');
// WebAPI URL
const url = 'https://fakerapi.it/api/v2/books?_quantity=1000';
try {
// フェッチAPIを使ってWebAPIからデータを取得
const response = await fetch(url);
// JSONに変換
const json = await response.json();
console.log(json);
} catch (error) {
console.error(error.message);
}
console.log('handleData終了');
}
URLにある”https://fakerapi.it/~”は、Faker APIという、テスト用のダミーデータを提供しているサイトです
fetch()でテータ取得し、json()でJSON変換する。シンプルですね。結果はこうなります。
handleData開始
{status: 'OK', code: 200, locale: 'en_US', seed: null, total: 1000, …}
handleData終了
やっていることは、割と単純そう。これの何がうれしいんでしょう?
途中で他の処理が割り込める?
非同期処理の効果を確かめるため、コードをちょっと改造します。
タイマー処理を加えてみます。1秒ごとに”tick.”とログ表示するタイマーです。
function tickTime() {
console.info("tick.");
}
setInterval(tickTime,1000);
動かしてみると、こんな結果になります。
tick.
tick.
tick.
handleData開始
tick.
tick.
tick.
tick.
{status: 'OK', code: 200, locale: 'en_US', seed: null, total: 1000, …}
handleData終了
tick.
tick.
handleDataが開始して終了するまでの間に、tickTime関数が動いているのが分かります。
つまり、ある関数が動いている最中に、別の関数が割り込んで動いているのです。
画面のブロックを避ける
関数が割り込んで動くのが、なぜうれしいのか?
一番の意義は、画面を止めないようにするためです。
WebAPIの呼び出しは、時には長い時間がかかります。でもその間、画面を止めるわけにはいきません。
- ユーザーの入力に反応しないといけないし
- 画面を更新する必要だってあります
非同期処理にして、割り込みを許すことで、こんなメリットがあるのです。
謎のキーワード、await/async
この不思議な動作のカギとなるのが、async と awaitです。では次の疑問。
asyncとawaitって、何してるの?
この見慣れないキーワードが、いったい何をしているのか?解き明かしていきましょう。
asyncがないとどうなるの?
まず注目は、async。関数の先頭に、asyncというのがついていますね。
async function handleData() {
これ何の意味があるのでしょう?試しに外してみましょうか。
function handleData() {
動かしてみると、エラーになります。
Uncaught SyntaxError: await is only valid in async functions and the top level bodies of modules
ここではひとまず、これだけ覚えておきましょう。
awaitがないとどうなるの?
次はawaitです。このawaitの部分で、割り込み可能としている感じがしますね。
const response = await fetch(url);
awaitつけないとどうなるのでしょう?
const response = fetch(url);
動かしてみると、エラーになります。
handleData開始
response.json is not a function
handleData終了
なぜエラーになるのか?それは、awaitをつけた時とつけない時で、戻ってくる型が変わるから。
具体的には、動作がこのように変わります。
awaitをつけたとき
- データ取得が終わるまで待っている
- 取得が終わると、Response型(=フェッチ結果)で結果が返る
awaitをつけないとき
- データ取得が終わるのを待たずにすぐ返ってくる
- Promise型が返る
呼び出しているのは同じ関数なのに、awaitのありなしで挙動が変わる。不思議ですね。
Promiseの意味は「約束」。結果は待たないが、後で結果を返すことを「約束」する。そんな動作になります。
Promiseを使った非同期処理
awaitをつけないとPromiseが返ってきます。実はこのPromiseを使っても、非同期処理を作ることができます。
試しにやってきましょう。(分かりやすくするため、やや冗長に書いています)
function handleData() {
console.log('handleData開始');
const url = 'https://fakerapi.it/api/v2/books?_quantity=1000';
try {
// フェッチ実行、プロミスを取得
const promiseFetch = fetch(url);
// 成功時のハンドラ
const successHandler = (response) => {
console.log('successHandler開始');
// JSON取得実行、プロミスを取得
const promiseJson = response.json();
// プロミス成功時のハンドラ
promiseJson.then(json => {
console.log(json);
});
console.log('successHandler終了');
};
// 失敗時のハンドラ
const errorHandler = (reason) => {
console.log(reason);
};
// プロミスに成功時および失敗時のハンドラを設定
promiseFetch.then(successHandler);
promiseFetch.catch(errorHandler);
} catch (error) {
console.error(error.message);
}
console.log('handleData終了');
}
これで、awaitを使ったときと同じ結果が得られます。が、なんか複雑なコードになりました。
動きを追ってみましょう。最初にhandleDataが呼ばれると、こんな動きをします。
function handleData() {
console.log('handleData開始');
const url = 'https://fakerapi.it/api/v2/books?_quantity=1000';
try {
// フェッチ実行、プロミスを取得
const promiseFetch = fetch(url);
// 成功時のハンドラ
const successHandler = (response) => {...
// 失敗時のハンドラ
const errorHandler = (reason) => {...
// プロミスに成功時および失敗時のハンドラを設定
promiseFetch.then(successHandler);
promiseFetch.catch(errorHandler);
} catch (error) {
console.error(error.message);
}
console.log('handleData終了');
}
- fetchを実行。fetchはPromiseを返却して、すぐ終了
- Promiseに、fetch成功時および失敗時のハンドラを登録
ここでのポイントは、handleData関数自体は、すぐに終了してしまうこと。
handleData本体では、fetchの結果を扱いません。
その後、fetchリクエスト結果が到着すると、Promiseに登録したsuccessHandler関数が動きます。
function handleData() {
try {
// 成功時のハンドラ
const successHandler = (response) => {
console.log('successHandler開始');
// JSON取得実行、プロミスを取得
const promiseJson = response.json();
// プロミス成功時のハンドラ
promiseJson.then(json => {
console.log(json);
});
console.log('successHandler終了');
};
// プロミスに成功時および失敗時のハンドラを設定
promiseFetch.then(successHandler);
promiseFetch.catch(errorHandler);
} catch (error) {
}
}
- successHandlerで、受け取ったresponseからJSONデータを取り出す
これで、最終的にWebAPIの取得結果が得られます。
プログラムに同期しないから「非同期処理」
Promise実装版を見ると、こんなことが見えます。
- successHandler関数は、handleData関数と連動して動いているわけではない
- successHandler関数は、HTTPリクエスト到着という別のタイミングで動きはじめる
このように、何かの処理とは、別のタイミングで動く処理だから、「非同期処理」というわけです。
Promiseで実装すると、非同期のイメージがしやすいですね。
先ほどのコードは、分解した様子を分かりやすくするため、あえて冗長に書いています。
実際は、thenやcatchをつなげて、こんな書き方をすることが多いです。だいぶすっきりしますね。
function handleData() {
console.log('handleData開始');
const url = 'https://fakerapi.it/api/v2/books?_quantity=1000';
try {
// フェッチ実行
fetch(url)
.then(response => {
console.log('successHandler開始');
return response.json();
})
.then(json => {
console.log(json);
})
.catch( reason => {
console.log(reason);
});
} catch (error) {
console.error(error.message);
}
console.log('handleData終了');
}
async/awaitで「非同期処理」を「同期的」に作る
さて、さきほどのPromise版コード、実は、async / await を全く使っていません。気付きましたか?
async/awaitを使わなくとも、Promiseで非同期処理が書けるのです。ただPromiseを使った実装は、ハンドラが出てきたり、処理があっちやこっちに行ったり、複雑になりがち。
そこで、async / await。これを使うと、上から下へ素直に流れるような処理が書けます。
見た目は同期的でシンプルに、でもちゃんと非同期処理として動く、そんな処理ができるわけです。
async/awaitが本当にやっていること
async / awaitが、非同期処理をなんかうまくやってくれていることが、分かりました。
実際なにをやっているか、もう少し深堀りしてみましょう。
非同期関数を作ってみよう
今度は、自前で非同期関数を作ってみましょう。
冒頭に出てきたコードの、フェッチAPIとJSON取得の部分。ここを切り出して、非同期関数にしてみます。
async function handleData() {
console.log('handleData開始');
// WebAPI URL
const url = 'https://fakerapi.it/api/v2/books?_quantity=1000';
try {
// フェッチAPIを使ってWebAPIからデータを取得
const response = await fetch(url);
// JSONに変換
const json = await response.json();
console.log(json);
} catch (error) {
console.error(error.message);
}
console.log('handleData終了');
}
getDataFromURLという関数で切り出しましょう。こんな感じ。
async function getDataFromURL(url) {
try {
// フェッチAPIを使ってWebAPIからデータを取得
const response = await fetch(url);
// JSONに変換
const json = await response.json();
// JSONを返却
return json;
} catch (error) {
console.error(error.message);
throw error;
}
}
関数の先頭にあるasyncは必須です。awaitを使う関数にはasyncをつけないといけないのでしたね。
async関数は、自動的にPromiseを返す
次は、この関数を使う処理を作ります。
ここで、async関数を呼び出す際の、重大なルールがあります。
async関数では、returnしたオブジェクトがそのまま返ってくるわけではないのです。先ほどの例で言うと、
- returnに書いているのは json オブジェクト
- しかし実際に返ってくるのは Promise
と、自動的に変換されるのです。
awaitは、Promiseを待ち合わせる
Promiseを返してくるということは…これ、先ほどawaitのところで、同じようなパターン見ましたね。
そう、asyncで定義した関数は、awaitで受けられるのです。
awaitの本当の役割は、こう。
awaitを使うと、呼び出すコードはこうなります。
async function handleData() {
console.log('handleData開始');
// WebAPI URL
const url = 'https://fakerapi.it/api/v2/books?_quantity=1000';
try {
const json = await getDataFromURL(url);
console.log(json);
} catch (error) {
console.error(error.message);
}
console.log('handleData終了');
}
呼び出すほうも、非常にすっきり。始めにfetchを使ったコードと非常に似ていますね。
ここから、実は、fetch関数もasync関数である、ということが分かります。
階層構造の非同期処理をシンプルに
さてここまでの、非同期関数の呼び出し関係をまとめると、こうなります。
関数 funcParent から、非同期関数 funcChild を呼び出すとき
funcChild :
- フェッチAPIなどの非同期処理をawaitで呼び出す
- awaitを使っているので、async funcChild と定義する
funcParent :
- await funcChild で呼び出す
- await を使っているので、async funcParent と定義する
コードにすると、こんなイメージ。
async function funcChild {
...
const xxx = await fetch(...);
...
return yyy;
}
async function funcParent {
...
const zzz = await funcChild(...);
...
}
非同期関数に呼び出し階層があった場合も、async/awaitを連鎖させると、シンプルに書けるのです。
この場合、呼び出し上位から下位までまとめて、非同期処理として扱われます。
JavaScriptの非同期処理API
ここまでの説明で想像できるように、フェッチAPIも、Promiseを返す非同期関数として定義されています。
JavaScriptには、このように非同期処理が可能なAPIが他にもあります。一例を紹介しましょう。
- フェッチAPI HTTPリスエストとレスポンスを処理する
- IndexedDB API 大量のデータを扱う
- ファイルシステムAPI ファイルのアクセスを扱う
- ウェブオーディオAPI ウェブで音声を扱う
特に、時間がかかる可能性のあるI/O処理が、非同期関数で定義されています。
これらのAPIを使うときも、async / await の機構を使うことができます。
非同期処理のアンチパターン2選
非同期処理をシンプルに書ける、async,await。
ただ、すこし複雑なことをしようとした場合に、その背景を知らないと、思わぬ落とし穴にハマることがあります。
よく知られるアンチパターンを2例紹介しましょう。
(1)複数のリクエストの扱い方
異なる2つのWebAPIから、一度にデータ取得が必要になったとします。
さきほどのgetDataFromURL関数を、2回使えば実現できそう。こんなコードを組みました。
async function handleData() {
console.log('handleData開始');
const urlBooks = 'https://fakerapi.it/api/v2/books?_quantity=2000';
const urlUsers = 'https://fakerapi.it/api/v2/users?_quantity=1000';
try {
const books = await getDataFromURL(urlBooks);
const users = await getDataFromURL(urlUsers);
console.log(books);
console.log(users);
} catch (error) {
console.error(error.message);
}
console.log('handleData終了');
}
シンプルですね。全然おかしくない。ちゃんと動きます。
でもこのコード、パフォーマンスを改善する余地があるのです。
それがこの部分。
const books = await getDataFromURL(urlBooks);
const users = await getDataFromURL(urlUsers);
これ、urlBooks取得し、それが終わったらurlUsersを取得します。
でもこれ、片方の終了を待っている必要はないのです。
両方を同時に取得するほうが、効率がいい。
一度に2つ動かして、両方終わって時点で続きをする、という組み方ができます。
async function handleData() {
console.log('handleData開始');
const urlBooks = 'https://fakerapi.it/api/v2/books?_quantity=2000';
const urlUsers = 'https://fakerapi.it/api/v2/users?_quantity=1000';
try {
const promiseBooks = getDataFromURL(urlBooks);
const promiseUsers = getDataFromURL(urlUsers);
const [jsonBooks, jsonUsers] = await Promise.all([promiseBooks, promiseUsers]);
} catch (error) {
console.log('catch開始');
console.error(error.message);
}
console.log('handleData終了');
}
個々のasync関数をawaitで待つのではなく、
- getDataFromURL(urlBooks) と getDataFromURL(urlUsers) は、awaitを使わずPromiseを受ける
- 次の await Promise.all で、Promiseが全て完了するのを待ち受ける
こうすると、不必要な待ち合わせがなくなり、パフォーマンスが向上します。
複数の処理があるときは、同期性を判断して、awaitの使いかたを検討しましょう。
(2)重たい計算処理の扱い方
async / await を使うと、バックグラウンドで同時に実行できる、と思ってしがいがち。
そうすると、CPU負荷がかかる処理を、非同期にしてしまう場合があります。
例えばこんな処理。
// フィボナッチ数列計算
async function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
async function handleData() {
console.log('handleData開始');
try {
const result = await fibonacci(40);
console.log(result);
} catch (error) {
console.error(error.message);
}
console.log('handleData終了');
}
この処理、例えawaitをつけて動かしたとしても、割り込みが起こりません。
awaitは、あくまで待ち時間がある処理について、合間で割り込みを許すもの。
CPUをフルに使うような処理は、非同期にしようとしても割り込めないのです。
これは覚えておきましょう。
async/awaitは、基本的にシングルスレッドモデル下で動作します。なのでCPUバウンドタスクは並列処理になりません。計算処理をバックグラウンド処理したいなら、ウェブワーカーAPIの使用を検討しましょう。
まとめ
JavaScriptで頻出する非同期処理。ひと昔まえは、大量のコールバックが絡む複雑な実装になりがちでした。
今は、async / awaitの登場で、非常にシンプルな実装が可能です。
ただ、あまりにもシンプル(そうに見える)ため、ちゃんと動きを理解していないと、後で混乱に陥ります。
一度Promise型の実装を試してみるのもいいでしょう。async/awaitが何をしているのか見えやすくなります。
いろいろ試して、理解を深めていきましょう。
コメント