Panda Noir

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

React で touchstart で preventDefault したいとき

touch イベントと mouse イベントの両方に対応したいとき、touchstart 内で preventDefault を呼び出すというテクニックがあります。こうすると、touchstart、touchend のみが発火してそのあとのmousedown、mouseup、click が発火しなくなり、touch イベントと mouse イベントをそれぞれ独立して設定ができます。

しかし、React では事情があって onTouchStart のなかで preventDefault を呼び出すことができません (後述)。そのため、特殊なやり方をする必要があります。

結論

以下の usePreventDefault を使うことで、touchstart でも preventDefault を呼び出すことができます。

import { useEffect, useRef } from 'react';

export const usePreventDefault = <T extends HTMLElement>(
  eventName: string,
  enable = true
) => {
  const ref = useRef<T>(null);
  useEffect(() => {
    const current = ref.current;
    if (!current) {
      return;
    }
    const handler = (event: Event) => {
      if (enable) {
        event.preventDefault();
      }
    };
    current.addEventListener(eventName, handler);
    return () => {
      current.removeEventListener(eventName, handler);
    };
  }, [enable, eventName]);

  return ref;
};

このように使います。

const App = () => {
  const ref = usePreventDefault<HTMLDivElement>('touchstart');
  return (
    <div
      ref={ref}
      onTouchStart={() => {}}
      onTouchEnd={() => {}}
      onMouseDown={() => {}}
      onMouseUp={() => {}}
      onClick={() => {}}
    >click me</div>
  );
};

usePreventDefault の内部で touchstart 時に preventDefault を呼び出すように設定したので、あとは好きなようにできます。タッチしたあとに mousedown が発火することもありません。

そもそもなぜ React では touchstart 内で preventDefault ができないのか

愚直に書くとしたら、次のようになるはずです。しかし、これでは動きません。

<div onTouchStart={event => event.preventDefault()}>

demo

f:id:panda_noir:20210529132151p:plain

「Unable to preventDefault inside passive event listener invocation.」というエラーが出ます。passive なイベントリスナーというのは、かんたんに言えば「preventDefaultは呼び出さないよ」と宣言したイベントリスナーです。呼び出さないと宣言しているのに preventDefault を呼び出しているので怒られているというわけです。

では、なぜ React は touchstart イベントハンドラーを passive で登録しているのでしょうか?実はこれはブラウザの仕様と関係しています。

React ではイベントリスナーを各要素につけるのではなく、documentレベルにイベントリスナーをまとめてアタッチしています*1。Chrome などには document レベルに貼られた touchstart イベントは自動的に passive なイベントリスナーとして扱われるという制約があるため(参照)、React が document ルートにはりつけた onTouchStart は passive として登録されます*2。そのため preventDefault を呼び出せないのです。

解決方法

解決方法はかんたんで、passive でない形で自前でイベントリスナーを貼るだけです。

const App = () => {
  const ref = useRef<HTMLDivElement>(null);
  useEffect(() => {
    const current = ref.current;
    if (!current) return;
    const onTouchStart = (event: Event) => event.preventDefault();
    current.addEventListener('touchstart', onTouchStart);
    return () => {
      current.removeEventListener('touchstart', onTouchStart);
    };
  }, []);
  return <div ref={ref}>click me</div>;
};

これで touchstart のデフォルト動作を止めることができたので、あとは React の onTouchStart を自由に使えます。

カスタムフックに抜き出す

上のままでもいいですが、カスタムフックに抜き出すとよりわかりやすくなります。

import { useEffect, useRef } from 'react';

export const usePreventDefault = <T extends HTMLElement>(
  eventName: string,
  enable = true
) => {
  const ref = useRef<T>(null);
  useEffect(() => {
    const current = ref.current;
    if (!current) {
      return;
    }
    const handler = (event: Event) => {
      if (enable) {
        event.preventDefault();
      }
    };
    current.addEventListener(eventName, handler);
    return () => {
      current.removeEventListener(eventName, handler);
    };
  }, [enable, eventName]);

  return ref;
};

*1:v17からは React ツリーをレンダーしようとしているルート DOM コンテナにアタッチします

*2:v17からはドキュメントルートではなくなりましたが、breaking changes を最小限にとどめるために、明示的に passive を指定してあります

ここがすごいよMoonlander

Moonlander という分割キーボードを買ったのでレビュー。

ここがすごいよ Moonlander

分割であることはどうでも良くて、Moonlanderはカスタマイズ性がとんでもなく高い。これが最高。しかもカスタマイズの選択肢も豊富。おおよそキーボードに欲しい機能が全部詰まってる。

以下すごい所をかいつまんで。

キーレイアウトがソフトウェアから変更可能

Moonlander はとにかくカスタマイズ性が高く、頻繁にキーレイアウトが変わる。しかし、安心して欲しい。Moonlander はメチャクチャ手軽にキー配列を編集できる。

なんと、たった3ステップでキーレイアウトを書き換え可能。

  1. webページ(下図)でレイアウトを設定
  2. ファイルをダウンロード
  3. ソフトから書きこむ

f:id:panda_noir:20210512233128p:plain

こんな感じのUI。かわいい。使い方もすぐ理解できてGood。

72キーを丸ごと入れ替えるレイアウト機能

次はこれ。Moonlander にはレイアウトという概念がある。レイアウトを切り替えると、同じキーボードで全く別のキーバインドが使える。例えば通常の配列、ゲーム用の配列、テンキーの配列などを作って、それがキーボードから切り替えできる。どのキーを使って切り替えるかも自由に設定できる。つまり、72キー×レイアウトの数だけ自在に設定ができる。最高じゃない?

僕はこんな感じのレイアウトを作った。

f:id:panda_noir:20210512232800p:plain

左手側にテンキー配列を作って、ルービックキューブのタイムを記録しやすくした。ゲーム用配列を作ったりもできる。かなり応用の幅が広い。絵を書く人もショートカット用レイアウトを作るとかなり捗ると思う。

キー長押しで別の動作ができる

最初見たときはとても驚いた。キー長押し時に全く別の動作をさせることができる。

たとえば「Zキーを長押ししたらctrlキーとして扱う」といったことができる。メタキーが端っこ以外に設定できるし、なんならアルファベットキーの位置にいくつでも仕込める。すごく画期的。

確かに、キーを長押しすることはあまりない。これは盲点だった。他のキーボードでも取り入れて欲しい機能だ。

しかもMoonlanderはすべてのキーで自由に長押しの動作を設定できる。すごくない?

(もちろん、ゲームをするときはwasdとか長押しするけど、それならwasdに設定しなければいいだけ。自由なのだから)

1つのキーにCtrl + Space を割り当てみたいなことができる

「Ctrl+Shift+Q」のようなショートカットをを1つのキーで入力できる。どんな組み合わせも自由に設定できる。僕は日本語切り替えをCtrl+Spaceに設定してるので、これが一発で入力できて、もうメチャクチャ便利。すごい。

f:id:panda_noir:20210512234714p:plain

もちろん、長押し設定とも併用できる。実際、僕はそれを活用した設定をしてる。

  • タップしたらCtrl+Space(日本語切り替え)
  • 長押しでCtrlキー

これがとんでもなく使いやすい。親指箇所の配列は拘ればこだわるほど便利になる。最高すぎる。

左だけ接続できる

左側だけつなぐということができる。こんな感じ。

f:id:panda_noir:20210513000036j:plain

右側の空いたスペースにマウスを置ける。これがかなりいい。僕はルービックキューブをやるときタイマーやマットを置くので、省スペースは最高にうれしい。

おまけ: ここをなんとかしてくれMoonlander

かなりほめてきたし、実際最高のキーボードなんだけどいくつか文句を言いたい。

  • デフォルトのレイアウトがあまりに最悪
  • Rキーの真下が Vキー

といってもこれだけ。

いや、本当にデフォルトのレイアウトがひどい。まともに使えたもんじゃない。メタキーの位置がバラバラ。結局メタキーはほぼ丸ごと設定しなおした。最初にやってしまえばそれ以降は問題なくなるけど、最初の設定がとにかくめんどくさい。

それから、Rキーの真下がVキーなのも気に食わない。普通のキーボードだとRの右斜め下にVがいるので、その感覚で入力してしまってtypoが起こりやすい。まだ3日目なのでもっと使い込んでいけば問題なくなるだろうけど、なんとかならないか… src と頻繁に入力するのでそのたびtypoするのが腹が立つ。

String Enum に重複がないことを静的型検査で保証する

数値の Enum であればかんたんに重複なく生成できます。

// 0始まりの連番を生成
const [ITEM1, ITEM2, ITEM3] = Array(10).keys();

(この例では ITEM1 などが number になってしまっています。最後にいい感じに型付けする方法をおまけで紹介しています。)

しかし、String Enum の場合、重複がないようにするのが難しいです。

const STATE1 = `prefix_state1`,
  STATE2 = `prefix_state2`,
  STATE3 = `prefix_state1`;
// コピペして作ったので STATE3 が prefix_state1 になっている!!

こういう事故を防ぐために、型チェックで弾きたいですよね?

String Enum で同じ値がないことを型検査で保証する

createStringEnum という型チェックのための恒等関数を作りました。(引数が2つあるので厳密には恒等関数ではないですが)

const createStringEnum = <T extends object, Prefix extends string>(
  _prefix: Prefix,
  obj: {
    [K in keyof T]: K extends string ? `${Prefix}_${Lowercase<K>}` : never;
  }
) => obj;

createStringEnum 関数を使えば、型チェックで重複がないと保証された String Enum を生成できます。

const { STATE1, STATE2, STATE3 } = createStringEnum('prefix', {
  STATE1: `prefix_state1`,
  STATE2: `prefix_state2`,
  STATE3: `prefix_state3`,
} as const);

もし先ほどのように STATE3 が prefix_state1 になっていて重複がある場合、 createStringEnum 関数は型エラーを起こします。よって、STATE1、STATE2、STATE3 がそれぞれ異なっていることが型的に保証されました。

おまけ: 数値の Enum でもちゃんと型的に異なる値を作りたい

下の例では ITEM1 などが number 型となっていました。

const [ITEM1, ITEM2, ITEM3] = Array(10).keys();

しかし、Enum として使いたいので number ではなく、0 や 1 などリテラル型を振りたいですよね?

そう思い、Sequence 型を作りました。

type Sequence<
  N extends number,
  Acc extends number[] = []
> = Acc['length'] extends N ? Acc : Sequence<N, [...Acc, Acc['length']]>;

const createSequence = <N extends number>(n: N): Sequence<N> =>
  Array.from(Array(n).keys()) as Sequence<N>;

const [ITEM1, ITEM2, ITEM3] = createSequence(3);

これで ITEM1 は 0、ITEM2 は 1、ITEM3 は 2 というふうに型がつけられます。

(Assertion を使っているので完全に型安全というわけではないですが、さすがに Array(n).keys() というコードで連番を生成できることは静的型検査で保証せずとも使ってよいと思います。)

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 呼び出しの際に使われていることをテストするのは有意義なので書いても良い