Chapter 98

非同期処理

miku
miku
2021.11.20に更新

ブラウザで画像のようなデータを読み込むには非同期処理という概念を理解する必要がある。実際に画像を読み込む例で考えてみよう。

同期処理 / 非同期処理

loadImage("0.png");

画像を読み込むことができる loadImage() という関数が仮に存在するとする。

const image = loadImage("0.png");
drawImage(image);

非同期処理に対する概念として同期処理というものがある。同期処理の環境の場合、今いる行にあるコードの処理が終わるまで次の行に移動しない。もし上記コードを実行する環境が同期処理であったら、0.png を読み込み終えるまで次の行に移動しない。

画像の読み込みに失敗するなどのケースも考えられるが、それを含めて、次の行に移動したときには画像の読み込み処理が終わっていることが保証される。ただ、もし画像の読み込みに10秒かかってしまったら当然10秒間処理が止まってしまう。これが同期処理の利点でもあり欠点でもあるところだ。

ブラウザ上での画像や音声のようなデータの読み込みは同期処理ではなく非同期処理の環境になる。非同期処理の環境では、画像の読み込みを開始したあと次の行に移動する。つまり読み込みが完了するまで待つことはない。

読み込みをしているデータにアクセスするには、データの読み込みが終わるタイミングを知る必要がある。

loadImage("0.png").complete = (image) => {
  console.log("画像の読み込みが終わったら呼ばれる関数");
};

画像読み込み時に、こちらが用意した任意の関数を渡せるようにする。 そして、loadImage 側で画像の読み込みが終わったタイミングでその関数を呼び出ださせる。

上記コード例では complete というプロパティを用意しておき、ここに関数を登録しておく。そして画像読み込みが終わった際に、complete() を実行、つまり登録しておいた関数を実行する。

画像読み込みが終わった時点で関数が呼ばれるので、読み込みが成功していれば関数内では画像を確実に参照できる。

このような仕組みをコールバックと呼ぶ。

コールバック

loadImage は存在しない関数なので、実際にコールバックを使用して画像を読み込んでみよう。

const image = new Image();
image.onload = function () {
  console.log("読み込み完了");
};
image.src = "0.png";

Image はHTMLの <img> タグを生成するクラスだ。これは実際に存在するクラスである。src には画像のURLを入れる。入れた時点で読み込みが開始する。

読み込みが完了した時点で呼ばれるコールバックの関数は onload だ。ここに関数を登録しておくと、画像読み込み完了時にその関数が呼ばれる。src で読み込みが開始するので、onload はその前に書いておく。

このコールバックの書き方は非常にシンプルでよく使用される形だが欠点もある。まず1つ目は、代入という形による関数の登録なので、関数を1つしか登録できないことだ。

function a() {}
function b() {}

image.onload = a;
image.onload = b;

a の後に b を登録すると、代入なので a の登録は上書きされて消えてしまう。

2つ目は、コールバックに関数を登録する際、その関数しか渡せないことだ。場合によっては関数以外の情報を渡したい場合もあるだろう。

これらを解決する方法として addEventListener という関数がある。

addEventListener

const image = new Image();
image.addEventListener("load", function(){
  console.log("読み込み完了");
});
image.src = "0.png";

addEventListner の1つ目の引数には、何に対するコールバック関数を登録するのかを文字列で指定する。画像読み込み完了の場合は "load" だ。2つ目の引数には登録する関数を指定する。

他にも、画像読み込みに失敗した場合は "error"、画像の読み込みが現在どれくらいなのかを知るためには "progress" を1つ目の引数に指定すればいい。これら1つ1つの情報のことをイベントと呼ぶ。

先程の onload のようなコールバック用のプロパティとは若干名前が異なっている場合がほとんどなので注意してほしい。

const image = new Image();
image.addEventListener("load", function () {
  console.log("読み込み完了");
});
image.addEventListener("load", function () {
  console.log("読み込み完了2");
});
image.src = "0.png";

addEventListner では複数の関数を登録できる。これは渡された関数を内部では配列に入れて保持しており、イベント発生時、たとえば "load" だと画像読み込み完了時に、配列内の関数を順番に呼び出しているからだ。

このような登録する関数のことをリスナーと呼ぶ。各イベントにリスナーを登録するから addEventListner という関数名になっている。

image.addEventListener("load", listener, { once: true });

3つ目の引数は登録に関して補足の情報を入れるためのものだ。たとえば once : true を指定すると、同じイベントが複数回発生してもリスナーが呼ばれるのは一度きりになる。

"load" はそもそも一度しか呼ばれないのでここでの使い方は不適切だが、複数回呼ばれるイベントも中には存在する。

Promise

コールバックの書き方にはまだ問題が存在する。

new TextLoader("a.txt").load = () => {
  new ImageLoader("b.png").load = () => {
    new VideoLoader("c.mp4").load = () => {     
    } 
  }
}

Aを読み込んだ後に、Bを読み込んで、その後にCを・・・のような処理をコールバックで書くと多段になってしまうことだ。このような形をコールバック地獄と呼ぶ。

これの解決策として Promise という機能を利用したい。

new Promise((resolve) => {
  resolve();
}).then(() => {
  console.log("終了");
});

まず new Promise() でPromiseのオブジェクトを作る。引数として関数を渡す。更にその関数の引数には resolve という関数オブジェクトが渡される。名前は resolve ではなく別の名前を付けてもいい。resolve を実行すると、メソッドチェーンの形で繋いでいる .then() に渡した関数が呼び出される。

複雑に見えるかもしれないが実際のところは非常にシンプルな設計だ。Promiseに渡した関数内では何を書いてもよい。ただし次の処理、つまり .then() に渡した次の関数に移動するには resolve を実行する必要がある。

具体的な例で見てみよう。

new Promise((resolve) => {
  const image = new Image();
  image.onload = resolve;
  image.src = "0.png";
}).then(() => {
  // 画像読み込み完了後に呼ばれる
});

Promiseオブジェクトに渡した関数では、画像の読み込みを行い、読み込みが完了したら次に移動したい。画像の読み込み完了を検知するにはコールバックの onload に関数を渡せばいい。この関数を抜けるには resolve を実行すればいいので、onload にそのまま resolve を渡してしまえばいい。画像の読み込みが完了すると onload が呼ばれるが、その中身は resolve なので次の then() に指定した関数が実行される。

もう一つ別の例を見てみよう。

new Promise((resolve) => {
  setTimeout(resolve, 3000);
}).then(() => {
  // 約3秒後に呼ばれる
});

setTimeout(func, delay) は、delay ミリ秒後に関数 func を呼び出す機能なので、delay ミリ秒後に次の then() に指定した関数に移動する場合は、setTimeout(resolve, delay) と指定すればいい。

繰り返しになるが、関数内では何を書いてもいい。ただ、次の処理に移動するには resolve() を呼び出す必要がある。

new Promise((resolve) => {
  resolve();
}).then(() => {
  console.log(1);
}).then(() => {
  console.log(2);
});

複数の then() を繋げて書くこともできる。この場合2つ目以降の then() は前の then() に指定した関数の処理が終了次第、即座に呼ばれる。

次は多段のコールバックに対するPromiseの書き方だ。

new Promise((resolve) => {
  resolve();
}).then(() => {
  return new Promise((resolve) => {
    resolve();
  });
}).then(() => {
});

then() の中で新たなPromiseオブジェクトを返すコードを書く。書き方は先程と同様だが return でPromiseオブジェクトを返す必要がある。

このように書くと、2つ目の then() は2つ目のPromise内で resolve() を実行しない限り呼び出されない。

new Promise((resolve) => {
  setTimeout(resolve, 3000);
}).then(() => {
  console.log(1);
  return new Promise((resolve) => {
    setTimeout(resolve, 3000);
  });
}).then(() => {
  console.log(2);
});

3秒経って 1 を出力、更に3秒経って 2 を出力する例。

const a = (resolve) => {
  console.log("a");
  setTimeout(resolve, 3000);
};
const b = () => {
  console.log("b");
};

new Promise(a).then(() => new Promise(a)).then(b);

Promiseに指定する関数を分離した例。こちらの方が流れがわかりやすいかもしれない。

new Promise((resolve) => {
  const image = new Image();
  image.onload = (event) => {
    resolve(event.target); // 次の.then()にImageオブジェクトを渡す
  };
  image.src = "0.png";
}).then((image) => {
  console.log(image.width);
  console.log(image.src);
});

resolve() に引数を渡すと、次の then() に指定した関数の引数にそのデータが渡ることになる。

次は非同期処理が失敗した場合についての対処だ。代表的なのは画像の読み込みだろう。指定したファイル名の画像が無かったり、通信が不安定で時間内に読み込めないということは、そう珍しいことではない。

new Promise((resolve, reject) => {
  const image = new Image();
  image.onload = resolve;
  image.onerror = reject;
  image.src = "0.png";
}).then(
  () => {
    console.log("読み込み成功");
  },
  () => {
    console.log("読み込みエラー");
  }
);

Promiseに指定した関数の2つ目の引数に reject を記述する。resolve と同じく関数オブジェクトであり、名前は別に reject で無くてもいい。

reject は非同期処理が失敗した場合に呼び出す。画像の読み込みの失敗はもちろん読み込めなかった場合だ。Imageには onerror という読み込みに失敗した場合のコールバックがあるので、そこに reject を入れておく。

resolve ではなく reject が呼び出された場合は、次の then() の2つ目の引数の関数が呼ばれる。

new Promise((resolve, reject) => {
  const image = new Image();
  image.onload = resolve;
  image.onerror = (event) => {
    reject("読み込めなかった理由を書く");
  };
  image.src = "0.png";
}).then(
  () => {
    console.log("読み込み成功");
  },
  (reason) => {
    console.log(reason);
  }
);

受け手側である then() 側でエラー理由が分からないケースもあるので、その場合は resolve の場合と同じく、reason の引数にオブジェクトを渡してあげよう。

async/await

async/awaitを使用すると、同期処理の書き方のように非同期処理を記述することできる。

function wait(time) {
  return new Promise((resolve) => {
    setTimeout(resolve, time);
  });
}

async function f() {
  await wait(1000);
  console.log(1);
  await wait(2000);
  console.log(2);
}
f();

wait() は引数でミリ秒単位の時間を受け取り、その時間が経ったら resolve() を呼び出すPromiseオブジェクトを返す関数だ。

そして実際に wait() を利用する際に、前に await を付けて await wait(1000) のように記述する。これは await の後に記述した関数が返すPromiseオブジェクト内で resolve() が呼び出されるまでこの行で待機するという意味になる。

つまり、async wait(1000) だと、1秒間この行で待機するという意味になる。まさに同期処理のように書けて、非常に便利な機能だが、いくつか制限がある。

1つ目は await を記述している関数には async を付ける必要がある。2つ目は await は関数のない場所、つまりトップレベルに記述することはできない。

function wait(time) {
  return new Promise((resolve) => {
    setTimeout(resolve, time);
  });
}

async function f() {
  await wait(1000);
  console.log(1);
  await wait(2000);
  console.log(2);
}

f();
console.log(3);

同期処理のような結果になるのは async の付いた関数内だけである。上のコードを見てみよう。これを実行すると、1, 2, 3 ではなく、3, 1, 2 の順番で出力される。f() の外側は普通の非同期処理で実行される範囲だからだ。

async function f() {
  await wait(1000);
  console.log(1);
  await wait(2000);
  console.log(2);
}

async function f2() {
  await f();
  console.log(3);
}
f2();

f() の中身が実行し終わるまで 3 の出力を待つには、f() にも await を付ければいい。ただし、 await を付けた以上は、関数に包んで、その関数の前に async を付ける必要がある。なぜ f()await が付けられるのか。これは async を付けた関数はPromiseオブジェクトを返すからである。そのPromiseオブジェクトの resolve() は関数を抜けた時点で自動で実行されるので、await f() と書くと、 f() の実行が終了次第、次の行に移動する。

function loadImage(url) {
  return new Promise((resolve) => {
    const image = new Image();
    image.onload = () => {
      return resolve(image);
    };
    image.src = url;
  });
}

async function f() {
  const image = await loadImage("0.png");
  console.log(image.width);
  console.log(image.src);
}

f();

resolve() に指定した引数は await の戻り値となるので、上記のように記述できる。

fetch

async function f() {
  const response = await fetch("0.png");
  if (response.ok) {
    const imageData = await response.blob();
    console.log(imageData);
  }
}
f();

async/awaitを利用した機能として、外部データを読み込む fetch() がある。fetch() はURLを指定すれば、画像でもテキストでも音声でも動画でも読み込むことができる。何でも読み込めるのでデータ形式がバイナリデータとなる。

generator

function* generator() {
  yield;
  yield;
  yield;
}

const g = generator();
g.next();
g.next();
g.next();

関数名の前に function ではなく function* と記述すると、関数がジェネレータ関数というものになる。

ジェネレータ関数を呼び出すと、Generatorオブジェクトが生成される。生成したGeneratorオブジェクトの next() を呼び出すと、関数内の最初の yield まで実行され、そこで処理が中断する。更に next() を呼び出すと、前に中断した地点から再開して、次の yield まで実行され、また処理が中断する。

function* generator() {
  yield 1;
  yield "a";
  yield true;
}

const g = generator();
console.log(g.next().value); // 1
console.log(g.next().value); // "a"
console.log(g.next().value); // true

yield は値を返すことができ、受け手側では g.next().value のようにして取得できる。

function* generator() {
  let i = 0;
  while (true) {
    yield i;
    i++;
  }
}

const g = generator();
console.log(g.next().value); // 0
console.log(g.next().value); // 1
console.log(g.next().value); // 2
console.log(g.next().value); // 3

yield が書かれている地点で処理が中断するので、無限ループで値を生成しても問題がない。