Webpack4でReact16のSSR(サーバサイドレンダリング)する
webpack4になり、ビルド速度の大幅改善や
developmentモードとproductionモードのサポートがされました。
productionモードでビルド時はいくつかの最適化plugin不要でコードサイズの圧縮などをやってくれたりします。
さらに、splitChunksのoptimize設定をすることでwebpackの設定レベルでCodeSplitを行ってくれます。
webpack 4: released today!!
最新版で学ぶwebpack 4入門 – JS開発のモジュールバンドラ
webpack 4 as a zero configuration module bundler
今回はwebpack4でReactJS16+Redux+ReactRouter+MaterialUIのSSRをやってみます。
(※全て組み合わせて実践レベルで使えるサンプルのため、中・上級者向けの内容となっています。)
サンプルコード一式は次のリポジトリのSSRブランチにおいてあります。
https://github.com/teradonburi/learnReactJS/tree/SSR
11/8追記:hydrate(DOM一致部分)をミスってたので色々修正しました。Readme.mdのほうを参考にしてください
次のコマンドで実行できます。
$ brew install yarn
$ yarn
# 開発版実行
$ yarn dev:server-build
$ yarn dev
# リリース版実行
$ yarn build
$ yarn prod
Webpack4に対応したサンプル一式は下記masterにあります。
ReactJSが初めての方でも上から順番にやっていけば、ReactJSが理解できるはず・・・
https://github.com/teradonburi/learnReactJS
SSRとは
クライアントサイドのみでReactJSをレンダリング(CSR)することが一般的です。
サーバ側でReact Componentのビルドを行い、初回のHTML生成(レンダリング)をサーバ側で行うことをサーバサイドレンダリング(SSR)と呼びます。
アプリケーションの複雑性が増すため、以下のケースを除いて安易に導入するべきではありません。
- OGP用metaタグの切り替え(Twitter、Facebook用)
- 初回の描画が早くなる(特にComponentの量が増えてきた時)
- React Componentを用いたAMP対応
デメリットとしては、
- アプリケーションの複雑度が増す
- サーバ側のDOMとクライアントサイド側のDOMの一致(初期化時)を強いられる
- 公開ページのルーティングが一致していないといけない(APIアクセス→SSR→CSR(初回以外のルーティングはクライアント側のReact Routerとなるため))
基本的にデメリットの方が大きいです。
使わなくて済むのならば、使わない方が懸命です。
SSRを使わずにCSRのみで高速化されている良記事もあるのでそれも参考にしてください。
参考:サイト高速化による罪滅ぼしと、SSRをしない勇気
(ちなみに弊社は上記対応を一通りやった上でSEO的な観点から部分SSRも実装しています・・・)
今回は主にSSRとwebpackの設定ファイル周りに関して説明します。
package.jsonの説明
サンプルで使っているpackage.jsonです。
開発時はyarn dev
コマンドでサーバ起動します。
本番時はyarn build
でリリースビルドした後、yarn prod
でサーバ起動します。
{
"name": "learnreactjs",
"version": "1.0.0",
"main": "index.js",
"repository": "https://github.com/teradonburi/learnReactJS.git",
"author": "teradonburi <daikiterai@gmail.com>",
"license": "MIT",
"scripts": {
"dev": "run-p dev:*",
"dev:server-build": "webpack --config webpack.server.js --watch",
"dev:server": "NODE_ENV=dev node-dev --inspect server/server.js",
"dev:client": "webpack-dev-server",
"lint": "eslint .",
"rm": "rm -rf dist/*",
"build-webpack": "NODE_ENV=production parallel-webpack -p --config webpack.build.js",
"build": "run-s rm build-webpack",
"prod": "NODE_ENV=production node server/server.js"
},
"devDependencies": {
"autoprefixer": "^7.1.6",
"axios": "^0.17.1",
"babel-core": "^6.26.0",
"babel-eslint": "^8.2.1",
"babel-loader": "^7.1.2",
"babel-plugin-direct-import": "^0.5.0",
"babel-plugin-transform-class-properties": "^6.24.1",
"babel-plugin-transform-decorators-legacy": "^1.3.4",
"babel-plugin-transform-react-jsx": "^6.24.1",
"babel-polyfill": "^6.26.0",
"babel-preset-env": "^1.6.1",
"babel-preset-react": "^6.24.1",
"babel-preset-stage-0": "^6.24.1",
"copy-webpack-plugin": "^4.2.3",
"eslint": "^4.15.0",
"eslint-loader": "^1.9.0",
"eslint-plugin-react": "^7.5.1",
"history": "^4.7.2",
"html-webpack-plugin": "^2.30.1",
"material-ui": "^1.0.0-beta.34",
"material-ui-icons": "^1.0.0-beta.17",
"npm-run-all": "^4.1.2",
"parallel-webpack": "^2.2.0",
"precss": "^2.0.0",
"react": "^16.2.0",
"react-dom": "^16.2.0",
"react-hot-loader": "^3.1.3",
"react-redux": "^5.0.6",
"react-router-dom": "4.2.2",
"react-router-redux": "^5.0.0-alpha.8",
"redux": "^3.7.2",
"redux-devtools": "^3.4.1",
"redux-form": "^7.2.0",
"redux-thunk": "^2.2.0",
"webpack": "^3.9.1",
"webpack-dev-server": "^2.9.5"
},
"dependencies": {
"express": "^4.16.2",
"jsdom": "^11.6.2"
}
}
webpackの説明
SSRはサーバ側でReactのComponentをビルドしてレンダリングするため、
サーバサイドでビルド用のwebpack設定を記述します。
webpack.server.jsの設定は次のようになります。
targetにnodeを指定、libraryTargetにCommonJS形式を指定するようにしていることに注意してください。
今回はssr.jsをビルドして作成したssr.build.jsファイルを読み込むようにします。
/*globals module: false require: false __dirname: false */
const path = require('path')
module.exports = {
mode: 'development', // 開発モード
devtool: 'inline-source-map', // ソースマップファイル出力
watch: true, // 修正時に再ビルドする
target: 'node', // NodeJS用ビルド
entry: {
ssr: [
'babel-polyfill',
path.join(__dirname, '/server/ssr.js'), // エントリポイントのjsxファイル
],
},
name: 'ssr', // [name]に入る名前
output: {
path: path.join(__dirname, '/server'), // serverフォルダに出力する
filename: '[name].build.js', // 変換後のファイル名
libraryTarget: 'commonjs2', // CommonJS形式で出力
},
module: {
rules: [
{
test: /\.js$/,
include: __dirname, // 直下のJSファイルが対象
exclude: [/node_modules/, /dist/ ], // node_modules, distフォルダ配下は除外
use: {
loader: 'babel-loader',
query: {
cacheDirectory: true, // キャッシュディレクトリを使用する
presets: [
[
'env', {
targets: {
node: '8.6', // NodeJS バージョン8.6
},
modules: false,
useBuiltIns: true, // ビルトイン有効
},
],
'stage-0', // stage-0のプラグイン
'react',
],
plugins: [
['direct-import', [
'material-ui', // material-ui
'redux-form', // redux-form
]],
'babel-plugin-transform-decorators-legacy', // decorator用
],
},
},
},
],
},
}
webpack.config.jsのwebpack-dev-serverにproxyの設定を追加します。
これにより、webpack-dev-serverにアクセス時でもサーバ経由のシミュレーションができます。(サーバは8000ポートで起動している想定です)
index.htmlだとルートパス(/)が正しくルーティングされない問題が発生するのでtemplate.htmlにリネームしています。(static/template.html)
上記に伴い、HtmlWebpackPluginの読み込みhtmlをtemplate.htmlにしています。
/*globals module: false require: false __dirname: false */
const webpack = require('webpack')
const precss = require('precss')
const autoprefixer = require('autoprefixer')
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
mode: 'development', // 開発モード
devtool: 'cheap-module-source-map', // ソースマップファイル出力
entry: [
'babel-polyfill', // babelのpolyfill設定
'react-hot-loader/patch',
path.join(__dirname, '/index'), // エントリポイントのjsxファイル
],
// React Hot Loader用のデバッグサーバ(webpack-dev-server)の設定
devServer: {
contentBase: path.join(__dirname, '/static'), // template.htmlの格納場所
historyApiFallback: true, // history APIが404エラーを返す場合にtemplate.htmlに飛ばす
inline: true, // ソース変更時リロードモード
hot: true, // HMR(Hot Module Reload)モード
port: 8080, // 起動ポート,
publicPath: '/',
proxy: {
'**': {
target: 'http://0.0.0.0:8000',
secure: false,
logLevel: 'debug',
},
},
},
output: {
publicPath: '/', // 公開パスの指定
filename: 'bundle.js',
},
plugins: [
new HtmlWebpackPlugin({
filename: 'template.html', // 出力ファイル名
template: 'static/template.html', // template対象のtemplate.htmlのパス
}),
new webpack.HotModuleReplacementPlugin(), // HMR(Hot Module Reload)プラグイン利用
// autoprefixerプラグイン利用、cssのベンダープレフィックスを自動的につける
new webpack.LoaderOptionsPlugin({options: {
postcss: [precss, autoprefixer({browsers: ['last 2 versions']})],
}}),
],
module: {
rules: [{
test: /\.js?$/, // 拡張子がjsで
exclude: [/node_modules/, /dist/ ], // node_modules, distフォルダ配下は除外
include: __dirname, // 直下のJSファイルが対象
use: {
loader: 'babel-loader',
options: {
plugins: ['react-hot-loader/babel'],
},
},
}],
},
}
webpack.build.jsにwebpack.server.jsを含めるようにします。
リリースビルド時にssr.build.jsも生成するようにします。
splitChunksにて指定のnpmモジュールをCodeSplitingしています。
webpack.optimize.UglifyJsPluginはwebpack4のproductionモードでは勝手にやってくれるため、不要です。
設定ファイル不要!webpack 4 でReactをビルドしてみた
/*globals module: false require: false __dirname: false process: false */
const webpack = require('webpack')
const path = require('path')
const webpackConfig = require('./webpack.config.js')
const webpackServer = require('./webpack.server.js')
const CopyWebpackPlugin = require('copy-webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const revision = require('child_process').execSync('git rev-parse HEAD').toString().trim()
function createConfig() {
const config = Object.assign({}, webpackConfig)
// ソースマップファイルをファイル出力
config.mode = 'production'
// ソースマップファイルをファイル出力
config.devtool = 'source-map'
// React Hot loaderは外す
config.entry = {
'bundle': [
'babel-polyfill',
path.join(__dirname, '/index'), // エントリポイントのjsxファイル
],
}
// 出力ファイル
config.output = {
path: `${__dirname}/dist`,
filename: 'js-[hash:8]/[name].js',
chunkFilename: 'js-[hash:8]/[name].js',
publicPath: '/',
}
// 指定のモジュール単位でCodeSplitingを行う
config.optimization = {
splitChunks: {
cacheGroups: {
// react.jsに分離
react: {
test: /react/,
name: 'react',
chunks: 'all',
},
// core.jsに分離
core: {
test: /redux|core-js|jss|history|matarial-ui|lodash|moment|rollbar|radium|prefixer|\.io|platform|axios/,
name: 'core',
chunks: 'all',
},
},
},
}
config.plugins = [
// 環境変数をエクスポート
new webpack.DefinePlugin({
'process.env': {
'NODE_ENV': JSON.stringify(process.env.NODE_ENV),
'GIT_REVISION': JSON.stringify(revision),
},
}),
// HTMLテンプレートに生成したJSを埋め込む
new HtmlWebpackPlugin({
filename: 'template.html',
template: 'static/template.html',
}),
]
// staticフォルダのリソースをコピーする(CSS、画像ファイルなど)
config.plugins.push(
new CopyWebpackPlugin([{ from: 'static', ignore: 'template.html' }]),
)
return config
}
// SSR用webpackビルド設定追加
function createServerConfig() {
const config = Object.assign({}, webpackServer)
config.mode = 'production'
config.plugins = [
new webpack.DefinePlugin({
'process.env': {
'NODE_ENV': JSON.stringify(process.env.NODE_ENV),
},
}),
]
return config
}
const configs = [
createConfig(),
createServerConfig(),
]
module.exports = configs
クライアント側の説明
index.jsにCSR側の初期化処理を記述しています。(Redux、Material-UI、React-Router-Redux、React Hot Module)
レンダリングにはReact.renderではなくReact.hydrateを使用します。
hydrateはReact16の新機能で、SSRのDOMとCSRのDOMが一致する場合にCSR側の初回renderをスキップできます。かつHTML文字列として埋め込む必要がなくなるため、シンプルに書けます。
React v16でのサーバーサイドレンダリング
React.hydrate使うにはCSRとSSRで生成されたDOMとが一致する必要があります。
initial-dataはSSR側からのRedux Storeの初期状態を取得し、
CSRのレンダリング時にRedux Storeを引き継ぎするためのものです。
createStore生成時にinitialDataとして付与しています。
SSR側でのパラメータの渡し方は後述します。
/*globals module: false process: false */
import React from 'react'
import ReactDOM from 'react-dom'
import createHistory from 'history/createBrowserHistory'
import { createStore, applyMiddleware, compose } from 'redux'
import { Provider } from 'react-redux'
import { MuiThemeProvider } from 'material-ui/styles'
import client from 'axios'
import thunk from 'redux-thunk'
import { hot } from 'react-hot-loader'
import { routerMiddleware } from 'react-router-redux'
import reducer from './reducer/reducer'
import theme from './theme'
import App from './App'
// redux-devtoolの設定
let composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose
// 本番時はredux-devtoolを無効化する
if (process.env.NODE_ENV === 'production') {
composeEnhancers = compose
}
const render = () => {
const initialData = JSON.parse(document.getElementById('initial-data').getAttribute('data-json'))
// ブラウザ履歴保存用のストレージを作成
const history = createHistory()
// axiosをthunkの追加引数に加える
const thunkWithClient = thunk.withExtraArgument(client)
// redux-thunkをミドルウェアに適用、historyをミドルウェアに追加
const store = createStore(reducer, initialData, composeEnhancers(applyMiddleware(routerMiddleware(history), thunkWithClient)))
ReactDOM.hydrate(
<MuiThemeProvider theme={theme}>
<Provider store={store}>
<App history={history} />
</Provider>
</MuiThemeProvider>,
document.getElementById('root'),
)
}
// Webpack Hot Module Replacement API
hot(module)(render)
render()
template.htmlです。
Redux Store初期パラメータ渡し用のscriptタグを追加してあります。
data-jsonパラメータ経由で取得します。
<html>
<head>
<meta charset="utf-8" />
<title>learnReactJS</title>
</head>
<body>
<div id="root"></div>
<script id="initial-data" type="text/plain" data-json="{}"></script>
</body>
</html>
UserPage.jsです。
SSRでは、withWidthが使えない(バックエンドのため、画面サイズ取得できない)ので代わりにHiddenコンポーネントを使っています。
また、SSRでもcomponentWillMountは呼ばれてしまうので
APIコールなどはcomponentWillMountではなく、componentDidMountで行うようにします。
サーバ側から経由する際の初期化パラメータはinitialDataとしてRedux Storeから取得します。(@connectのパラメータ)
import { Hidden } from 'material-ui'
// connectのdecorator
@connect(
// propsに受け取るreducerのstate
state => ({
users: state.user.users,
}),
// propsに付与するactions
{ load }
)
@withTheme()
@withStyles({
root: {
fontStyle: 'italic',
fontSize: 21,
minHeight: 64,
},
})
export default class UserPage extends React.Component {
componentDidMount() {
// user取得APIコールのactionをキックする
this.props.load()
}
render () {
const { users, theme, classes } = this.props
const { primary, secondary } = theme.palette
// 初回はnullが返ってくる(initialState)、処理完了後に再度結果が返ってくる
// console.log(users)
return (
<div>
<AppBar position="static" color="primary">
<Toolbar classes={{root: classes.root}}>
<Hidden xsDown>
ユーザページ(PC)
</Hidden>
<Hidden smUp>
ユーザページ(スマホ)
</Hidden>
<Button style={{color: '#fff', position: 'absolute', top: 15, right: 0}} onClick={() => this.handlePageMove('/todo')}>TODOページへ</Button>
</Toolbar>
</AppBar>
</div>
)
}
}
yarn build
のビルド時に生成されたtemplate.htmlです。
CodeSplitされたJSのScriptタグがHtmlWebpackPluginによって埋め込まれます。
server.js側で本番起動時は下記のスクリプトタグのパスを読み込みます。(server.jsで後述)
<html>
<head>
<meta charset="utf-8" />
<title>learnReactJS</title>
</head>
<body>
<div id="root"></div>
<script id="initial-data" type="text/plain" data-json="{}"></script>
<script type="text/javascript" src="/js-55351532/core.js"></script><script type="text/javascript" src="/js-55351532/react.js"></script><script type="text/javascript" src="/js-55351532/bundle.js"></script></body>
</html>
SSR
server.jsです。
expressフレームワークによるサーバ実装をしています。
React Componentを含むssr.jsをwebpackビルドしてssr.build.jsを読み込みます。
開発時のwebpack-dev-serverで起動時はwebpack-dev-server側のbundle.jsを取得するようにします。本番時はビルド済みのdistフォルダをホスティングし、bundle.jsのパスをhtmlより取得しています。
(パス取得にJSDOMというライブラリを使用しています)
取得したパスをapp.allにて各ページ表示apiアクセス時にreqパラメータに付与しています。
const express = require('express')
const app = express()
// webpackでbuild済みのSSRモジュールを読み込む
const ssr = require('./ssr.build').default
let bundles = []
if (process.env.NODE_ENV === 'dev') {
// webpack-dev-serverのbundle.jsにredirect
app.get('/bundle.js', (req, res) => res.redirect('http://localhost:8080/bundle.js'))
} else if (process.env.NODE_ENV === 'production') {
const jsdom = require('jsdom')
const { JSDOM } = jsdom
// distフォルダをホスティング
app.use(express.static('dist'))
// distのtemplate.htmlのbundle.jsパスを取得
JSDOM.fromFile(__dirname + '/../dist/template.html').then(dom => {
const document = dom.window.document
const scripts = document.querySelectorAll('script[type="text/javascript"]')
for (let i = 0; i < scripts.length; i++) {
const s = scripts[i]
if (s.src.indexOf('bundle.js') !== -1 || s.src.indexOf('core.js') !== -1 || s.src.indexOf('react.js') !== -1) {
bundles.push(s.src.replace('file:///', '/'))
}
}
console.log(bundles)
})
app.all('*', (req, res, next) => {
req.bundles = bundles
next()
})
}
app.get('/', (req, res) => {
// redux storeに代入する初期パラメータ、各ページの初期ステートと同じ構造にする
const initialData = {
user: {
users: null,
},
}
ssr(req, res, initialData)
})
app.get('/todo', (req, res) => {
const initialData = {}
ssr(req, res, initialData)
})
app.listen(8000, function () {
console.log('app listening on port 8000')
})
// 例外ハンドリング
process.on('uncaughtException', (err) => console.log('uncaughtException => ' + err))
process.on('unhandledRejection', (err) => console.log('unhandledRejection => ' + err))
ssr.jsです。ReactのComponentをレンダリングし、各ページの初期表示用のhtmlを返却します。
SheetsRegistry、JssProvider、MuiThemeProvider、createGenerateClassNameでMaterial-UIのSSRを初期化します。
createStoreでRedux Storeを作成します。
各ページの初期状態をinitial-dataに埋め込みます。(クライアント側のRedux Storeに渡す)
<script id='initial-data' type='text/plain' data-json={JSON.stringify(props.initialData)}></script>
StaticRouter、Route、Switchで各apiパスでページのルーティングを割当します。(apiパスとReact Routerのルーティングが一致している必要があります)
template.htmlとDOM構造が一致した各ページのルーティングに対応したmetaタグ要素を変更したhtmlを返却します。
import React from 'react'
// SSR用ライブラリ
import ReactDOMServer from 'react-dom/server'
// Redux
import { Provider } from 'react-redux'
import { createStore, applyMiddleware } from 'redux'
import thunk from 'redux-thunk'
// Material-UI SSR
import { SheetsRegistry } from 'react-jss/lib/jss'
import JssProvider from 'react-jss/lib/JssProvider'
import { MuiThemeProvider, createGenerateClassName } from 'material-ui/styles'
// React Router
import { StaticRouter } from 'react-router'
import { Route, Switch } from 'react-router-dom'
// reducer
import reducer from '../reducer/reducer'
// material-ui theme
import theme from '../theme'
// クライアントサイドと同じComponentを使う
import UserPage from '../components/UserPage'
import TodoPage from '../components/TodoPage'
export default function ssr(req, res, initialData) {
console.log('------------------ssr------------------')
const context = {}
// Material-UIの初期化
const sheetsRegistry = new SheetsRegistry()
const generateClassName = createGenerateClassName({productionPrefix: 'mm'})
// Redux Storeの作成(initialDataには各Componentが参照するRedux Storeのstateを代入する)
const store = createStore(reducer, initialData, applyMiddleware(thunk))
const body = () => (
<JssProvider registry={sheetsRegistry} generateClassName={generateClassName}>
<MuiThemeProvider theme={theme} sheetsManager={new Map()}>
<Provider store={store}>
{/* ここでurlに対応するReact RouterでComponentを取得 */}
<StaticRouter location={req.url} context={context}>
<Switch>
<Route exact path="/" component={UserPage} />
<Route path="/todo" component={TodoPage} />
</Switch>
</StaticRouter>
</Provider>
</MuiThemeProvider>
</JssProvider>
)
// htmlを生成
ReactDOMServer.renderToNodeStream(
<HTML
bundles={req.bundles}
style={sheetsRegistry.toString()} // Material-UIのスタイルをstyleタグに埋め込む
initialData={initialData}
>
{body}
</HTML>
).pipe(res)
}
const HTML = (props) => {
return (
<html lang='ja'>
<head>
{/* ここでmetaタグの切り替えやAMP用のhtml出力の切り替えを行う、今回は具体例は省略 */}
<meta charSet="utf-8" />
<title>learnReactJS</title>
<style>{props.style}</style>
</head>
<body>
<div id='root'>{props.children}</div>
<script id='initial-data' type='text/plain' data-json={JSON.stringify(props.initialData)}></script>
{
props.bundles ?
props.bundles.map(bundle => <script key={bundle} type='text/javascript' src={bundle}></script>)
:
<script type='text/javascript' src='/bundle.js'></script>
}
</body>
</html>
)
}