TL;DR
チェックサムが毎回違うのでDOMを再レンダリングしない判定に失敗していたと思ったけどそんなことはなかったぜ。
はじめに
ReactRubyというRubyからReactを呼び出すブリッジを作っている。
サーバサイドでは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
メソッドは終わる。_renderNewRootComponent
がrender
メソッドの返り値である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文字列だ。mountComponent
でmarkup
を生成するときに、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のコードも追ってみたい。