react-railsでサーバーサイドレンダリングしつつクライアントでsetStateできて最高になった

  • 298
    Like
  • 0
    Comment
More than 1 year has passed since last update.

土日でreact-railsとturbolinksを勉強してみた成果です

やりたいこと

  • 画面遷移するときは<div id='content'></div> の中身だけ入れ替えて、pushStateで行き来できるようにしたい
  • reactを使ったリッチなページでも、イニシャルロードやSEOの為にサーバーサイドでレンダリングしておきたい
  • サーバーサイドレンダリングした要素を破棄することなくReactで初期化してsetStateでガンガンViewを書き換えたい

結果どうなったか

  • サーバーサイドでReactComponentをレンダリングしてクライアントのReact.renderで初期化情報を揃えて引き継ぎ
    • どんな画面でもapp.component.setState({})が反映されて最高
  • TurbolinksでReactComponentをマウントしたルート要素だけ入れ替え
    • その為にTurbolinksをforkしてパッチあてた

(とくに説明がない限りjsのプリコンパイラとしてbabelを使っています)

補足: クライアントでReactのsetState できると何が嬉しいのか

(React詳しくないであろうRailsエンジニア向け。わかってる人は飛ばす)

ReactはDOMに挿入したい状態が確定すると、前後の状態を比較してその差分だけを書き換える。これによって、今までのjQueryによるプリミティブな差分操作と違って、サーバーサイドの常であるように、入力(おそらくテンプレートへの引数)に対して一意な出力を作ってプッシュすることができる。

たとえば、今までのhtmlならボタンのtoggle状態を変わる度にそれを含むテンプレート全体を書き換えて再出力、というのは現実的に厳しかったが(なのでjQueryで操作していたであろう), ReactだとactiveHogeButton: false|true という入力だけを変更して画面全部を更新しても、ほとんど問題がない。このアトミックな振る舞いによってクライアント設計の健全性を担保することができる。

詳しくは VirtualDom - なぜ仮想DOMという概念が俺達の魂を震えさせるのか - Qiita にて

react-railsの大雑把な仕組み

  • ExecJSでV8のコンテキストを作って指定したファイルを突っ込んで初期化
    • デフォルトだと ['react.js', 'components.js']
    • SprocketsHelperのreact_component(name, props) ヘルパはV8コンテキストからnameで参照を解決し、React.renderToString()して返す
    • V8コンテキストのpool数は指定できる config.react.server_renderer_pool_size ||= 8

V8:Contextで名前を解決できればいいので、グローバルの名前空間を使いたくない場合、次のようにしてもよい。

global.T = {}
T.Foo = React.createClass({render() {return React.createElement('div')}});

(以下このテンプレートを使う)

index.js.erb
<%= react_component('T.Foo', {a: 1}, {prerender: true}) %>

展開するとこんなdata属性が埋まってる。

<div data-react-class='T.Foo' data-react-props="{"a":1}">
...
</div>

この情報を使ってクライアントサイドでrender済みのコンポーネントで情報を引き継ぐことができる。

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

補足: ReactのcomponentDidMount, componentWillMountについて

クライアントでReact.renderする際は componentWillMount(DOMとして実体化する直前), componentDidMount(DOMの初期化が終わったところ) まで実行される。

サーバーサイドレンダリングの為に用いる React.renderToStringする際は componentWillMount まで呼ばれるが、componentDidMountは呼ばれない。これによってDOMに触るステップを明確に分離できる。

render, renderToString はどちらも差分検知のためにDOMの内部に react-data-id というデータを埋め込む。React.renderToStaticMarkup は react-data-id を埋め込まない。renderToStaticMarkup は nodeでテンプレートのテストの時などに使う。

クライアントでrender済みのコンポーネントの状態を引き継ぐ

さっきも言及したけど技術的には reactjs - Reactでサーバーサイドで生成したHTMLに対してDOMを初期化せずにReactComponentとして状態を更新する - Qiita が前提となる。

app/views/layout/application.index.html をこういう感じにしておく

<body>
<header>
   <!- ここもあとでheader用componentを作って埋めることを想定 ->
</header>

<div id='content'>
<%= yield %>
</div>

最近のCSSやmarkupの傾向をみるに、あんまりid属性は使いたくないんだけど、これは置き換えるコンテントが一つであることを保証しないといけないので使っていいと思う。

この状態でjsのconsoleで状態を引き継げることを確かめてみる。

var component = React.render(React.createElement(T.Foo, {a: 1}), document.querySelector('#content'))

無事破棄せずにComponentを初期化できた。(できなかった場合は警告文がでた上で書き換わる)
この後、クライアントの各種イベントに応じてcomponent.setState({...}) を呼ぶことで画面構成が変化することを期待している。その設計はFluxでやるとうまく回ると思うが、今回は立ち入らない(長くなるので)。

Turbolinksに伴う初期化

というわけで状態引き継ぎが確認できたので、画面遷移時にturbolinksで#contentの中身を置き換えさせ、appを初期化するところまで共通化する。

まずturbolinksのコードを読んだ結果, body 決め打ちになってる部分を fork して書き換えた。とりあえずPullRequestだしてるけど採用されるかは微妙。
Add setRootSelector() to replace optional element instead of body by mizchi · Pull Request #583 · rails/turbolinks

このforkのsetRootSelectorを使って任意エレメントの置き換えができるようになるので、次のようなコードを書いた。

global.app = {};
const ROOT_SELECTOR = '#content';
const HEADER_OBJECT = T;
const HEADER_PREFIX = 'T.';

Turbolinks.setRootSelector(ROOT_SELECTOR);

function parseProps(el) {
  const str = el.getAttribute('data-react-props');
  try {
    return JSON.parse(str);
  } catch (e) {
    return {};
  }
}

function getComponentClass(el) {
  const str = el.getAttribute('data-react-class');
  let componentName = str.replace(HEADER_PREFIX, '')
  return HEADER_OBJECT[componentName];
}

function init() {
  let root = document.querySelector(ROOT_SELECTOR);
  let renderedRoot = document.querySelector(ROOT_SELECTOR + ' > *');

  let props = parseProps(renderedRoot);
  let component = getComponentClass(renderedRoot);
  app.component = React.render(React.createElement(component, props), root);
}

window.addEventListener("DOMContentLoaded", init);
window.addEventListener("page:load", init);

page:load は turobolinksによって画面遷移する度に呼ばれるEvent。明示的にturbolinksで遷移したい場合はTurbolinks.visit(path)を使う。

キモは renderedRoot のdata-react-*属性から初期化引き継ぎに必要な属性を抜き出し、その情報を用いてReactElementを初期化する。これで app.component.setState({...}) で与えた情報に従ってViewが最小コストで変形するようになる。

ページロード前後で同じcomponentを使うことを想定する場合、initを次のようにするとより綺麗に遷移できる。

var lastComponent = null;
function init() {
  let root = document.querySelector(ROOT_SELECTOR);
  let renderedRoot = document.querySelector(ROOT_SELECTOR + ' > *');

  let props = parseProps(renderedRoot);
  let component = getComponentClass(renderedRoot);
  if (app.component && lastComponent === component) {
    app.component.setProps(props);
  } else {
    app.component = React.render(React.createElement(component, props), root);
    lastComponent = app.component;
  }
}

ReactComponent#setPropsはpropsのImmutable性を損ねるので推奨される関数ではなく警告がでるけど、このケースは使っても良いだろう。

サンプル

ここにある mizchi-sandbox/react-rails-turbolinks-test

home / foo / bar の3つの画面があって、それぞれがリクエストに応じて書き換わっている。

このリポジトリだと js/ がnpmによるjsビルド環境になってて, gulp 叩くと app/assets/javascripts 以下に bundle.js と components.js を出力する。サーバーサイドレンダリング時には components.js だけ読み込んでるはず。テンプレートにはreact-jadeを使っている。

他、参考