2020/07/16
ReactJSでpreventDefaultが動かない問題を解消する

いくつかのイベント(タッチやウィールに関するものが多い)では受動イベントリスナでは、所定の挙動を上書きするのに必要なEvent.preventDefault()を呼び出すことができません。こういった例外が起こっているのをみたことはないですか?

[Intervention] Unable to preventDefault inside passive event listener due to target being treated as passive. See https://www.chromestatus.com/features/6662647093133312

これらのイベントリスナは特に明示しなければ受動になるので意識しなければ関連イベントに対して自作の操作を設定できなくなってしまいます。これはScroll jankなどと言われる、スクロールの前に若干つっかえて動かなく現象を防ぐのが目的で導入されたものです。例えばwheelイベントに関してはこの変更を公表する記事で比較動画が出されています。実際に非受動イベントリスナでのEvent.preventDefault()の呼び出しを制限することでスクロールの速度が大幅に改善しているのがわかります。

この例でいうと、イベントが発火してからブラウザがスクロール処理を行うためにはEvent.preventDefault()が呼び出されていないことを確認する必要があります。その確認のラグが効いているらしいのですが、もしイベントリスナが「Event.preventDefault()を呼び出さない」ことを保証できれば、それ以外のイベントリスナだけについて確認を取ればいいのでこのラグを縮小できます。受動イベントリスナとは、Event.preventDefault()を呼び出さないことを保証するオプションがついているイベントリスナのことです。他にもピンチイン・ピンチアウトなどの操作に関連するイベントにも同様の制約がかかっています。

通常のJavaScriptであれば、addEventListener(type, listener, options)を実行する際にoptions{passive: false}を含めることで解決できます。しかしReactJSでは現在受動的でないイベントリスナを設定する方法を提供していません。関連するIssueがたくさん建てられてはいますが、2016年に建てられたこの問題についてのIssueはいまだOpenのままになっています。下手に仕様を変更すると既存の資産を破壊することになるので難しいのかも知れません。

ここではこの問題に対応する2つの方法をReactJS X TypeScriptのコードで示します。片方が使えればそれで十分なので好みに合う方を採用していただけたらとおもいます。

(1) アプリ内でのイベントリスナ設定方法を書き換えてしまう方法 (2) コンポーネント作成時にpassiveオプションを指定する方法

各手法の長所短所はそれぞれ解説していますが、おおよそ(2)のほうが設計上利点がおおいものの実装にやや時間がかかる可能性があります。僕個人の価値観でいえば、新規のプロジェクトである場合には(2)を利用し、すでに関連するイベントを大量に利用する資産が有る場合には一旦(1)で対応することをおすすめします。

アプリ内でのイベントリスナ設定方法を書き換えてしまう方法

以下の内容をReactを利用する際のトップレベル、例えばapp.tsxに配置するだけです。


const EVENTS_TO_MODIFY = [  ] // Fill in events you want to modify
const originalAddEventListener = document.addEventListener.bind(null);
document.addEventListener = (type: string, listener: EventListenerOrEventListenerObject, options:AddEventListenerOptions) => {
  let modOptions = options;
  if (EVENTS_TO_MODIFY.includes(type)) {
    if (typeof options === 'boolean') {
      modOptions = {
        capture: options,
        passive: false,
      };
    } else if (typeof options === 'object') {
      modOptions = {
        passive: false,
        ...options,
      };
    }
  }

  return originalAddEventListener(type, listener, modOptions);
};

Chrome 73 breaks wheel events#14856というIssueyspektorさんが提供してくださったものを型付けしただけの単純なものです。仕組みとしてはaddEventListenerを既存のものを呼び出す前にoptions{passive: false}を付加するものになっています。

長所:

  • 既存の実装済み資産や知的資産がそのまま活用できる。

欠点:

  • 明示的にpassiveでないことを各リスナで宣言できない。
  • 部分的に非受動的なリスナを使いたいときに手間がかかる。
  • addEventListenerの既存の仕様を破壊する。

コンポーネント作成時にpassiveオプションを指定する方法

ReactJSのコンポーネントのライフサイクル図から分かるように、コンポーネントが作成してマウントされたときにcomponentDidMountが実行されます。このときに、明示的にaddEventListener{passive: false }を含めて実行することができます。

模式図はこんな感じです。

import React, { Component } from "react"
import ReactDOM from "react-dom"

type PropsType = ...
type StateType = ...

class SampleClass extends Component<PropsType, StateType> {

    sampleNonPassiveEventListenerFunction(event: any) {
        // Not Passive!
        event.preventDefault()
    }

    componentDidMount() {
        ReactDOM
            .findDOMNode(this)
            ?.addEventListener(TYPE_NAME, this.sampleNonPassiveEventListenerFunction, { passive: false })

    }
}

一箇所、sampleNonPassiveEventListenerFunctionの引数をanyを使って甘えているのは、僕の型付け力が足りてないからです。。イベントリスナを定義するときにはReact.TouchEventなどReactが提供する型で実装したいのにaddEventListenerでは対象のイベントリスナはEventListener型((e: Event) => void)型)でないといけません。おそらくは整合する型付けが有ると思うのですが調査不足と型付け力不足が祟って上のような醜い実装になっております。。

長所:

  • 明示的にどのイベントリスナが非受動的か宣言される。
  • コンポーネントごとにイベントリスナの振る舞いを独自に設定できる。
  • 特に既存のメソッドの振る舞いを変更しない。

短所:

  • 既存の資産を再利用する際に大規模な改修が必要になる可能性がある。