reactjs

サーバサイドで生成したReactコンポーネントをブラウザで再描画するときの処理を追ってみた

More than 3 years have passed since last update.

TL;DR

チェックサムが毎回違うのでDOMを再レンダリングしない判定に失敗していたと思ったけどそんなことはなかったぜ。

はじめに

ReactRubyというRubyからReactを呼び出すブリッジを作っている。

http://tanstaafl.0pt.jp/posts/2014/11/19/b22b24ca7ea5

https://github.com/minoritea/react_ruby

サーバサイドではRubyからExecJS経由でReactを呼び出して、描画している。サーバサイド、クライアントサイド両方で実行できるのがReactの一つの長所だと思っていて、Ruby製WebアプリでもReactを使いたいと思い作った。

さて、サーバサイドでHTMLを生成した場合、クライアントMVCで問題になるのがビューのレンダリングである。サーバ側でビューを実行しているのだから、クライアントではビューの再描画は実行せず、データに変更があった場合のみ更新処理が走ることが望ましい。

幸いReactでは、初回にロードしたとき、すでに存在しているHTML要素の中身が、コンポーネントのレンダリング結果と同じであれば、描画をスキップするという機能がある。

この機能では、ルートとなるコンポーネントごとにチェックサム値を持っていて、コンポーネントをレンダリングする先のDOM要素にもチェックサム値があれば照合して、合っていれば、再レンダリングなしでReactのコンポーネントをロードできる。

しかし、React.renderToStringを実行すると、実行毎にチェックサム値が異なるので、自分のアプリではこの機能は使えていないのではないかと思っていた。

ReactRuby.compile(jsx: "var Hello = React.createClass({render: function(){return <div>Hello, {this.props.user}</div>;}});")
ReactRuby.render("<Hello user='John' />")
#=> "<div data-reactid=\".k0wwacbaww\" data-react-checksum=\"196619668\"><span data-reactid=\".k0wwacbaww.0\">Hello, </span><span data-reactid=\".k0wwacbaww.1\">John</span></div>"
ReactRuby.render("<Hello user='John' />")
#=> "<div data-reactid=\".13eogc33eo0\" data-react-checksum=\"-720163874\"><span data-reactid=\".13eogc33eo0.0\">Hello, </span><span data-reactid=\".13eogc33eo0.1\">John</span></div>"

ところが、サーバサイドで生成した画面上でReactを実行するとどうもDOMの再生成は実行されていない、つまり、既存のHTMLを更新しているようである。

そこで、renderメソッドを呼び出した時に実際にどのようなコードが実行されているのか追ってみた。

react.js

render

React.renderのコードを追って見る。

render: function(nextElement, container, callback)

途中のバリデーションは飛ばす。

var prevComponent = instancesByReactRootID[getReactRootID(container)];

getReactRootIDはコンテナ要素の子要素のdata-reactid属性値を取得している。
instancesByReactRootIDは既存のコンポーネントのインスタンスをキャッシュする連想配列である。
キャッシュに既存のコンポーネント(prevComponent)が存在していた場合は更新処理がかかるようだがその部分は飛ばす(ReactのVirtualDOMに関しては他の方が解説してくれることを期待)。

関数の処理を下に追っていくとと、コンテナ要素にReactで生成済みの要素があるかを判別する部分がある。

    var reactRootElement = getReactRootElementInContainer(container);
    var containerHasReactMarkup =
      reactRootElement && ReactMount.isRenderedByReact(reactRootElement);

    var shouldReuseMarkup = containerHasReactMarkup && !prevComponent;

コンテナ要素にReactで生成したElementが含まれているかどうかを判別している。
getReactRootElementInContainerは現在の実装だと単純に最初の子要素を返しているだけである(documentの場合はdocumentのルート要素)。

次にisRenderByReactの中身を見てみる。

isRenderByReact

  isRenderedByReact: function(node) {
    if (node.nodeType !== 1) {
      // Not a DOMElement, therefore not a React component
      return false;
    }
    var id = ReactMount.getID(node);
    return id ? id.charAt(0) === SEPARATOR : false;
  }

要素からIDを取得し、IDの1文字目がSEPARATOR(".")かどうかを判別している。単純だ。
IDを取得するReactMount.getIDはコンポーネントのルート要素からdata-reactid属性を取得している。

render

元のrenderメソッドに戻ってさらに処理を追う。
上記からshouldReuseMarkup変数は、要素の子要素がdata-reactid属性を持っており、かつ既存でReactComponentのインスタンスが生成されていないことを示す判定値が入っていることがわかる。

    var component = ReactMount._renderNewRootComponent(
      nextElement,
      container,
      shouldReuseMarkup
    );
    callback && callback.call(component);
    return component;
  }

ここでrenderメソッドは終わる。_renderNewRootComponentrenderメソッドの返り値であるReactComponentを生成する関数のようだ。
コンポーネントを生成する際、ReactMount._renderNewRootComponentを呼び出しており、そこに先ほどのshouldReuseMarkupの判定値が渡されている。
次にReactMount._renderNewRootComponentの処理を追う。

_renderNewRootComponent

function(
        nextComponent,
        container,
        shouldReuseMarkup) {

途中のバリデーションは飛ばす。

var componentInstance = instantiateReactComponent(nextComponent, null);

ここではReactElementからReactComponentのインスタンスを生成しているようだ。

      var reactRootID = ReactMount._registerComponent(
        componentInstance,
        container
      );
      componentInstance.mountComponentIntoNode(
        reactRootID,
        container,
        shouldReuseMarkup
      );

_registerComponentは、instancesByReactRootIDという連想配列に、ルートIDをキーにconponentInstanceを登録した後、reactRootIDを返している。
_registerComponent関数の中で、ルートIDを生成しているのはregisterContainer関数で、この関数も、中で別の関数からルートIDを取得した後、連想配列にcontainerを登録している。
結局どこでルートIDを生成しているのかというと、ReactMount.getIDでコンテナ要素の最初の子要素のdata-reactidを取ってきている。ルートIDが存在しない場合は、ReactInstanceHandles.createReactRootIDを呼んでいるようだ。createReactRootIDはサーバーだと乱数、ブラウザだと連番でルートIDを生成している。

つまり、サーバサイドで生成したコンポーネントのdata-reactidが毎回異なるのはランダム生成していたからである。
また、コンテナ要素の子要素のdata-reactid属性がすでに存在していれば、それを使いまわしていることがわかる。

mountComponentIntoNodeの中では_mountComponentIntoNodeを呼んでいる。

_mountComponentIntoNode

    _mountComponentIntoNode: function(
        rootID,
        container,
        transaction,
        shouldReuseMarkup) {
      var markup = this.mountComponent(rootID, transaction, 0);
      mountImageIntoNode(markup, container, shouldReuseMarkup);
    },

markupは実際に挿入するHTML文字列だ。mountComponentmarkupを生成するときに、rootIDを渡している。つまり既存のdata-reactid属性があるのであれば、それを使いまわしている。

mountImageIntoNode内では、shouldReuseMarkupが真で、かつReactMarkupChecksum.canReuseMarkupでチェックサムを取り正しい場合は、何もしない。判定に失敗した場合はmarkupをコンテナ要素のinnerHTMLにセットする。

canReuseMarkup

  canReuseMarkup: function(markup, element) {
    var existingChecksum = element.getAttribute(
      ReactMarkupChecksum.CHECKSUM_ATTR_NAME
    );
    existingChecksum = existingChecksum && parseInt(existingChecksum, 10);
    var markupChecksum = adler32(markup);
    return markupChecksum === existingChecksum;
  }

まず、Reactのルート要素(コンテナ要素の最初の子要素)のdata-react-checksum属性値を取る。
ここでようやくreact-checksumが出てきた。
次にmarkup(HTML文字列)からadler32アルゴリズムでチェックサムを取り、比較する。

ここまででブラウザでReact.renderを呼んだ時に、サーバサイドで生成したコンポーネントが存在していた場合どういう挙動になるのかを追ってみた。

結論

shouldRuseMarkupが真になるのはコンテナの子要素がdata-reactid属性を持っていた時だけなので、結論としてはサーバサイドで描画したコンポーネントはdata-reactid属性を持ち、react-checksumの値がサーバ側で生成した値と同じであれば、コンポーネントの再描画は実行されないことがわかる。

react-checksumが毎回異なっていた理由であるが、サーバサイドで生成したHTML文字列も以下のようになるはずである(チェックサム属性は後で付与される)。

<div data-reactid=".14sq0tlqadc" >...</div>

サーバサイドでdata-reactidを付与する際はランダムな値が付与される。このランダムな属性値を含むHTML文字列を元にチェックサムをとっていたため、毎回チェックサム値が異なっていたというわけである。

クライアントサイドで再描画の判定を行うときは、data-reactidはサーバーで生成された値を使いまわすため、チェックサムは成功しており、DOMの再描画はされていなかったということである。

感想

自分の勘違いで、時間を潰してしまったが、Reactの中身を追うことが出来たので有意義だった。
ただし、VirtualDOM Advent Calendarなのに一切Virtual DOMに触れていないので、次はVirtualDOMのコードも追ってみたい。