Michi's Tech Blog

一人前のWebエンジニアを目指して

【React】モバイル版のタッチイベントが発火しない問題に対処する

こんにちは!
スマレジ テックファームのMichiです!

先日、Reactを使ったアプリ開発において、モバイル版のタッチイベントが発生しないというバグに遭遇しました。そして、このバグがReactのライブラリやブラウザの仕組みにも深く関わっているということがわかったので、忘れないように忘備録としてまとめておきます。

※この問題については自分も完全に理解しているとはいえず、まだまだわかっていないことも多いです。 そのため、間違った情報も含んでいるかもしれませんが、その際はブログのコメントやTwitterから教えていただけると助かります。

対象バージョン

ライブラリ

  • React: 17.0.2
  • react-calendar-timeline: 0.28.0

ブラウザ

遭遇した現象

次のようにReactとReact-time-calendarというライブラリを使用したコンポーネントがあります。React-time-calendarはReactのコンポーネントを使ってカレンダーのタイムライン表示を描画できるライブラリです。

import React from "react";
import Timeline from "react-calendar-timeline";

// タイムライン上のカスタムアイテム
const CustomItem = ({ item, getItemProps }) => {

  // touchstartイベントが発火した時の処理
  const handleTouchStart = () => {
    console.log("Item was touched");
  };

  return (
    <div {...getItemProps({ onTouchStart: handleTouchStart })}>
      {item.content}
    </div>
  );
};

// タイムラインコンポーネント
const MyTimeline = ({ groups, items }) => {
  return (
    <Timeline
      groups={groups}
      items={items}
      itemRenderer={({ item, getItemProps }) => (
        <CustomItem item={item} getItemProps={getItemProps} />
      )}
    />
  );
};

export default MyTimeline;

このコードはデスクトップでは問題なく動作しますが、一部のモバイルブラウザでは期待通りに動作しない可能性があります。

自分が遭遇した現象としては、以下の通りです。

  • カレンダーのアイテムをタッチしても、イベントが発生しない
  • モバイル版Chromeでは画面をタッチしたりスクロールすると、Unable to preventDefault inside passive event listener invocationというエラーメッセージがコンソールに表示される
  • Safariではコンソールエラーは発生しないが、画面をタッチしてもイベントが発火しない現象は存在する

※実際に自分が遭遇したコードはもっと複雑ですが、抽象化するためにあえて簡略にしています。そのため、このコードでは上記のような現象が起こるかはわかりません。

次項では、

  • タッチイベントが発生しない
  • コンソールにエラーが出る

という2つの問題を切り離して、別々に考えていきます。

解決策

タッチイベントが発生しない現象

まず、この現象を理解するために、ReactのDOMイベントの処理方法について知る必要があります。

Reactは実際のDOMイベントを直接処理するのではなく、合成イベント(SyntheticEvent)と呼ばれるReactのイベントシステムを使用します。これにより、ブラウザや環境に関係なく一貫したイベント処理を行うことができます。

しかし、Reactの合成イベントはモバイルブラウザ上の一部のイベント(特にTouchEvent)に対していくつかの問題を引き起こすことが知られています。具体的には、モバイルブラウザではtouchstartイベントが一部の要素でキャンセルされることがあります。

これを解決するためには、React由来ではないネイティブのDOMイベントリスナーを手動で登録することが必要です。

import React, { useEffect, useRef } from "react";
import Timeline from "react-calendar-timeline";

// タイムライン上のカスタムアイテム
const CustomItem = ({ item, getItemProps }) => {
  const itemRef = useRef(null);

    // touchstartイベントが発火した時の処理
    const handleTouchStart = (event) => {
       // デフォルトのtouchstartイベントに登録されている処理をキャンセル      
       event.preventDefault();
       console.log("Item was touched");
    };

  useEffect(() => {
    // 手動でtouchstartのイベントリスナーを登録する(パッシブモードはOFFにする ※後述)
    const itemElement = itemRef.current;
    itemElement.addEventListener("touchstart", handleTouchStart, { passive: false });

    // クリーンアップ関数(イベントリスナーを削除)
    return () => {
      itemElement.removeEventListener("touchstart", handleTouchStart);
    };
  }, [handleTouchStart]);

  return (
     // 登録したイベントリスナーをrefを使って参照する
    <div {...getItemProps({ ref: itemRef })}>
      {item.content}
    </div>
  );
};

// タイムラインコンポーネント
const MyTimeline = ({ groups, items }) => {
  return (
    <Timeline
      groups={groups}
      items={items}
      itemRenderer={({ item, getItemProps }) => (
        <CustomItem item={item} getItemProps={getItemProps} />
      )}
    />
  );
};

export default MyTimeline;

ここでのポイントは、useEffect内でクリーアップ関数を使用し、コンポーネントのアンマウント時に登録したイベントリスナーを削除してあげることです。

これには、CustomItemコンポーネントが再レンダリングされるたびに新しいイベントが登録され、溜まっていくことを防ぐ意味があります。

これで、モバイルブラウザから対象をタッチしても、イベントを発生させることができます。

コンソールに警告が出る現象

ただし、上記の修正だけでは、Unable to preventDefault inside passive event listener invocationの警告を消すことができません。

このエラーは、パッシブイベントリスナーがevent.preventDefault()を呼び出すことはできないというブラウザの制約に関連しています。

これだけ説明しても「は?」という感じなので、順を追って説明します。

JavaScriptのpreventDefault()

event.preventDefault()JavaScriptでイベント処理を行う際に、ライブラリやフレームワークの中でよく使われるメソッドです。このメソッドは、特定のイベントがブラウザによって自動的に実行されることを防ぎます。言い換えれば、ブラウザのデフォルトの挙動をキャンセルするということです。

記事の最初の方に、Reactは合成イベント(SyntheticEvent)と呼ばれる独自のイベントシステムを使用すると書きました。このイベントシステムを利用するためには、ブラウザのデフォルトのイベントシステムをOFFにする必要があります。その際に、このpreventDefault()が使用されています。

ブラウザのパッシブモード

ブラウザのイベントリスナーにはアクティブ(能動)とパッシブ(受動)の2つのモードが存在します。

パッシブモードでは、ユーザーの操作そのものを阻止(例えば、スクロールを止めるなど)することなく、その操作に対する反応だけを受動的に実行します。モバイルデバイスではパフォーマンスを向上させるために、多くのタッチやスクロールのイベントリスナーはデフォルトでパッシブになっています。これにより、イベントリスナーが動作を遅延させることなく、スムーズなユーザー体験を提供することができるためです。

パッシブモードでpreventDefault()を実行できない

さて、予備知識の解説が終わったので本題に戻ります。

デフォルトでパッシブになっているtouchstartイベントに対して、ReactのSyntheticEventがpreventDefault()を呼び出します。が、ここで問題が発生します。

「パッシブなイベントリスナーは、preventDefault()を呼び出せない」というルールが存在するからです。(そもそも、パッシブモードはユーザーの操作を阻止しないという機能ですので、当然ですね。)

そのため、先述の解決法ではイベントリスナーの登録の際、{ passive: false }としていました。

// 手動でtouchstartのイベントリスナーを登録する(パッシブモードはOFFにする)
const itemElement = itemRef.current;
itemElement.addEventListener("touchstart", handleTouchStart, { passive: false });

しかしながら、ReactのSyntheticEventで登録されているtouchstartイベントリスナーは{ passive: true }のため、そちらも同時に呼び出されることによってpreventDefault()のエラーが発生してしまいます。

React(react-dom)の中身。preventDefault()を呼び出そうとして失敗している。

私がバグに遭遇した環境では、残念ながらこのコンソールエラーを消去する方法はみつかりませんでした。React内で{ passive: true }に制御している以上、それを直す方法はないという結論でチーム内でも検討を終えました。

Safariではなぜコンソールエラーが発生しないのか?

最後に、なぜSafariではUnable to preventDefault inside passive event listener invocationの警告が発生しないのかについて触れておきます。

こちらの表をご覧ください。

引用: https://github.com/facebook/react/pull/19654

赤線で囲まれた箇所を確認すると、Safariではタッチやスクロールのイベントリスナーはデフォルトでパッシブモードにならないことがわかります。

ゆえに、Safariではコンソールのエラーが発生しなかったわけです。ただし、ReactのSyntheticEventでpreventDefaultされていることには変わりないので、タッチイベントはChromeと同じで発生しなかったというわけです。

まとめ

  • Reactは独自の合成イベントを使用するため、モバイル版の一部のイベントはキャンセルされることがある
  • キャンセルされるイベントを起こしたい場合は、addEventListenerで手動で登録する
  • Reactではタッチやスクロールのような一部イベントリスナーはパッシブモードとなり、preventDefaultできない
  • ただし、Safariでは上記イベント時にデフォルトでパッシブモードにならないので、preventDefault可能

参考

developer.mozilla.org

qiita.com

www.pandanoir.info

github.com