Help us understand the problem. What is going on with this article?

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

More than 1 year has passed since last update.

Ruby on Rails Advent Calendar 2017の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

capture2.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

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
tsconfig.json
{
  "extends": "./tsconfig.server.json",
  "compilerOptions":{
    "module": "es2015"
  }
}

クライアント用の設定。

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

webpack

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'
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
}
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

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

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

** helper

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ファイル

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

hypernovaにすると5.65msまで下がりました。
スクリーンショット_2017-12-13_1_05_47.png

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした