patorashのブログ

方向性はまだない

SprocketsをやめてWebpackerに移行したのでどうやったか公開する

ようやくSprocketsからWebpackerに移行したので、そのためにやったことをまとめておきます。

移行前の状態

  • Railsのバージョンは6.0系
  • Sprockets4
  • CoffeeScript
  • Sass
  • Bootstrap3を使用
  • yarnは使ってる

筆者(私)はECMAScriptに関してはそこまで詳しくなくて、今後習得していきたいと思っているレベル。

方針

「とにかくWebpackerに移行する」ということを念頭に置き、JavaScriptを完璧にモジュール化する等は目指さない。Webpackerで動けばいい。後でリファクタリングしていくから!

Webpackerをざっくり理解する

Webpackerはwebpackの設定などをほとんど意識することなく、いい感じに使えるようにしてくれるやつです。

webpackをざっくり理解する

じゃあwebpackって何?となるかと思いますが、webpackはフロントエンドに関連するファイル群を(基本的には1つに)まとめる(bundleする)役割を担います。具体的に言うと、JavaScript, CSS, 画像を全部JavaScript内にbundleしてしまうというものです。画像もbase64の文字列データにしてしまいます(後述するloaderを使う等)。

loaderをざっくり理解する

基本的にはwebpackはまとめることしか行いません。しかし、ただまとめただけでは実際のウェブサイトでは使い物になりません。例えばJavaScriptの新しいバージョンの記法(ES2015以上)で実装すると、レガシーなブラウザでは動作しなくなりますし、Sassはcssに変換されません。そのため、webpackにはbundleする途中に処理を追加する仕組みがあります。それがloader(ローダー)です。

あまり詳しくないのですが、Node.jsにはStream.pipeline(パイプライン)という仕様があって、データを処理した後に次のpipelineに渡して更に処理、更に次のpipelineに渡して処理…のようにすることができます。loaderもその仕組みを使っています。

例えば、以下のような設定があるとします。(webpack.config.jsの途中の設定。webpackerを使っていたら登場しません)

module: {
  rules: [
    {
      test: /\.scss$/,
      exclude: /node_modules/,
      use: [
        {
          loader: MiniCssExtractPlugin.loader,
        },
        {
          loader: 'css-loader',
          options: {
            // 0 => no loaders (default);
            // 1 => postcss-loader;
            // 2 => postcss-loader, sass-loader
            importLoaders: 2
          },
        },
        {
          loader: "sass-loader",
        },
      ]
    }
  ]
}

これは、以下のような処理を行う設定です。

  1. test:で、正規表現に該当するファイル(今回は拡張子が.scss)を対象とすることを宣言
  2. exclude:で、除外する設定を行う(ライブラリは既に変換済のため)
  3. use:で、対象のファイルに使うloaderを配列で指定する。その際、loaderが適用される順番は配列の逆順であるので注意する。
  4. sass-loaderでsassをcssに変換する。pipelineで次のloaderに渡される。
  5. css-loaderでcssJavaScriptで扱える形に変換する。pipelineで次のloaderに渡される。
  6. MiniCssExtrctPlugin.loderでminifyする(余計な空白・改行を取り除く)
  7. 次のローダーがないので終了する。

で、Webpackerは何をしているのか?

webpackでは、webpack.config.jsに上記のようなローダーの設定などを行わなければなりません。 Webpackerでは、この辺りが隠蔽されていて、最初からいい感じに処理してくれるようになっています。webpackerの設定から動的にwebpack.config.jsを生成するようなイメージです。 メリットとしては、モダンなフロントエンドがすぐに使えるので楽!

しかし、webpackerが何をやっているのかがぱっと見でわからないので、モダンなフロントエンドよくわからない、webpack怖い、というふうになってしまいます。そこがwebpackerのデメリットかなと思います。

また、開発者自身が新たにloaderを追加したいこともあります。Webpackerではloaderの差込も行うことはできますが、webpack.config.jsを書くわけではないので、そこがまたわかりにくさを助長しているように思えます。 Webpackerをある程度使いこなそうと思ったら、webpackに関する理解もしないといけません。

私はwebpackに関しては、Kindleで読めるwebpack実践入門でざっくり理解しました。

patorash.hatenablog.com

500円で買えますし、Kindle Unlimitedユーザならば無料で読めますのでおすすめしておきます。

webpackerの導入

まずはwebpackerを入れます。入れ方はgithubのreadmeに書いてありますが、一応載せておきます。

github.com

Gemfileに追加します。

# Gemfile
gem 'webpacker', '~> 5.x'

そしてインストール。ついでにerb-loaderも入れます。gem js-routesを使っている場合はerb-loaderもここで入れときましょう。

bundle install
bundle exec rails webpacker:install
bundle exec rails webpacker:install:erb

これで様々なファイルが自動生成されます。babel.config.jsとかpostcss.config.jsも作成されますが、編集しません。 今後、編集するファイルは以下になります。

  • ./app/javascript/*
  • ./config/webpack/*
  • ./config/webpacker.yml

もし開発環境としてDockerを使っていて、webpack-dev-serverもDocker経由で起動する場合は、フロントエンドのファイルの変更検知ができない可能性があるので、ポーリングで検知するようにしておいてください。

patorash.hatenablog.com

SassをWebpackerに委ねる

まずは、CoffeeScriptはSprocketsのままで、SassをWebpackerに移行しようとしました。

scssをapplication.jsで読み込む

webpackのエントリーポイントである、/app/javascript/packs/application.jsで、/app/javascript/stylesheets/application.scssを読み込みます。

import '../stylesheets/application.scss'

これで、application.scssはwebpackのbundleの対象になりました。

css用のヘルパーメソッドを変更する

View側のCSSの読込をstylesheet_include_tagから、stylesheet_pack_tagに変更しました。

vendor以下をwebpackの対象にする

7年くらい前から開発しているRailsアプリなので、未だにvendor/assetsの中にJSやCSSがあったりします。 それらをwebpackでbundleする対象にします。

webpacker.ymlの、resolved_pathを修正します。

default: &default
  # 略

  # Additional paths webpack should lookup modules
  # ['app/assets', 'engine/foo/app/assets']
  resolved_paths: ['vendor/assets/javascripts', 'vendor/assets/stylesheets', 'vendor/assets/images']

ファイルを移動する

/app/assets/stylesheetsから、scssファイルをごっそり/app/javascripts/stylesheetsに移動させます。 もちろん、移動させただけだと動きません。

import文を変更する

Sprocketsの頃は、以下のように読み込んでいました。

@import 'bootstrap-sass';

これが、wepbackerだと以下のようになります。

@import '~bootstrap-sass';

node_modules以下のcssを読み込む場合は~が先頭に必要になるで注意しましょう。

resolve-url-loaderを導入する

いつも悩まされるのが、ライブラリのフォントへのパスを通すやつです。何回同じことやってるんだろう?という気持ちになります。 手元にあった作業ログによると、

  • $icon-font-pathを"~bootstrap-sass/assets/fonts/bootstrap/";にしたこと
  • resolve-url-loaderを入れたこと
  • bootstrap-sass-asset-helperを使わないようにしたこと

によって、Bootstrap3のglyphiconの表示に成功したようです。 bootstrap-sass-asset-helperは、sassにfont_pathなどのメソッドを定義して使うやつだったかと思います。もう不要です。

では、$icon-font-pathを設定しておきます。

$icon-font-path: "~bootstrap-sass/assets/fonts/bootstrap/";
@import '~bootstrap-sass';

次に、resolve-url-loaderを入れましょう!

yarn add -D resolve-url-loader

/config/webpack/environment.jsを編集します。sass-loaderを取得し、その前にresolve-url-loaderを差し込みます。

const { environment } = require('@rails/webpacker')

// resolve-url-loader must be used before sass-loader
const sass_loader = environment.loaders.get('sass')
sass_loader.use.splice(-1, 0, {
  loader: 'resolve-url-loader'
});

module.exports = environment

これで、フォントへのパスは通るようになりました。

import-glob-loaderを導入する

Sprocketsの場合は、自作のscssファイルの読込は@import "modules/*";等で一気に読み込むことができましたが、webpackだけではそれができませんので、import-glob-loaderを入れます。

yarn add -D import-glob-loader

そして、先ほどと同様にloaderをwebpackerに設定します。

/config/webpack/environment.jsを編集します。sass-loaderを取得し、その前にresolve-url-loaderを差し込みます。

const { environment } = require('@rails/webpacker')

// resolve-url-loader must be used before sass-loader
const sass_loader = environment.loaders.get('sass')
sass_loader.use.splice(-1, 0, {
  loader: 'resolve-url-loader'
});
sass_loader.use.push('import-glob-loader') // <= 追加

module.exports = environment

これで、Sprocketsの頃と同様に@import "modules/*";等で一気に読み込めるようになりました。

画像をWebpackerに委ねる

次に、画像もWebpackerに任せようと思います。設定はscssのときと似ていますが、webpackに画像をbundleさせるように認識させなければなりません。 これはrails webpaker:installで作られた/app/javascript/packs/application.jsの時点で、コメントアウトされているものがあります。 このコメントアウトを外します。

// Uncomment to copy all static images under ../images to the output folder and reference
// them with the image_pack_tag helper in views (e.g <%= image_pack_tag 'rails.png' %>)
// or the `imagePath` JavaScript helper below.
//
const images = require.context('../images', true)
const imagePath = (name) => images(name, true)

これで、画像はwebpackで扱えるようになりました。

image_tagをimage_pack_tagへ変更する

このままだと画像が読み込めないので、image_tagメソッドをimage_pack_tagメソッドにしていきます。私は雑に一斉置換しました。 しかし、それだと外部から参照している画像(例えばAWS S3にある画像)やDBにバイナリで保存している画像を表示する際に、「manifest.jsにない」と怒られてエラーになります。そういう部分は再びimage_tagに戻しましょう。

favicon_link_tagをfavicon_pack_tagへ変更

地味ですがこれも忘れずに…。

image_pathやimage_urlを変更していく

image_path, image_urlメソッドで画像のパスやURLが取れなくなるので、それらを直しておきます。asset_pack_path, asset_pack_urlメソッドを使うようになるのですが、パス指定が'media/images/'が先に付くようになります。

# before
image_path('logo.png')
image_url('logo.png')

# after
asset_pack_path('media/images/logo.png')
asset_pack_url('media/images/logo.png')

JavaScriptで画像を参照している場合

これはCoffeeScriptをやめた後の話にはなるのですが、画像の話なのでここでしておきます。 GoogleMap等でピンを立てるための画像を読み込んでいた場合、Sprocketsの頃はcoffee.erbに拡張子を変更して、image_urlヘルパーメソッドを使って画像のパスを取っていましたが、webpackでは、画像をimportできます。あとはimgタグのsrcにその変数を渡すだけです。

// before
var MapIcon = "<%= image_url("map_icon.png"); %>";

// after
import MapIcon from '../images/map_icon.png'

CoffeeScriptをやめる

当初はCoffeeScriptのまま移行しようと思っていたのですが、なかなか思うように行かなかったのでJavaScriptに変換することにしました。参考にした記事が以下の記事の前編・後編なのですが、Sprocketsを使ってたときのようにWebpackを使わないことという文言をみて、踏み切ることにしました。読んでおくことをオススメします。

techracho.bpsinc.jp

decaffeinateを使う

こちらの記事を参考にして、decaffeinateを使ってCoffeeScriptファイルをJSファイルにトランスパイルしました。

kohtaro24.hatenablog.jp

まずはdecaffeinateをインストール。

npm install -g decaffeinate

そして、雑に/app/assets/javascriptsから/app/javascript/srcに移動させてからトランスパイルしました。 また、/vendor/assets/javascriptsもトランスパイルの対象にすることをお忘れなく。

結構いい感じにトランスパイルはしてくれるのですが、もちろんそれだけでは動きません。ES2015以降、JSは基本的にモジュールとして扱わなくてはならないので、importしたりclassをexportしたりしなければなりませんし、CoffeeScriptの持つ暗黙のreturnへの対応なども必要です。これはもう地道に直していきました。

デカフェ後にハマったところを紹介

クラスを継承したらコンストラクタで先にsuperを呼ばないとthisが使えない

参考情報はこちら。

qiita.com

このアプリではMVVMにknockout.jsを使っているのですが、バインディングする処理を基底クラスのコンストラクタでやっていました。 そのため、継承後はプロパティを追加で定義してからsuperを呼び出していました。それができないとわかったので、基底クラス含めて結構書き直して継承後のクラスでバインディングさせるようにしました。

CoffeeScriptのswitch文の変換でbreakが差し込まれなかった

CoffeeScriptのswitch文だと、breakが必要ありません。Rubyのcase式のように使えるわけです。 ところが、トランスパイル後のコードにもbreakが入っていなくて、ずっと想定していない動作をしていました。CoffeeScriptに慣れすぎていたため、なかなかbreakがないことに気づけませんでした。

GemになっているJS便利ツールを使えるようにする

みなさんもgem js-routesは結構使っているんじゃないでしょうか?このアプリも使っています。今更パスを直していくのも辛いので、erb-loaderを使って解決します!

/app/javascript/src/js-routes.js.erbを作ります。

<%= JsRoutes.generate %>

そして、これを/app/javascript/packs/application.jsで読み込んでwindow.Routesに定義します。

// 追加
import Routes from '../src/js-routes.js.erb';
window.Routes = Routes;

これで使えるようになりました。参考情報はこちら。

github.com

JSライブラリをグローバル変数に定義する

ProvidePluginでは対応できない課題

webpackとjQueryを調べるとwebpackのProvidePluginを使って解決しました、という話をよく見かけるのですが、ダメなケースがありました。ProvidePluginは、jQueryに限らずですが、指定した文字列で各ファイルで動的にimportしてくれることで擬似的にグローバル変数ぽく使える、と私は理解しています。

webpackでJSを使うだけならば、全然これでいいのですが、Viewでjs.erbを使っているときに、エラーが起きました。こんなやつです。

// $がなくて落ちる!
$('#foo').html("<%= j(render('list')) %>");

私がほしいのは擬似的なグローバル変数ではなく、本物のグローバル変数に定義されたjQueryなわけです!

expose-loaderを使う

そこで、expose-loaderを使います。

github.com

expose-loaderを使えば、今までと同様にライブラリをグローバル変数に割り当てることができるので上記のような問題が解決します。 このアプリではjQueryだけでなく、underscore.jsやknockout.jsも使っていたので、それらもexpose-loaderで読み込ませるようにしました。

いよいよ、webpackerにローダーを定義していきます。といっても大したことはありません。/app/config/webpack/loaders/erb.jsを参考に、expose-loaderの設定を書くだけです。 /app/config/webpack/loaders/expose.jsを作ります。ただし、exportしたいものが複数あるので、module.exportsではなく、exportsを使いました。

exports.jquery = {
  test: require.resolve('jquery'),
  use: [{
    loader: 'expose-loader',
    options: {
      exposes: [
        { globalName: '$', override: true },
        { globalName: 'jQuery',override: true },
      ],
    },
  }]
}

exports.underscore = {
  test: require.resolve('underscore'),
  use: [{
    loader: 'expose-loader',
    options: {
      exposes: [
        { globalName: '_',override: true },
      ],
    },
  }]
}

exports.knockout = {
  test: require.resolve('knockout'),
  use: [{
    loader: 'expose-loader',
    options: {
      exposes: [
        { globalName: 'ko', override: true },
      ],
    },
  }]
}

これを、/config/webpack/environment.jsで読み込みます。

const { environment } = require('@rails/webpacker')
const erb = require('./loaders/erb')
const expose = require('./loaders/expose')
environment.loaders.prepend('erb', erb)
environment.loaders.prepend('expose-jquery', expose.jquery)
environment.loaders.prepend('expose-underscore', expose.underscore)
environment.loaders.prepend('expose-knockout', expose.knockout)

// 略
module.exports = environment

これで、Sprocketsの時と同様にjQueryやunderscore.jsをグローバル変数に持つことができるようになりました。

jsonを読み込む

jsonファイルの読込をするために、今までは一度画面を表示してからAjaxで取得させてから使っていたのですが、webpackだったらjson-loaderが使えるので導入しました。

こちらも、json-loaderの設定を作っていきます。/app/config/webpack/loaders/json.jsを作成します。

module.exports = {
  type: 'javascript/auto',
  test: /\.json$/,
  use: [{
    loader: 'json-loader',
  }]
}

これを、/config/webpack/environment.jsで読み込みます。

const { environment } = require('@rails/webpacker')
// 略

const json = require('./loaders/json')
environment.loaders.prepend('json', json)

// 略
module.exports = environment

これで、jsonを簡単にロードできるようになりました。リクエスト数が減ってよかったです。

const data = require('data.json');

レガシーブラウザ対応もwebpackerに任せる

CoffeeScript時代はpolyfillを個別に入れていたのですが、Webpackerにすると自動でbabelとcore-jsが入ります。なので、core-jsにpolyfillを任せて、他のpolyfillを削除しました。

core-jsを使うには、/app/javascript/packs/application.jsで以下を追加します。先頭のほうに追加しておいたほうがいいでしょう。

import "core-js/stable";
import "regenerator-runtime/runtime";

Eventクラスのpolyfillはcore-jsにはない

しかし、罠がありました😱core-jsにはEventクラスのpolyfillが含まれていませんでした。 IEはEventクラスでconstructorが未実装だったりと難があります…。

developer.mozilla.org

調査したところ、core-jsはECMAScriptのpolyfillは対応するけれど、それ以外(つまりはIEのみの対応と思われる)は、やらないとのこと…。

github.com

そのため、個別にevents-polyfillを入れました。

github.com

events-polyfillをインストールします。

yarn add events-polyfill

/app/javascript/packs/application.jsを編集します。

import "core-js/stable";
import "regenerator-runtime/runtime";
import 'events-polyfill';

ViewからCoffeeScriptを削除

slimを使っているので、coffee:で検索して、内部をJavaScriptに直しました。これは大したことはないですね。

CircleCIでRspecを実行する前にwebpacker:compileする

CircleCIでも明示的にbundle exec rails webpacker:compileしておいたほうがいいです。一応、やっていなくてもCapybaraが初回アクセス時に動的にコンパイルしてくれるのですが、量が多くてあまりに遅いとなんか不安定だなと感じました。

ステージングへのリリース時にトラブル

これで開発環境の動作検証でも問題なく、CircleCIのテストも通りました。/app/assetsをバッサリと削除して、Gemfileからもsprockets関連のgemを削除して、漸くSprocketsとはオサラバーだわ〜と思っていた矢先、ステージングにデプロイしたら悲劇が待っていました。

assets/config/manifest.jsは削除できない

なんと、sprockets-railsに依存しているgemがあったのです。js-routesとgraphiql-rails等。これらがsprocketsを使う過程でmanifest.jsがないというエラーでHerokuへのデプロイが失敗しました。そのため、/app/assets/config/manifest.jsは復活させました。その他は削除済です。また、ただ復活させただけだと、linkなどの記述が残っていてsprocketsがファイルを探しにいってエラーになるため、manifest.jsのファイルの中身は空にしておく必要があります。

uglifierを削除したのに設定が残っていた

/config/environments/production.rb に、config.assets.js_compressor = :uglifierが残っていたため、assets:precompileで落ちました。uglifierはもう削除したので、コメントアウトしました。

これで、ステージング環境へのリリースに成功し、動作検証もパスしました。

まとめ

誰か褒めてほしい。

f:id:patorash:20200702165147p:plain

そして、本記事がSprocketsからWebpackerに移行するのをためらっている人のお役に立てば幸いです。