Rails, Typescript, React, ReduxでSSR(Hypernova)とHMRをやる

[Ruby on Rails Advent Calendar 2017](https://qiita.com/advent-calendar/2017/ruby_on_rails)の23日目の記事です。

SSRとHMRをRailsでやります。
資産があったので、webpackerなし。

## キーワード
* Rils
* Typescript
  * awesome-typescript-loader
* React
* Redux

* webpack
  * code splitting

* SSR
  * Hypernova

* Hot Module Replacement
  * react-hot-loader

* CSS in JS
  * aphrodite

## 簡単なカウンターとHMR
![capture.gif](https://qiita-image-store.s3.amazonaws.com/0/89860/0f966c16-6933-877e-9f77-543a7367ea4d.gif)

![capture2.gif](https://qiita-image-store.s3.amazonaws.com/0/89860/a655a7b7-940b-9a18-ec4c-0586b02b2834.gif)

※ consoleのエラーはhypernovaとhmrの相性が悪くexport defaultしてエラーが出ているが、シャーなしと思ってます。

## システム概要
**railsとの連携について**
webpackでapp/assets/javascripts/components/**にes5で展開して、sprocketsで配信やdigest付与をします。
code splittingして、vendorは共通で読み込み、個々のファイルをページ単位で読み込む。

**hypernovaとの連携について**
webpackでSSR用にassets/javascripts/serverside/**にes5で展開して、frontend/hypernova.jsがそのファイルを読み込む。
クライアントとは別でファイルをアウトプットしなければいけない理由はcommonjsでないといけないのと、code splittingをしないようにするため。
CSS in JSでもSSRで設定しなければいけないポイントがあるので、hypernova-aphroditeを使う。
後は、hypernovaの設定をしていけばok

**HMRとの連携**
react-hot-loaderとwebpack-dev-serverでサーバー立てて、railsにヘルパーメソッド追加して開発でのみ読み込むよう設定する。

## 注意
webpackerなしはwebpackerに依存しないので、ポータビリティ(Rails以外のNext.jsなどのレンダリングサーバーへの移行)が上がったり、フロントエンドの拡張に追随しやすい反面、webpackerがやってくれてた面倒ごとをやらないといけなかったりします。また、設定はwebpack, tsconfig, hypernovaなど色んなツールと連携してるため、ちょっと変えると動かなかったりします...
pros, consを検討して導入してください。

## リポジトリ
https://github.com/akichim21/rails-ts-react-redux

## Typescript
### tsconfig

```json:tsconfig.server.json
  "exclude": [
    "node_modules"
  ],
  "compilerOptions": {
    "allowSyntheticDefaultImports": true,
    "strictNullChecks": true,
    "noUnusedLocals": true,
    "noImplicitThis": true,
    "alwaysStrict": true,
    "outDir": "./dist/",
    "sourceMap": true,
    "noImplicitAny": true,
    "lib": ["dom", "es2017"],
    "module": "commonjs",
    "target": "es5",
    "jsx": "react",
    "moduleResolution": "node",
    "types": ["node", "webpack-env"],
    "baseUrl": "./src/",
    "paths": {
      "*": ["./@types/*"],
      "actions*": ["actions*"],
      "reducers*": ["reducers*"],
      "constants*": ["constants*"],
      "components*": ["components*"],
      "containers*": ["containers*"],
      "store*": ["store*"]
    }
  }
}
```

SSR用の設定。ポイントは以下です。

* module: commonjs
* target: es5にしてbabelは使わない
* baseUrlとpathsでimportを絶対パスで読み込めるようにした
* HMR用にtypes: webpack-env

```json:tsconfig.json
{
  "extends": "./tsconfig.server.json",
  "compilerOptions":{
    "module": "es2015"
  }
}
```

クライアント用の設定。

* SSRの設定をbaseにする
* module: es2015にしてブラウザで動くようにする

## webpack
```js:webpack.config.js
// 一部抜粋

function createModule(tsconfig) {
  return {
    rules: [
      {
        enforce: 'pre',
        test: /\.tsx?$/,
        exclude: /node_modules/,
        loader: "tslint-loader",
        options: {
          configFile: 'tslint.json',
          emitErrors: true,
          typeCheck: true
        }
      },
      {
        test: /\.tsx?$/,
        use: {
          loader: 'awesome-typescript-loader',
          options: {
            configFileName: tsconfig
          }
        },
        exclude: /node_modules/
      }
    ]
  };
}

module.exports = [{
  cache: DEBUG,
  devtool: DEBUG ? 'source-map' : false,
  entry: entryToOutput.entry,
  output: {
    path: entryToOutput.clientsideOutputPath,
    filename: '[name].js'
  },
  watchOptions: watchOptions,
  module: createModule('tsconfig.json'),
  resolve: resolve,
  plugins: [
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production')
    }),
    new webpack.optimize.CommonsChunkPlugin({
      name: 'vendor'
    })
  ],
  externals: externals
},
{
  cache: DEBUG,
  devtool: DEBUG ? 'source-map' : false,
  entry: entryToOutput.entry,
  target: 'node',
  output: {
    path: entryToOutput.serversideOutputPath,
    libraryTarget: 'commonjs',
    filename: '[name].js'
  },
  watchOptions: watchOptions,
  module: createModule('tsconfig.server.json'),
  resolve: resolve,
  plugins: [
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production')
    })
  ],
  externals: externals
}];
```

* loaderはawesome-typescript-loader
* クライアント用, SSR用の順にアウトプットするように設定。
* クライアント用はCommonsChunkPluginでcode splitting
* SSR用 libraryTarget: 'commonjs' , target: 'node'

```js:entryToOutput.js
// 一部抜粋
var entry = {
  'Counter': './src/components/Counter',
};
var hmrEntry = {}
Object.keys(entry).forEach(function (key) {
  hmrEntry[key] = [
    'react-hot-loader/patch',
    'webpack-dev-server/client?http://127.0.0.1:3232',
    'webpack/hot/only-dev-server',
    entry[key],
  ];
})
module.exports = {
  hmrEntry: hmrEntry
}
```

```js:webpack.hmr.config.js
// 一部抜粋
function createModule(tsconfig) {
  return {
    rules: [
      {
        enforce: 'pre',
        test: /\.tsx?$/,
        exclude: /node_modules/,
        loader: "tslint-loader",
        options: {
          configFile: 'tslint.json',
          emitErrors: true,
          typeCheck: true
        }
      },
      {
        test: /\.tsx?$/,
        loaders: ['react-hot-loader/webpack', 'awesome-typescript-loader'],
        exclude: /node_modules/
      }
    ]
  };
}
module.exports = {
  cache: DEBUG,
  devtool: DEBUG ? 'source-map' : false,
  entry: entryToOutput.hmrEntry,
  output: {
    path: path.resolve("./components"),
    // railsとwebpack-dev-serverが違うので
    // https://github.com/webpack/webpack-dev-server/issues/262
    publicPath: "http://127.0.0.1:3232/components",
    filename: '[name].js'
  },
  watchOptions: watchOptions,
  module: createModule('tsconfig.json'),
  resolve: resolve,
  plugins: [
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify('development')
    }),
    new webpack.HotModuleReplacementPlugin(),
    new webpack.NamedModulesPlugin()
  ],
  devServer: {
    port: 3232,

    publicPath: '/components',

    historyApiFallback: true,
    // respond to 404s with index.html
    host: '127.0.0.1',

    hot: true,
    // enable HMR on the server
    contentBase: path.resolve(__dirname, 'dist'),

    // railsとwebpack-dev-serverのurlとportが違うので
    headers: {
      "Access-Control-Allow-Origin": "*",
    }
  },
  externals: externals
};
```

* host: 127.0.0.1, port: 3232
* railsとwebpack-dev-serverのhostが違うのでheaderにallow origin
* react-hot-loader/patchなどはentry key単位で設定して、viewで読み込むのもentryのkey単位とする(code splittingで共通でvendorに押し込むなどはできず。。)

## hypernova

```js:hypernova.js
const hypernova = require('hypernova/server');
const entryToOutput = require('./entryToOutput')

const isDev = process.env.NODE_ENV !== 'production' ? true : false;

hypernova({
  devMode: isDev,

  getComponent(name) {
    if (entryToOutput.outputFilePath[name]) {
      // 開発のときは毎回キャッシュクリアする
      if (isDev) {
        delete require.cache[entryToOutput.outputFilePath[name]];
      }
      return require(entryToOutput.outputFilePath[name]).default;
    }
    return null;
  },

  port: 3030,
});
```

* 開発の時はrequireのcacheクリアする
* entryToOutputでes5に展開されたものを読み込む
(コードはないですが、本番ではpm2で管理しています)

** view

```erb:counter/index.html.erb
<%= render_react_component('Counter', count: 1) %>
<%= javascript_tag 'components/Counter', true %>
```

** helper

```ruby:application_helper.rb
module ApplicationHelper
  def javascript_tag(path, is_dev = false)
    if is_dev && Rails.env.development?
      "<script type='text/javascript' src='http://127.0.0.1:3232/#{path}.js'></script>".html_safe
    else
      javascript_include_tag path
    end
  end
end
```
* flg: trueで開発モードだったらwebpack-dev-serverのjsを読み込む
* is_dev: trueは本番配布前に静的解析などで弾きたい

## entryファイル
```ts:Counter.tsx
import * as React from 'react';
import { renderReactWithAphrodite } from 'hypernova-aphrodite';
import { Store } from "redux";
import { configureStore } from "store/configureStore";
import { initCount } from 'actions/Counters';
const CounterComponent: any = require('./counters/CounterComponent').CounterComponent;

const store: Store<any> = configureStore();

interface IProps {
  count: number;
}

function createClass(component: JSX.Element, init: boolean): any {
  return (
    class Counter extends React.Component<IProps, {}> {
      public componentWillMount(): void {
        if (init) {
          const { count } = this.props;
          store.dispatch(initCount(count));
        }
      }
      public render(): JSX.Element {
        return component;
      }
    }
  );
}

export default renderReactWithAphrodite(
  'Counter',
  createClass(<CounterComponent store={store} />, true),
);

if (module.hot) {
  module.hot.accept("./counters/CounterComponent", () => {
    const NewCounterComponent: any = require('./counters/CounterComponent').CounterComponent;
    renderReactWithAphrodite(
      'Counter',
      createClass(<NewCounterComponent store={store} />, false),
    );
  });
}
```

* storeはreplaceReducerでしかHMRで変更してはいけない(store/configureStore.tsでやってる)
* Railsとのデータやり取りで変換してstoreにdispatchしたいシーンが結構あるので、componentWillMountでやる。ただし、HMRの時にすると既存のstoreがリセットされるのでしないように設定
* hypernovaの設定上、componentを作った状態で渡せない
* HMRでwatchするcomponentにstoreを渡さないといけない

上記の制約が組み合わさってちょっといびつな感じになってます。。

あとはgithubのコードをみてください！

# 本番のパフォーマンス

react-railsでSSRしようとすると、componentが10ほどのNewrelicの平均実行時間。1090ms(1.09s)もかかってる。。react-railsはSSR用のgemとしては使い物にならないですね。
![スクリーンショット_2017-12-11_14_10_43.png](https://qiita-image-store.s3.amazonaws.com/0/89860/1eb1f685-6af8-e8d8-a2d0-8558775138f9.png)

hypernovaにすると5.65msまで下がりました。
<img width="619" alt="スクリーンショット_2017-12-13_1_05_47.png" src="https://qiita-image-store.s3.amazonaws.com/0/89860/7ba35110-0051-d58e-58f7-9bf4650d052c.png">