Help us understand the problem. What is going on with this article?

ReactJSで作る今時のSPA入門(リリース編)

More than 1 year has passed since last update.

ReactJSで作る今時のSPA入門(基本編)で作成したプロジェクトをリリースビルドしてデプロイする構成についての説明です。

リリースビルド

リリースビルド用に次のようなディレクトリ構成に変更します。
client部分だけBabelでリリース用ビルドをするため、
package.jsonをclient、server、プロジェクト全体で持つ構成にします。
distフォルダにBabelのビルド結果を出力して
ローカルからAWS EC2サーバにrsyncでデプロイする想定です。
configフォルダにはサーバ、クライアント共通の設定ファイルを格納してあります。(サーバの起動ポート番号など)

├── README.md
├── client
│   ├── dist
│   ├── package.json
│   ├── src
│   │   ├── App.js
│   │   ├── components
│   │   │   ├── NotFound.js
│   │   │   ├── TodoPage.js
│   │   │   └── UserPage.js
│   │   ├── index.js
│   │   ├── reducer
│   │   │   ├── reducer.js
│   │   │   └── user.js
│   │   └── static
│   │       └── index.html
│   ├── webpack.build.js
│   └── webpack.config.js
├── config
│   ├── default.js
│   ├── default-0.js
│   └── production.js
├── package.json
├── script
│   ├── connect.sh
│   ├── deploy.sh
│   └── env.sh
└── server
    ├── package.json
    ├── pm2_prod.json
    └── src
        └── server.js

プロジェクト全体のpackage.jsonです。
client用とserver用の開発用コマンドdev
deploy用のコマンドdeployを作成してあります。
後述のdeploy.shのrsyncコマンドで公開サーバにアップロードします。

package.json
{
  "scripts": {
    "dev:client": "cd client && npm run dev",
    "dev:server": "cd server && npm run dev",
    "dev": "run-p dev:*",
    "build:client": "cd client && npm run build",
    "rsync": "./script/deploy.sh",
    "deploy": "NODE_ENV=production run-s build:* rsync"
  },
  "devDependencies": {
    "npm-run-all": "^4.1.1"
  }
}

サーバ側のpackage.jsonです。
NodeJSサーバ専用のパッケージを分離しました。
公開サーバはpm2上で永続化している想定です。

server/package.json
{
  "scripts": {
    "dev": "NODE_CONFIG_DIR=../config node-dev --inspect src/server.js",
    "prod": "pm2 delete learnReactJS;pm2 start pm2_prod.json"
  },
  "dependencies": {
    "axios": "^0.17.1",
    "body-parser": "^1.18.2",
    "config": "^1.28.1",
    "express": "^4.16.2",
    "nedb": "^1.8.0"
  },
  "devDependencies": {
    "node-dev": "^3.1.3"
  }
}

configフォルダのdefault.jsです。
サーバの起動ポート番号を指定してあります。

config/default.js
module.exports = {
  port: 8080
}

default-0.jsです。
本番サーバの起動の都合上入れています。
空で構いません。

config/default-0.js
module.exports = {
}

production.jsです。
default.jsのパラメータをオーバライドします。
今回はポート番号の指定を公開サーバでも変えないので中身は空で問題ありません。
default.js > default-0.js > production.jsの順番で読み込まれます。

config/production.js
module.exports = {
}

クライアント側のpackage.jsonです。
基本的にライブラリはBabelするためdevDependenciesでインストールします。
リリースビルド用に下記のパッケージを追加でインストールしてあります。

$ yarn add --dev npm-run-all autoprefixer precss html-webpack-plugin copy-webpack-plugin babel-preset-stage-0 babel-preset-env parallel-webpack

buildコマンドにてリリースビルドを行います。
NODE_CONFIG_DIRはconfigフォルダのパスを指定しています。

client/package.json
{
  "scripts": {
    "dev": "NODE_CONFIG_DIR=../config webpack-dev-server",
    "rm": "rm -rf dist/*",
    "build-webpack": "NODE_CONFIG_DIR=../config parallel-webpack -p --config webpack.build.js",
    "build": "run-s rm build-webpack"
  },
  "devDependencies": {
    "autoprefixer": "^7.1.6",
    "axios": "^0.17.1",
    "babel-core": "^6.26.0",
    "babel-loader": "^7.1.2",
    "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",
    "config": "^1.28.1",
    "copy-webpack-plugin": "^4.2.1",
    "history": "^4.7.2",
    "html-webpack-plugin": "^2.30.1",
    "material-ui": "^1.0.0-beta.21",
    "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.1.1",
    "react-dom": "^16.1.1",
    "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.1.2",
    "redux-thunk": "^2.2.0",
    "webpack": "^3.8.1",
    "webpack-dev-server": "^2.9.4"
  }
}

webpack.config.jsです。
HtmlWebpackPluginとautoprefixerのプラグインを追加しました。
HtmlWebpackPluginはindex.htmlのbundle.js埋め込みを動的に行ってくれます。
autoprefixerはcss3のベンダープレフィックスをBabelビルド時に付与してくれます。
presetsにenvオプションを追加しました。
対象ブラウザは最新から2つ前までをメインターゲットとします。

webpack.config.js
const config = require('config')
const webpack = require('webpack')
const precss = require('precss')
const autoprefixer = require('autoprefixer')
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  devtool: 'inline-source-map', // ソースマップファイル追加 
  entry: [
    'babel-polyfill',
    'react-hot-loader/patch',
    path.join(__dirname,'/src/index'), // エントリポイントのjsxファイル
  ],
  // importの相対パスを絶対パスで読み込みできるようにする
  resolve: {
    modules: ['src', 'node_modules'], // 対象のフォルダ
    extensions: ['.js', '.json'] // 対象のファイル
  },
  // React Hot Loader用のデバッグサーバ(webpack-dev-server)の設定
  devServer: {
    contentBase: path.join(__dirname,'/src/static'), // index.htmlの格納場所
    historyApiFallback: true, // history APIが404エラーを返す場合にindex.htmlに飛ばす
    inline: true, // ソース変更時リロードモード
    hot: true, // HMR(Hot Module Reload)モード
    port: config.port + 1, // 起動ポート,
    host: '0.0.0.0',
    // CORSの対策(debugホストが違うため)
    proxy: {
      // CORSを許可するパスとサーバ
      '/api/**': {
        target: 'http://localhost:' + config.port,
        secure: false,
        changeOrigin: true
      }
    }
  },
  output: {
    publicPath: '/', // デフォルトルートにしないとHMRは有効にならない
    filename: 'bundle.js'
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: 'src/static/index.html',
      filename: 'index.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/, // node_modulesフォルダ配下は除外
      include: [path.join(__dirname , '/src')],// src配下のJSファイルが対象
      use: {
        loader: 'babel-loader',
        options: {
          // babel build presets
          presets: [
            [
              'env', {
                targets: {
                  browsers: ['last 2 versions', '> 1%']
                },
                modules: false,
                useBuiltIns: true
              }
            ],
            'stage-0',
            'react'
          ],
          // babel トランスパイルプラグイン
          plugins: [
            "babel-plugin-transform-decorators-legacy", // decorator用
            "react-hot-loader/babel" // react-hot-loader用
          ] 
        }
      }
    }]
  }
}

index.htmlからはbundle.jsの埋め込みを削除します。
(HtmlWebpackPluginが動的に埋め込みを行ってくれます)

index.html
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>learnReactJS</title>
</head>
<body>
  <div id="root"></div>
  <!-- <script src='bundle.js'></script>-->
</body>
</html>

webpack.build.jsです。
リリースビルド時はwebpack.config.jsの設定を上書きしてビルドします。
プラグインでソースコードの圧縮、環境変数の埋め込みを行います。

webpack.build.js
const webpack = require('webpack')
const webpackConfig = require('./webpack.config.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()


const entries = [
  {path: 'src', out: ''},
]

const configs = entries.map(entry => {

  const config = Object.assign({}, webpackConfig)

  delete config.devtool
  config.entry = {
    'bundle': [
      'babel-polyfill',
      `${__dirname}/${entry.path}/index`,
    ]
  }
  // distフォルダに出力、bundle.jsファイル名を動的にリネーム
  config.output = {
    path: `${__dirname}/dist/${entry.out}`,
    filename: 'js-[hash:8]/[name].js',
    chunkFilename: 'js-[hash:8]/[name].js',
    publicPath: `/${entry.out}`,
  }

  config.plugins = [
    // Scope Hoisting
    // スコープの巻き上げによる呼び出し回数の削減と圧縮
    new webpack.optimize.ModuleConcatenationPlugin(),
    // 共通モジュールをまとめる
    new webpack.optimize.CommonsChunkPlugin({
      name: 'vendor',
      filename: 'js-[hash:8]/vendor.js',
      minChunks: (module) => {
        return module.context && module.context.indexOf('node_modules') !== -1
      }
    }),
    // 環境変数をエクスポート
    new webpack.DefinePlugin({
      'process.env': {
        'NODE_ENV': JSON.stringify(process.env.NODE_ENV),
        'GIT_REVISION': JSON.stringify(revision),
      },
    }),
    // JSミニファイ
    new webpack.optimize.UglifyJsPlugin({
      sourceMap: true,
      minimize: true,
      compress: {
        drop_debugger: true,
        drop_console: true,
        warnings: false
      }
    }),
    // HTMLテンプレートに生成したJSを埋め込む
    new HtmlWebpackPlugin({
      template: `src/static/${entry.out}index.html`,
      filename: 'index.html',
    }),
  ]

  return config
})

// source mapの出力形式を上書き
configs[0].devtool = 'source-map'
configs[0].plugins.push(
  // エントリーディレクトリーからdistディレクトリにindex.html以外のファイルをコピー
  new CopyWebpackPlugin([{ from: 'src/static', ignore: 'index.html' }]),
)

module.exports = configs

次のコマンドでリリースビルドを行います。

$ npm run build-webpack

成功するとdistフォルダにリリースビルド完了後のソースファイルが出力されるのでこのフォルダをデプロイします。

スクリーンショット 2017-11-27 2.32.18.png

デプロイ

deploy.shです。
env.shで設定したEC2ターゲットにデプロイを行います。

deploy.sh
#!/bin/sh

dir=`echo $(cd $(dirname $0) && pwd)`

source $dir/env.sh

set -eu

# deploy 
rsync -av $dir/../config/ ec2-user@$domain:/var/www/learnReactJS/config
rsync --exclude-from $dir/.rsyncignore -av $dir/../client/dist/* ec2-user@$domain:/var/www/learnReactJS/public
rsync -av $dir/../server/ ec2-user@$domain:/var/www/learnReactJS/server

deploy先のフォルダ構成は以下の想定です。

└── var
    └── www
        └── learnReactJS
            ├── config
            ├── public
            └── server

env.shにはEC2のデプロイ対象のpublic IP
Route53のAレコードに設定したデプロイターゲットのドメイン名を指定します。

env.sh
#!/bin/sh

ip='{xx.xx.xx.xx}'
domain='{domain}'

.rsyncignoreには転送時に除外ファイルを指定します。
本番環境なのでソースマップファイルは対象から外します。

*.map
*.db

サーバ起動の永続化

EC2側ではpm2をインストールしておきます。
pm2はnodeJSのプロセスを永続化してくれます。

$ npm install -g pm2

デプロイ後、
serverプロセスをpm2で起動します。

$ cd /var/www/learnReactJS/server
$ npm run prod

npm run prodは以下のコマンドです。
pm2のlearnReactJSプロセスを削除し、pm2_prod.jsonを参照してプロセスを起動します。

$ pm2 delete learnReactJS;pm2 start pm2_prod.json

pm2_prod.jsonです。
nameには起動プロセス名を指定します。
watchはファイル変更時にプロセスを再起動するフォルダ、ファイルを指定します。
ignore_watchは対象のファイルに変更があっても再起動しません。
scriptはプロセス起動対象のファイルパスです。
envには環境変数の指定をします。

pm2_prod.json
{
  "apps":[
    {
      "name": "learnReactJS",
      "watch":["/var/www/learnReactJS/server/src/**/*"],
      "ignore_watch":["/var/www/learnReactJS/server/user.db"],
      "script": "/var/www/learnReactJS/server/src/server.js",
      "env": {
        "NODE_ENV":"production",
        "NODE_CONFIG_STRICT_MODE": 0,
        "NODE_CONFIG_DIR":"../config"
      }
    }
  ]
}

下記のコマンドでpm2プロセスの起動状態とログを確認できます。

$ pm2 list
$ pm2 log
Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away