【Rails】 Webpack経由でBootStrap、material-icons、vue.js、自作css、sassを一つにまとめて、capistranoでdeployする方法

# 初めに

こんにちは。今回の記事ではwebpackを利用してJSやCSS、SCSSを一纏めにし、capistranoでデプロイする方法がようやくできたのでここに執筆します。
筆者はまだ、webpack自体がなんなのか非常に曖昧な所があり、この記事の執筆が完了するまでの間には明確に理解できているという願掛けも込めてどのような方法を取ったのか共有します。そのせいか、色々なツールの公式ドキュメントの直訳みたいなところになってしまっているところもありますが、追々直していきます汗

## 各ツールのバージョン

* Rails 5.2.0
* webpack 3.12.0
* ruby 2.5.1p57
* bootstrap-material-design-icons 2.2.0
* bootstrap.native 2.0.23(JSのみ利用)
* bootstrap 4.1.1(CSSのみ利用)
* vue 2.5.16
* その他、自作のJSやCSS,SASS

## webpackとは

webpackとは、複数のJavaScriptを一つのファイルにまとめる事ができるモジュールバンドラです。仕組みとしては、アプリケーションで必要とする全てのモジュールをマッピングし、依存関係のグラフを内部的に構築している模様です。
webpack4以降では各アプリケーションの設定が基本的には必要なく、[https://webpack.js.org/configuration/](https://webpack.js.org/configuration/) に乗っ取ってカスタマイズすればあらかたコンパイルできる模様です。

さて、最初にJSのファイルをまとめると供述してしまったのですが、webpackは基本的に以下の5つの定義でグラフを構築します。

1. Entry
2. Output
3. Loaders
4. Plugins
5. Mode

ここで、特に重要なのが、 `Loaders` と `Plugins` の二つです。

LoadersはJS以外の拡張子のファイルやモジュールを依存グラフに追加できるように変換してくれます。使い方として、

1. `test` 項目で変換対象のファイルを識別
2. `use` 項目で変換対象のファイルで利用するローダーを指定

する事でJS以外のファイルをwebpackに追加する事ができます。

さらにPluginsでは、Loadersで変換を行なったファイルに対して機能拡張を行なってくれます。ここで扱うPluginはnpmやYarnなどのパッケージマネージャ経由でインストールし、　`require()` で利用したいプラグインを追加することができます。
webpackの概要はこのあたりにしておいて、次から `Rails` アプリケーションの例でwebpackを導入してみましょう。

## Railsにwebpackを導入

webpackを導入したRailsアプリを構築するために、新たにRailsアプリを作成します。
手順としては[https://techracho.bpsinc.jp/hachi8833/2017_12_26/49931](https://techracho.bpsinc.jp/hachi8833/2017_12_26/49931) の記事を参考にします。
この記事の手順を要約すると、

1. `app/javascripts` を `app/frontend` に名前変更
2. `application.html.erb` の `javascript_include_tag "application"` を `javascript_pack_tag "application"` に置き換え
3. `application.html.erb` の `stylesheet_link_tag 'application', media: 'all'` を `javascript_pack_tag "application"` に置き換え
4. `webpacker.yml` でバンドルを探索する場所を `frontend` に変更
5. `application_controller.rb` で `frontend` をコントローラが見つけられるように指定

HTMLのテンプレートエンジンを `slim` に置き換えている場合は適宜読み変えてください。

## capistranoの導入

`Capistrano` 関係のGemは以下を入れています。

```ruby:Gemfile
(中略)
  gem 'capistrano'
  gem 'capistrano-bundler'
  gem 'capistrano-ext'
  gem 'capistrano-rails'
  gem 'capistrano-rbenv'
  gem 'capistrano-npm'
  gem 'capistrano3-puma'
  gem 'capistrano-postgresql'
```

本当は `npm` ではなく、より機能が豊富な `yarn` でdeploy時にパッケージを追加するべきなのですが、一旦 `npm` で都度deploy時にパッケージをインストールするようにします。

`deploy.rb` は以下のように書きました。

```ruby:config/deploy.rb
(中略)
namespace :deploy do
  desc "Make sure local git is in sync with remote."
  task :check_revision do
    on roles(:app) do
      # unless `git rev-parse HEAD` == `git rev-parse origin/master`
      #   puts "WARNING: HEAD is not the same as origin/master"
      #   puts "Run `git push` to sync changes."
      #   exit
      # end
    end
  end

  desc 'Run rake npm install'
  task :npm_install do
    on roles(:web) do
      within release_path do
        execute("cd #{release_path} && npm install")
      end
    end
  end
  desc 'Initial Deploy'
  task :initial do
    on roles(:app) do
      before "deploy:restart", "puma:start"
      invoke "deploy"
    end
  end

  desc "Restart application"
  task :restart do
    on roles(:app), in: :sequence, wait: 5 do
      Rake::Task["puma:restart"].reenable
      invoke "puma:restart"
    end
  end
  before :starting, :check_revision
  before 'deploy:assets:precompile', 'deploy:npm_install'
  after :finishing, :compile_assets
  after :finishing, :cleanup
end
```

先ほど申し上げた通り、deploy時にnpm installでdeploy先ディレクトリの node_modules配下にパッケージをインストールするようにします。

## Webpackの設定

最後に、webpackの設定です。

```js:webpack/production.js

const webpack = require('webpack');
const path = require('path');

/**
 * Require webpack plugins
 */
const environment = require('./environment');
const ManifestPlugin = require('webpack-manifest-plugin');
const ExtractTextPlugin = require('extract-text-webpack-plugin');

/**
 *  Entries
 */
const entries = {
    application: ['./frontend/packs/application.js'],
    markdown_preview: ['./frontend/packs/markdown_preview.js']
}

module.exports = Object.assign({}, environment.toWebpackConfig(), {
    entry: entries,
    module: {
        loaders: [
            {
                test: /\.js$/,
                exclude: /node_modules/,
                loader: 'babel-loader'
            },
            {
                test: /\.css$/,
                use: ExtractTextPlugin.extract({
                    use: [{
                        loader: "css-loader"
                    }, {
                        loader: "sass-loader"
                    }],
                    // use style-loader in development
                    fallback: "style-loader"
                })
            },
            {
                test: /\.scss$/,
                use: ExtractTextPlugin.extract({
                    use: [{
                        loader: "css-loader"
                    }, {
                        loader: "sass-loader"
                    }],
                    // use style-loader in development
                    fallback: "style-loader"
                })
            },
            {
                test: /\.sass$/,
                use: ExtractTextPlugin.extract({
                    use: [{
                        loader: "css-loader"
                    }, {
                        loader: "sass-loader"
                    }],
                    // use style-loader in development
                    fallback: "style-loader"
                })
            },
            {
                test: /\.svg(\?v=\d+\.\d+\.\d+)?$/,
                use: [{
                    loader: 'url-loader?mimetype=image/svg+xml'
                }],
            },
            {
                test: /\.woff(\d+)?(\?v=\d+\.\d+\.\d+)?$/,
                use: [{
                    loader: 'url-loader?mimetype=application/font-woff'
                }],
            },
            {
                test: /\.eot(\?v=\d+\.\d+\.\d+)?$/,
                use: [{
                    loader: 'url-loader?mimetype=application/font-woff'
                }],
            },
            {
                test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/,
                use: [{
                    loader: 'url-loader?mimetype=application/font-woff'
                }],
            },
            {
                test: /\.(jpg|png|gif)$/,
                use: [{
                    loader: 'file-loader?name=[name]-[hash].[ext]'
                }],
            }
        ]
    },
    plugins: [
        new ManifestPlugin(),  // manifest.jsonを出力するプラグイン
        new ExtractTextPlugin({ // define where to save the file
            filename: "./application.css",
            allChunks: true,
        }),
    ],
    resolve: {
        modules: [
            'node_modules',
            path.join(__dirname, 'frontend')
        ],
        alias: {
            vue: 'vue/dist/vue.esm.js'
        },
        extensions: ['.js', '.css', '.scss'],
    }
})

```

各設定を見てみましょう。

* extract-text-webpack-plugin

`extract-text-webpack-plugin` は、JSの中でimportされているcssファイルをJSの中で展開せずに、外部CSSファイルとして展開するために、定義しています。

* entries

```js
const entries = {
    application: ['./frontend/packs/application.js'],
    markdown_preview: ['./frontend/packs/markdown_preview.js']
}
```

entriesでは、JSファイルを展開する際のファイル名を明示的に指定します。
今回のプロジェクトでは、`javascript_pack_tag "application"` (および、vueのmarkdownプレビュー用のJS)を slimのheadに読み込むように設定しているため、
`application.js` (プロジェクトで共通利用するjsファイル)と`markdown_preview.js` と名前づけています。

* test

```js

            {
                test: /\.js$/,
                exclude: /node_modules/,
                loader: 'babel-loader'
            },
```

上のjsファイルでは、 `node_modules`　をコンパイルするのは避けたいため、 excludeでコンパイル対象から除外しています。
また、 `babel-loader` のloaderを利用して、 JSをBabelでコンパイルできるようにしています。

```js

            {
                test: /\.css$/,
                use: ExtractTextPlugin.extract({
                    use: [{
                        loader: "css-loader"
                    }, {
                        loader: "sass-loader"
                    }],
                    // use style-loader in development
                    fallback: "style-loader"
                })
            },
            {
                test: /\.scss$/,
                use: ExtractTextPlugin.extract({
                    use: [{
                        loader: "css-loader"
                    }, {
                        loader: "sass-loader"
                    }],
                    // use style-loader in development
                    fallback: "style-loader"
                })
            },
            {
                test: /\.sass$/,
                use: ExtractTextPlugin.extract({
                    use: [{
                        loader: "css-loader"
                    }, {
                        loader: "sass-loader"
                    }],
                    // use style-loader in development
                    fallback: "style-loader"
                })
            },
            {
                test: /\.svg(\?v=\d+\.\d+\.\d+)?$/,
                use: [{
                    loader: 'url-loader?mimetype=image/svg+xml'
                }],
            },
            {
                test: /\.woff(\d+)?(\?v=\d+\.\d+\.\d+)?$/,
                use: [{
                    loader: 'url-loader?mimetype=application/font-woff'
                }],
            },
            {
                test: /\.eot(\?v=\d+\.\d+\.\d+)?$/,
                use: [{
                    loader: 'url-loader?mimetype=application/font-woff'
                }],
            },
            {
                test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/,
                use: [{
                    loader: 'url-loader?mimetype=application/font-woff'
                }],
            },
            {
                test: /\.(jpg|png|gif)$/,
                use: [{
                    loader: 'file-loader?name=[name]-[hash].[ext]'
                }],
            }
        ]
    },
    plugins: [
        new ManifestPlugin(),  // manifest.jsonを出力するプラグイン
        new ExtractTextPlugin({ // define where to save the file
            filename: "./application.css",
            allChunks: true,
        }),
    ],
    resolve: {
        modules: [
            'node_modules',
            path.join(__dirname, 'frontend')
        ],
        alias: {
            vue: 'vue/dist/vue.esm.js'
        },
        extensions: ['.js', '.css', '.scss'],
    }
})
```

対して、js以外のファイルは各ファイルに応じたloaderを利用してコンパイルできるようにします。
なお、`extract-text-webpack-plugin` 経由でコンパイルする場合は `fallback` で `style-loader` を使わないようにします。
`style-loader` はJSのコード中でstylesheetをrequire出来るようになるloaderですが、extract-text-webpack-pluginで外部CSSファイルに展開したいので、これを定義してしまうと設定が競合してしまいます。

BootStrapやmaterial-iconsで定義されている画像もここで一緒にコンパイルします。

* webpack-manifest-plugin

`webpack-manifest-plugin` では、webpackでコンパイルしたファイルよりmanifest.jsonを自動的に生成してくれます。 manifest.jsonは、Webアプリやサイトを表示する方法を制御してくれるjsonファイルです。


* resolve

```js

    resolve: {
        modules: [
            'node_modules',
            path.join(__dirname, 'frontend')
        ],
        alias: {
            vue: 'vue/dist/vue.esm.js'
        },
        extensions: ['.js', '.css', '.scss'],
    }
```

最後の `resolve` の項目ですがこれにより、コンパイルされたモジュールの解決方法を定義します。
なお、今回、 `Vue.js` も利用しているのですが、 `Vue.js` の書き方によっては、ブラウザでアクセスした際にコンパイルが走るような設定をする必要があります。今回の書き方がそうでした。
そこで、webpackの `alias` の設定を設定することで、 vueファイルをimport(require)できるようにしてあります。
以上で、BootStrap, material-icons, vue.js, 自作css, sassをwebpack経由でコンパイルすることができます。

後は
`% bundle exec cap production deploy`
でサーバデプロイをしてあげましょう。

## まとめ

ということでwebpackによる設定を行い、capistranoでdeployする手順でした。
フロントエンドエンジニアの方には「なんだこの設定は」って突っ込まれそうな設定ですが(実際deployには1分程度かかる。主に、npm install)、及第点な設定は書けたような気がするので、今後はコンパイルが早くなるような設定を目指します。

また、Rails5.1よりAPIモードでrails newすることが可能となったので、vueでMVVMを任せてRailsはAPIとして通信するようなWebアプリを作っていきたいですね。
今回はここまで。

## 参考文献
* [https://jp.vuejs.org/v2/guide/installation.html](https://jp.vuejs.org/v2/guide/installation.html)
* [https://developers.google.com/web/fundamentals/web-app-manifest/?hl=ja](https://developers.google.com/web/fundamentals/web-app-manifest/?hl=ja)
* [https://webpack.js.org/concepts/](https://webpack.js.org/concepts/)
* [https://techracho.bpsinc.jp/hachi8833/2017_12_26/49931](https://techracho.bpsinc.jp/hachi8833/2017_12_26/49931)