🕌

脳筋ReactがReactコンパイラーで賢くなる話

に公開

logo

かわいい

Reactと宣言的UI

いきなりですが、Reactの宣言的UI表現とは何でしょう。

答えは、仮想DOMです。

まず以てDOMとは何かですが、MDNに簡潔に記されています。

DOM は文書を論理的なツリーで表現します。ツリーのそれぞれの枝はノードで終わっており、それぞれのノードがオブジェクトを含んでいます。 DOM のメソッドでプログラム的にツリーにアクセスできます。これにより、文書構造やスタイルやコンテンツを変更することができます。

MDN

要は、「ツリー上のすべてのノードがJavaScript上で操作可能なオブジェクトである」ということです。

ちなみに「文書=Document」という表現は、そもそもHTMLが文書用だったことに端を発しています。

文書用が進化して、あらゆるユーザーインターフェースの構築に使えるようになったと思ってください。

仮想DOMは「DOM」を「任意に操作可能なオブジェクト」から「特定の操作のみを用いた宣言」にラップすることで、仮想的にモデル化したものです。(refを通せばオブジェクトは取得可能です)

この「特定の操作」とは、主に「作成操作」です。

Reactだと、createElementにあたります。

const button = createElement( // ← コレ
  "button",
  {
    style: {
      padding: "4px",
    },
  },
  "click me!"
);

const root = createRoot(document.getElementById("root")!);

root.render(button);

JSXを使うと、マークアップ風に書けます。※トランスパイル後のコードが同じになるわけではないです

const button = <button style={{ padding: "4px" }}>click me!</button>;

const root = createRoot(document.getElementById("root")!);

root.render(button);

Reactには、changeElementdeleteElementはありません。

これは完全に脳筋と言えます。

なぜなら、これでは差分を適用する手段が無いからです。

しかし、Reactはさらに脳筋な方法でこの問題を解決します。

コンポーネント

Reactで宣言的UIを構築する方法は分かったものの、現実的な運用ではユーザー操作や時間経過に対して、動的に仮想DOMが変化しなければ使い物になりません。

この課題に対してReactは「状態」を導入しました。

そして、状態に対して仮想DOMを作成しまくるようにしました。

なので、Reactでは新しい仮想DOMがポンポンと生まれます。

そして、新しい仮想DOMと古い仮想DOMを比較して、差分適用をします。

これは、ごり押しの脳筋です。

状態を保持し、新しい仮想DOMを作り出す役目を担うのがコンポーネントです。

例えば、以下の関数コンポーネントは、setCountを呼び出した後のどこかしらのタイミングで再度関数として実行され、次の仮想DOMを生みます。

export function Counter() {
  const [count, setCount] = useState(0);

  return createElement(
    "button",
    {
      style: {
        padding: "4px",
      },
      onClick: () => {
        setCount((c) => c + 1); // 状態が変更される
      },
    },
    "count:",
    count
  );
}

const root = createRoot(document.getElementById("root")!);

root.render(createElement(Counter));

このときReactは何回も新しく仮想DOMを作るわけですが、ボタンは常に一つで、クリックイベントのハンドラが二重にアタッチされてしまうようなこともないわけです。

ボタン要素はともかく、ハンドラはcountの値が変われば毎回変化します。

内部的には!==でチェックをしているわけで、新しい関数は参照が違うので、変更と判定されます。

すると、レンダリング毎に新しくハンドラが作られ、古いハンドラのデタッチと再アタッチが起きます。

レンダリング毎というのは、十分なタイミングと言えるでしょう。

この十分とは必要の逆で非効率になるくらいやりすぎているということです。

あまりに脳筋ですが、それゆえに自動で更新が行われ、ゆえに安全なのです。

最適化とReactコンパイラー

この十分必要十分まで減らすための機能をReactは提供しています。

例えば、明示的なメモ化です。

export function Counter() {
  const [count, setCount] = useState(0);

  const onClick = useCallback(
    // 第1引数
    () => setCount(c => c + 1), 
    // 第2引数
    []
  );

  return createElement(
    "button",
    {
      style: {
        padding: "4px",
      },
      onClick,
    },
    "count:",
    count
  );
}

useCallbackを使ってハンドラの関数(第1引数)をメモ化しました。

これによって毎回同じ関数への参照が使われるため、!==で比較しているReactは再アタッチを見送ります。

この時、第2引数に配列が指定されるようになったわけですが、これは依存配列であり、メモ化のパラメータです。

もしこの第2引数に関数内で利用している変数を入れておかないと、キャッシュが更新されずに意図しない挙動を引き起こします。(今回は、setState(fn)方式をとったので必要が無い)

eslintを設定していれば警告が出ますが、逆に言えばeslintがわざわざ監視対象にするような危険な方法に手を出したとも言えます。

また、関数内で変数を使っているのに、それを更に依存配列に手動で書かないといけないというのは、冗長な感じもします。

もちろんこれが全くできないのも問題なのですが、仮想DOMによって確保した安全性にヒビが入る印象です。

Reactコンパイラー

ここでReactコンパイラーです。

Reactコンパイラーは、Reactのコードを静的に解析して、可能な限りメモ化を行うコードに自動変換します。

先ほど言っていた十分が、自動のまま必要十分まで削れる可能性があるということです。

これは脳筋ではなく、賢いですね。

まとめ

この進化には、Reactの方針として「なるべくuseStateと仮想DOMだけでUI周りの事を済ませて欲しい」という意志を感じます。

逆に、useEffectはなるべく使わないで欲しいという意志も感じます。

つまるところ、「ライフサイクルの管理を自前でしない方がいいよ」ということだと思います。

Fiberで飛躍的に進化したスケジューラ周りの話をすると、また違う切り口でReact脳筋話ができると思うのですが、今回はUI周りとReactコンパイラーの話だけにしておきます。

ご精読ありがとうございました。

Discussion