ようやくSprocketsからWebpackerに移行したので、そのためにやったことをまとめておきます。
移行前の状態
筆者(私)は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: {
importLoaders: 2
},
},
{
loader: "sass-loader",
},
]
}
]
}
これは、以下のような処理を行う設定です。
- test:で、正規表現に該当するファイル(今回は拡張子が.scss)を対象とすることを宣言
- exclude:で、除外する設定を行う(ライブラリは既に変換済のため)
- use:で、対象のファイルに使うloaderを配列で指定する。その際、loaderが適用される順番は配列の逆順であるので注意する。
- sass-loaderでsassをcssに変換する。pipelineで次のloaderに渡される。
- css-loaderでcssをJavaScriptで扱える形に変換する。pipelineで次のloaderに渡される。
- MiniCssExtrctPlugin.loderでminifyする(余計な空白・改行を取り除く)
- 次のローダーがないので終了する。
で、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に追加します。
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
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')
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')
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の時点で、コメントアウトされているものがあります。
このコメントアウトを外します。
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に戻しましょう。
地味ですがこれも忘れずに…。
image_pathやimage_urlを変更していく
image_path, image_urlメソッドで画像のパスやURLが取れなくなるので、それらを直しておきます。asset_pack_path, asset_pack_urlメソッドを使うようになるのですが、パス指定が'media/images/'が先に付くようになります。
image_path('logo.png')
image_url('logo.png')
asset_pack_path('media/images/logo.png')
asset_pack_url('media/images/logo.png')
これはCoffeeScriptをやめた後の話にはなるのですが、画像の話なのでここでしておきます。
GoogleMap等でピンを立てるための画像を読み込んでいた場合、Sprocketsの頃はcoffee.erbに拡張子を変更して、image_urlヘルパーメソッドを使って画像のパスを取っていましたが、webpackでは、画像をimportできます。あとはimgタグのsrcにその変数を渡すだけです。
var MapIcon = "<%= image_url("map_icon.png"); %>";
import MapIcon from '../images/map_icon.png'
当初は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
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ファイルの読込をするために、今までは一度画面を表示してから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';
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はもう削除したので、コメントアウトしました。
これで、ステージング環境へのリリースに成功し、動作検証もパスしました。
まとめ
誰か褒めてほしい。
そして、本記事がSprocketsからWebpackerに移行するのをためらっている人のお役に立てば幸いです。