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

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

More than 1 year has passed since last update.

追記

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さんのブログが参考になりました。
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

ここからはこちらのブログが大変参考になりました。

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.rbproduction.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導入

単一ファイルコンポーネントについて

コンポーネントを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の公式を読む必要があります。

package.jsonに追記

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

webpackの設定

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

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-serverrails 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

geek_shanshan
Rails/Vue.js/HTML/CSS/JavaScript
http://shanshan.hatenadiary.jp
Why not register and get more from Qiita?
  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
No 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
ユーザーは見つかりませんでした