🐬

【React】仮想DOM~StackとFiberを理解する~

2021/12/13に公開

【Step1】React18に向けて

先日のReact confでも発表がありましたが、React18からConcurrent Featuresが実装されます。以前はConcurrent Modeと呼ばれていましたが、名称が変更され、それと同時にConcurrent Modeの機能の段階的な導入が可能となります。

このConcurrent Featuresですが、ReactのFiberと呼ばれるアルゴリズムに対して提供されるAPIであることから、Reactの最新版で新たに追加される機能を理解するためには、仮想DOMの差分検出処理のアーキテクチャについて簡単に知っておく必要があります。

もともとReactの仮想DOMを解析するアーキテクチャとしてバージョン15まではStack、16以降はFiberと呼ばれるアーキテクチャが使われており、リコンサイラと呼ばれる機能がそれを実現していました。
順番に確認していきましょう。

【Step2】DOMと仮想DOM登場の歴史を振り返る

DOMとは、Document Object Modelの略で、HTMLドキュメントのツリー構造をプログラムから利用するためのAPIのことです。元々は、数学のグラフ理論を背景として構築された技術になります。

このような技術を用いることで、JSでHTMLのタグを操作することが出来るようになります。

// DOMの例
{
  "nodeName" : "div",
  "attributes" : { "id" : "app" }
  "children" : [
                  {
                    "nodeName" : "h1",
                    "attributes" : { "class" : "title" }
                    "children" : ["hoge"]
                  }
                ]
}

フロントエンドでは長らく、jQueryなどの技術によってこのDOMを操作してwebページを作成することが中心でした。しかしながら、2012年頃から、webページをパーツの集まりとして考えるアトミックデザイン的な発想が登場してきます。
そのため、webページをパーツ、今でいうとコンポーネントという単位に分割して、ページ更新の都度コンポーネントをレンダリングした際、どのくらいの計算量になるのか?ということが研究されました。

このような研究の一部は、Reactの公式サイトでも閲覧することが可能です。
https://grfia.dlsi.ua.es/ml/algorithms/references/editsurvey_bille.pdf

このような研究の結果、そのまま実装した場合、計算量が爆発的に多くなるということがわかったため、コンポーネント単位でのwebページ構築はそのままでは具合が悪い、という話になりました。
こういった文脈の中で仮想DOMの技術が登場してきます。

簡単に言えば、HTMLはそもそもツリー構造なんだからDOMの形で変更前と変更後の形を保持しておいて、差分だけ更新してあげれば、常に最小の計算量で画面の更新を表現できるよね、というアイデアになります。これはかなり画期的な考え方で、後のフロントエンド技術を大きく動かすきっかけとなりました。

// 差分を元のDOMと比較し、追加された分だけ更新処理を実行する
{
  "nodeName" : "div",
  "attributes" : { "id" : "app" }
  "children" : [
                  {
                    "nodeName" : "h1",
                    "attributes" : { "class" : "title" }
                    "children" : ["hoge"]
                  },
		  {
			"nodeName" : "h2",
			"attributes" : { "class" : "title" }
			"children" : ["fuga"]
		  }
                ]
}

【Step3】Stackリコンサイラを簡単に理解する

HTMLのツリー構造をDOMの形で変更前と変更後の形を保持しておいて、差分だけ更新してあげるという処理を差分検出処理(リコンシリエーション)と呼び、これを実現するプログラムをリコンサイラと呼びます。

Reactのバージョン15まではStackリコンサイラが使われていたので、処理を簡単に見てみましょう。

数字の順番に変更箇所がないかどうかを確認していきます。
実際にはそれぞれの要素の型を比較し同じであれば属性を比較する、といったように再帰的に処理が進むのですが、イメージとしてはまずHtmlタグで変更がないかを見て、変更がないならその下のHeadタグに変更がないかを見て…というように順番に変更箇所を探っていきます。

このイベントのトリガーとして使われるのがstateなので、stateが書き換わると、仮想DOMの再構築が起こる仕組みになっています。

より詳細な処理について知りたい方はReact公式サイトを参照ください。https://ja.reactjs.org/docs/implementation-notes.html

【Step4】Stackリコンサイラの問題点

先程のイメージ図のように変更箇所を処理していく事は、ある意味で同期的な処理と同義であり、それゆえDOMの更新処理を途中で停止したり、更新処理の間アプリが動かなくなる、といった問題が発生していました。
例えば、先程の図ではheadタグ以降の処理が完了した後にbodyタグの処理に移行していますが、headタグの処理で何らかの問題が発生し読み込みに時間がかかった場合、bodyタグ以降はその間処理が停止してしまいます。

そうではなく、headタグもbodyタグも一斉に読み込みし、headタグで時間がかかったなら先にbodyタグを処理、逆にbodyタグで時間がかかったなら先にheadタグを処理させる…というような非同期処理を差分検出処理(リコンシリエーション)に取り入れられたらより理想的ですね。

【Step5】Fiberリコンサイラを簡単に理解する

Reactのバージョン16からFiberが導入されました。
これは、仮想DOMによる差分検出処理を非同期的に処理するためのアーキテクチャになります。

どのように処理するかというと、差分検出処理の対象をDOMから、複数のDOMを束ねたFiberと言う単位に分割し、Fiberごとに処理を実行しようとします。複数のDOMが1つのFiberになったことにより、更新処理を途中で止めたり、再開する、つまり同期的な再帰処理を非同期の再帰処理に変化させたのです。

例えばFiber1とFiber2が非同期的に処理され、もしFiber1の中で何らかの問題が発生した場合にはFiber2が先に解析され、そのあとでFiber1に戻って処理をするといったイメージとなります。

念のため、Fiberの型を見てみましょう。
return、child、siblingの箇所が特に重要なkeyとなっており、ここの値を通じてDOMがFiberという単位でまとめられる形をとります。

type Fiber = {
      tag: TypeOfWork,
      key: null | string,
      type: any,
      stateNode: any,
      return: Fiber | null, //他のfiberへの参照箇所
      child: Fiber | null, //他のfiberへの参照箇所
      sibling: Fiber | null, //他のfiberへの参照箇所
      index: number,
      ref: null | (((handle: mixed) => void) & {_stringRef: ?string}),
      pendingProps: any, 
      memoizedProps: any, 
      updateQueue: UpdateQueue | null,
      memoizedState: any,
      effectTag: TypeOfSideEffect,
      nextEffect: Fiber | null,
      firstEffect: Fiber | null,
      lastEffect: Fiber | null,
      pendingWorkPriority: PriorityLevel,
      progressedPriority: PriorityLevel,
      progressedChild: Fiber | null,
      progressedFirstDeletion: Fiber | null,
      progressedLastDeletion: Fiber | null,
      alternate: Fiber | null,
    };

このような機能の登場により以下のような機能の実現が可能になりました。

  • 中断可能な作業を小分けに分割する機能
  • 進行中の作業に優先順位を付けたり、再配置や再利用をする機能
  • React でレイアウトをサポートするための親子間を行き来しながらレンダーする機能
  • render() から複数の要素を返す機能
  • error boundary のサポートの向上

なお、バージョン16.13から実験的機能としてConcurrent Mode(並列モード)が導入されたものの、これを操作するためのAPIがこれまで用意されておらず、そのためユーザー側から特にリコンサイラとしてStackのものを使ったり、Fiberのものを使うといった指定をすることが出来ませんでした。

これを解決してくれるものが、React18から登場するConcurrent Featuresになります。

【Step6】まとめ

新モードであるConcurrent Featuresでは、レンダリング用のアルゴリズムがFiberになっているおかげでレンダリングの中断処理が可能となっており、これにより各コンポーネントのレンダリングを適宜停止&再開しつつ適切にスケジューリングしてくれるようになります。

React18についてはすでにアルファ版もベータ版も提供されているので、登場は間近です。
ただし追加される機能については、StackではなくFiberを操作するための機能が主になるので、これまであまり差分検出処理について意識していなかった方に関して、本記事が参考になれば幸いです。

Discussion