10
14

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.

Async GeneratorによるJavaScriptイベント処理のリファクタリング: お絵かきアプリを例に

Last updated at Posted at 2022-09-25

JavaScriptのもつ言語機能のなかで、なかなか理解が難しいとされるものにPromiseとGeneratorがあります(自分調べ) 。特にこれら2つが組み合わさったAsync Generator( async function* )と for await ... of 構文は使い所が分かりにくいので、具体的にどのようなケースで活用できるのか、お絵描きアプリを例にご紹介します。

なお、Async Generatorそれ自体について、何ぞや?という説明はしておりません。インターネットに転がっている情報(参考: MDN - イテレーターとジェネレーター)をご参照ください。あまりAsyncにフォーカスして詳しく解説されたものはありませんが、Generatorについて抑えられれば、それが非同期関数でも使えるのだなとふわっと理解できるかと思います。

お絵かきアプリのデモ

今回はこのようなお絵かきアプリを用意してみました。以下のサンプルの赤枠のなかでマウスをドラッグすると線を描くことができます。

See the Pen Untitled by arita_mii (@arita_mii) on CodePen.

愚直に書くとどうなるか?

インターネットに転がっている情報などを参考に、マウス操作のイベントの処理と、Canvasによる線の描画を愚直に実装すると、だいたい以下のような感じになると思います(参考: MDN - マウスイベントを使ったお絵かき)。PointerEventとCanvas2dContextの詳細についてはここでは踏み入りませんので、分からない方は各自でご確認いただけますと幸いです。

const canvas = document.getElementById("canvas");
const context = canvas.getContext("2d");

let dragging = false;
let prevPointerPosition;
canvas.addEventListener("pointerdown", (event) => {
  if (dragging) {
    return;
  }
  dragging = true;
  canvas.setPointerCapture(event.pointerId);

  const pointerPosition = {
    x: event.offsetX,
    y: event.offsetY,
  };

  prevPointerPosition = pointerPosition;
});

canvas.addEventListener("pointermove", (event) => {
  if (!dragging) {
    return;
  }

  const pointerPosition = {
    x: event.offsetX,
    y: event.offsetY,
  };

  context.beginPath();
  context.moveTo(prevPointerPosition.x, prevPointerPosition.y);
  context.lineTo(pointerPosition.x, pointerPosition.y);
  context.closePath();
  context.stroke();

  prevPointerPosition = pointerPosition;
});

canvas.addEventListener("pointerup", (event) => {
  if (!dragging) {
    return;
  }
  dragging = false;
  canvas.releasePointerCapture(event.pointerId);

  const pointerPosition = {
    x: event.offsetX,
    y: event.offsetY,
  };

  context.beginPath();
  context.moveTo(prevPointerPosition.x, prevPointerPosition.y);
  context.lineTo(pointerPosition.x, pointerPosition.y);
  context.closePath();
  context.stroke();

  prevPointerPosition = undefined;
});

愚直なコードの何が問題か?

本アプリの本質はポインタ情報を元に線を描画することにあります。そのことを念頭に「ポインタイベント情報を元に描画に必要なデータを作成し、描画する部分」を赤で、それを実現するための技術的な詳細=「マウスの状態管理しながら、イベントの待ち受ける部分」を青で塗り分けてみました。
image.png

ご覧の通り、両者が入り組んだ配置になってしまっています。これでは「どのように描画がされるのか?」というアプリの本質を見通したいと思ったときに、コード全体を追いながら、イベントの呼び出し順序や、グローバル変数( draggingprevPointerPosition )の状態遷移を理解することが必要になります。これは、非常に認知負荷が高い作業です。

愚直な実装は、JavaScriptのイベントシステムの枠組み=「どんなイベントを監視し、それぞれが発生したときにどんな手順を実行すると、描画できるか?」に強く引きずられています。この枠組を転換して 「どんな手順で描画を実行し、各ステップどんなイベントの発生を監視すべきか?」 という考え方のコードにリファクタリングすることで、見通しを良くしてみましょう。そこで必要になってくるのが、PromiseとAsync Generatorというわけです。

リファクタリング

イベントのプロミス化

「どんな手順で描画を実行し、各ステップどんなイベントの発生を監視すべきか?」という考え方でコードを書くためには、イベントの待ち受けという非同期的な処理を、同期的に書けるようになる必要があります。それを実現するのがPromiseとAsync/Awaitです。これらの詳細は既知として省きますが、以下のような promisifyEvent を実装することで、同期的なイベントの待ち受けが実現できます。 promisifyEvent によって登録されるイベントハンドラ= resolve は、 { once: true } オプションによって1度だけ呼び出されて、元のasync functionに処理を返します。

function promisifyEvent(target, name, option = {}) {
  return new Promise((resolve, reject) => {
    target.addEventListener(name, resolve, { ...option, once: true });
  });
}

async function main() {
  await promisifyEvent(canvas, 'pointerdown');
  await promisifyEvent(canvas, 'pointermove');
  await promisifyEvent(canvas, 'pointermove');
  await promisifyEvent(canvas, 'pointerup');
}

お絵描きアプリに promisifyEvent を導入する

「どんな手順で描画を実行し、各ステップどんなイベントの発生を監視すべきか?」という考え方でコードの根幹部分を書き換えます。上記の main 関数と比べて、pointermoveイベントを、2回だけではなく pointerup が呼ばれるまで待ち受け続けることになります。したがって、大まかな構造は以下のようになるはずです。

async function main() {
  const pointerDownEvent = await promisifyEvent(canvas, "pointerdown");
  canvas.setPointerCapture(pointerDownEvent.pointerId);

  /** pointerDownEventの情報を元に始点のマウス位置を記録する処理 */

  while (true) {
    const pointerMoveEvent = await promisifyEvent(target, "pointermove");

    /** pointerMoveEventの情報を元にマウス位置を取得して線分を描く処理 */

    // TODO: 何らかの方法で pointerup イベントがあったときにループを抜け出す
  }

  canvas.releasePointerCapture(pointerDownEvent.pointerId);
}

いかがでしょうか。イベント処理の考え方の枠組みを転換することで、「コード全体を追いながら、イベントの呼び出し順序や、グローバル変数の状態遷移を理解すること」なく、描画ロジックが理解できそうな感じのコードへと転換できました。

Async Generator を用いて関心を分離する

ここまででも既にある程度見通しが良くなりましたが、愚直なコードの何が問題か? を見たときに赤と青で分けたような、本アプリの本質=「ポインタイベント情報を元に描画に必要なデータを作成し、描画する部分」と、それを実現するための技術的な詳細=「マウスの状態管理しながら、イベントの待ち受ける部分」を、まだ分離できてはいません。

そこで、いよいよAsync Generator( async function* )と for await ... of の出番となります。「マウスの状態管理しながら、イベントの待ち受ける部分」を watchPointer というAsync Generatorに切り出すことで、本アプリの本質=「ポインタイベント情報を元に描画に必要なデータを作成し、描画する部分」が main の for await ... of の中に抽出することができます。

// Async Generator (async function*) !!!
async function* watchPointerDrag() {
  const pointerDownEvent = await promisifyEvent(canvas, "pointerdown");
  canvas.setPointerCapture(pointerDownEvent.pointerId);

  yield pointerDownEvent;

  while (true) {
    const pointerMoveEvent = await promisifyEvent(target, "pointermove");

    yield pointerDownEvent;

    // TODO: 何らかの方法で pointerup イベントがあったときにループを抜け出す
  }

  canvas.releasePointerCapture(pointerDownEvent.pointerId);
}

async function main() {
  let prevPointerPosition;

  // for await ... of !!!
  for await (const event of watchPointerDrag(canvas)) {

    const pointerPosition = {
      x: event.offsetX,
      y: event.offsetY,
    };

    if (prevPointerPosition) {
      /** prevPointerPositionからpointerPositionへ線分を描く処理 */
    }

    prevPointerPosition = pointerPosition;
  }
}

whileループからの脱出

// TODO: の部分については、ここでは詳細には立ち入りませんが、 AbortControllerPromise.race を使うことで以下のように実現できます。

  while (true) {
    const controller = new AbortController();
    const option = { signal: controller.signal };

    const pointerUp = promisifyEvent(target, "pointerup", option);
    const pointerMove = promisifyEvent(target, "pointermove", option);

    const event = await Promise.race([pointerUp, pointerMove]);
    yield event;

    controller.abort();
    if (event.type === "pointerup") {
      break;
    }
  }

リファクタリング後のコード

以上の議論を反映したリファクタリング後のコード全体はこちらになります。また、外観・動作は冒頭のものと同じですが参考までにこちらからCodePenのデモをご覧いただけます。

function promisifyEvent(target, name, option = {}) {
  return new Promise((resolve, reject) => {
    target.addEventListener(name, resolve, { ...option, once: true });
  });
}

async function* watchPointerDrag(target) {
  const event = await promisifyEvent(target, "pointerdown");
  yield event;

  target.setPointerCapture(event.pointerId);

  while (true) {
    const controller = new AbortController();
    const option = { signal: controller.signal };

    const pointerUp = promisifyEvent(target, "pointerup", option);
    const pointerMove = promisifyEvent(target, "pointermove", option);

    const event = await Promise.race([pointerUp, pointerMove]);
    yield event;

    controller.abort();
    if (event.type === "pointerup") {
      break;
    }
  }

  target.releasePointerCapture(event.pointerId);
}

async function main() {
  const canvas = document.getElementById("canvas");
  const context = canvas.getContext("2d");

  while (true) {
    let prevPointerPosition;
    for await (const event of watchPointerDrag(canvas)) {
      const pointerPosition = {
        x: event.offsetX,
        y: event.offsetY,
      };

      if (prevPointerPosition) {
        context.beginPath();
        context.moveTo(prevPointerPosition.x, prevPointerPosition.y);
        context.lineTo(pointerPosition.x, pointerPosition.y);
        context.closePath();
        context.stroke();
      }

      prevPointerPosition = pointerPosition;
    }
  }
}

main();

色分けしたものも掲載しておきます。
image.png

まとめ

今回はAsync Generator( async function* )と for await ... of 構文を実践でどのように使うことができるのか、具体例を紹介してみました。類似のパターン(順序関係のあるイベント処理)は、マウス・ポインタ関連のイベントや、メディア・ファイル関連のイベントで頻出です。Async Generatorは他にも、例えば、順次的に外部APIをコールするようなケースで、APIの呼び出し処理とAPIの結果を利用する処理を切り分けたい場合などにも活用できます。

今回の例では、開発者・チームの考え方やコーディング規約によっては、愚直な実装とリファクタリング後の実装を比べて、必ずしも後者がよいと判断されるとは限らないと思います。実際問題、リファクタリング後の技術的な複雑度は増加しており、賛否両論あるでしょう。しかしながら、こうした実際的な例を紹介することで、Async Generatorというマイナーな機能が、適切なタイミングで活用されるようになってほしいと願いを込めて今回の紹介記事を書きました。少しでも皆さまのご参考になれば幸いです。

おまけ

今回の実装にはバグがあり、マルチタッチをすると面白い感じになります。
Screenshot_20220926-004538_Chrome.jpg

10
14
1

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
10
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?