bitjourney team
14206
7
0

RailsエンジニアがReactを始めてSSRとReduxとTypeScriptを導入するまで

Published at May 27, 2017 12:04 p.m.

RailsエンジニアがReactを始めてSSRとReduxを導入するまで

Roppongi.rb #3 "Rails x Frontend-Tech"

自己紹介

  • github.com/gfx
  • BitJourneyでKibelaを開発
  • Speee, Inc. で技術顧問をしてる
  • Reactは現職で初めて触った(2016年8月~)

今日の話

Kibelaのフロントエンドの話です。

  • 読みはキベラ
  • Markdownで書けて、フロー情報(Blog)とストック情報(Wiki)を区別して整理できる情報共有ツール

これまでの話

新規Railsアプリに小さく導入するReact // Speaker Deck (dex1t, 2016/09/05)

  • 小さく導入して学びながら開発を進める
    • ⭕ Interactive UI componentをReactで&jQueryも併用
    • ❌ viewをすべてReactで実装してSPA
  • 定番だからといってゴツいフレームワークをいきなり導入しない
    • ⭕ React MicroContainer
    • ❌ React Redux + (?: Redux Think | Redux Saga | Redux XXX ... )+

現状確認 2017年・夏

  • Rails x React (+jQuery) という構成は変化なし
    • Railsは v4.2.x (v5.0移行のためのブランチは作業中)
  • HypernovaでSSR: Server Side Renderingを導入した(2016年12月)
  • React Reduxを導入した(2017年4月)
  • TypeScriptを導入した(2017年5月)

時間を遡って2016年・夏

  • はじめてReactを触る
  • パラダイムの違いに戸惑う
    • イベントドリブン vs 宣言型
  • React的な状態管理に慣れるところから

困惑ポイント: イベントドリブン vs 宣言型

たとえば「クリックしてdropdownが開く」という定番のUIパターン。

イベントドリブン的発想

  1. 要素Aをクリック
  2. onClick イベントハンドラが起動
  3. イベントハンドラでDropdown componentを開く(たとえば classに is-dropdown-open を追加するなどして)
  4. main loopの次のtickのdropdownのviewが開く

こういうコードになる:

jsx
class DropdownContainer extends React.Component {

  onDropdownClick() {
    // 「clickされたらdropdownを開く」というイベントドリブン的な発想
    this.dropdown.setOpen(true);
  }
  
  render() {
    return <Dropdown
      ref={(dropdown) => this.dropdown = dropdown}
      onClick={() => this.onDropdownClick()}>
         ...
      </Dropdown>;
  }
}

宣言的プログラミングの発想

  1. 要素Aをクリック
  2. onClick イベントハンドラが起動
  3. setState({ isDropdownOpen: true })
  4. main loopの次のtickで React.Component#render() を実行
  5. <Dropdown isOpen={this.state..isDropdownOpen}/>` の状態が変わりdropdownのviewが開く
jsx
class DropdownContainer extends React.Component {
  // constructorは省略

  onDropdownClick() {
    // 「clickされたらdropdownの状態を変える」という宣言的な発想
    this.setState({ isDropdownOpen: true })
  }
  
  render() {
    return <Dropdown
      isOpen={this.state.isDropdownOpen}
      onClick={() => this.onDropdownClick()}>
         ...
      </Dropdown>;
  }
}
  • この辺はどちらがいいというものではなく思想の違い
  • ただし、Reactは後者に寄っている
    • たとえば標準コンポーネントの <input/> 要素に「valueをセット」するAPIは存在せず、ただ renderで <input value={...}/> として与えることしかできない。

新しい技術を学ぶということ

  • 新しい技術をいきなりマスターするのは難しい
    • 思想、ベストプラクティス、アンチパターンなど学ばねばならないことは無数にある
  • 小さく導入して学びながら広めるのがベスト
    • ある程度のレベルになったら一気に書き換えてもよいと思うが

2016年・冬

  • それまでは完全にReact componentを動的にバインドしていた
  • $(() => $(".react-componet").each((_, elem) => ReactDOM.render(...))) という感じ

問題: UIの構築がに時間がかかるとガタガタする

  1. リロード直後は「Search」フォームのみ
  2. 数秒後、ボタンがいくつか出現する

React components in a page

  • 赤丸が React components = ガタガタするUIパーツ
    • 少ないページで数個、多いページだと数十個

UIガタガタ問題

  • UIがガタガタするのは、単に見栄えが悪いというだけでなく、たとえばこの箇所だと「Search」フォームにカーソルを合わせようとして「Write Entry」ボタンを押して画面遷移してしまう操作ミスを誘発してしまう
  • また、コメントをReact componentに置き換えたとき、コメントへのアンカーリンク(e.g. /blogs/42/#comment_123)が動作しなくなった。ページを開いた段階ではDOM要素が存在しないので、アンカーリンクが動かないのは当然
    • React componentへの置き換えは必要があってやったことなので、revertは取れない選択だった

UIガタガタ問題の解決方法

  • SSRを導入すると根本的に解決できて汎用性が高い
    • SSRしなくても個別対応で解決はできるかもしれないが…
  • なお、Kibelaは社内用ツールなのでSEOの観点は考えていない

SSR: Server Side Rendering

  • 広義にはサーバーサイドでテンプレートエンジンを使ってHTMLを生成すること
    • たとえばhaml/slimでHTMLを生成して返すのはSSR
  • 狭義にはJavaScriptで作ったview componentをサーバーサイドで実行してHTMLを生成すること
  • ここではこちらの意味で、React componentからHTMLのパーツを生成して、それを最終成果物(=HTML)に埋め込むということ
    • すべてのHTMLが揃っているので描画時のガタガタはない
    • ただしイベントハンドラの設定は DOMContentLoaded event で起動する初期化後なので、それまでリッチなUIは動かない

SSRを支える技術

  • ReactDOMがSSRのための基本APIを用意してくれているので実装自体は簡単
  • サーバーサイドでは ReactDOMServer.renderToString(reactElement) でHTML文字列を得られるのでそれをhamlなどに埋め込むだけ
  • クライアントサイドでは React.render(reactElement, domContainerNode) するだけでイベントハンドラの設定などもよしなにしてくれる

課題: RailsからどうやってJavaScriptのコードを実行するか

方法は三つ。どれも一長一短ある。

  • therubyracermini_racer などのembedded JavaScript engineで実行する
  • nodejsを単発のコマンドとして実行する
  • nodejs serverを立ててIPCで結果を受け取る

Embedded JavaScript engineで実行する

  • オーバヘッドは最も小さくできる可能性がある
    • ただしコンパイル結果(=JITされたコード)を再利用するための方法は自明ではない
  • JavaScriptエンジンを選択できるのでチューニングの余地が広まる可能性がある
  • NodeJSの豊富なAPIを使用できない
  • 既存のツールだとエラーメッセージやスタックトレースが分かりにくくデバッグが困難
    • 最終的にはデバッグの難しさを理由として諦めた
    • ただし技術的にはsource-mapsを参照して分かりやすいスタックトレースにはできるはず

nodejsの単発のコマンドとして実行する

  • オーバーヘッドが非常に大きい
    • たとえば node -e 'require("react"); require("react-dom")' だけで 150ms くらいかかる
    • 最終的にはパフォーマンスへの影響を懸念して諦めた
  • NodeJSにはsource-mapsを透過的に使うライブラリがあるのでデバッグはそんなに難しくはないと思われる(未検証)

nodejs serverを立ててIPCで実行する

  • オーバーヘッドは小さく、コンパイル結果(=JITされたコード)も活かしやすい
  • デーモンプロセスの運用がやや面倒
    • 監視、graceful restart、開発環境の整備、デバッグ手法など考える事が多い
  • NodeJSなのでsource-mapsを透過的に使えるためデバッグはしやすい
  • この手法を採用している airbnb/hypernova を採用することにした

Hypernova

  • 実体はRailsアプリなどからcomponent名とpopsを受け取りHTMLパーツを返すサーバ
  • 3つのコンポーネントからなる
    • hypernova - nodejs製のHTTPサーバ(hypernovaサーバ)
    • hypernova-react - hypernovaサーバ用のJavaScript用ライブラリ(server, client両用)
    • hypernova-ruby - hypernovaサーバと連携するためのruby gem(*-rubyというもののRails専用)
  • Rails用view helperの render_react_component() で一旦 __hypernova_render_token[id]__ という文字列を返し、around helperの最後にまとめてhypernovaサーバにリクエストして 先程のtokenを正式な結果とするという振る舞い(バッチモード)
    • リクエストが1度で済むのでオーバーヘッドは少ないが、fragment cacheを使えなくなるのが欠点

Hypernovaの構成

PlantUML diagram
  • hypernovaサーバはrailsサーバに同居している
    • 運用がシンプルで済むのでよい
    • 今のところパフォーマンス的な問題は起きてない
      • 1 componentにつき 1~5ms くらいでレンダリングできている
  • プロセスはpm2で管理している
    • わりとハマりどころが多いのでこれは捨てたい

Hypernovaその他

  • 慣れればデバッグはそれほど大変ではないし、SSRゆえの問題も深刻なものはほとんどない
  • React componentはnodejsでも実行できなければならないので、Universal JSを意識するようになった
    • 非Universal JSなコードは componentDidMount() で起動するように
  • Universal JS部分はglobal state(グローバル変数)に決して依存してはいけない
    • localeやredux storeなど、globalでよさそうなものでもダメ
    • つまりUniversal JS部分は ステートレス にする必要がある

2017年・春

問題: 複雑な階層をもつcomponentの状態管理が大変になってきた

  • MicroContainerの構造の問題と捉えてflux frameworkを再検討することにした
  • React Redux を導入した

MiroContainer (from dex1t's slide):

  • 親が子の状態を持つのはいい
  • 親が孫の状態を持つようになるとつらい
    • 親が直接関与しない孫について知識をもってないといけなくなる
    • 親は孫の構造をまったく知らないのに?

React Redux

  • Reduxだとstoreがグローバルになるのでそれぞれのcomponentが対応するstoreとやりとりするだけになる
    • つまり container in container にになる
      container とaction creator & reducerを実装する ducks がstateの管理を行う
    • このへんはいくつか流儀があるらしい
  • redux-devtools-extension でイベントの発行とredux stateの更新を観察できる

React Reduxの印象

  • See Also: React Reduxファーストインプレッション by gfx
  • React componentはReduxの存在をまったく意識しなくて済むのはよい
    • 特にReact MicroContainerと比較したときに顕著な特徴
  • containerの設計はむずかしい
    • 特に root container has many child containers みたいになると…

Root Container has many Child Containers

  • たとえばKibelaの左サイドバーのbaord list:

↓ ActionButtonというコンポーネントをクリックするとdropdownが開く

↓ 「名前を変更する」でモーダルを開く

jsx
// 簡略化するとこういう構造
<BoardList>
  <BoardEntry name="Infra" id={1}>
    <ActionButtonContainer id={1}>
      <BoardUpdateModal id={1} .../>
      <BoardDeleteModal id={1} .../>
   </ActionButtonContainer>
  <BoardEntry>
  <BoardEntry name="WIP" id={2}>
    <ActionButtonContainer id={2}>
      <BoardUpdateModal id={2} .../>
      <BoardDeleteModal id={2} .../>
   </ActionButtonContainer>
  <BoardEntry>
</BoardList>
  • ActionButtonContainer が沢山ある…

  • redux storeはglobal stateなので、たとえばboardのrenameのためのstateをどうやって表現する?

    1. Array<BoardEntryState> みたいなコレクション
    2. focusedBoard みたいな「現在フォーカスのあるbaord」というstateを作り、そこに必要な情報をいれる
  • 今回は(2) の focused entiy にした、というのも一度に開くモーダルは一つしかないため、そのほうが考えることが少なそうだから

ts
function mapStateToProps({ a }, ownProps) {
  const boardId = ownProps.boardId;
  const isFocused = boardId === a.boardId;

  return {
    boardId,
    // 現在フォーカスのあるところ以外は自動的にfalse、それ以外について状態を管理する
    isDropdownOpen: isFocused ? a.isDropdownOpen : false,
    isBoardUpdateModalOpen: isFocused ? a.isBoardUpdateModalOpen : false,
    isBoardDeleteModalOpen: isFocused ? a.isBoardDeleteModalOpen  :false,
  };
}

Redux x Hypernova

  • Reduxのstateはグローバル
  • Hypernovaはステートレス

おや…global stateの様子が…?

Redux global state x stateless Hypernova という問題

  • 公式ドキュメントあり: Server Rendering · Redux
  • "create a fresh, new Redux store instance on every request" とのこと
  • Hypernovaはcomponent単位でしか処理できないので、componentごとにstoreを生成している
    • storeのほとんどは使わないので無駄が多い
    • SPA前提の設計だからだこうなっていると思われる

2017年・夏

Reduxを導入したらcontainerとducksとcomponentをいじるのが大変になってきた

  • container, ducks, componentのファイルをそれぞれ開いて眺めながらコーディングするのはつらい

静的型付けaltJSの導入

  • 静的型付け言語にすれば、補完が強力になるので解決を期待できた
  • 検討の結果TypeScriptの導入を決定

TypeScript vs flow

  • どちらも JavaScript + 静的型システム なaltJS
    • TypeScriptのキャッチコピーは "JavaScript that scales"
    • flowのキャッチコピーは "A sttic type checker for JavaScript"
  • どちらを導入するかは好みの差かなと
  • Reactのコードベースにflowが導入されつつあるので、Reactをやるならflowのほうが手間は少ないかも
  • もともとES201xのコードであれば移行コストは大差ない
    • 移行コストはflowのほうが低いとかよくいわれるが、TypeScriptはコンパイルオプション次第でかなりゆるくできるので、その状態であればほぼ同じと考えてよい

TypeScriptの導入

  • テストも含めて一気に置き換えた
  • 作業自体は約2週間(調査期間は除く)
  • 正しい設定ファイルを作るのが一番大変
  • babelは babel-plugin-transform-remove-console のためだけに使っている
    • これもwebpack pluginでやるようにすればbabelへの依存をなくせる
  • 差分は思ったほど多くなかった
    • webpack ES modulesと互換性のないimport記法
    • extends React.Component<any, any>
    • (<any>expr).hogehoge などの型合わせ(数は少ない)

典型的な差分の例:

patch
 import * as PropTypes from 'prop-types';
 import * as React from 'react';
-import Modal from 'react-modal';
-import classNames from 'classnames';
+import * as Modal from 'react-modal';
+import * as classNames from 'classnames';
 import { FormattedMessage, intlShape } from 'react-intl';
 import KibelaSymbol from '../shared/kibela_symbol';
 
-export default class BoardDeleteModal extends React.Component {
+export default class BoardDeleteModal extends React.Component<any, any> {
+  board: any;
+  defaultBoard: any;
+
   static get propTypes() {
     return {
       intl: intlShape.isRequired,
  • import文の書き換え
  • React.Component<any, any> のように型パラメータを追加
  • クラスのフィールドを追加(自明でないものは一旦anyで)

TypeScript所感

言語

  • TypeScript言語(≒型システム)自体は完成度が高い
    • とはいえ
      • ❌ 清く正しく美しく型付けされてバグのない世界
      • ⭕ 型付けはないよりマシ程度だけど補完はJSよりちゃんとされるから書きやすい
  • 静的型とreduxと相性がいいのでそのへんは楽にはなった

ツールチェイン

  • tslintのクオリティがまだeslintほどではない
  • コンパイルエラーは分かりにくい。まだこなれてない印象
  • 型定義ファイルのホスティングリポジトリである「DefinitelyTyped 」というのはあるが…
    • 基本的に品質はバラバラ
    • @types/react はあったほうがいいが、他のものは…
  • 型定義なしでもJSより悪くはならないし、DefinitelyTyped は「無理に使わない」のがよい
  • Visual Studio Codeとの相性はすばらしい

vscodeでaction creatorを補完する様子:

続きは懇親会で

ご静聴ありがとうございました。