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
※ 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
"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
{
"extends": "./tsconfig.server.json",
"compilerOptions":{
"module": "es2015"
}
}
クライアント用の設定。
- SSRの設定をbaseにする
- module: es2015にしてブラウザで動くようにする
webpack
// 一部抜粋
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'
// 一部抜粋
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
}
// 一部抜粋
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
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
<%= render_react_component('Counter', count: 1) %>
<%= javascript_tag 'components/Counter', true %>
** helper
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ファイル
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としては使い物にならないですね。