SmartHR Tech Blog

SmartHR 開発者ブログ

React v17 の変更に関するこぼれ話

こんにちは、フロントエンド技術顧問の @koba04 です。 今回は React v17 での変更に対して気になった部分や、RC からリリースまでにあった修正の中で個人的に興味深いと思った話を紹介したいと思います。 v17 自体の変更点については下記で紹介している公式ブログで確認することをオススメします。

SmartHR では毎週フロントエンド MTG を行っており、今回紹介するような内容はこの MTG で取り上げています。 SmartHR での フロントエンド MTG のようなプロダクト開発以外の活動に興味のある方は下記のブログ記事を参照ください。

tech.smarthr.jp

Changes to Event Delegation

v17 で最も大きな影響があるのは、イベントハンドラが登録される方法が変わったことでしょう。 これまでは、Event Delegation を利用して document で全てのイベントをハンドリングしてましたが、v17 からは ReactDOM.render() の第二引数で渡された要素上でイベントをハンドリングします。

どう変わったのかについては公式サイトで紹介されている図がわかりやすいので引用します。

イベントハンドリングを行う場所の違い

https://reactjs.org/blog/2020/08/10/react-v17-rc.html より

この図にある通り、React は DOM の Event Delegation を利用して document でイベント毎にイベントハンドラを登録して全てをそこでイベントを受け取り、受け取ったイベントを自前で Event Delegation をしてました。 DOM の Event Delegation の仕組みについては下記の W3C の図がわかりやすいので引用します。

Event Delegation

https://www.w3.org/TR/uievents/#event-flow より

これにより何が変わるのかというと、React の外、つまり addEventListener などを使いイベントハンドラを登録している場合に、React 内部のイベントハンドラと処理される順番が変わる可能性があります。

例えば下記のようなケースを考えてみます。

const App = () => {
  useEffect(() => {
    document.body.addEventListener('click', () => {
      console.log('click on body');
    })
  }, [])
  return (
    <div>
      <h1>Hello</h1>
      <button
        onClick={e => {
          e.stopPropagation();
          console.log('click');
        }}
      >
        click
     </button>
    </div>
  );
};

ReactDOM.render(
  <App />,
  document.getElementById('root')
);

上記のコードでは、body に対して addEventListener を使い click イベントを登録しています。また、React 内部では button の click イベントに対して e.stopPropagation() を呼ぶことでイベントの伝播を止めています。上記の onClick と addEventListener は、Bubbling Phase で呼ばれます。(Event Delegation の図を参照)

余談ですが、addEventListener を使い Capture Phase のイベントをハンドリングしたい場合は、第三引数に true または { capture: true } を指定します(オブジェクト形式は IE は未対応です)。

developer.mozilla.org

React で Capture Phase のイベントをハンドリングしたい場合は、onClickCapture のようにハンドラの名前に Capture を指定します。

reactjs.org

上記の場合、DOM ツリー上では button は body の子孫要素ですが、React v16 までは React 内のイベントハンドラは document に登録されるため、 e.stopPropagation() を呼び出した段階ではすでに body に登録したイベントハンドラは処理されており、body に対するイベントの伝播を止めることはできません。

v17 の場合、イベントハンドラは ReactDOM.render() の対象である要素 (id="root") で処理されるため、body に対するイベントの伝播を止めることができます。その結果、body に対して登録したイベントハンドラは呼ばれません。

上記の挙動の違いは下記の CodePen で確認できます

実ケースではモーダルの外側がクリックされたときにモーダルを閉じたいといったケースで上記のパターンを使われることがあります。 この場合、v17 においてはモーダルを閉じる処理を document.body.addEventListener('click', fn) で登録して、モーダルの一番外の要素上で e.stopPropagation() を呼ぶことで、モーダルの外側がクリックされた時にだけモーダルを閉じることができます。v16 までは React は document にイベントハンドラを登録するので e.stopPropagation() を呼んでもモーダルを閉じる処理を止めることができませんでした。 そのためにクリックされた要素がモーダルの中にいるか判別する処理を書く必要があったのですが、v17 ではそれが必要なくなります。この処理は smarthr-uiDialog コンポーネントにおいて実装されています。

github.com

Portal と Event Delegation

ReactDOM には createPortal という API があります。この API を使うことで実際の DOM ツリーとは異なる親子関係を React のツリーで作ることができます。

const App = () => (
  <div onClick={() => {
    // Modal のクリックイベントも受け取る
  }}>
    <p>Hello</p>
    {ReactDOM.createPortal(<Modal />, document.body)}
  </div>
);

上記の場合、React 上では ModalApp の子要素として扱われるため、Modal で発生したイベントを div のイベントハンドラでキャッチできます。これは React が独自に Event Delegation を行なっているからこそ出来ることです。 今回の変更で ReactDOM.render() の対象要素上にイベントハンドラが登録されるようになると、DOM 上では親子関係にない Portal のイベントをキャッチできなくなりそうですが、React は Portal の対象要素(ReactDOM.createPortal() の第二引数)に対してもイベントを登録するため問題ありません。

RC の間に修正されたイベント周りの Issue について

v17 RC がリリースされてから正式版リリースされるまでの間、いくつかイベント周りで興味深い修正があったので紹介します。

touchstart, touchmove, wheel の passive 化

github.com

前述したとおり、React は v16 まで document に対してイベントハンドラを登録していました。一方 Chrome や Firefox などのブラウザは document に対する一部のイベントリスナ(touchstart, touchmove, wheel, mousewheel)をデフォルト Passive イベントとして扱うという変更を適用しています。

developer.mozilla.org

したがって、v16 ではこれらのイベントは何も指定しなくても Passive イベントとして登録されており、 e.preventDefault() で止めることができませんでした。一方 Passive イベントとして扱われているためパフォーマンス面ではメリットがある状態でした。 Passive イベントについてはこちらを確認してください。

developer.mozilla.org

v17 ではイベントハンドラが document に登録されないようになったため、このままだとイベントが Passive イベントではなくなり、挙動が変わってしまうという問題がありました。またこれはパフォーマンスの面でも問題が出る可能性があり、v16 との互換性という意味でも大きな変更なので、RC の段階で明示的に Passive イベントとして登録するように修正されました。 修正された PR は下記です。

github.com

イベントを登録する場所を変えたことで思わぬ影響が出たという例です。

Portal にイベントハンドラが登録されていないイベントが Portal の外に伝播しない問題

github.com

この Issue は少しややこしいのですが、下記の条件が組み合わさった結果起きた問題です。

  • v17.0.0-rc.0 の時点では、React は ReactDOM.render() の対象要素に対して、登録する必要があるイベントに対するイベントハンドラのみを動的に登録していた。つまり、ReactDOM.render() に渡した ReactElement の中で onClick しか使っていない場合は click イベントに対するイベントハンドラのみ登録していた
  • Portal 内の要素に対しては前述した通り、ReactDOM.createPortal() の対象要素に対してイベントリスナを登録していた

上記の条件から、ReactDOM.createPortal() に渡された ReactElement の中でイベントハンドラが定義されていないイベントについてはReactDOM.createPortal() の対象要素に対してイベントハンドラが登録されてないため、React はハンドリングできません。その結果、ReactDOM.createPortal() の外側で Portal 内のイベントをハンドリングしようとしたときに、Portal 内でイベントハンドラを登録しているもの以外はハンドリングできないという問題が発生します。

const App = () => (
  <div onClick={() => {
    // Portal 内の button に対するクリックイベントが受け取れない
  }}>
    <p>Hello</p>
    {/* el にはイベントハンドラが登録されない。button に onClick を登録すれば動作する */}
    {ReactDOM.createPortal(<button />, el)}
  </div>
);
// rootNode に対しては click イベントのイベントハンドラが登録される
ReactDOM.render(<App />, rootNode);

それに対応する PR は下記の通りです。

github.com

このパターンは想定していなかったようで、結果的に大きな変更になりました。 対応方法としては、

  • 最初に React が内部で持ってるイベントの種類全部に対してイベントハンドラを登録する方法
  • v17 から採用された必要に応じてイベントハンドラを登録する方法は維持しつつ、登録されたイベントハンドラをPortal と ReactDOM.render() の対象要素のどちらにも登録する方法

という 2 案ありましたが、最終的には後者は実装が複雑になり過ぎるという理由から前者が選択されました。

ただし、この変更は影響範囲が大きいので上の PR の時点では Feature Flag でデフォルト無効にした形でリリースされて、下記の PR によってデフォルトで有効化されました。

github.com

この変更の影響として、ReactDOM.unstable_createEventHandle という ref を渡すことでイベントハンドラ を登録できる API で CustomEvent を扱えなくなってしまうという副作用がありました。こちらの API は公開されている API ではないので特に影響はないのですが今後の Custom Elements 周りの対応に影響があるかもしれません。

余談ですが、React の Custom Elements 対応については再び議論が活発になっているので興味のある人は下記の Issue をウォッチすることをオススメします。

github.com

今回は主に React v17 でのイベント周りの変更や RC で発生した修正点について紹介しました。

React のコードベースは複雑ですがこのように Issue を起点に追いかけてみると、コントリビューションのチャンスがあったり、React に関する知識だけでなく DOM に関する知識も学ぶことが出来、何か問題に遭遇した時の解決の糸口になることもあるので、気になる Issue があればウォッチしてことをオススメします。

最後に

SmartHR ではフロントエンドエンジニアを募集中です!

hello-world.smarthr.co.jp

speakerdeck.com