Rails
TypeScript
reactjs
hypernova
ssr

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