BLOG

HP制作
Reactの仮想DOMが理解できてなかった!?
Reactの神髄ともいえるDOMのレンダリングする仕組み
それが仮想DOMの比較による調整です。
Reactは、この仮想DOMの比較を通じて、実際のDOM更新を最小限に抑えることで、高速なパフォーマンスを実現しています。
しかし、この仕組みの中で、外部ライブラリや手動でのDOM操作が行われると、深みにはまるというお話です。

Reactを始めると仮想DOMという言葉を耳にするようになります。
ChatGPTの説明です。

生成: Reactコンポーネントがレンダリングされるとき、仮想DOMツリーが生成されます。これは実際のDOMツリーの軽量な表現です。
差分検出: 状態やpropsの変更によりコンポーネントが再レンダリングされると、新しい仮想DOMツリーが生成され、前回のツリーと比較されます。
再調整 (Reconciliation): 2つの仮想DOMツリーの差分を検出し、変更が必要な部分だけを特定します。
実際のDOM更新: 差分検出を通じて得られた変更のみが、効率的に実際のDOMに適用されます。
この仕組みにより、Reactは不必要なDOM操作を避け、高速なUI更新を実現しています。

文書で表現するとわかりにくいです。
ちょっと図式化してみましょう。

これでなんとなくわかった気になります。
実際、この程度の説明で十分なことが多いと思います。
しかし、この程度の説明だと実際に起きた不具合が、その仕組みを十分に理解していないことが原因だと気が付かず、時に相当深い奈落に落ちていくことになります。
そうならないように、この際、私の体験を材料にして、Reactの仮想DOMによるレンダリングの仕組みを深堀して徹底理解しましょう。

ハマったきっかけはGoogle Code Prettify

皆さんはGoogle Code Prettifyというライブラリをご存じでしょうか?
ブログなどでコードを紹介するとき、ハイライト表示するために使います。
コードをハイライト表示させるライブラリは他にもあると思いますが、これはかなりメジャーなライブラリで使い勝手がいいのです。

そこで、これを使ったWordpressのブロックを作ろうと思い制作にかかりました。
このサイトでも紹介されていたので参考にさせてもらいました。

概ねできたのですが、不具合が出ています。

右のトグルボタンで行番号を表示したり、非表示にしたりできるはずでした。

しかし、非表示にはならず、何度かトグルボタンをクリックすると、行番号がどんどん重なっていきます。

Google Code Prettifyの仕組み

なぜ、このような現象が起きるのかは、Google Code Prettifyの仕様を調べたらすぐにわかりました。
今回の説明のためにGoogle Code Prettifyについて、ごく簡単に、このライブラリの仕組みを説明します。
ライブラリを使用する側は次のようなHTMLを用意します。

<pre class="prettyprint linenums" >
    code Hilight
</pre>

そして、このHTMLをReactコンポーネントとしてレンダリングした後、useEffectで

useEffect(() => {
    PR.prettyPrint();
}, []);

としてやれば、先ほど用意したHTMLから自動的に次のようなDOMツリーを生成してくれます。

<pre class="prettyprint linenums prettyprinted">
    <span class="pln">code </span>
    <span class="typ">Hilight</span>
</pre>

このようにクラス付きのDOMツリーを生成することで、別に用意されたCSSが当たって、きれいなハイライトが表示されるわけです。
(今回の説明ではライブラリを読み込む部分の説明は省略しています。一からライブラリを読み込んで実装したい方は、このブログが参考になると思います。)

ところが、このようにきれいにラッピングしてくれるのは、pre要素の中味がテキストになっている状態で

PR.prettyPrint();

が実行される必要があります。
しかし、すでにpre要素の中味がDOMツリーになっていると、そのDOMツリーの最初のDOM要素の下に新たなツリーを作ってしまいます。
これが、今回起きた不具合の原因です。

再レンダリングでDOMツリーはもとのテキストにもどる?

だったら、最初に用意したHTMLを再レンダリングさせればいいんじゃないのか思いますよね。
ある程度Reactを習熟した方なら誰しもそう思うのではないでしょうか。
では、実際にコードにしてみましょう。

export default function Component() {
    const [isLine, setIsLine] = useState('');//⓵
    const code = 'line 1\nline 2'//⓶
    useEffect(() => {//⓷
        PR.prettyPrint();
    }, [isLine]);
    
    return(
        <button onClick={() => setIsLine('linenums')}>//⓸
            render
        </button>
        <pre class="prettyprint {isLine}" >//⓹
            {code}//⓺
        </pre>
    )
}

⓵では状態変数isLineを用意します。
⓶ではレンダリングするコードをcodeという変数内にセットして⓺でレンダリングさせるようにします。
⓷はコンポーネントマウント時とisLineの更新時に発火するuseEffectです。
⓸でボタンを押せばisLineが更新され、再レンダリングが起き、その後⓷のuseEffectが実行されます。

これでpre要素にはlinenumsクラスが付加されて行番号付きのシンタックスハイライトが現れるというシナリオを期待しているのですが、このシナリオは実現しません。
その理由は⓺の再レンダリングが実行されないからです。
⓺は'line 1\nline 2という文字列でした。しかし、コンポーネントがマウントされたとき⓷のuseEffectが発火してPR.prettyPrint();が実行されたため、DOMツリーに変容しています(useEffectは依存配列の更新時だけでなく、コンポーネントのマウント時にも実行される。)。
だから、isLineを更新することで、もう一度DOMツリーがもとのテキストに戻った上で、PR.prettyPrint();が実行されると考えたのです。
しかし、そうはならないのです。
なぜなら、そこにはReactの仮想DOMによる比較に基づいた変更内容の決定というプロセスが働くためです(これを”reconciliation”(調整)と呼ぶそうです。)。
⓺の部分は外見上変化していますが、それはReactが知らないところで外部のライブラリ(Google Code Prettify)がDOM要素を直接更新したものであって、React側から見れば、テキストのままのはずなのです。
したがって、仮想DOMには差分が生じておらず、再レンダリングの対象から除外されてしまいます。
その結果、pre要素にはisLineという状態変数が変化したことによってクラスが追加されるというレンダリングは起きますが、⓺の部分は外部ライブラリによって更新されたままの状態になります。
この状態でPR.prettyPrint();が実行されると、先に示した奇妙な現象が起きるわけです。

手動で元に戻すしかない

では、どうするのか?
結論としては手動で元に戻すしかありません。
Google Code PrettifyにはDOMツリーを元のテキストに戻すという機能はないでしょう。
手動といっても、それほど手間がかかるわけではありません。
コードを示します。

export default function Edit() {
    const [isLine, setIsLine] = useState('');
    const preRef = useRef(null);//⓵
    const code = 'line 1\nline 2'
    useEffect(() => {
        if (!preRef.current.classList.contains('prettyprinted')) {
            preRef.current.innerText = code;//⓶
        }
        PR.prettyPrint();
    }, [isLine]);

    return (
        <>
            <button onClick={() => setIsLine('linenums')}>
                render
            </button>
            <pre ref={preRef} class={`prettyprint ${isLine}`} >//⓷
                {code}
            </pre>
        </>

    )
}

⓵でuseRefを宣言し、⓷でpre要素を参照しておきます。
⓶でその参照を使用してinnerTextプロパティをもとにもどしてやります。
その後、PR.prettyPrint()が実行されることになるので、期待どおりのレンダリングが実行されます。

まとめ

今回は仮想DOMの仕組みを理解していないと、再レンダリングされるはずなのになぜ再レンダリングがおこならないのか、見当がつかないという状況になるということをお伝えすることに焦点を絞りました。
そのため、Google Code Prettifyの使い方や、useRefの利用のところは、それほど説明を加えませんでした。useRefは非常に重要なReactのフックスですので、他のブログで詳しく説明したいと思います。

Reactには仮想DOMによるレンダリングという仕組みがあることは、多くの技術者が意識していることだと思いますが、その実態を目の当たりにすることは少ないのではないでしょうか。
レンダリング後のDOM要素を開発者ツールで確認しても、それが再レンダリングされた結果なのか、再レンダリングされなかったのか見分けがつかないし、確認する必要性に迫られることが少ないからです(本当はプラグイン等で不要なレンダリングが起きていないかチェックすべきなんでしょうね。)。
しかし、今回のような不具合が起きるとそうはいきません。
外部ライブラリの利用は実践的なコンポーネントを作る上で欠かせない存在です。それとReactをうまく組み合わせるためには、基本の徹底理解がいかに大事か身につまされました。

このブログが少しでもお役に立てば光栄です。