reactjs

Reactでサーバーサイドで生成したHTMLに対してDOMを初期化せずにReactComponentとして状態を更新する

More than 3 years have passed since last update.

この記事は VirtualDOM Advent Calendar 2014 - Qiita のネタが切れた時にどこからか指定されるやつです。中の人なんであんまり行儀悪いことすると怒られるんだけどな!

やりたいこと

UXとSEOのためにイニシャルビューはサーバーサイドで生成し、再描画時にクライアントで同じテンプレートから生成するも、一旦画面を捨てて再構築するのを避けたい。

具体的には、サーバーサイドで何かしらの方法(nodeやreact-railsを想定)で実行して生成されたReactのId付きのDOM React.renderToString(Component({})) して生成したreactId付きHTMLに対して、再描画せずにReactを適用できるようにする(イベント注入含む)

手法

reactのid付きDOMはこんなもの。

> React.renderToString(React.createElement('div'))
"<div data-reactid=".1" data-react-checksum="-1841297075"></div>"

このHTMLに対し、React.render(vdom, mountNode)で同じ仮想DOM, 同じ引数が生成元だと予想されるmountNodeに対して、再描画なしで適用できる(とReactは認識する)。

試してわかったこと

サーバーサイドから再描画せずにComponentを適用したい場合、イニシャルビューでrenderToStringする際に使った引数と同じ引数を教えてやる必要がある。

# dataset
setA = [1]
setB = [1, 2]

# mount node
el = document.createElement 'div'
document.body.appendChild el

# サーバーで文字列を作るときと同じようにinnerHTMLに突っ込む
html = React.renderToString(Foo({items: setA}))
el.innerHTML = html

# setA で生成したものに対して、 setBで生成したものを当てる
React.render(Foo({items: setB}), el) # failed

これは失敗する。正確に言うと描画は成功するのだが、一度el要素のすべてのDOMを破棄して自前で生成したものに置き換えてしまう。

同じ見た目になるならいいじゃん、っていうのは、侮る無かれ、巨大なHTMLが相手だと目に見えて違和感になる。Facebookみたいなサイトが読み込み後もう一回自分自身を構築し直すの想像して欲しい。

同じ引数を与えるとDOMを破棄することなく成功する。

html = React.renderToString(Foo({items: setA}))
el = document.createElement 'div'
document.body.appendChild el
el.innerHTML = html

foo = React.render(Foo({items: setA}), el)
foo.setProps {items: setB}

最後の二行を見て欲しい。setBを適用したい場合、一度setAを当ててからsetPropsすると setAを適用したinitialView -> setAのFoo -> setBのFoo が、diff/patchされながらシームレスに遷移できる。

他の実装例

Expressで実装した例 mhart/react-server-example

をみると、一度描画したものに対しスクリプトタグで自分自身をもう一度当てている。

    res.end(
      // <html>, <head> and <body> are for wusses

      // Include our static React-rendered HTML in our content div. This is
      // the same div we render the component to on the client side, and by
      // using the same initial data, we can ensure that the contents are the
      // same (React is smart enough to ensure no rendering will actually occur
      // on page load)
      '<div id=content>' + myAppHtml + '</div>' +

      // We'll load React from a CDN - you don't have to do this,
      // you can bundle it up or serve it locally if you like
      '<script src=//fb.me/react-0.12.0.min.js></script>' +

      // Then the browser will fetch the browserified bundle, which we serve
      // from the endpoint further down. This exposes our component so it can be
      // referenced from the next script block
      '<script src=/bundle.js></script>' +

      // This script renders the component in the browser, referencing it
      // from the browserified bundle, using the same props we used to render
      // server-side. We could have used a window-level variable, or even a
      // JSON-typed script tag, but this option is safe from namespacing and
      // injection issues, and doesn't require parsing
      '<script>' +
        'var MyApp = React.createFactory(require("myApp"));' +
        'React.render(MyApp(' + safeStringify(props) + '), document.getElementById("content"))' +
      '</script>'
    )

これは過激な例かもしれないが、発想としてはアリだと思う。

自分ならこうする。

var props = <safeStringify(props)>; //=> サーバーから貰うJSON
var MyApp = React.createFactory(require("myApp"));
window.app = React.render(MyApp(props),document.getElementById("content"))


//状態を更新したいとき
app.setProps(nextState); 

これで嬉しい感じ

お行儀の良いSPA/pjax

次のようなjsonを返して、クライアントで上のステップを踏めば良い。

{elementName: 'Foo', props: {items: [1,2,3]}}

もちろんコンポーネントの名前から参照を解決するロジックは自分で書かないといけないが。

サンプルコード

React = require?('react') ? React
$ = React.createElement
Foo = React.createClass
  render: ->
    items = @props.items ? []
    $ 'div', {key: 'x'}, [
      $ 'ul', {key: 'y'}, items.map (t, index) ->
        $ 'li', key: index, t
    ]

createItems = (text, n) -> (text for i in [1..n])

window.addEventListener 'load', ->
  setA = createItems 'foo', 100
  setB = createItems 'bar', 240

  html = React.renderToString(Foo({items: setA}))
  el = document.createElement 'div'
  document.body.appendChild el
  el.innerHTML = html
  # React.render(Foo({items: setB}), el) # failed

  foo = React.render(Foo({items: setA}), el)
  foo.setProps {items: setB}

次にやること

  • react-railsで試す
  • テンプレートエンジンにreact-jadeを使えるようにする