Rails環境でJS , CSSをwebpackで完全に管理する

# 追記
Viewファイル内でJSとCSSを読み込んでいたのをテンプレートファイル内で読み込むように変更。

リポジトリを用意しました。開発にそのまま使用できます。
記事を読んでいちいち設定めんどいなと思ったらこのリポジトリをクローンしてアプリ開発に使ってください。
https://github.com/shan-shan95/rails-vue-template

## 1/27 追記
「webpackに乗り換えることのメリット」の項目を追加しました。
ファイル構成、webpackの設定を少し変更しました。

この構成にさらにbulma, fontawesome5, normalize.cssを入れる手順です。
めんどくさい人は上のリポジトリに入っているのでそのまま使ってください。
https://shanshan.hatenadiary.jp/entry/2019/01/26/152807

# なぜ人類は'webpacker'ではなく'webpack'でJS, CSSを管理すべきか？

Railsのバージョン5.1から登場したwebpackerでは、ReactやVueといったAltJSを簡単に導入することが可能になりました。
しかし、実際に導入・運用してみると**webpackerの学習コストやwebpackのバージョンアップについていけないことが辛い**といった経験が出てきます。
Railsにはサーバーサイドのロジックに集中してもらい、フロントエンドのことはwebpackに任せましょう。

# 前提
- 脱Turbolinks(Ajaxを利用して画面遷移してくれるが、不必要なことが多い、Vueとの連携がうまくいかないので削除する。)
- 脱Sprocket（webpackでアクション毎にバンドルしたファイルを各View内で読み込む。他のページのJSやCSSが邪魔をしない。)
- 脱Webpacker
- Vueを使う(単一ファイルコンポーネント)
- webpack-dev-serverのHMRを利用してサクサク開発する

# 完全にwebpackに乗り換えることのメリット
- webpackerの学習コストを0にする。
- webpackのバージョン移行時にgemの依存関係を考えなくて良い。
- HMRを利用して開発ができる
ファイルの変更を検知して自動でリロードしてくれる。

# 手順
### 脱Turbolinks
Vueと相性が悪く、turbolinks:loadedイベントを監視しないといけません。
[Misocaさんのブログが参考になりました。](https://tech.misoca.jp/entry/2018/02/02/110000)
Vueと一緒に使うことも不可能では無いようですが、開発のしやすさを優先して削除します。
`$ bundle exec gem uninstall turbolinks`
Gemfileから`gem 'turbolinks'`の記述を削除
`$ bundle install`

テンプレートファイルの以下から、`data-turbolinks-track`を削除する。
削除前

```
= stylesheet_link_tag "application", media: "all", "data-turbolinks-track": true
= javascript_include_tag "application", "data-turbolinks-track": true
```
削除後

```
= stylesheet_link_tag "application", media: "all"
= javascript_include_tag "application"
```

assets内の`application.js`から以下の一行を削除する。

```
//= require jquery
//= require jquery_ujs
//= require turbolinks <- 削除
//= require_tree .
```

### 脱Sprockets
今回はSprocketsのアセットパイプラインによるファイルの配信を行わず、webpackでビルドしたjsとcssをViewファイル内で読み込ませます。
その為に思い切って`app/assets/`ディレクトリを削除します。

### 脱webpacker
ここからは[こちらのブログ](https://numb86-tech.hatenablog.com/entry/2019/01/10/211416)が大変参考になりました。

Gemfileから`gem webpacker`を削除して`bundle install`
`yarn remove @rails/webpacker`を実行
以下のファイルを削除

- bin/webpack
- bin/webpack-dev-server
- config/webpack/development.js
- config/webpack/environment.js
- config/webpack/loaders/vue.js
- config/webpack/production.js
- config/webpack/test.js
- config/webpacker.yml

`config/environment/development.rb`と`production.rb`から`config.webpacker.check_yarn_integrity`を削除

### ディレクトリ構成
ルートディレクトリ直下に`frontend`ディレクトリを作り、フロントエンドに関してのコードは基本的にこの中で管理します。
※ここで関係のないディレクトリについては省いて説明しています。

```
- frontend // フロントエンド開発用ディレクトリ
  - pages // ページ全体のコンポーネント
    - root // コントローラ名
      - rootIndex.js // 名前を一意にする為に(コントローラ名)+(アクション名)にする。ページコンポーネントを登録する。
      - index.vue // `root/index`ページのファイル
      - rootShow.js
      - show.vue // `root/show`ページのファイル
  - components
    - app.vue
  - images
    - image.jpg
- public
  - assets // 以下にバンドルファイルを出力する。実際にはファイル名にハッシュがついている。
    - manifest.json
    - javascripts
      - rootIndex.js
      - rootShow.js
    - stylesheets
      - rootIndex.css
      - rootShow.css
    - images
      - image.jpg
```
`frontend`ディレクトリでフロントエンドに関する開発を行い、`public/assets/`以下にwebpackでバンドルしたファイルを吐き出します。
今回はページ全体をvueによってコンポーネントとし（便宜上ページコンポーネントと呼びます）、jsによってそれを登録します。

### Vue導入
[単一ファイルコンポーネントについて](https://jp.vuejs.org/v2/guide/single-file-components.html)

コンポーネントを1つのファイルで構成する単一ファイルコンポーネントを利用します。
ここではプリセットはPug, Sass(scss)を利用します。
他のプリセットを利用する場合は適宜、loader等を読み替えてください。

Vueを導入します。

```
$ yarn add vue --save
```

### webpack導入
webpack関連

```
$ yarn add webpack webpack-cli -D
```
JavaScriptをトランスパイルするBabel関連

```
$ yarn add @babel/core @babel/polyfill @babel/preset-env babel-loader -D
```
Vueファイル内で使用する言語のloader等

```
$ yarn add css-loader file-loader mini-css-extract-plugin pug pug-plain-loader sass-loader vue-loader vue-style-loader vue-template-compiler webpack-manifest-plugin -D
```

`mini-css-extract-plugin`は単一ファイルコンポーネント内に記述したCSSを`.js`ファイルではなく`.css`ファイルに抽出するためのものです。
webpack@4では`extract-text-webpack-plugin`ではなく`mini-css-extract-plugin`を使わなければいけません。
ここに気づかず、古い記事を参考にしたりしてかなりの時間ハマってしまいました。
vueのwebpackでの設定方法については[vue-loaderの公式](https://vue-loader.vuejs.org/guide/extract-css.html#webpack-4)を読む必要があります。

package.jsonに追記

```package.json
"scripts": {
  "build:dev": "webpack --progress --mode=development",
  "build:pro": "webpack --progress --mode=production"
},
```

### webpackの設定
`webpack.config.js`を以下のように作成します。

```webpack.config.js
const path = require('path')
const glob = require('glob')
const webpack = require('webpack')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const VueLoaderPlugin = require('vue-loader/lib/plugin')
const ManifestPlugin = require('webpack-manifest-plugin')

let entries = {}
glob.sync('./frontend/pages/**/*.js').map(function(file) {
  let name = file.split('/')[4].split('.')[0]
  entries[name] = file
})

module.exports = (env, argv) => {
  const IS_DEV = argv.mode === 'development'

  return {
    entry: entries,
    // devtool: IS_DEV ? 'source-map' : 'none',
    output: {
      filename: 'javascripts/[name]-[hash].js',
      path: path.resolve(__dirname, 'public/assets')
    },
    plugins: [
      new VueLoaderPlugin(),
      new MiniCssExtractPlugin({
        filename: 'stylesheets/[name]-[hash].css'
      }),
      new webpack.HotModuleReplacementPlugin(),
      new ManifestPlugin({
        writeToFileEmit: true
      })
    ],
    module: {
      rules: [
        {
          test: /\.js$/,
          exclude: /node_modules/,
          loader: 'babel-loader',
          options: {
            presets: [
              [
                '@babel/preset-env',
                {
                  targets: {
                    ie: 11
                  },
                  useBuiltIns: 'usage'
                }
              ]
            ]
          }
        },
        {
          test: /\.vue$/,
          loader: 'vue-loader'
        },
        {
          test: /\.pug/,
          loader: 'pug-plain-loader'
        },
        {
          test: /\.(c|sc)ss$/,
          use: [
            {
              loader: MiniCssExtractPlugin.loader,
              options: {
                publicPath: path.resolve(__dirname, 'public/assets/stylesheets')
              }
            },
            'css-loader',
            'sass-loader'
          ]
        },
        {
          test: /\.(jpg|png|gif)$/,
          loader: 'file-loader',
          options: {
            name: '[name]-[hash].[ext]',
            outputPath: 'images',
            publicPath: function(path) {
              return 'images/' + path
            }
          }
        }
      ]
    },
    resolve: {
      alias: {
        vue: 'vue/dist/vue.js'
      },
      extensions: ['.js', '.scss', 'css', '.vue', '.jpg', '.png', '.gif', ' ']
    },
    optimization: {
      splitChunks: {
        cacheGroups: {
          vendor: {
            test: /.(c|sa)ss/,
            name: 'style',
            chunks: 'all',
            enforce: true
          }
        }
      }
    }
  }
}
```
これで、`$ yarn run build:dev`を実行することで`public/assets/`以下にそれぞれバンドルファイルとmanifest.jsonが生成されます。
しかしこれでは画面は表示されません。
テンプレートファイル内でバンドルされたjsとcssを読み込む必要があります。

### ヘルパータグの実装
```app/helpers/webpack_bundle_helper.rb
require "open-uri"

module WebpackBundleHelper
  class BundleNotFound < StandardError; end

  def javascript_bundle_tag(entry, **options)
    path = asset_bundle_path("#{entry}.js")

    options = {
      src: path,
      defer: true,
    }.merge(options)

    # async と defer を両方指定した場合、ふつうは async が優先されるが、
    # defer しか対応してない古いブラウザの挙動を考えるのが面倒なので、両方指定は防いでおく
    options.delete(:defer) if options[:async]

    javascript_include_tag "", **options
  end

  def stylesheet_bundle_tag(entry, **options)
    path = asset_bundle_path("#{entry}.css")

    options = {
      href: path,
    }.merge(options)

    stylesheet_link_tag "", **options
  end

  private

  def asset_server
    port = Rails.env === "development" ? "3035" : "3000"
    "http://#{request.host}:#{port}"
  end

  def pro_manifest
    File.read("public/assets/manifest.json")
  end

  def dev_manifest
    OpenURI.open_uri("#{asset_server}/public/assets/manifest.json").read
  end

  def test_manifest
    File.read("public/assets-test/manifest.json")
  end

  def manifest
    return @manifest ||= JSON.parse(pro_manifest) if Rails.env.production?
    return @manifest ||= JSON.parse(dev_manifest) if Rails.env.development?
    return @manifest ||= JSON.parse(test_manifest)
  end

  def valid_entry?(entry)
    return true if manifest.key?(entry)
    raise BundleNotFound, "Could not find bundle with name #{entry}"
  end

  def asset_bundle_path(entry, **options)
    valid_entry?(entry)
    asset_path("#{asset_server}/public/assets/" + manifest.fetch(entry), **options)
  end
end
```

そしてテンプレートファイル内で今までJSとCSSを読み込んでいたメソッドを削除します。

```layout/application.haml
// この2行を削除
= stylesheet_link_tag 'application', media: 'all'
= javascript_include_tag 'application'

// この2行を追加
= javascript_bundle_tag "#{params[:controller]}#{params[:action].capitalize}"
= stylesheet_bundle_tag "#{params[:controller]}#{params[:action].capitalize}"
```

これでバンドルファイルを読み込むことができました。
しかしこれではstyleを1行変更するだけでも、再ビルドしてブラウザをリロードしなければいけません。
非常に効率が悪いです。
そこでファイルの変更を検知して自動でビルドしてくれるようにwebpack-dev-serverを導入し、HMR(Hot Module Replacement)を有効にします。
HMRを有効にすることでファイルのセーブ時にファイルの変更を検知して自動でビルドを行ってくれます。
![1_KdajGOiUJTeIiUmFId5ivw.gif](https://qiita-image-store.s3.amazonaws.com/0/181017/95d4b504-ed08-37cd-0f71-9f974ea743b3.gif)


### web-pack-dev-serverの導入

```
$ yarn add webpack-dev-server -D
```

`webpack.config.js`に追記します。

```webpack.config.js
    devServer: {
      host: 'localhost',
      port: 3035,
      publicPath: 'http://localhost:3035/public/assets/',
      contentBase: path.resolve(__dirname, 'public/assets'),
      hot: true,
      disableHostCheck: true,
      historyApiFallback: true
    }
```

#### 最終的なwebpack.config.js
```webpack.config.js
const path = require('path')
const webpack = require('webpack')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const VueLoaderPlugin = require('vue-loader/lib/plugin')
const ManifestPlugin = require('webpack-manifest-plugin')

module.exports = (env, argv) => {
  const IS_DEV = argv.mode === 'development'

  return {
    entry: {
      main: './frontend/application.js'
    },
    // devtool: IS_DEV ? 'source-map' : 'none',  // HMRが重くなる原因なので外した方がいい。
    output: {
      filename: 'javascripts/bundle/[name]-[hash].js',
      path: path.resolve(__dirname, 'app/assets')
    },
    plugins: [
      new VueLoaderPlugin(),
      new MiniCssExtractPlugin({
        filename: 'stylesheets/bundle/[name]-[hash].css'
      }),
      new webpack.HotModuleReplacementPlugin(),
      new ManifestPlugin({
        writeToFileEmit: true
      })
    ],
    module: {
      rules: [
        {
          test: /\.js$/,
          exclude: /node_modules/,
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env']
          }
        },
        {
          test: /\.vue$/,
          loader: 'vue-loader'
        },
        {
          test: /\.pug/,
          loader: 'pug-plain-loader'
        },
        {
          test: /\.(c|sc)ss$/,
          use: [
            {
              loader: MiniCssExtractPlugin.loader,
              options: {
                publicPath: path.resolve(
                  __dirname,
                  'app/assets/stylesheets/bundle'
                )
              }
            },
            'css-loader',
            'sass-loader'
          ]
        },
        {
          test: /\.(jpg|png|gif)$/,
          loader: 'file-loader',
          options: {
            name: '[name]-[hash].[ext]',
            outputPath: 'images/bundle',
            publicPath: function(path) {
              return 'images/bundle/' + path
            }
          }
        }
      ]
    },
    resolve: {
      alias: {
        vue: 'vue/dist/vue.js'
      },
      extensions: ['.js', '.scss', 'css', '.vue', '.jpg', '.png', '.gif', ' ']
    },
    optimization: {
      splitChunks: {
        cacheGroups: {
          vendor: {
            test: /.(c|sa)ss/,
            name: 'style',
            chunks: 'all',
            enforce: true
          }
        }
      }
    },
    devServer: {
      host: 'localhost',
      port: 3035,
      publicPath: 'http://localhost:3035/app/assets/',
      contentBase: path.resolve(__dirname, 'app/assets'),
      hot: true,
      disableHostCheck: true,
      historyApiFallback: true
    }
  }
}
```

これでwebpack-dev-serverを利用することができます。

```
$ yarn run webpack-dev-server --mode development
```

そして`rails s`すると確かに画面が表示され、ファイルを変更すると検知してくれます。

### foreman
`webpack-dev-server`と`rails server`をいちいち立ち上げるのはめんどくさいので、foremanというgemを利用して、二つのプロセスを一度に立ち上げられるようにします。
`gem "foreman"`をGemfileに追記して`$ bundle install`します。
`Procfile`という名前のファイルをルートディレクトリ直下に作成し、下記のように記載します。

```
rails: bundle exec rails server
webpack-dev-server: yarn run webpack-dev-server --color --mode development
```
その後、

```
$ bundle exec foreman start -p 3000
```
を叩くことで通常と変わらずポート3000番でプロセスが立ち上がります。

### 画像を表示できるようにプロキシー設定
これでwebpackによるビルド、webpack-dev-serverのHMR有効化が完了しました。
しかし、この状態で画像を表示しようとするとエラーになります。
なのでプロキシを設定します。

```lib/tasks/assets_path_proxy.rb
require "rack/proxy"

class AssetsPathProxy < Rack::Proxy
  def perform_request(env)
    if env["PATH_INFO"].include?("/images/")
      if Rails.env != "production"
        dev_server = env["HTTP_HOST"].gsub(":3000", ":3035")
        env["HTTP_HOST"] = dev_server
        env["HTTP_X_FORWARDED_HOST"] = dev_server
        env["HTTP_X_FORWARDED_SERVER"] = dev_server
      end
      env["PATH_INFO"] = "/public/assets/images/" + env["PATH_INFO"].split("/").last
      super
    else
      @app.call(env)
    end
  end
end
```
次に、config/environment/development.rbとproduction.rbのそれぞれに一行追記

```
config.middleware.use AssetsPathProxy, ssl_verify_none: true
```

これで画像が表示されるようになったはずです。

### Vueの利用の仕方
さて、ここで実際にどのようにVueを利用していくかお見せします。
`frontend`ディレクトリの構成をもう一度お見せします。

```
- frontend // フロントエンド開発用ディレクトリ
  - pages // ページ全体のコンポーネント
    - root // コントローラ名
      - rootIndex.js // 名前を一意にする為に(コントローラ名)+(アクション名)にする。ページコンポーネントを登録する。
      - index.vue // `root/index`ページのファイル
      - rootShow.js
      - show.vue // `root/show`ページのファイル
  - components
    - app.vue
  - images
    - image.jpg
```
ファイルの内容をお見せしながらどのような構成になっているのか詳しく説明します。

#### テンプレートファイル
```layout/application.haml
!!!
%html
	%head
		%meta{ charset: "utf-8" }
		%meta{ name: "viewport", content: "width=device-width" }
		%title
			MieMa
		= csrf_meta_tags
		= csp_meta_tag
		= javascript_bundle_tag "#{params[:controller]}#{params[:action].capitalize}" // 追記
		= stylesheet_bundle_tag "#{params[:controller]}#{params[:action].capitalize}" // 追記
	%body
		#app // <div id="app">を追加
			= yield
```
各ページコンポーネントをレンダリングする際にelementが必要になるので`#app`を
追記しています。
各アクションで使用するアセットを読み込んでいます。

#### 各Viewファイル
```index.haml
%root-index // ViewにはVueのページ全体となるコンポーネントのみを書く
```
Viewファイルに記述することはこの1行だけです。
コンポーネントを読み込むだけ。

#### コンポーネント
`components`ディレクトリにはボタンやモーダルといった普通のコンポーネントを書きます。

```components/app.vue
<template lang="pug">
  div
    p {{ message }}
</template>

<script>
export default {
  data() {
    return {
      message: 'Hello Vue!'
    }
  }
}
</script>

<style lang="scss" scoped>
p {
  font-size: 2em;
  text-align: center;
  background-color: greenyellow;
}
</style>
```


#### ページコンポーネント
ページコンポーネントでは使用する普通のコンポーネント（ここではApp）を読み込んで使用できます。

```pages/root/index.vue
<template lang="pug">
#root-index
  App
  p Index page
  img(src='../../images/image.jpg')
</template>

<style lang="scss"></style>

<script>
import App from '../../components/app'

export default {
  components: {
    App
  }
}
</script>
```

#### ページコンポーネント登録
ここで`el: '#app'`を指定して登録することでViewファイル内で呼び出すことができます。

```pages/root/rootIndex.js
import Vue from 'vue'
import RootIndex from './index'

new Vue({
  el: '#app',
  components: {
    RootIndex // これでViewファイル内では<root-index />で呼べます。
  }
})
```

### まとめ
今回はRailsからwebpackerを完全に捨てて、webpackでアセットを管理する方法を紹介しました。
Vueでページコンポーネントを作成することによるファイル管理のしやすさもかなりいい感じにまとまりました。

参考になりましたら「いいね！」やTwitterフォローお願いします。



### その他参考サイト
https://inside.pixiv.blog/subal/4615
https://medium.com/studist-dev/goodbye-webpacker-183155a942f6