【async?await?】JavaScriptの非同期処理をちゃんと理解する

asynchronous-javascript

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を使っている関数には、asyncをつける必要がある

awaitがないとどうなるの?

次はawaitです。このawaitの部分で、割り込み可能としている感じがしますね。

const response = await fetch(url);

awaitつけないとどうなるのでしょう?

const response = fetch(url);

動かしてみると、エラーになります。

handleData開始
response.json is not a function
handleData終了

なぜエラーになるのか?それは、awaitをつけた時とつけない時で、戻ってくる型が変わるから

具体的には、動作がこのように変わります。

awaitの違い

awaitをつけたとき

  • データ取得が終わるまで待っている
  • 取得が終わると、Response型(=フェッチ結果)で結果が返る

awaitをつけないとき

  • データ取得が終わるのを待たずにすぐ返ってくる
  • Promise型が返る

呼び出しているのは同じ関数なのに、awaitのありなしで挙動が変わる。不思議ですね。

非同期処理にawaitをつけないと、Promise型が返る

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が呼ばれると、こんな動きをします。

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関数が動きます。

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実装版を見ると、こんなことが見えます。

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で、Promise型の「複雑な非同期処理」を「同期的」なコードで書ける

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

と、自動的に変換されるのです。

async関数:returnしたオブジェクトを含んだPromiseを返却する

awaitは、Promiseを待ち合わせる

Promiseを返してくるということは…これ、先ほどawaitのところで、同じようなパターン見ましたね。

そう、asyncで定義した関数は、awaitで受けられるのです。

awaitの本当の役割は、こう。

await:async関数が返してくるPromiseを、終了まで待ち合わせる

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関数である、ということが分かります。

階層構造の非同期処理をシンプルに

さてここまでの、非同期関数の呼び出し関係をまとめると、こうなります。

async/awaitの階層構造

関数 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を連鎖させると、シンプルに書けるのです

async/awaitで、呼び出し下位から上位まで、一連の非同期処理を同期的に書ける

この場合、呼び出し上位から下位までまとめて、非同期処理として扱われます。

JavaScriptの非同期処理API

ここまでの説明で想像できるように、フェッチAPIも、Promiseを返す非同期関数として定義されています。

JavaScriptには、このように非同期処理が可能なAPIが他にもあります。一例を紹介しましょう。

JavaScriptの非同期処理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でパフォーマンスが低下する場合がある

複数の処理があるときは、同期性を判断して、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をフルに使うような処理は、非同期にしようとしても割り込めないのです。

これは覚えておきましょう。

await のない async関数は、意味がない(非同期にならない)

async/awaitは、基本的にシングルスレッドモデル下で動作します。なのでCPUバウンドタスクは並列処理になりません。計算処理をバックグラウンド処理したいなら、ウェブワーカーAPIの使用を検討しましょう。

まとめ

JavaScriptで頻出する非同期処理。ひと昔まえは、大量のコールバックが絡む複雑な実装になりがちでした。

今は、async / awaitの登場で、非常にシンプルな実装が可能です。

ただ、あまりにもシンプル(そうに見える)ため、ちゃんと動きを理解していないと、後で混乱に陥ります。

一度Promise型の実装を試してみるのもいいでしょう。async/awaitが何をしているのか見えやすくなります。

いろいろ試して、理解を深めていきましょう。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

コメント

コメントする

CAPTCHA


目次