30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

Steb by Step で剥がす Webpacker

この記事では、Webpackerを使っている Rails アプリからWebpackerを剥がし、webpackを使うようにするための手順を書いていく。

Webpackerを止めたい理由は様々だが、主な理由は以下のような感じだろうか。

  • 現時点ではWebpackerが使用しているwebpackのバージョンは3.xで、最新バージョンに追従できていない
  • webpackの設定の他に、「Webpackerの設定」について学ばなければならず、無駄が多い
  • webpackを直接触れば済む話を、常にWebpackerという仲介者を通して作業しなければならない

手軽にフロントエンド開発環境を導入できる、というのがWebpackerの利点であり、カスタマイズやらアップデートやらが必要になってしまうのならあまりメリットはない、と個人的には感じている。

前回の記事で、Webpackerと Vue を使ったサンプルアプリを作成した。

numb86-tech.hatenablog.com

このアプリからWebpackerを削除し、webpackで Vue コンポーネントなどをビルドできるようにしていく。

この記事のゴールは「ちゃんと動くようにするための仕組みを作る」ことであって、Webpackerが行っていたことを忠実に再現することはしない。
Webpackerを剥がすために必要な作業の全体像を掴み、それを記録しておくことがこの記事の意図であり、細部には踏み込まない。
ただ、スタイルシートを別ファイルとして抽出するなどの基本的な動作は踏襲する。

Webpackerが果たしている主な仕事は以下。

  1. app/javascript/packsに入っているファイルをエントリファイルとして、public/packsにダイジェスト付きでビルドする
  2. マニフェストファイルとしてpubplic/packs/manifest.jsonを生成する
  3. webpack-dev-serverを使うことで、差分を検知し、自動的にビルドやブラウザのリロードを行う
  4. javascript_pack_tagstylesheet_pack_tagで、ビルドされたファイルを読み込める

これをwebpackで実現する。

以下のバージョンで作業している。

Step1 Webpacker の削除

まずは、Webpackerを削除していく。

Gemfileからgem 'webpacker'を削除して、$ bundleを実行。
$ 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

最後に、設定ファイルからWebpackerに関する記述を削除する。

diff --git a/config/environments/development.rb b/config/environments/development.rb
index 4b8cc17..1311e3e 100644
--- a/config/environments/development.rb
+++ b/config/environments/development.rb
@@ -1,6 +1,4 @@
 Rails.application.configure do
-  # Verifies that versions and hashed value of the package contents in the project's package.json
-  config.webpacker.check_yarn_integrity = true
   # Settings specified here will take precedence over those in config/application.rb.
 
   # In the development environment your application's code is reloaded on
diff --git a/config/environments/production.rb b/config/environments/production.rb
index 457946a..b4dbe4a 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -1,6 +1,4 @@
 Rails.application.configure do
-  # Verifies that versions and hashed value of the package contents in the project's package.json
-  config.webpacker.check_yarn_integrity = false
   # Settings specified here will take precedence over those in config/application.rb.
 
   # Code is not reloaded between requests.

また、Turbolinksが有効になっている場合は忘れずに無効化しておく。
これが有効になっていると、ページ遷移時に上手く動かなくなる。
Turbolinksと共存させる方法もあると思うが、ここでは扱わない。

Step2 webpack でビルドできるようにする

この時点では Rails との連携は考えず、まずはapp/javascript/packsに入っているファイルをビルドできるようにする。

必要なライブラリをインストールする。

$ yarn add -D webpack webpack-cli
$ yarn add -D css-loader file-loader sass-loader node-sass mini-css-extract-plugin vue-loader@latest
$ yarn add -D webpack-manifest-plugin

webpack@4からは、CSSの抽出にはextract-text-webpack-pluginではなくmini-css-extract-pluginを使うことが推奨されている。
しかし、Webpacker経由で Vue の開発環境を整えた場合はvue-loaderv14が入っており、これがmini-css-extract-pluginに対応していない。そのため、最新バージョンのvue-loaderを入れる必要がある。

次に、プロジェクトのルートディレクトリ直下にwebpack.config.jsを用意する。
もっとよい書き方があるとは思うし、プロジェクトによってはこれではダメかもしれないが、今回扱っているサンプルアプリの場合はこれで問題なく動く。
Babel の対応は次の Step で行う。

const path = require('path');
const VueLoaderPlugin = require('vue-loader/lib/plugin');
const ManifestPlugin = require('webpack-manifest-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');


module.exports = (env, argv) => {
  const isProduction = argv.mode === 'production';
  return {
    context: path.resolve(__dirname, 'app/javascript/packs'),
    entry: {
      application: './application.js',
      'application-stylesheet': './application.sass',
      hello_vue: './hello_vue.js',
    },
    output: {
      path: path.resolve(__dirname, 'public/packs'),
      filename: isProduction ? '[name]-[contentHash].js' : '[name]-[hash].js',
    },
    module: {
      rules: [
        {test: /\.(css|sass)$/, use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader']},
        {
          test: /\.(png|jpg|gif)$/,
          use: [
            {
              loader: 'file-loader',
              options: {
                outputPath: 'images/',
                name: '[name]-[hash].[ext]',
              },
            },
          ],
        },
        {
          test: /\.vue$/,
          exclude: /node_modules/,
          loader: 'vue-loader',
          options: {
            extractCSS: true,
          },
        },
      ],
    },
    plugins: [
      new VueLoaderPlugin(),
      new ManifestPlugin(),
      new MiniCssExtractPlugin({filename: '[name]-[contentHash].css'}),
    ],
  };
}

あとは、npm スクリプトとして"build": "yarn && webpack --mode=production"を登録し、$ yarn buildを実行すれば、public/packsにビルドされる。

Step3 Babel の対応と v7 へのマイグレーション

Rails5.2Webpacker環境で使われる Babel はv6なので、この機会にv7に上げておく。

必要なライブラリをインストール。

$ yarn add -D babel-loader @babel/core @babel/preset-env @babel/plugin-syntax-dynamic-import @babel/plugin-proposal-class-properties

次に、マイグレーションを行うが、公式のツールが便利なのでそれを使う。

github.com

このツールを使うことで、必要なライブラリのインストールなどが行われる。

$ npm i -g babel-upgrade
$ babel-upgrade --write --install

.babelrcの更新も自動的に行われる、はずなのだが、環境によっては実行されなかった。
取り敢えず今回のケースでは以下のようにすればよい。

diff --git a/.babelrc b/.babelrc
index 47cfe92..3233cc0 100644
--- a/.babelrc
+++ b/.babelrc
@@ -1,24 +1,31 @@
 {
   "presets": [
-    ["env", {
-      "modules": false,
-      "targets": {
-        "browsers": "> 1%",
-        "uglify": true
-      },
-      "useBuiltIns": true
-    }]
+    [
+      "@babel/preset-env",
+      {
+        "modules": false,
+        "targets": {
+          "browsers": "> 1%"
+        }
+      }
+    ]
   ],
-
   "plugins": [
-    "syntax-dynamic-import",
-    "transform-object-rest-spread",
-    ["transform-class-properties", { "spec": true }]
+    "@babel/plugin-syntax-dynamic-import",
+    "@babel/plugin-proposal-object-rest-spread",
+    [
+      "@babel/plugin-proposal-class-properties",
+      {
+        "spec": true
+      }
+    ]
   ],
-
   "env": {
     "test": {
-      "presets": ["env", "power-assert"]
+      "presets": [
+        "@babel/preset-env",
+        "power-assert"
+      ]
     }
   }
 }

あとはwebpackbabel-loaderを使うようにすればよい。

diff --git a/webpack.config.js b/webpack.config.js
index 89d3790..2fcb99d 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -19,6 +19,11 @@ module.exports = (env, argv) => {
     },
     module: {
       rules: [
+        {
+          test: /\.js$/,
+          include: path.resolve(__dirname, 'app'),
+          use: 'babel-loader',
+        },
         {test: /\.(css|sass)$/, use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader']},
         {
           test: /\.(png|jpg|gif)$/,

これで、ビルドの際に Babel によるトランスパイルが行われるようになる。

Babel のバージョンをv7に上げたことで、babel-preset-power-assertの最新バージョン(v3)を使えるようになったので、対応しておく。

$ yarn add -D babel-preset-power-assert@3.0.0

Step4 webpack-dev-server を導入

ビルドは出来るようになったので、次は、webpack-dev-serverを使えるようにする。これがないと自動ビルドや自動リロードが出来ないので、開発に支障をきたしてしまう。

Webpackerをインストールしたときに導入されたwebpack-dev-serverはバージョンが古いので、更新する。

$ yarn add -D webpack-dev-server@latest

コマンドの追加と設定ファイルへの追記を行う。

diff --git a/package.json b/package.json
index 98cde84..b573325 100644
--- a/package.json
+++ b/package.json
@@ -2,6 +2,7 @@
   "name": "webpacker-example",
   "private": true,
   "scripts": {
+    "start": "yarn && webpack-dev-server --mode=development",
     "build": "yarn && webpack --mode=production",
     "test": "jest"
   },
diff --git a/webpack.config.js b/webpack.config.js
index 2fcb99d..4e94f0b 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -52,5 +52,16 @@ module.exports = (env, argv) => {
       new ManifestPlugin(),
       new MiniCssExtractPlugin({filename: '[name]-[contentHash].css'}),
     ],
+    devServer: {
+      publicPath: '/packs/',
+      historyApiFallback: true,
+      port: 3035,
+    },
   };
 }

$ yarn startを実行し、http://localhost:3035/packs/manifest.jsonにアクセスできれば成功である。

Step5 ヘルパータグの実装

次はいよいよ、Rails とのつなぎ込みを行う。
javascript_pack_tagに代わるヘルパータグを作成する。

app/helpers/webpack_bundle_helper.rbとして、以下のファイルを作成した。

require 'open-uri'
# webpackによるビルドファイル読み込み用ヘルパー
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 === 'production' ? '3000' : '3035'
      "http://#{request.host}:#{port}/"
    end

    def pro_manifest
      File.read('public/packs/manifest.json')
    end

    def dev_manifest
      # webpack-dev-serverから直接取得する
      OpenURI.open_uri("#{asset_server}packs/manifest.json").read
    end

    def test_manifest
      File.read('public/packs-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}packs/" + manifest.fetch(entry), **options)
    end
end

そしてこれを、テンプレートファイルのなかで使う。

diff --git a/app/views/posts/index.html.erb b/app/views/posts/index.html.erb
index 3bfde83..a418880 100644
--- a/app/views/posts/index.html.erb
+++ b/app/views/posts/index.html.erb
@@ -26,7 +26,7 @@
 
 <%= link_to 'New Post', new_post_path %>
 
-<%= javascript_pack_tag 'application' %>
-<%= stylesheet_pack_tag 'application' %>
-<%= javascript_pack_tag 'hello_vue' %>
-<%= stylesheet_pack_tag 'hello_vue' %>
+<%= javascript_bundle_tag 'application' %>
+<%= stylesheet_bundle_tag 'application-stylesheet' %>
+<%= javascript_bundle_tag 'hello_vue' %>
+<%= stylesheet_bundle_tag 'hello_vue' %>

$ bin/rails s$ yarn startを同時に使って、http://localhost:3000/にアクセスしてみる。
プロダクション環境を確認したい場合は、$ yarn buildしたうえで$ bin/rails s -e productionを実行する。

概ね動いているが、app/javascript/app.vueのなかで使っている画像が表示されていない。

f:id:numb_86:20190109221045p:plain

最後の仕上げとして、この問題を修正する。

Step6 Vueのコンポーネントで使っている画像を表示できるようにする

表示されていない画像だが、確認してみると、http://localhost:3000/images/usa-xxxxx.pngを参照している。

だがこのパスは誤りであり、以下のパスが正しい。

環境 パス
デベロップメント http://localhost:3035/packs/images/usa-xxxxx.png
プロダクション http://localhost:3000/packs/images/usa-xxxxx.png

この問題はプロキシを作成して対応することにした。

まず、rack-proxyをインストール。
Gemfilegem 'rack-proxy'と追記して、$ bundleを実行すればよい。

次に、lib/tasks/assets_path_proxy.rbという名前でプロキシを作成。

require 'rack/proxy'

# Vue のコンポーネントのなかで利用している画像にアクセスできるようにするためのプロキシ
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'] = "/packs/images/" + env['PATH_INFO'].split("/").last
      super
    else
      @app.call(env)
    end
  end

end

そして、設定ファイルにプロキシを使う設定を書き込む。

diff --git a/config/environments/development.rb b/config/environments/development.rb
index 1311e3e..f4a4f26 100644
--- a/config/environments/development.rb
+++ b/config/environments/development.rb
@@ -1,3 +1,5 @@
+require_relative '../../lib/tasks/assets_path_proxy'
+
 Rails.application.configure do
   # Settings specified here will take precedence over those in config/application.rb.
 
@@ -12,6 +14,8 @@ Rails.application.configure do
   # Show full error reports.
   config.consider_all_requests_local = true
 
+  config.middleware.use AssetsPathProxy, ssl_verify_none: true
+
   # Enable/disable caching. By default caching is disabled.
   # Run rails dev:cache to toggle caching.
   if Rails.root.join('tmp', 'caching-dev.txt').exist?
diff --git a/config/environments/production.rb b/config/environments/production.rb
index b4dbe4a..dc6fac5 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -1,3 +1,5 @@
+require_relative '../../lib/tasks/assets_path_proxy'
+
 Rails.application.configure do
   # Settings specified here will take precedence over those in config/application.rb.
 
@@ -14,6 +16,8 @@ Rails.application.configure do
   config.consider_all_requests_local       = false
   config.action_controller.perform_caching = true
 
+  config.middleware.use AssetsPathProxy, ssl_verify_none: true
+
   # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"]
   # or in config/master.key. This key is used to decrypt credentials (and other encrypted files).
   # config.require_master_key = true

これで、表示されるようになる。

f:id:numb_86:20190109105929p:plain

2019/1/25 追記

webpack + Rails のウェブアプリを作った。
numb86-tech.hatenablog.com

参考資料