0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

JavaScriptの非同期を理解するにはMDN Web Docsを読むべき。翻訳しといたよ Part.4

Last updated at Posted at 2022-01-11

こんにちはWebエンジニアのmasakichiです。

JavaScriptの非同期を理解するにはMDN Web Docsを読むべき。翻訳しといたよ 4つ目の記事です。

全部で5記事あります。

翻訳箇所について

こちらのページの日本語未翻訳記事です。

slide.jpeg

なぜ翻訳したか

JavaScriptの非同期処理。特にPromiseというやつが、ぼんやりわかっているけど完全には理解していない状態がずっと続いていました。

そんな中、MDN Web Docsの解説がすごくわかりやすく一気に理解できました。

しかし、これらの記事は日本語に翻訳されていないという問題が。。。

ぜひ非同期処理で悩める同志にも読んでほしい。という想いで翻訳作業をしてみました。

留意点

筆者は英語がそこまで得意というわけではありません。DeepLの力を借りて、翻訳していますので、日本語訳が不自然なところや一部、情報を省略・意訳しています。あらかじめご了承ください。

ライセンスは下記です。
This Article by Mozilla Contributors is licensed under CC-BY-SA 2.5.

asyncとawaitで非同期プログラミングを容易にする

最近、JavaScriptには、ECMAScript 2017でasync関数とawaitキーワード追加されました。これらの機能は基本的にPromiseの糖衣構文として機能し、非同期コードを書きやすくし、その後の読み込みも容易にします。これらは非同期コードをより旧来の同期コードのように見せるので、学ぶ価値は十分にあります。この記事はあなたが知る必要のあることを提供します。

前提条件
基本的なコンピュータリテラシー / JavaScriptの基礎の合理的な理解、非同期コード一般とPromiseに関する理解

目的
async/awaitの使い方を理解すること。


async/awaitの基礎知識

コードでasync/awaitを使用するには、2つのパートがあります。


asyncキーワード

まず、asyncキーワードですが、これは関数宣言の前に置くと非同期関数になります。async関数とは、awaitキーワードが非同期コードを呼び出すために使われる可能性を想定している関数です。

ブラウザのJSコンソールに次の行を入力してみてください。

function hello() { return "Hello" };
hello();

この関数は "Hello "を返しますが、これは特別なことではありませんね。

しかし、これを非同期関数にしたらどうでしょうか?次のようにしてみてください。

async function hello() { return "Hello" };
hello();

関数を呼び出すと、今度はPromiseが返されるようになりました。これは非同期関数の特徴の1つで、その戻り値はPromiseに変換されることが保証されています。

また、以下のように非同期関数式を作成することもできます。

let hello = async function() { return "Hello" };
hello();

アロー機能を使うこともできます。

let hello = async () => "Hello";

これらはすべて基本的に同じことをします。

Promiseが実行されたときに返される値を実際に消費するには、Promiseを返しているので、.then()ブロックを使用することができます。

hello().then((value) => console.log(value));

以下のような省略形でも構いません。

hello().then(console.log)

前回の記事で見た形式ですね。

このように、関数にasyncキーワードを追加すると、値を直接返すのではなく、Promiseを返すように指示することができるのです。


awaitキーワード

awaitは、通常のJavaScriptコードの非同期関数の中でしか動作しませんが、JavaScriptモジュールでは単独で使用することができます。

await を非同期Promise関数の前に置くと、Promiseが成立するまでその行のコードを一時停止し、その結果の値を返します。

Web API 関数を含め、Promise を返す任意の関数を呼び出す際に await を使用することができます。

以下は些細な例です。

async function hello() {
  return await Promise.resolve("Hello");
};

hello().then(alert);

もちろん、上記の例は構文の説明にはなっていますが、あまり役に立ちません。次に、実際の例を見てみましょう。


async/awaitでPromiseコードを書き直す

前回の記事で見た、簡単なfetch()の例を振り返ってみましょう。

fetch('coffee.jpg')
  .then(response => {
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    return response.blob();
  })
  .then(myBlob => {
    let objectURL = URL.createObjectURL(myBlob);
    let image = document.createElement('img');
    image.src = objectURL;
    document.body.appendChild(image);
  })
  .catch(e => {
    console.log('There has been a problem with your fetch operation: ' + e.message);
  });

ここまでで、Promiseとその仕組みについてそれなりに理解できたと思いますが、これをasync/awaitを使うように変換して、どれだけ物事がシンプルになるかを見てみましょう。

async function myFetch() {
  let response = await fetch('coffee.jpg');

  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`);
  }

  let myBlob = await response.blob();

  let objectURL = URL.createObjectURL(myBlob);
  let image = document.createElement('img');
  image.src = objectURL;
  document.body.appendChild(image);
}

myFetch()
  .catch(e => {
    console.log('There has been a problem with your fetch operation: ' + e.message);
  });

コードがよりシンプルになり、理解しやすくなりました。.then()ブロックをあちこちに使うのはもはや必要ありません。

asyncキーワードは関数をPromiseに変えるので、Promiseとawaitのハイブリッドアプローチを使うようにコードをリファクタリングして、関数の後半を新しいブロックに出し、より柔軟性を持たせることもできます。

async function myFetch() {
  let response = await fetch('coffee.jpg');
  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`);
  }
  return await response.blob();
}

myFetch()
  .then(blob => {
    let objectURL = URL.createObjectURL(blob);
    let image = document.createElement('img');
    image.src = objectURL;
    document.body.appendChild(image);
  })
  .catch(e => console.log(e));

どういう仕組みなのでしょうか

上記のコードは関数内にラップされており、functionキーワードの前にasyncキーワードを含んでいることに注意してください。これは必要なことです。非同期コードを実行するコードブロックを定義するために、非同期関数を作成しなければなりません。先ほど述べたように、awaitは非同期関数の中でしか動作しません。

myFetch()関数定義の内部では、コードが以前のPromiseバージョンに酷似していることがわかりますが、いくつかの相違点があることがわかります。Promiseの各メソッドの末尾に .then() ブロックを連結する必要がなく、メソッド呼び出しの前に await キーワードを追加し、結果を変数に代入するだけでよいのです。awaitキーワードは、JavaScriptの処理を一時停止させ、非同期関数呼び出しがその結果を返すまで、他のコードの実行を許可しないようにします。

そして、それが完了すると、コードは次の行から実行されます。例えば

let response = await fetch('coffee.jpg');

fullfilledしたfetch()のPromiseが返すレスポンスは、そのレスポンスが利用可能になった時点でresponse変数に代入され、パーサーはそれが発生するまでこの行で一時停止します。レスポンスが利用可能になると、パーサーは次の行に移動し、そこからBlobを作成します。この行も非同期Promiseのメソッドを呼び出すので、ここでもawaitを使用しています。操作の結果が返ってきたら、それをmyFetch()関数から返しています。

つまり、myFetch()関数を呼び出すとPromiseが返されるので、その末尾に.then()をチェーンして、画面上にBlobを表示する処理を行うことができるのです。

.then()ブロックが少なく、同期コードのように見えるので、とても直感的に操作できます。


エラー処理の追加

エラー処理を追加したい場合は、いくつかのオプションがあります。

async/awaitで同期的なtry...catch構造を使うことができます。この例は、上で紹介した最初のバージョンのコードを発展させたものです。

async function myFetch() {
  try {
    let response = await fetch('coffee.jpg');

    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    let myBlob = await response.blob();
    let objectURL = URL.createObjectURL(myBlob);
    let image = document.createElement('img');
    image.src = objectURL;
    document.body.appendChild(image);
  } catch(e) {
    console.log(e);
  }
}

myFetch();

catch() {} ブロックには、e と呼ばれるエラーオブジェクトが渡されます。これをコンソールにログ出力すると、コードのどこでエラーが発生したかを示す詳細なエラーメッセージが表示されます。

もし、上で紹介したコードの2番目の(リファクタリングされた)バージョンを使いたい場合は、ハイブリッドアプローチを続けて、.then()呼び出しの最後に.catch()ブロックをこのように連結する方がよいでしょう。

async function myFetch() {
  let response = await fetch('coffee.jpg');
  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`);
  }
  return await response.blob();
}

myFetch()
  .then(blob => {
    let objectURL = URL.createObjectURL(blob);
    let image = document.createElement('img');
    image.src = objectURL;
    document.body.appendChild(image);
  })
  .catch(e => console.log(e));

これは、.catch()ブロックが、非同期関数呼び出しとPromiseチェーンの両方で発生するエラーをキャッチするためです。ここで try/catch ブロックを使ってしまうと、myFetch() 関数が呼び出されたときに、まだ未処理のエラーが発生する可能性があります。


Promise.all()のAwait

async/awaitはPromiseの上に構築されているので、Promiseが提供するすべての機能と互換性があります。これは Promise.all() を含みます。単純な同期コードのように見える方法で、変数に返されたすべての結果を得るために Promise.all() の呼び出しを待つことができます。再び、前回の記事で見た例に戻りましょう。別のタブで開いておいて、以下の新しいバージョンと比較対照してください。

これをasync/awaitに変換すると、次のようになります。

async function fetchAndDecode(url, type) {
  let response = await fetch(url);

  let content;

  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`);
  } else {
    if(type === 'blob') {
      content = await response.blob();
    } else if(type === 'text') {
      content = await response.text();
    }
  }
  return content;
}

async function displayContent() {
  let coffee = fetchAndDecode('coffee.jpg', 'blob');
  let tea = fetchAndDecode('tea.jpg', 'blob');
  let description = fetchAndDecode('description.txt', 'text');

  let values = await Promise.all([coffee, tea, description]);

  let objectURL1 = URL.createObjectURL(values[0]);
  let objectURL2 = URL.createObjectURL(values[1]);
  let descText = values[2];

  let image1 = document.createElement('img');
  let image2 = document.createElement('img');
  image1.src = objectURL1;
  image2.src = objectURL2;
  document.body.appendChild(image1);
  document.body.appendChild(image2);

  let para = document.createElement('p');
  para.textContent = descText;
  document.body.appendChild(para);
}

displayContent()
  .catch(e => console.log(e));

fetchAndDecode()関数が、ほんの少しの変更で簡単に非同期関数に変換されたことが分かると思います。Promise.all()の行をご覧ください。

let values = await Promise.all([coffee, tea, description]);

ここでawaitを使うことで、3つのPromiseの結果がすべて利用可能になったときに、同期コードのように非常によく見える方法で、値の配列に返されるすべての結果を取得することができるようになりました。すべてのコードを新しい非同期関数 displayContent() で包まなければならず、コードの行数はあまり減っていませんが、コードの大部分を .then() ブロックから移動させることができるので、より読みやすいプログラムを残して、素晴らしく便利な簡略化を実現しています。

エラー処理のために、displayContent()の呼び出しに.catch()ブロックを含めました。これは、両方の関数で発生したエラーを処理します。


async/awaitの遅延への対応

Async/awaitはあなたのコードを同期的に見せ、ある意味ではより同期的に動作させることができます。awaitキーワードは、同期操作とまったく同じように、Promiseが実行されるまで、それに続くすべてのコードの実行をブロックします。その間に他のタスクが実行されることはありますが、awaitされたコードはブロックされます。例えば、以下のようになります。

async function makeResult(items) {
  let newArr = [];
  for(let i = 0; i < items.length; i++) {
    newArr.push('word_' + i);
  }
  return newArr;
}

async function getResult() {
  let result = await makeResult(items); // Blocked on this line
  useThatResult(result); // Will not be executed before makeResult() is done
}

その結果、多数の待ち受けPromiseが次々と発生し、コードが遅くなる可能性があります。それぞれのawaitは前のものが終了するのを待ちます。一方、実際にあなたが望むのは、async/awaitを使っていないときのように、同時に処理を開始する約束のためかもしれません。

slow-async-await.html (ソースコード参照) と fast-async-await.html (ソースコード参照) という2つの例を見てみましょう。どちらも、setTimeout()呼び出しで非同期処理を装ったカスタムPromise関数から始まります。

function timeoutPromise(interval) {
  return new Promise((resolve, reject) => {
    setTimeout(function(){
      resolve("done");
    }, interval);
  });
};

それぞれにtimeTest()非同期関数が含まれており、3回のtimeoutPromise()呼び出しを待ちます。

async function timeTest() {
  ...
}

それぞれ、開始時刻の記録、timeTest()Promiseが実行されるまでの時間を確認、終了時刻を記録、操作にかかった総時間を報告して終了します。

let startTime = Date.now();
timeTest()
  .then(() => {
    let finishTime = Date.now();
    let timeTaken = finishTime - startTime;
    alert("Time taken in milliseconds: " + timeTaken);
  })

それぞれのケースで異なるのはtimeTest()関数です。

slow-async-await.htmlの例では、timeTest()は次のようになります。

async function timeTest() {
  await timeoutPromise(3000);
  await timeoutPromise(3000);
  await timeoutPromise(3000);
}

ここでは、3つのtimeoutPromise()呼び出しを直接待ち、それぞれ3秒後に警告を出すようにしています。最初の例を実行すると、アラートボックスに合計約9秒の実行時間が表示されるのがわかるでしょう。

fast-async-await.htmlの例では、timeTest()は次のようになります。

async function timeTest() {
  const timeoutPromise1 = timeoutPromise(3000);
  const timeoutPromise2 = timeoutPromise(3000);
  const timeoutPromise3 = timeoutPromise(3000);

  await timeoutPromise1;
  await timeoutPromise2;
  await timeoutPromise3;
}

ここでは、3つのPromiseオブジェクトを変数に格納し、関連するプロセスをすべて同時に起動させる効果を持たせています。

次に、その結果を待ちます。約束はすべて基本的に同時に処理を始めたので、約束はすべて同時に達成されます。2番目の例を実行すると、アラートボックスで実行時間の合計が3秒強であることが報告されます!


エラー処理

しかし、上記のパターンには問題があり、処理されないエラーが発生する可能性があります。

前の例を更新して、今度は拒否されたPromiseを追加し、最後にcatch文を追加してみましょう。

function timeoutPromiseResolve(interval) {
  return new Promise((resolve, reject) => {
    setTimeout(function(){
      resolve("successful");
    }, interval);
  });
};

function timeoutPromiseReject(interval) {
  return new Promise((resolve, reject) => {
    setTimeout(function(){
      reject("error");
    }, interval);
  });
};

async function timeTest() {
  await timeoutPromiseResolve(5000);
  await timeoutPromiseReject(2000);
  await timeoutPromiseResolve(3000);
}

let startTime = Date.now();

timeTest()
  .then(() => {})
  .catch(e => {
    console.log(e);
    let finishTime = Date.now();
    let timeTaken = finishTime - startTime;
    alert("Time taken in milliseconds: " + timeTaken);
  })

上記の例では、エラーは適切に処理され、約7秒後にアラートが表示されます。

次に2つ目のパターンです。

function timeoutPromiseResolve(interval) {
  return new Promise((resolve, reject) => {
    setTimeout(function(){
      resolve("successful");
    }, interval);
  });
};

function timeoutPromiseReject(interval) {
  return new Promise((resolve, reject) => {
    setTimeout(function(){
      reject("error");
    }, interval);
  });
};

async function timeTest() {
  const timeoutPromiseResolve1 = timeoutPromiseResolve(5000);
  const timeoutPromiseReject2 = timeoutPromiseReject(2000);
  const timeoutPromiseResolve3 = timeoutPromiseResolve(3000);

  await timeoutPromiseResolve1;
  await timeoutPromiseReject2;
  await timeoutPromiseResolve3;
}

let startTime = Date.now();

timeTest()
  .then(() => {})
  .catch(e => {
    console.log(e);
    let finishTime = Date.now();
    let timeTaken = finishTime - startTime;
    alert("Time taken in milliseconds: " + timeTaken);
  })

この例では、コンソールに未処理のエラーが発生し(2秒後)、約5秒後にアラートが表示されています。

Promiseを並列に起動し、エラーを適切にキャッチするには、先に説明したように Promise.all() を使用すればよいでしょう。

function timeoutPromiseResolve(interval) {
  return new Promise((resolve, reject) => {
    setTimeout(function(){
      resolve("successful");
    }, interval);
  });
};

function timeoutPromiseReject(interval) {
  return new Promise((resolve, reject) => {
    setTimeout(function(){
      reject("error");
    }, interval);
  });
};

async function timeTest() {
  const timeoutPromiseResolve1 = timeoutPromiseResolve(5000);
  const timeoutPromiseReject2 = timeoutPromiseReject(2000);
  const timeoutPromiseResolve3 = timeoutPromiseResolve(3000);

  const results = await Promise.all([timeoutPromiseResolve1, timeoutPromiseReject2, timeoutPromiseResolve3]);
  return results;
}

let startTime = Date.now();

timeTest()
  .then(() => {})
  .catch(e => {
    console.log(e);
    let finishTime = Date.now();
    let timeTaken = finishTime - startTime;
    alert("Time taken in milliseconds: " + timeTaken);
  })

この例では、約2秒後にエラーが適切に処理され、また約2秒後にアラートが表示されています。

Promise.all()は、入力された約束のいずれかが拒否されたときに拒否されます。もし、いくつかの約束が拒否されたときでも、すべての約束を解決して、その満たされた値のいくつかを使いたいなら、代わりに Promise.allSettled() を使うことができます。


Async/awaitクラスのメソッド

最後に、クラスやオブジェクトのメソッドの前にasyncを追加して、それらがPromiseを返すようにしたり、その中でPromiseを待ったりすることも可能です。オブジェクト指向JavaScriptの記事で見たESクラスのコードを見て、そしてasyncメソッドを使った修正版を見てみてください。

class Person {
  constructor(first, last, age, gender, interests) {
    this.name = {
      first,
      last
    };
    this.age = age;
    this.gender = gender;
    this.interests = interests;
  }

  async greeting() {
    return await Promise.resolve(`Hi! I'm ${this.name.first}`);
  };

  farewell() {
    console.log(`${this.name.first} has left the building. Bye for now!`);
  };
}

let han = new Person('Han', 'Solo', 25, 'male', ['Smuggling']);

最初のクラスのメソッドは、次のように使うことができます。

han.greeting().then(console.log);

ブラウザサポート

async/awaitを使用するかどうかを決定する際に考慮すべきことの1つは、古いブラウザのサポートです。これらはほとんどのブラウザのモダンバージョンで利用可能で、Promiseと同じです; 主なサポートの問題は Internet Explorer と Opera Mini で起こります。

もし、async/awaitを使いたいが、古いブラウザのサポートが心配であれば、BabelJSライブラリの使用を検討してみてください。非同期/待機をサポートしないブラウザに遭遇した場合、Babelのポリフィルは自動的に古いブラウザで動作するフォールバックを提供することができます。


結論

async/awaitは、読みやすく保守しやすい非同期コードを書くためのシンプルで素晴らしい方法を提供します。この記事を書いている時点では、ブラウザのサポートは他の非同期コード機構よりも制限されていますが、現在も将来も、学習して利用を検討する価値は十分にあると思います。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?