React における配列の reconciliation に関する検証と考察

Published: 2023/8/15


React Component は render する度に、 React Element を生成する。 形状としては、

{
  type: stringOrCompoent,
  props: pojo,
  children: arrayOfReactNode,
  key?: string
}

のような javascript のオブジェクトである。

React Element を受け取った React のフレームワーク(おそらく、 fiber まわり) は、それを dom (react-dom の場合) に変換するべく、処理を行う。 type が文字列である場合には、それは DOM の生成だと解釈し、 children の render を行っていく。 type がコンポーネントである場合には、 react element として指定された props や children を用いて、コンポーネントのインスタンス化を行っていく。 この再帰的処理が終端すると、仮想 DOM が生成されているので、それを実際に DOM へ反映する。

コンポーネントの state が変更されると、そのコンポーネントは、再度 render される。 この時、再レンダーの結果として得られる React Element を、前回実行した時の React Element と比較し、差分を検出し、必要なアップデートを行っていく処理が、 reconciliation と呼ばれる処理になる。

Reconciliation の処理は、単一要素同士の比較と、配列の比較とで処理が若干違う(ように見える)。 ある React Element と別の React Element を比較する際には、 type の中身を Object.is によって比較をし、それが一致していれば、同一のコンポーネントであったとして処理される。 違った場合には、古い Element によって生成されていたコンポーネントのインスタンスを破棄し、新しい Element のコンポーネントをインスタンス化する。

配列同士を比較する際には、 key をベースに要素の突合が行われる。 key が指定されない配列の要素については、 __raw_array__${index} のように、配列の要素の位置を表す key が付与されているかのような処理になっている。 このように、配列の要素の node は、そのすべてが何かしら key を持つものであると考える場合、配列の reconciliation は純粋に key ベースで動作する。 古い配列のみに存在する key に対応するコンポーネントはすべて破棄され、新しい配列のみに存在する key は新規にインスタンス化、対応する key が新旧で存在していた場合(かつ、 type が一致している場合)には、その component は、同一性が保持され、ただ再 render すればよいとして処理されていく。

上記の振舞いの検証

import { useState } from 'react';

function App() {
  const [flag, setFlag] = useState(false);
  const toggle = () => {
    setFlag(!flag)
  }
  const ret = (
    <div>
      {
        flag ? (
          <input key="a" />
        ) : (
          <input />
        )
      }
      {
        flag ? (
          <input />
        ) : (
          <input key="a" />
        )
      }
      <input />
      <button onClick={toggle}>Toggle!</button>
    </div>
  );
  return ret;
}

上記のような React App を動かしてみると、 "Toggle!" ボタンを押す度に、 key="a" を付与した input についてはその内容が保持されていくのが分かり、一方、 key を付与しない方は toggle の度に内容がクリアされるのが分かる。 そして、3番目の input には特に変化が見られない。

上記の App が生成する React Element は、

{
  type: 'div',
  props: {},
  children: [
    { type: 'input', key: 'a', () },
    // or, { type: 'input', (略) },
    { type: 'input', () },
    // or, { type: 'input', key: 'a', (略) },
    { type: 'input', () },
    { type: 'button', () },
  ]
}

であり、 key による reconciliation は children という配列に対しても適用されているのが分かる。 この reconciliation の振舞いにおいて、合理的な実装はどうなっているかを想像すると、上で述べた通り、 key がない配列要素には、配列上のインデックス番号を表す仮想的な key が付与されている、と考えると、いろいろ分かりやすい。

また、 JSX の中に配列を埋め込むと key を各要素に付与しろ warning が出力されるが、これは、コンポーネントを render した再に、 children 直下は JSX によって生成されるのが一般的であり、 JSX 上の順番(index) によって突合処理してもあまり問題にならない一方で、 children の中で、要素として配列が表れる場合には、その配列は動的に生成された可能性が高く、なので、 key がないと恐らく意図しない挙動になるであろうから、 warning を出力する処理が、コンポーネントの render の処理に組込まれているから、と考えられる。


Tags: react

関連記事