LoginSignup
53
48

More than 5 years have passed since last update.

webpack4でReact16のSSR(サーバサイドレンダリング)をする

Last updated at Posted at 2018-03-05

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でサーバ起動します。

package.json
{
  "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ファイルを読み込むようにします。

webpack.server.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にしています。

webpack.config.js
/*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をビルドしてみた

webpack.build.js
/*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側でのパラメータの渡し方は後述します。

index.js,index.jsx
/*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パラメータ経由で取得します。

template.html
<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のパラメータ)

UserPage.js,UserPage.jsx
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で後述)

template.html
<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パラメータに付与しています。

server.js
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を返却します。

ssr.js,ssr.jsx
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>
  )
}
53
48
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
53
48