LoginSignup
8
6

More than 5 years have passed since last update.

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

Posted at

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を検討して導入してください。

リポジトリ

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

8
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
6