Panda Noir

JavaScript の限界を究めるブログでした。最近はいろんな分野を幅広めに書いてます。

Puppeteer の await 書きすぎ問題を多少マシにする

immer っぽい API だとうれしいなと思って書いてみました。

元となるコード

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();

  await page.emulate(iPhone);
  await page.goto('https://example.com');
  await page.screenshot({ path: 'screenshot.png' });

  await browser.close();
})();

await が多くないですか?

関数でラップするタイプ

こんな感じになります。

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();

  await removeAwait(page, (page) => {
    page.emulate(iPhone);
    page.goto('https://example.com');
    page.screenshot({ path: 'screenshot.png' });
  });

  await browser.close();
})();

removeAwait の実装がこちら

const removeAwait = async <T extends object>(
  obj: T,
  callback: (obj: T) => void
) => {
  const commands: [string | symbol, unknown[]][] = [];
  const proxied = new Proxy(obj, {
    get: (...[, methodName]) =>
      new Proxy(() => {}, {
        apply: (...[, , argumentsList]) => {
          commands.push([methodName, argumentsList]);
        },
      }),
  });
  callback(proxied);
  for (const [methodName, args] of commands) {
    await (obj[methodName as keyof typeof obj] as any)(...args);
  }
};

ネストが増えない版

こんな感じになります。

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();

  const draft = createDraft(page);

  draft.emulate(iPhone);
  draft.goto('https://example.com');
  draft.screenshot({ path: 'screenshot.png' });

  await finish(draft);
  await browser.close();
})();

finish を呼び出すまでメソッド呼び出しが保留されます。createDraft の実装はこちら

const FinishKey = Symbol('finish');
const createDraft = <T extends object>(obj: T) => {
  const commands: [string | symbol, unknown[]][] = [];
  const processCommands = async () => {
    for (const [methodName, args] of commands) {
      await (obj[methodName as keyof typeof obj] as any)(...args);
    }
  };

  return new Proxy(obj, {
    get: (...[, methodName]) => {
      if (methodName !== FinishKey) {
        return new Proxy(() => {}, {
          apply: (...[, , argumentsList]) => {
            commands.push([methodName, argumentsList]);
          },
        });
      }
      return processCommands;
    },
  }) as T & { [FinishKey]: () => void };
};
const finish = (draft: { [FinishKey]: () => void }) => draft[FinishKey]();

書いておいてなんですが、割と微妙だな…というのが正直な感想です。

他に注意点として、Puppeteer で使うことを前提にいくらか実装をサボっている(関数であることを検証するのが面倒で as any 使ってたり、page.mouse.up みたいなのを想定してなかったり)ので、ほかの用途にこの関数は使わないでください

E2Eテストに対して思うところ

E2Eテストを本格的にやったことがないのでしてみたいのだが、そもそもいくつか疑問があるのでここでぶちまけておく。

この記事ではwebサイトのE2Eテストを想定しています。他のケースはわかりません

結論

  • ユーザーの挙動をシミュレートするのは、コストが大きいのに恩恵少なくない??
  • ブラウザの挙動に関するロジックのテストが本義なのでは??

結論はこれです。ご意見お待ちしております。

E2Eテストに対する疑問・疑念

ユーザーの挙動をシミュレートするテストは基本的には要らないのでは? と思っている。ちゃんと単体テストできるように各モジュールを書いていたのならば、それらの単体テストだけでロジックに関しては事足りるはず。

E2E テストが不要そうなケース

たとえば以下のようなパターンは不要のはず。

  • ボタンをクリックしたらこの関数が呼ばれてこういうAPIを呼んで~(略)
  • フォームに情報を入力して、ログインボタンを押したらこのページに遷移

まず前者だが、関数のロジックは単体テストで保証できる。ボタンをクリックしたら関数が呼ばれるのはUIフレームワークが保証している。API に関してもモックを使えばよい。E2Eテストを持ち出すまでもない。

次に後者。これも基本的には不要のは*1。書くコスト > 恩恵。また、E2Eテストせずとも ViewModel に対する単体テストを書けばある程度カバーできる。

テストのコスト > 恩恵

上の例はどちらも、テストを書くコストに対して得られるものが少ない。しかも、上記のケースは変更も頻繁に起こりやすい箇所に対するテストなので、追従するコストも含めると完全にマイナスと言ってよい。

E2Eテストの目的は、単体テストでテストできない箇所を検証することのはずだ。しかし、上記ケースはどちらも単体テストでほぼカバーできてしまう。これでは徒にコストが嵩むだけだ。

E2Eテストが必要だと思うケース

逆に必要なのは以下のパターン。

  • stopPropagation など、ブラウザのイベントハンドリング周りの挙動
  • アニメーション関連(こっちはもしかしたら要らんかも)

ブラウザの状態、挙動に依存したコードはE2Eテストでなければ検証が難しい。 ブラウザの状態などに依存した箇所は単体テストでは扱いづらい。

テストしたい例としては、stopPropagation を使ってイベントの伝播を抑止できているかなどが挙げられる。複数コンポーネントがある状態で stopPropagation がちゃんと動くか調べるのは意外と難しい。

また、DOM構造に依存した箇所も問題となる可能性がある。たとえば z-index の値を間違えてしまい、クリックしたい要素に別の要素が重なるのはよく起こる。テストしても良いだろう。

*1:もちろんフォームに入力した情報がちゃんと API 呼び出しの際に使われていることをテストするのは有意義なので書いても良い

React v17 からイベントリスナーの張り方変わるけど、breaking changes は抑え気味だよ

touchmove について調べてて気になったので。

React v16 まで

React v16 までは、React コンポーネントに張られたイベントリスナーは ドキュメントのルート要素にすべて集約して貼られていました。そのため、React v16 までは、onTouchStart や onTouchMove 内で event.preventDefault() を呼び出すことができずエラーになっていました。

(一部ブラウザでは、document.body, window などドキュメントのルート要素に touchstart イベントや touchmove イベントのリスナーを張ろうとすると、自動的に {passive: true} を引数に渡したとみなされます)

React v17 から

結論から書くと、React v17 からも基本的に preventDefault は呼び出せないままです

React v17 から、イベントリスナーを張る対象が変更されました。v17 からは すべてのイベントリスナーは React のエントリポイント要素へ張られます。よって、touchstart イベントや touchmove イベントも passive: true ではなくなる…はずでした。しかし、この変更を加えてしまうと大きな breaking changes になってしまうので、いったん明示的に passive: true を渡すようにしたそうです。

結論

もしや React v17 のイベントリスナーの張り方、breaking changes なんじゃないのか? と思って調べたけれども、ちゃんと考慮してありましたという話でした。

Timers Promises API が最高

名前から既にワクワクするこのAPIは、なんとPromiseを返すsetTimeout、setInterval関数を提供しています!最高です…

というわけで今回はそれの紹介です。

基本的な使い方

await setTimeout(1000) ←これができるんです!素晴らしくないですか??

top-level await や for-awaitと組み合わせるとこんな感じで書けます

import { setTimeout } from 'timers/promises';

console.log('start');
await setTimeout(1000); // これでいける!!
console.log('1s passed');
import { setInterval } from 'timers/promises';

console.log('start');
for await (const startAt of setInterval(1000, Date.now()) {
  console.log(Date.now() - startAt);
}

特に setTimeout は最高ですね…

キャンセルする

キャンセルはAbortControllerを使ってできます。

import { setTimeout } from 'timers/promises'

const controller = new AbortController();

(async() => {
  console.log('start');
  await setTimeout(1000, null, { signal: controller.signal });
  console.log('end');
})().catch(()=>console.log('aborted'));

// 上の setTimeout が発火する前にキャンセルしてみる
await setTimeout(500);
controller.abort();

AbortController ということは React の useEffect とも相性が良いです。

useEffect(() => {
  const controller = new AbortController();
  const { signal } = controller;

  (async () => {
    const res = await fetch('http://example.com', { signal });
    await setTimeout(1000, null, { signal });
    console.log(res);
  })();

  return () => controller.abort();
});

clearTimeout を使うよりグッとシンプルになりました。

timers/promises の 型定義

型定義がまだなさそうだったので自分で書きました。おそらく合ってますが間違えている可能性はあります。

module 'timers/promises' {
  const setTimeout: <T>(
    delay?: number,
    value?: T,
    options?: { ref?: boolean; signal?: AbortSignal }
  ) => Promise<T>;
  const setImmediate: <T>(
    value?: T,
    options?: { ref?: boolean; signal?: AbortSignal }
  ) => Promise<T>;
  const setInterval: <T>(
    delay?: number,
    value?: T,
    options?: { ref?: boolean; signal?: AbortSignal }
  ) => AsyncIterable<T>;
}

debounce や throttle を実装してみる

試しに debounce や throttle を実装してみました。Promisify されていない setTimeout を使った方がわかりやすいかもしれないです。

import { setTimeout } from 'timers/promises';

let controller: null | AbortController = null;
export const debounce = <T extends unknown[]>(
  f: (...args: T) => void,
  wait: number
) => async (...args: T) => {
  if (controller) controller.abort();
  controller = new AbortController();
  setTimeout(wait, null, controller)
    .then(() => f(...args))
    .catch(() => {});
};
import { setTimeout } from 'timers/promises';

let timer: null | Promise<unknown> = null;
export const throttle = <T extends unknown[]>(
  f: (...args: T) => void,
  wait: number
) => async (...args: T) => {
  if (timer == null) {
    f(...args);
    await (timer = setTimeout(wait));
    timer = null;
  }
};

Vite所感

ここ最近 Vite で軽くアプリ作ってたので感想。

Vite とは?

4行で書くと

  • webアプリ開発に特化したビルドツール
  • バンドラではない(バンドル"も"できる)
  • 超高速 dev server & HMR
  • もちろんプロダクションビルドもできる

こんな感じ。ビルドツールなので、webpack や Rollup と似た役割。特徴は ES Module を前提とした dev server。これのおかげでノーバンドルを実現してる。

Good default だから環境構築が楽

webアプリ開発の速度が上がる。デフォルトのままで CSS や JSON、画像まで import できる。画像を読み込めるの、ヤバすぎない?まさにGood default。web アプリ開発のユースケースをしっかりと押さえてる。

さらに、インストールもカンタン。インストールは質問に3つ答えるだけで完了。とてもスッキリ。環境構築コストがかなり低いので、開発に集中できる。

ディレクトリ構成が自由

不必要にディレクトリ構成を縛られたりしない。ディレクトリ名を指定されたりもしない。vite.config.js と index.html を置く以外は何もしなくていい。

webpack から vite に移行したときも、vite.config.js と index.html を追加して package.json 直すだけで行けた。ディレクトリ構成を制限されないのは結構嬉しい。

Dev server が爆速

早い。とにかく早い。Dev server がほんと1秒未満で起動する。最高。これですよ、我々が求めてたのは…という感じ。

適材適所。ライブラリには向いてない

ライブラリ作るときにはあまり向かない。てか使う必要がない。ライブラリ作るときにHMRとか要らんし。ライブラリ開発なら素直にバンドラを使う方が良さそう。

React の新しい JSX transform は未対応

React の新しい JSX 変換にはまだ対応してない(将来的にはするらしい)。ここは惜しい。早く対応して欲しいな。

Backend と組み合わせようとすると難しいかも

Backend と組み合わせて使うこともできる(つまり Laravel とかでも使える)。かなりエレガントな手法を提供してくれてる。

けど、フロント側のリポジトリとサーバーのリポジトリを分けている場合は難しいかも。というのも、ビルド時に生成される manifest.json というファイルを参照してスクリプトを読み込まなければならない。これをフロント側のリポジトリからどうやってサーバー側に共有するかが課題になりそう。

でも、実際に試した訳じゃないので、エレガントな解決策があるかも。