SprocketsとWebpackerを使わずWebpackのみでassets周りを管理した話(動かすまで編)

Rails 6 がリリースされてWebpackerが標準搭載されるようになった。
個人的にSprocketsとWebpackerってどっちかでよくないかと思ったので、Sprocketsは使わずWebpackerでassets周りを全部管理しようかと思った
でもこのWebpackerはフロントエンドのエンジニアの方々からあまり好かれていない様子。
それならWebpackだけでassets周りを管理できたらいいのかなと思いチャレンジしてみる。
せっかくなのでwebpack-dev-serverも使いたいのでその設定も
今回は使わないがフロントエンドでVueを使う想定していて作ったものなのでちょくちょくVueの設定も含まれている

出来上がったもの

github.com

参考にした記事

inside.pixiv.blog
qiita.com
medium.com

Railsプロジェクトの作成

testはMinitestではなくRSpecを使いたいので--skip-testする。

rails new . -d mysql --skip-turbolinks --skip-test

Gemfileから不要なものを消す

とりあえずこんな感じのGemfile

source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }

ruby '2.6.3'

# Bundle edge Rails instead: gem 'rails', github: 'rails/rails'
gem 'rails', '~> 6.0.0'
# Use mysql as the database for Active Record
gem 'mysql2', '>= 0.4.4'
# Use Puma as the app server
gem 'puma', '~> 3.11'
# Transpile app-like JavaScript. Read more: https://github.com/rails/webpacker
gem "rack-proxy"

# Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder
# gem 'jbuilder', '~> 2.7'
# Use Redis adapter to run Action Cable in production
# gem 'redis', '~> 4.0'
# Use Active Model has_secure_password
# gem 'bcrypt', '~> 3.1.7'

# Use Active Storage variant
# gem 'image_processing', '~> 1.2'

# Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', '>= 1.4.2', require: false

# Use Rack CORS for handling Cross-Origin Resource Sharing (CORS), making cross-origin AJAX possible
# gem 'rack-cors'

gem "slim-rails"

group :development, :test do
  # Call 'byebug' anywhere in the code to stop execution and get a debugger console
  gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
end

group :development do
  gem "web-console", ">= 3.3.0"
  gem 'listen', '>= 3.0.5', '< 3.2'
  # Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring
  gem 'spring'
  gem 'spring-watcher-listen', '~> 2.0.0'
  gem "rubocop", "~> 0.63.1", require: false
end

group :test do
  gem "capybara"
  gem "selenium-webdriver"
  gem "database_cleaner"
  gem "webmock"
  gem "simplecov"
  gem "factory_bot_rails"
end

# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby]

package.jsonを設置してyarn installする

package.json はこんな感じ。

{
  "name": "webpack_pure",
  "private": true,
  "dependencies": {
    "@rails/actioncable": "^6.0.0",
    "@rails/actiontext": "^6.0.0",
    "@rails/activestorage": "^6.0.0",
    "@rails/ujs": "^6.0.0",
    "axios": "^0.18.0",
    "import-glob-loader": "^1.1.0",
    "moment": "^2.24.0",
    "trix": "^1.0.0" ,
    "vue": "^2.6.8",
    "vue-loader": "^15.7.0",
    "vue-template-compiler": "^2.6.8"
  },  
  "version": "0.1.0", 
  "scripts": {
    "build:dev": "webpack --progress --mode=development --config ./webpack.config.js", 
    "build:pro": "webpack --progress --mode=production --config ./webpack.config.js"
  },                                                                                                                                                                                                                                   
  "devDependencies": {
    "@babel/core": "^7.3.4", 
    "@babel/plugin-proposal-class-properties": "^7.3.4",
    "@babel/plugin-syntax-dynamic-import": "^7.2.0",            
    "@babel/plugin-transform-runtime": "^7.3.4",
    "@babel/polyfill": "^7.2.5",
    "@babel/preset-env": "^7.3.4",
    "@fortawesome/fontawesome-free": "^5.8.1",
    "babel-loader": "^8.0.5",
    "babel-plugin-macros": "^2.5.0",
    "css-loader": "^2.1.1",
    "mini-css-extract-plugin": "^0.5.0", 
    "node-sass": "^4.11.0", 
    "pug": "^2.0.3",
    "pug-plain-loader": "^1.0.0", 
    "sass-loader": "^7.1.0", 
    "vue-style-loader": "^4.1.2", 
    "webpack-cli": "^3.2.3",
    "webpack-dev-server": "^3.2.1",
    "webpack-manifest-plugin": "^2.0.4"
  }                                                                                                                                                                                                                                    
}

yarn install する

$ yarn install

webpack.config.js を設置する

webpack.config.jsはこんな感じ
gemのほうのwebpackerで出力されるconfigをカスタマイズしながら作った
RAILS_ROOT/frontend 以下にimage css javascript のファイルを置くのを想定

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/entry/**/*.js").map(function(file) {
  let name = file.replace("./frontend/entry/", "").split(".")[0];
  entries[name] = file;
});

const assets_path = path.join(__dirname, "public", "assets");

module.exports = (env, argv) => {

  return {
    entry: entries,
    output: {
      filename: "js/[name]-[hash].js",
      chunkFilename: "js/[name]-[hash].chunk.js",
      hotUpdateChunkFilename: "js/[id]-[hash].hot-update.js",
      path: assets_path,
      publicPath: "/assets/",
      pathinfo: true
    },
    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$/,
          use: [
            {
              loader: "vue-loader",
              options: { extractCSS: true }
            }
          ]
        },
        {
          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 "/assets/images/" + path;
            }
          }
        },
        }
      ]
    },
    resolve: {
      alias: {
        vue: "vue/dist/vue.js"
      },
      extensions: [
        ".vue",
        ".mjs",
        ".js",
        ".sass",
        ".scss",
        ".css",
        ".module.sass",
        ".module.scss",
        ".module.css",
        ".png",
        ".svg",
        ".gif",
        ".jpeg",
        ".jpg"
      ],

      modules: ["node_modules"]
    },

    devServer: {
      clientLogLevel: "info",
      compress: true,
      quiet: false,
      disableHostCheck: true,
      port: 3035,
      https: false,
      host: "0.0.0.0",
      hot: true,
      contentBase: assets_path,
      inline: false,
      useLocalIp: false,
      public: "0.0.0.0:3035",
      publicPath: "/assets/",
      historyApiFallback: { disableDotRule: true },
      headers: { "Access-Control-Allow-Origin": "*" },
      overlay: true,
      watchOptions: { ignored: "**/node_modules/**", poll: true }
    }
  };
};

Hot Module Replacement の設定は下記部分のinlineをtrueに設定すれば有効になるがローカルで開発する場合のみ有効
EC2のインスタンス上で開発していたりする場合はJSのエラーが出るのでfalseにしといたほうがよさそう

    devServer: {
      clientLogLevel: "info",
      compress: true,
      quiet: false,
      disableHostCheck: true,
      port: 3035,
      https: false,
      host: "0.0.0.0",
      hot: true,
      contentBase: assets_path,
      inline: true, // ⬅︎これ
      useLocalIp: false,
      public: "0.0.0.0:3035",
      publicPath: "/assets/",
      historyApiFallback: { disableDotRule: true },
      headers: { "Access-Control-Allow-Origin": "*" },
      overlay: true,
      watchOptions: { ignored: "**/node_modules/**", poll: true }
    }

下記コマンドを叩くと RAILS_ROOT/public/assets/ 以下に manifest.json と各assetsが配布される
本番へデプロイするときに叩く用

$ yarn build:pro

RAILS_ROOT/app/helpers/webpack_bundle_helper.rbを設置する

RAILS_ROOT/frontend 以下にあるassetsファイル群を呼び出すためのhelper

module WebpackBundleHelper
  class BundleNotFound < StandardError; end

  def asset_bundle_path(entry, **options)
    valid_entry?(entry)
    asset_path(manifest.fetch(entry), **options)
  end

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

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

    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

  def image_bundle_tag(entry, **options)
    raise ArgumentError, "Extname is missing with #{entry}" if File.extname(entry).blank?

    image_tag asset_bundle_path(entry), **options
  end

  private

    def manifest
      @manifest ||= JSON.parse(manifest_json_file)
    end

    def manifest_json_file
      return OpenURI.open_uri("http://railswebpack:3035/assets/manifest.json").read if Rails.env.development?

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

    def valid_entry?(entry)
      return true if manifest.key?(entry)

      raise BundleNotFound, "Could not find bundle with name #{entry}"
    end
end

RAILS_ROOT/lib/dev_server_proxy.rbを設置してRAILS_ROOT/config/environments/development.rbを追記する

開発環境側のRAILS_ROOT/app/viewsからwebpack-dev-server上で作成されているassetsを参照するためにproxyを使う
RAILS_ROOT/lib/dev_server_proxy.rb を作成する。中身はこんな感じ

require "rack/proxy"
class DevServerProxy < Rack::Proxy
  def perform_request(env)
    if env["PATH_INFO"].start_with?("/assets/")
      env["HTTP_HOST"] = dev_server_host
      env["HTTP_X_FORWARDED_HOST"] = dev_server_host
      env["HTTP_X_FORWARDED_SERVER"] = dev_server_host
      super
    else
      @app.call(env)
    end
  end

  private

    def dev_server_host
      "railswebpack:3035"
    end
end

dev_sever_host はwebpack-dev-serverが動いている環境のhostを指定する
今回はdocker上でやっていたのでこんな感じ

RAILS_ROOT/config/environments/development.rbでdev_server_proxy.rbをrequireする

require Rails.root.join("lib", "dev_server_proxy.rb")

Rails.application.configure do
defultで設定されているもの
...
  config.middleware.use DevServerProxy, ssl_verify_none: true # ⬅︎これを追記
end

image css js の使い方

まずはエントリーポイントを設定する
今回はここに設置RAILS_ROOT/frontend/entry/application.js
actiontextも使えるようにしておく

require("@rails/ujs").start();
require("@rails/activestorage").start();
import "../../../node_modules/@fortawesome/fontawesome-free/js/all";
require("../javascripts/channels");
require("@rails/actiontext");

import "../stylesheets/application";
const images = require.context("../images/", true);

// RAILS_ROOT/frontend/javascripts/ 以下のJSをインポートして使いたい場合はそれを記述しておく
// import "../javascripts/common";

slim での利用を想定

レイアウトファイルで読み込むCSSとJSの設定
RAILS_ROOT/app/views/layouts/application.html.slim

doctype html
html
  head
    title
      | Webpack Pure
    = csrf_meta_tags
    = csp_meta_tag

    / 使いたいエントリーポイント
    = javascript_bundle_tag "application"
    / 使いたいエントリーポイントの中でimportしているcss名
    = stylesheet_bundle_tag "application", media: "all"

  body
    = yield

画像の使い方
RAILS_ROOT/app/views/tops/index.html.slim

.center
  = image_bundle_tag("images/ruby.jpg")

次はこのwebpackを使った状態でcapybaraのテストを書いていく