1
/
5

Rails 7.0でアセットパイプラインはどう変わるか

Rails 7.0ではフロントエンドサポートが刷新されます。新たなライブラリが多数導入され、選択肢が増えるため、「Rails公式のものを選べばOK」という戦略が通用しなくなります。

本稿では、Railsでフロントエンドを書くための選択肢について、その歴史と実装を踏まえて比較検討します。

結論から言うと

(まだアルファ版なので今後も状況が変わる可能性はありますが、) 新規アプリケーションではSprocketsの役割は無くなりそうです。新しいライブラリとして Propshaft, importmap-rails, jsbundling-rails, cssbundling-rails が登場し、主要な選択肢として以下が提供されます。 (各ライブラリの詳細については後述します)

  • Propshaft + importmap-rails
    • デフォルトの選択肢。Node.jsが不要。
    • トランスパイルを含め、複雑なことをしたくなったら別の選択肢に移行する。
  • Propshaft + jsbundling-rails (+ cssbundling-rails)
    • 好きなバンドラーと組み合わせられる。設定も自由
    • ホットリロードはできない。
  • Webpacker
    • Webpackとのフルの統合を提供する。
    • 設定に若干癖があるが、基本的にはフロントエンド寄りのやり方でいじれるようになっている。
    • Propshaftとの共存は可能。

残念ながら「どれが一番いいのか?」については確定的な答えは出せません。それがあるなら、そもそもこんなに沢山の選択肢が一度に提示されることはないでしょう。ベストプラクティスはRails 7.0.0の普及にともなって判明していくのかもしれません。なお、筆者の所感はこの記事の末尾に記してあります。

移行用の選択肢として以下が使えます。 (Sprocketsがある場合はPropshaftは不要)

  • Sprockets + importmap-rails
  • Sprockets + jsbundling-rails (+ cssbundling-rails)
  • Sprockets + Webpacker

ディレクトリ構成に注目して各ツールを分類すると以下のようになります。こういう複雑な処理が起こっているという図ではありません。Railsが提供する全ての選択肢を重ねて表示することで相互の関係を示したもので、個々のツールはこれよりもシンプルです。



黎明期のRails JS (2005年頃)

ここからはRails誕生からWebpackerまでのRailsフロントエンドの歴史を順に見ていきます。 (筆者自身がRailsを触りはじめたのは5.x系の頃のため、古い情報は当時の情報のサルベージに基づいています。また、前後関係がおおよそわかるように参考程度に年情報を付与しています)

Railsでは ./public に置いたファイルが静的ファイルとして配信されます。これはRailsの初期からありました。また javascript_include_tagstylesheet_link_tagRails 0.10.0で追加されています。この時点では以下の仕様でした。

  • 絶対パスはそのまま、相対パス (`/` を含まない) ならば `/javascripts/` や `/stylesheets/` からの相対

そのため、JSやCSSは /public/javascripts, /public/stylesheets に配置していました。その後シンボル指定でJavaScript/CSSをまとめてincludeする仕組みが追加されています。 (現在は存在しない)

# "defaults" に登録されているJSファイルに加えて、 /javascripts/extra.js もincludeする
javascript_include_tag :defaults, "extra"

この時代はrails newでプロジェクトを初期化するときに /public/javascripts 以下に必要なファイル (Prototype.js や今でいうrails-ujsなど) が配置されていたようです。

プレーンRailsを試す

この時代のRailsはアセットパイプラインがありませんでしたが、今でもアセットパイプラインを外したRailsアプリケーションを作ることは可能です。

6.1までの既存のRailsアプリケーションからSprocketsを外すには、まずGemfileにSprocketsや関連gem (sass-rails, coffee-railsなど) がない状態にします。また、Webpackerを外すには、webpacker gemをGemfileから外します。

# sprockets-railsは使わない
# gem 'sprockets-rails', '~> 3.2'
# sass-railsは使わない
# gem 'sass-rails', '>= 6'
# webpackerは使わない
# gem 'webpacker', '~> 5.0'

6.1までのRailsはrails/allでsprockets-railsを同梱するため、まずconfig/application.rbのrails/allを展開する必要があります。

# config/application.rb

# require "rails/all"  # これのかわりに以下のようにする
require "active_record/railtie"
require "active_storage/engine"
require "action_controller/railtie"
require "action_view/railtie"
require "action_mailer/railtie"
require "active_job/railtie"
require "action_cable/engine"
require "action_mailbox/engine"
require "action_text/engine"
require "rails/test_unit/railtie"
require "sprockets/railtie"

こうするとRailsの特定機能だけ無効化できるので、 sprockets/railtie をrequireしている行を外します。

# require "sprockets/railtie"

bin/rails assets:precompile を実行し、 "Don't know how to build task 'assets:precompile'" エラーが出たら、アンインストール成功です。

rails newをする場合は以下のオプションでSprockets/Webpacker抜きのアプリケーションが生成されます。 (Rails 7.0.0.alでも以下のオプションでimportmap-rails等のインストールを回避できます)

rails new my-app --skip-sprockets --skip-javascript

Rails 7.0.0.alpha1, 7.0.0.alpha2 でも同じコマンドでいけますが、 7.0.0.alpha3 以降 (未定) ではオプション名が変わっています。

rails new my-app --skip-asset-pipeline --skip-javascript

Sprockets/Webpackerがない場合、JavaScriptは直接 public/javascripts 以下に置きます。

// public/javascripts/application.js
alert("Hello!");

適当なビューに入れて表示してみます。

<%# ビューから参照 %>
<%= javascript_include_tag 'application' %>

Rails 2.0 cache (2007〜2011)

アセットパイプラインの先駆けにあたるのがRails 2.0のcacheオプションのようです。

# perform_cachingがtrueの場合は結合される
javascript_include_tag :all, :cache => true

とすると、 public/javascripts/ 以下の .js ファイルを結合した public/javascripts/all.js が生成され、このファイルを参照するような <script> タグが生成されます。その後Rails 3.0で concat オプションでも同じ機能を使えるようになっています。 (どちらかというとCSSの個数制限対応が意図されているようです)

# perform_cachingに関係なく結合される
javascript_include_tag :all, :concat => true

この処理はRails 4.0で削除されています

RJS (2006〜2011)

Rails 1.1から3.0まで、サーバーサイドコード (Ruby) でJavaScriptを生成するRJSという仕組みがあったようです。これは respond_to でJavaScriptを選択したときに自動的に使われ、拡張子 .rjs でビューとして記述することができるようになっていたようです。この機能はRails 3.1で削除されています。かわりに .js.erb を記述することは今でも可能です。

Sprockets (2011〜2021)

Rails 3.1Sprocketsによるアセットパイプラインが実装されました。なおSprocketsはRails 6.1まではrails/allでincludeされているため、 config/application.rb を編集することで無効化できます。Rails 7.0ではrails/allからも取り除かれます

SprocketsとRailsの連携部分の構成はバージョンにより少しずつ異なりますが、ここではRails 6.1 + Sprockets 4.0での仕様をベースに説明します。Sprocketsは(一応)Rails以外のフレームワークとも組み合わせられるように設計されていて、Railsと繋ぐためのブリッジ機能はsprockets-railsが提供しています。

ディレクトリ構成

/app/assets の中身をコンパイルして /public/assets に出力し、これをRails等が配信するという仕組みです。

  • `/public` 静的ファイル置き場
  • `/public/assets` コンパイル後のJS/CSS等 (config.assets.prefix で設定可能)
  • `/public/assets/.sprockets-manifest-*.json` マニフェストファイル
  • `/app/assets` コンパイル前のJS/CSS等
  • `/app/assets/javascripts` JS
  • `/app/assets/stylesheets` CSS
  • `/app/assets/config/manifest.js` マニフェストファイル
  • `/vendor/assets` ライブラリ置き場 (挙動は app/assets と同じ)
  • `/lib/assets` ライブラリ置き場 (挙動は app/assets と同じ)

アセットのコンパイル

production環境ではデプロイ前にアセットのコンパイルを行い、コンパイル済みのアセットを配信します。コンパイルは assets:precompile タスクによって行います。

コンパイル結果は /public/assets 以下にダイジェストつきで出力されます。ダイジェストつきファイル名の索引のためにマニフェストファイルがJSONで出力されます。

コンパイル済みアセットの参照

javascript_include_tagstylesheet_link_tag 等のアセットヘルパは共通の asset_path ヘルパを使ってURLを解決します。これはデフォルト (Sprocketsがない状態) では次のようなルールになっています。

  • ソース名がURLのようであったら、そのまま返す。以降の処理は行わない。
  • それ以外の場合はまず、ソース名にデフォルト拡張子を補完する。 (`application` → `application.js`)
  • ソース名が `/` で始まっていたら、次に進む。それ以外の場合は `compute_asset_path` でアセットディレクトリの相対パスに変換する。 (`application.js` → `/javascripts/application.js`)
  • relative_url_root や asset_host などの設定に基づいてパスを最終的なURLに変換する。

この compute_asset_path の挙動は差し替えられるように設計されていて、 skip_pipeline: true が指定されていなければ差し替えられたバージョンが実行されます。Sprocketsの提供する compute_asset_path は以下のような挙動に差し替えられます。

  • マニフェストファイルからアセットパスを索引する (production環境の場合)。あればそれをそのまま返す。 (`application.js` → `application-0123456abcdef0123456abcdef0123456abcdef0123456abcdef.js`)
  • なければエラーになる。ただし、Rails 5.0以前との互換性オプションが設定されているときはデフォルトの挙動にフォールバックする。

アセットの動的コンパイル

開発環境ではアセットのコンパイルを動的に行います。動的にコンパイルされたアセットを提供するためにsprockets本体で Sprockets::Environment というRack applicationが提供されています。これをsprockets-railsがRailtie内でルーターにマウントすることで、静的アセットと同様のURLでアセットを提供できるようになっています。

動的コンパイルされたアセットの参照

動的コンパイルが指定されたときは javascript_include_tag 等で使われる compute_asset_path の動作も変わります。マニフェストではなく、プロセス内のアセット一覧から索引を行います。

ファイル結合

Sprocketsの役割は大きく「ファイル結合」「トランスパイル」「最小化・難読化」に分けられます。

「ファイル結合」は現代の(Webpack等の)アセットパイプラインでいうところのモジュールバンドラーと同等の役割ですが、Sprocketsのそれは現在のものよりも原始的なものでした。

  • 事前処理でコメント中のrequire指令を探しておきます。
  • その後、require指令に基づいて元ファイルの先頭または末尾 (require_self指定に依存) に依存ファイルを追加します。この作業を再帰的に行い、全てのrequireを展開します。
  • 複数回requireされた場合は、最初のrequireだけが有効です。

require指令は各言語のコメント形式に合わせて解釈されます。以下のような書き方が主に使われています。

// JavaScript用
//= require library.js

# CoffeeScript用
#= require library.js

/* CSS用 */
/*= require library.css */

他にもrequire_treeなど多くの指令がありますが、ここではその詳細は重要ではないので省略します。

パスの解決

ディレクティブによってパスの解決ルールが異なります。

require, depend_on, depend_on_asset, stub, link など単一ファイルを指すディレクティブは仮想ファイルシステム上で解決されます。この仮想ファイルシステムは app/assets/javascriptsapp/assets/stylesheets などのディレクトリがルートに多重マウントされたツリーになっています。

  • `./` または `../` で始まるときは、仮想ファイルシステム上の相対パスで解決されます。
  • それ以外のときは、仮想ファイルシステム上の絶対パスで解決されます。

一方、 require_directory, require_tree, link_directory, link_tree など複数ファイルをまとめて指すディレクティブは実ファイルシステム上で解決されます。

トランスパイル

Sprocketsの2つ目の役割はトランスパイルです。代表的なトランスパイラとして以下の2つが使われていました。

  • CoffeeScript → JavaScript
    • 現在はCoffeeScriptはデフォルトでは使われない
  • SCSS/Sass → CSS

現在ではBabelを使ったJavaScript間のトランスパイルにも対応しています。

トランスパイルは結合前のファイルに対して行われるので、JavaScriptとCoffeeScriptを同じバンドルに入れることは可能です。

SprocketsのトランスパイラはあるMIME Typeのファイルを別のMIME Typeのファイルに変換する関数として表されています。たとえばCoffeeScriptのトランスパイラは text/coffeescript のファイルを application/javascript のファイルに変換する関数として登録されています。Sprocketsは要求されたMIMEタイプから適用するべきトランスパイラを判定します。

SprocketsはMIMEタイプを拡張子から判定します。CoffeeScriptは .js.coffee.coffee の両方を登録しているため、 application.js を生成するには application.js.coffee でも application.coffee でもよいことになります。

ERBもトランスパイラと同列に扱われていますが、 application.js.coffee.erbframe.html.erb, application.css.erb のように様々なファイルタイプに対して使えるようになっています。これは既知のMIMEタイプと拡張子を全て列挙して登録して回るという力技によって達成されています。

最小化・難読化

バンドル処理後の成果物に対して行う変換処理です。主に帯域節約と難読化を意図して行われます。Sprockets本体には以下のアダプタが付属しており、対応するライブラリを入れることで使えるようになります。

  • CSS: Sass-Ruby, Sass-C, YUI Compressor
  • JavaScript: Closure Compiler, JSMin (original C version), UglifyJS, YUI Compressor

CoffeeScriptのIIFE

SprocketsはJavaScriptファイルをただ結合するだけなので、グローバルスコープでそのまま実行されます。ただし、CoffeeScriptで書いた場合はトランスパイル時に自動的にIIFEで囲まれるため、ローカル変数はファイルスコープに閉じられます。

(function() {
  // CoffeeScriptのトランパイル後のコード
})();

いずれにせよ export や module.exports に相当する機能はないため、APIはグローバル変数に直接エクスポートすることになります。

SCSS/Sassの import/use

Sprocketsのバンドル処理とは別に、SCSS/Sassには以下の@ルールが存在しています。

/* baseをスコープ化せずに取り込む (現在はSassでは非推奨) */
@import 'base';
/* baseをスコープ化して取り込む */
@use 'base';

これらはトランスパイル中に行われるので、import/useされた側のスタイルシートでSprocketsのrequire指令を使っても意図通りに動くとは限りません。

SprocketsはSass-Ruby / Sass-C の呼び出し時にSprockets側のパスをload_paths引数として渡しているため、基本的にはSprockets側の仕組みと同じようなパス解決ができるはずです。また、カスタム関数として asset_path() などいくつかの関数が追加で使えるようになっています。

Bower / npm サポート

require指令(他の指令も含む)のパス解決に対する拡張としてBowerサポートとnpmサポートがあります。npmサポートでは参照先がpackage.jsonを含むディレクトリだったときに、以下のファイルに探索範囲を拡張します。

  • package.jsonがmainキーを含んでいたら、そのファイル。
  • 含んでいなかったら、 index.js。
  • package.jsonがstyleキーを含んでいたら、そのファイル。

またRails 5.1以降で生成される config/initializers/assets.rb には node_modules をパスに追加するような処理が書かれています。そのため、UMD形式で配布されているパッケージや style キーを通じてCSSを同梱しているパッケージはSprocketsから自然に扱えるようになっています。

デバッグモード

トランスパイラを使っていなくても、ファイルを結合するとソースコードの位置情報がずれてしまいます。この問題に対する古典的な解決方法としてsprockets-railsにはデバッグモードが用意されています。

現在のRailsの javascript_include_tag は1つの引数に対して1つの <script> タグを出力しますが、デバッグモードではこの挙動を上書きします。結合したファイルを使うかわりに、結合前のファイルを直接参照する <script> タグを順に出力します。

この問題はSprockets 4.xで導入されたsource map対応でも解決できるため、旧来のデバッグモードは無くしていく方向性のようです。 (その前にSprocketsが無くなってしまうかもしれませんが……)

Sprocketsを試す

今からわざわざSprocketsでコーディングを始めることはないでしょうが、本稿ではアセットパイプラインの理解を深めるためにSprocketsを含めた全てのツールを試していきます。

SprocketsはRails 7.0.0.alpha2まではRailsに同梱されているため、そのまま使えます。webpacker, importmap-rails, jsbundling-rails, cssbundling-rails は不要であれば外しておきましょう。rails newで生成するときは以下のオプションが使えます。

rails new my-app --skip-javascript

Rails 7.0.0.alpha3 (未定) 以降でPropshaftが入っている場合は、Gemfileからpropshaftを外してsprockets-railsを入れる必要があります。

# Propshaft
# gem "propshaft", ">= 0.1.7"
# Sprockets
gem "sprockets-rails", ">= 2.0.0"

または、生成時は以下のオプションが使えます。

# 現時点ではsprocketsがデフォルト
rails new my-app --asset-pipeline=sprockets --skip-javascript

まず生成するべきアセットを一覧したマニフェストファイルを作ります。

// app/assets/config/manifest.js

//= link_tree ../images
//= link application.js
//= link application.css

// 全てのディレクトリを含めるために以下のようにする流儀もある
//= link_directory ../stylesheets .css
//= link_directory ../javascripts .js

application.css と application.js を用意します。

/* app/assets/stylesheets/application.css */
// app/assets/javascripts/application.js

//= require rails-ujs
//= require jquery

$(function() {
  alert("Hello!");
});

上の例ではjQueryを入れたので、jquery-railsを依存関係に追加しておきます。

bundle add jquery-rails

ビューかレイアウトに入れて呼び出します。

<%= javascript_include_tag "application" %>

Webpack (2015〜)

WebpackはNode.jsベースのアセットパイプラインで、Node.js内では最もポピュラーなアセットパイプラインとしての地位を確立しています。 (その中核機能から、アセットパイプラインではなくバンドラーと呼称されることのほうが多いようです)

Webpack自身はRubyやRailsとは関係ないですが、以降の説明のために軽く説明しておきます。

バンドリング

Webpackの中核機能はモジュールバンドラーです。JavaScriptには2015年に標準化されたモジュール形式 (ES Modules) と、それ以前にコミュニティーで使われていたいくつかの形式があります。WebpackはES Modulesに加えて、Node.jsで長年使われているCommonJS Modulesという形式をサポートしています。

// ES Modules
import React from "react";
export const foo = 42;

// CommonJS Modules
const React = require("react");
exports.foo = 42;

いずれのモジュール形式も各モジュールごとに独立したスコープで実行され、APIをモジュールエクスポートとして提供することができます。これによりグローバルの名前空間を汚染せずにコード分割を行うことができます。

Webpackはモジュールのimport/requireを解析し、必要なモジュールが全て含まれた1つのスクリプトファイルを生成します。 (entrypointやchunkなどを使うと複数ファイルに生成することもできますが詳細は省きます) これは実際にJavaScriptのコードを構文解析した上で、以下のような処理を行っています。

  • 基本的には、各モジュールを関数として表現して、ファイル名から関数を索引する巨大なハッシュ (オブジェクトリテラル) を作る。import/requireが呼ばれると、このハッシュから関数を引いてきて実行する (またはキャッシュ済みの結果を利用する)。
  • ただし、解析した結果必ず特定の順番で読み込まれることがわかっているモジュールについては、あらかじめ1つのモジュールに結合する。このとき変数名が衝突しないように処理を施しておく。

CSSバンドリング

WebpackではCSSもバンドル対象にすることができます。出力方法として以下の2つがあります。

  • <style> タグを生成するJavaScriptコードとして出力 (style-loader)
  • 呼ばれる可能性のあるCSSを全てまとめて .css として出力 (MiniCssExtractPlugin)

Sassの依存解決がCSSの依存解決と連携できない問題はWebpackにも存在します。

  • SassはSass/SCSS中の `@import` や `@use` をSassの一部として解決します。
  • 一方、Webpackのcss-loaderはCSSの `@import` や `url()` を解決する処理を行います。

これによりSassで結合された url() 内で相対パスがうまく動作しない問題が生じます。これを解決するために resolve-url-loader というサードパーティーの仕組みが提供されています。

トランスパイル

ローダーと呼ばれるプラグインを組み合わせることでトランスパイル処理が行えます。トランスパイルを行うローダーとして、たとえば以下のようなものがあります。

  • babel-loader (Babel)
  • ts-loader (公式のTypeScriptコンパイラ)
  • coffee-loader (CoffeeScript)
  • sass-loader (Sass-C または Dart-Sass)

これらの細かい設定や適用順、適用条件などは webpack.config.js という設定ファイルで細かく指定できるようになっています。

最小化と難読化

生成されたバンドルに対して最小化・難読化を適用します。デフォルト設定でproduction向けにTerserが適用されるようになっています。

webpack-dev-server

Webpackのデフォルトの挙動はSprocketsで言うところの assets:precompile に近いです。開発用にはwebpack-dev-serverという機能が別途提供されています。

webpack-dev-serverはWebpack本体と以下のように異なります:

  • 生成されたアセットはファイルに出力せず、メモリ上に保持します。
  • 1回限りのコマンドではなく、サーバーが立ち上がります。このサーバーから、メモリ上に保持されたアセットを配信します。
  • ソースファイルの更新を監視し、再コンパイルを行います。
  • 自動リロード。ページ側で更新をSockJS (WebSocketのpolyfillのようなもの) で監視し、ページを再読み込みします。
  • ホットリロード。自動リロードに似ていますが、ページを再読み込みせずに変更のあったモジュールだけを再実行します。各モジュールでホットリロードに必要な処理が書かれているときに有効です。

webpack-dev-serverはSprocketsのEnvironmentモード (動的コンパイルモード) に似ていますが、サーバーサイド処理を持たないことに注意が必要です。 (webpack-dev-middleware を使えばExpressサーバーにwebpack-dev-serverの機能を持たせることもできますが、ここでは省略します)

サーバーサイド処理を持たないwebpack-dev-serverの使い方は主に以下の3つです:

  • webpack-dev-server自身が配信するダミーのindex.htmlを使う。 (SPAとしての使い方)
    • 動的なHTMLを返すことはできない。
    • history APIと組み合わせる場合はhistoryApiFallbackを設定する必要がある。
  • 別のオリジンからwebpack-dev-serverのアセットを参照する。
    • webpack-dev-server側でCORSの設定が必要。または、crossorigin属性をオフにする。
    • チャンク分割する場合はpublicPathの設定が必要。
  • 別のサーバーからwebpack-dev-serverにリバースプロキシする。

Webpacker (2017〜)

WebpackerはWebpackをRailsのアセットパイプラインとして使うためのブリッジライブラリです。Rails 5.1の時代にオプションとして登場し、Rails 6.0以降に生成されたアプリケーションではデフォルトになっています。Rails 6.xでの推奨構成は以下の通りです。

  • JavaScriptはWebpackerでコンパイルする。
  • CSSや静的アセットはSprocketsでコンパイルする。

ディレクトリ構成

Sprocketsとは独立したディレクトリに出力されるようになっています。

  • `/public` 静的ファイル置き場
  • `/public/packs` コンパイル後のアセット (config/webpacker.yml の public_output_path で設定可能)
  • `/public/packs/js` JSアセット
  • `/public/packs/manifest.json` マニフェストファイル
  • `/app/javascript` コンパイル前のアセット (source_path で設定可能)
  • `/app/javascript/packs` エントリポイント (Webpacker <= 5の場合。 source_entry_path で設定可能)

アセットのコンパイル

production環境ではデプロイ前にアセットのコンパイルを行い、コンパイル済みのアセットを配信します。コンパイルは assets:precompile タスクによって行います。 (Sprocketsが有効な場合はSprocketsの同名のタスクと共存します。)

コンパイル結果は /public/packs 以下にダイジェストつきで出力されます。ダイジェストつきファイル名の索引のためにマニフェストファイルがJSONで出力されます。

コンパイル済みアセットの参照

Webpackerは既存の javascript_include_tag / stylesheet_link_tag の挙動は変えず、新規にヘルパを提供します。特にJSとCSSは以下のヘルパを使って呼び出します。

  • javascript_pack_tag
  • stylesheet_pack_tag

Webpacker 6以降ではjavascript_pack_tagでdeferがデフォルトになります。

Webpackのチャンク分割を使うと、複数のエントリポイントで共通のコードが括り出されます。これらの共通チャンクが複数回ロードされるのは(挙動の点からも)好ましくないため、javascript_pack_tagの派生である javascript_packs_with_chunks_tag を用いて以下のように書くことが推奨されています。

<%# 一度に全てのエントリポイントを指定する (ヘルパを複数回に分けて呼ばない) %>
<%= javascript_packs_with_chunks_tag 'calendar', 'map' %>

Webpacker 6ではこのヘルパが javascript_pack_tag にリネームされ、旧来の javascript_pack_tag は削除されています。

webpack-dev-serverの扱い

dev_serverオプションがtrueのときはwebpack-dev-serverの利用が試みられます。開発環境でwebpack-dev-serverが起動していない場合はフォールバックとして「ソースが更新されていたら <script> タグの挿入時にコンパイルする」というオプションが選択されます。

Webpacker 2.x まではwebpack-dev-serverが提供するアセットを <script> で直接参照していたようですが、現在はRailsがwebpack-dev-serverにリバースプロキシするように設定されています。これによりCORSの対応が不要になっています。

なお、Rails 6.1からは config.ru に以下の行が追加されました。

Rails.application.load_server

この設定がある場合、Railsサーバー起動時に追加のサービスを起動することが可能です。Webpackerにはこれを利用した仕組みは今のところないようですが、アプリケーションで以下のように設定すればwebpack-dev-serverを自動で起動させることができそうです。

# config/application.rb

module MyApp
  class Application < Rails::Application
    # ...

    if Rails.env.development?
      ENV['NODE_ENV'] ||= Rails.env
      server do
        require "webpacker/dev_server_runner"

        Dir.chdir(File.expand_path("..", __dir__)) do
          Webpacker::DevServerRunner.run([])
        end
      end
    end
  end
end

設定ファイル

Webpackerの設定の構造はやや複雑です。これが理由でWebpackerの採用をためらっている人も多いかもしれません。

まずWebpackerの基本設定ファイルは config/webpacker.yml に配置されます。これは「当該設定ファイルをJavaScriptとRubyの両方から読み出せること」と「Railsの設定ファイルの慣習に合わせること」が意図されていると考えられます。

一方Webpackの設定ファイルは環境ごとに config/webpack/#{ENV['NODE_ENV']}.js に配置されます。Webpacker 5では、これはデフォルトで以下のような内容になっています。

// development.js
process.env.NODE_ENV = process.env.NODE_ENV || 'development'

const environment = require('./environment')

module.exports = environment.toWebpackConfig()
// environment.js
const { environment } = require('@rails/webpacker')

module.exports = environment

つまり、 @rails/webpacker が生成するデフォルト値を使うようになっています。その @rails/webpackerconfig/webpacker.yml の値を参照しながらデフォルト設定を生成します。 (たとえばWebpackerの設定から source_entry_path の値を読み取り、エントリポイントを列挙する)

Webpacker 6でも基本的な構造は同じですが、デフォルト設定のスリム化が図られています。Webpacker 5に存在していた cache_manifest, extract_css, static_assets_extensions, extensions などの設定項目がなくなり、必要に応じて config/webpack/ 以下で設定するよう改められました。今後この流れが続けば、Webpackerが独自の設定体系を持つために手を出しづらいという問題点は解消されるかもしれません。

Webpackerを試す

WebpackerはSprockets/Propshaftとは独立して動作するため、Webpackerを試すにあたってはSprockets/Propshaftは残しても消しても構いません。Sprocketsの無効化方法はすでに説明してあるので省略します。

Webpackerが入っていない場合、まずGemfileに依存を足します。

gem "webpacker", "~> 5.4"

必要なファイルを生成します。

bin/rails webpacker:install

Webpacker 5の場合は app/javascript/packs/application.js にコードが含まれています。適当にコードを書いてみます。

// app/javascript/packs/application.js

import "@rails/ujs";
import React from "react";
import ReactDOM from "react-dom";

const root = document.querySelector("#root");
if (root) {
  ReactDOM.render(
    <div>Hello!</div>,
    root
  )
}

これはJSXを使っているので、リネームします。

mv app/javascript/packs/application.js app/javascript/packs/application.jsx

必要なライブラリとプラグインを入れます。

yarn add @rails/ujs react react-dom @babel/preset-react

config/webpacker.yml に拡張子を足します。 (Webpacker 5の場合)

default: &default
  # ...
  extensions:
    - # ...
    - .js
    - .jsx
    - # ...

babel.config.js にプリセットを足します。 (Webpacker 5の場合)

// ...
  return {
    presets: [
      // ...,
      "@babel/preset-react",
    ],
    // ...
  }
// ...

生成されたバンドルをビューかレイアウトから呼び出します。

<%= javascript_pack_tag "application", defer: true %>

Reactのマウント先を用意しておきます。

<div id="root"></div>

webpack-dev-serverとRailsを起動します。

# 別々のターミナルかbackground jobで両方起動する
bin/webpack-dev-server
bin/rails s

これでReactが動いているのを見ることができます。

なお、Webpacker 4以降はデフォルトでproductionでもsource mapが有効なので注意が必要です。 (関連するDHHの発言)

Webpackerの今後の位置づけ

後述するimportmap-rails, jsbundling-railsの登場によってRailsの推奨するデフォルトからは影を潜めそうです (少なくとも現状のrails newからは仲間外れにされてそう)。

しかし技術的にはimportmap-railsやjsbundling-railsはWebpackerのユースケースを完全に置き換えることはできないと考えられます。このことはjsbundling-rails内の比較表を見てもわかります。少なくとも今すぐWebpackerが使われなくなる (メンテナンスされなくなる) ことはないと思います。

Simpacker

すでに説明したようにWebpackerは設定方法が独特である点がしばしば問題になります。そこでWebpackerの役割のうち「RailsとWebpackのブリッジライブラリ」としての最小限の仕事をするように実装したSimpackerというサードパーティーのライブラリがあるようです。詳しくは作者による紹介記事 (日本語) を読むといいでしょう。

Propshaft (2021〜)

PropshaftはRails 7.0での導入を目指して開発されているアセット管理インターフェースです。まだ設計・実装は固まりきってはいませんが、READMEによれば以下がPropshaftの存在意義のようです

  • トランスパイル、バンドリング、圧縮、などは原則として行わない。他のツールに任せる。
  • ファイル名にダイジェストをつけてコピーするだけ。

それ以外の部分は今後変わる可能性があります。とはいえ、今後どうなるかを占うためにも現時点 (2021年10月時点) でどうなっているのかを知っておくのは有意義でしょう。以降は現時点での実装に基づいた説明です。

現時点でのPropshaft

現時点でのPropshaftの特徴のひとつは、Sprocketsと同じインターフェースを使っていることです。これはSprocketsと共存できないことを意味します。WebpackerがSprocketsとの競合を避け独立したインターフェースを採用したのとは対照的だと言えます。

Propshaftでは assets:precompile を呼ぶとソースファイルが出力先にコピーされます。ここで、

  • ソースファイルはSprocketsと同様に探索されます。デフォルトでは `app/assets/*`, `vendor/assets/*`, `lib/assets/*` 以下のファイルです。
  • 宛先はデフォルトでは `public/assets` です。

このときファイル名にダイジェストが付与されます。これだけだとさすがに不便すぎるので、ダイジェストつきのファイルを参照できるように最低限のコンパイルが行われます。現在実装されているのは以下の処理です。

  • CSS中に `url(something.png)` のようなURL参照があったら、そのパスをダイジェストつきの絶対パスに置き換える。

Sprocketsと同様、開発環境では assets:precompile をしなくてもいいように、直接アセットを返すためのミドルウェアがマウントされます。

Propshaftを試す

PropshaftはRails 7.0.0.alpha2以上を要求します。あらかじめRailsをそこまで上げておきます。

Rails 7.0.0.alpha2時点ではSprocketsがデフォルトに含まれているので、取り除く必要があります (その手順は説明済み。) Rails 7.0.0.alpha2 でrails newする場合は以下のコマンドから始めます。

rails new my-app --skip-sprockets --skip-javascript

Propshaftを依存に追加します。

gem "propshaft", "~> 0.2.0"

Rails 7.0.0.alpha3以降 (未定) では以下のコマンドでPropshaft設定済みの状態から始められます。

rails new my-app --asset-pipeline=propshaft --skip-javascript

Propshaftで直接JavaScriptを書くことは想定されていませんが、Sprocketsの縮小版なのでできないこともありません。ここではapplication.jsを置いてみます。

// app/assets/javascripts/application.js
alert("Hello!");

ビューから参照すれば呼ばれます。

<%= javascript_include_tag 'application' %>

Propshaftの位置づけ

後述するjsbundling-rails, cssbundling-rails, importmap-railsはSprocketsまたはPropshaftの存在を前提にしています。特にSprocketsのアセット処理が要らない場合 (Rails 7.0で新しく作られるアプリケーションはこれに当たります) のデフォルトの選択肢として使われることになりそうです。

また、importmap-railsはJavaScriptのみでCSSには対応していないので、Propshaftはimportmap-railsとの併用も意図されていそうです。

Sprocketsは既存資産との互換性のために今後もメンテナンスされると考えられますが、他の方法への移行を済ませた段階でPropshaftに差し替えるのが良さそうです。

jsbundling-rails, cssbundling-rails (2021〜)

jsbundling-railsはRails 7.0でアセット処理の選択肢のひとつにするべく開発が進められているライブラリです。BabelやWebpackの力を借りる必要がある (e.g. JSX) が、それ以上の仕掛けは必要ない、というユースケースが想定されていそうです。以降は現時点の実装に基づいて説明します。

現時点でのjsbundling-rails

jsbundling-railsのRubyライブラリとしての実装はほぼ空です。本質的にはRake taskとWebpack(等)の初期設定の提供だけを行います。そのRake task ( assets:precompile → javascript:build ) が行うのは以下の処理です。

  • yarn install && yarn buildを呼ぶ。

そのyarn buildの中身はインストール時に自動生成されます。プリセットをWebpack, Rollup, esbuildから選べますが、いずれも以下の処理を行います。

  • `app/javascripts` 以下のソースをコンパイルし、 `app/assets/builds` に出力する。

その app/assets/builds はSprocketsまたはPropshaftのソースディレクトリとして認識されるため、最終的に public/assets に出力されます。

cssbundling-railsもやっていることはだいたい同じです。

jsbundling-railsを試す

jsbundling-railsにはSprocketsまたはPropshaftが必要です。Rails 6.1以前の場合はデフォルトでSprocketsが入っているのでそのまま使えます。Webpackerはソースディレクトリが競合するので外しておくとよいでしょう。

gem 'rails', '~> 6.1.4', '>= 6.1.4.1'
# webpackerは外す
# gem 'webpacker', '~> 5.0'
# jsbundling-railsを入れる
gem "jsbundling-rails", "~> 0.1.9"

インストールコマンドを実行します。

# Webpack, rollup, esbuild から選べる
bin/rails javascript:install:webpack

ここではReact + JSXのセットアップを目指します。まずソースはこんな感じ。拡張子をjsxにしておきます。

// app/javascript/application.js -> app/javascript/application.jsx

import "@rails/ujs";
import React from "react";
import ReactDOM from "react-dom";

const root = document.querySelector("#root");
if (root) {
  ReactDOM.render(
    <div>Hello!</div>,
    root
  )
}

依存を入れます。

yarn add @rails/ujs react react-dom babel-loader @babel/core @babel/preset-rea
ct

Webpackの設定をいじります。最低限動く程度の変更です。

const path    = require("path")
const webpack = require('webpack')

module.exports = {
  mode: "production",
  entry: {
    // jsxにした
    application: "./app/javascript/application.jsx"
  },
  // jsx等を足した
  resolve: {
    extensions: [".js", ".ts", ".jsx", ".tsx", ".wasm"],
  },
  // babel-loaderを通すようにした
  module: {
    rules: [
      {
        test: /\.(?:[jt]sx?|[mc][jt]s)$/,
        use: "babel-loader",
      }
    ]
  },
  output: {
    filename: "[name].js",
    path: path.resolve(__dirname, "app/assets/builds"),
  },
  plugins: [
    new webpack.optimize.LimitChunkCountPlugin({
      maxChunks: 1
    })
  ]
}

babel.config.jsを作ります。

module.exports = {
  presets: ["@babel/preset-react"],
};

ビューから参照します。マウント先を用意しておきます。

<%= javascript_include_tag "application", defer: true %>
<div id="root"></div>

以上ができたらビルドプロセスを立ち上げます。 (ホットリロードやwebpack-dev-serverなどの高級な仕組みはありません)

yarn build --watch

この状態でRailsを立ち上げて表示すればReactが実行されます。

jsbundling-rails, cssbundling-rails の位置づけ

jsbundling-railsのやり方はWebpack統合としては明らかに機能不足です。

  • webpack-dev-server やそれに付随する機能 (HMRなど) が利用できない。
  • dynamic chunkを使い始めた場合にSprockets/Propshaftのダイジェスト処理と競合する。

上記の欠点はjsbundling-railsの設計に根ざした本質的なものだと考えられます。逆に、jsbundling-railsはこれらの割り切りをすることでシンプルかつ柔軟な解決策を提示しているのだとも言えます。

これらの本質的な制約を突破する必要が出たらWebpackerに移行する、という棲み分けが考えられそうです。

importmap-rails (2021〜)

importmap-railsはRails 7.0でのJavaScriptアセット処理のデフォルトとしての採用を目指して開発されているライブラリです。

WebブラウザのES Modulesサポート

ES Modulesは2015年のES2015で標準化されたモジュールの仕様です。2018年頃から、WebブラウザがES Modulesをサポートするようになっています

<!-- このHTMLだけでReactが動く -->
<div id="root"></div>
<script type="module">
import { jsx } from "https://jspm.dev/react@17/jsx-runtime";
import ReactDOM from "https://jspm.dev/react-dom@17";
ReactDOM.render(
// <div>Hello!</div>
jsx("div", { children: "Hello!" }),
document.querySelector("#root")
);
</script>

type="module" を指定すると、ソースが Script ではなく Module として解釈され、import宣言が使えるようになります。ここでは jspm.dev からパッケージを取得しています。JSPMはnpmのパッケージを (CJSをESMに変換して) ブラウザから呼び出せる形で提供するCDNです。

モジュールは必ず defer 属性がついたものとして扱われる点には注意が必要です。

import maps

import maps はWebブラウザ上でモジュールに別名をつけるための仕組みで、現在はドラフト仕様の段階です。ChromeやEdgeでは既に使えるようになっています

import mapsはJSONのオブジェクトで、あるURLのモジュールが別のURL (パス) を参照するときに、その参照先を差し替えることができます。 package.json の export maps / import maps とも似ているので、既に馴染み深いという人も多いかもしれません。

たとえば、先ほどの例をあえてimport mapsを使って書き換えると以下のようになります。

<div id="root"></div>
<script type="importmap">
{
  "imports": {
    "react": "https://jspm.dev/react@17",
    "react/": "https://jspm.dev/react@17/",
    "react-dom": "https://jspm.dev/react-dom@17",
    "react-dom/": "https://jspm.dev/react-dom@17/"
  }
}
</script>
<script type="module">
import { jsx } from "react/jsx-runtime";
import ReactDOM from "react-dom";
ReactDOM.render(
  // <div>Hello!</div>
  jsx("div", { children: "Hello!" }),
  document.querySelector("#root")
);
</script>

import mapsが真価を発揮するのは間接依存が存在する場合です。Node.jsのエコシステムでは package-lock.jsonyarn.lock によって間接依存のバージョンを固定することができましたが、これはES Modulesの仕組み単独では実現できません。import mapsを使うことで、「間接依存のバージョンをトップレベルで固定する」という (現在ではJavaScript/npmに限らず多くのプログラミング言語で採用されている) ワークフローをWebブラウザの世界に持ち込むことができます。

JSPMは jspm.dev で従来のES Modules向けの配信を行っていますが、これとは別に jspm.io でimport maps向けの配信を行っています。jspm.devではimport宣言のファイル名がjspm.dev内 (パッケージの最新バージョンを指すリンク) に変換されるのに対して、jspm.ioが配信するモジュールはファイル名がそのままになっているため、import mapsで変換をかける必要があります (逆に言うと、import mapsと組み合わせて利用するのに特化した仕組みになっています。)

es-module-shims

import mapsはまだ標準化されておらず、Firefoxを含め実装されていないWebブラウザも多数あります。es-module-shimsは基本のES Modules機能を用いてimport mapsを含むES Modulesの追加機能を実現してくれるので、これがあればより多くのWebブラウザをimport mapsに対応させることができます。

config/importmap.rb と bin/importmap

Railsの出力するimportmapは config/importmap.rb から生成されます。つまりこの config/importmap.rb がnpm/yarnにおけるロックファイル ( package-lock.json, yarn.lock ) に相当すると言えます。

これらのロックファイルを操作するコマンドが bin/importmap にインストールされます。yarn installのかわりにこのコマンドを使うことになります。詳しくはimportmap-railsのドキュメントを参照してください。yarnとの構造上の違いとして以下が挙げられます。

  • パッケージ単位ではなくモジュール単位で管理する必要がある。 (たとえば react を入れても react/jsx-runtime は入らない)
  • ロックファイルの元となる `package.json` に相当するものはない。

コンパイル

SprocketsまたはPropshaftを使って /app/javascript の内容を /public/assets にコピーします。 (importmap-railsは、この設定を追加するという仕事だけをします)

アセットの参照

import mapsを使う都合上、既存のヘルパー (javascript_include_tag, javascript_pack_tag) とは異なるヘルパーを使うことになります。

<%# importmapを宣言する %>
<%= javascript_inline_importmap_tag %>

<%# preloadタグ、Linkヘッダーを出力する %>
<%= javascript_importmap_module_preload_tags %>
<%# または個別にpreloadすることも可能 %>
<%#= javascript_module_preload_tag "application" %>

<%# import map未対応ブラウザ用のshimを入れる (最初のタグはCSP対策に必要) %>
<%= javascript_importmap_shim_nonce_configuration_tag %>
<%= javascript_importmap_shim %>

<%# トップレベルモジュールの呼び出し %>
<%= javascript_import_module_tag "application" %>

以上をまとめたヘルパーが提供されています。

<%= javascript_importmap_tags %>

開発用モード

Sprockets または Propshaft の開発用モードがそのまま使われます。Webpackのようなホットリロードはありません。

importmap-railsを試す

importmap-railsにはSprocketsまたはPropshaftが必要です。Rails 6.1以前の場合はデフォルトでSprocketsが入っているのでそのまま使えます。Webpackerはソースディレクトリが競合するので外しておくとよいでしょう。

gem 'rails', '~> 6.1.4', '>= 6.1.4.1'
# webpackerは外す
# gem 'webpacker', '~> 5.0'
# importmap-railsを入れる
gem "importmap-rails", "~> 0.8.1"

インストールコマンドを実行します。

bin/rails importmap:install

Webpackerを消したので、 javascript_pack_tag を使っている箇所は消しておきます。

Rails 7.0.0-alpha2の場合は Sprockets + importmap-railsがデフォルトなので、ここまでの作業をせずにそのまま使えます。また、現在の最新のmainではPropshaft + importmap-railsがデフォルトです。

@rails/ujs が必要だと思うのでpinします。

bin/importmap pin @rails/ujs@6.1.4-1
# Rails 7の場合の例
bin/importmap pin @rails/ujs@7.0.0-alpha2

app/javascript/application.js にimportを書きます。

import "@rails/ujs";

同様に、Reactを追加します。パッケージごとではなくモジュールごとに追加する必要があるので注意が必要です。

bin/importmap pin react@17 react@17/jsx-runtime react-dom@17

application.js に処理を追加してみます。

import { jsx } from "react/jsx-runtime";
import ReactDOM from "react-dom";

const root = document.querySelector("#root");
if (root) {
  ReactDOM.render(
    // <div>Hello!</div>
    jsx("div", { children: "Hello!" }),
    root
  );
}

ビューにdivを追加するとrenderされます。

importmap-rails の位置づけ

ES Modules + import mapがRailsにとって福音なのは、Rubyだけでコンパイルが完結するという点にあります。開発できるまでのステップが少ないことはRailsにとって非常に重要な要件の一つだからです。 (もちろん、Webpackに太刀打ちできるだけの強力なモジュールバンドラーをRubyで作るという線がなかったわけではないでしょうが、やはりJSのパーサーから作るとなると大変だったでしょう。)

一方で、現在のimportmap-railsはトランスパイルができません。PropshaftのレイヤでBabelの処理を入れるなどの可能性もありますが、結局「画像やCSSとの連携」「ホットリロード」「sourcemap」…… と対応したい問題が増えていくことを考えると、Propshaft + importmap-railsは現在のシンプルな構成のままでいくのではないかと思います。

トランスパイルやその他の追加機能が必要になった段階でjsbundling-railsまたはWebpackerに移行する、というのが現在の立ち位置なのではないかと思います。

所感

RailsはSprocketsによって「JavaScript, CSSを前処理する」というフローを確立しましたが、その後の進化はNode.jsエコシステムで進むようになり、Railsは「Rubyでワンストップのエコシステムを提供することにこだわるか」「Node.jsのよくできた(そして多くのフロントエンドエンジニアが使い慣れた)エコシステムに身を委ねるか」というジレンマに悩まされるようになったのだといえます。Webpackerはその中間的な存在であり、お世辞にも上手くいったとは言えないのではないかと思います。

Rails 7.0では、ひとつの方法で全てのユーザーを幸せにすることを諦めて、複数の選択肢を提示するという方向に舵を切ったのでしょう。これは今までのRailsのあり方とは異なるようにも見えますが、フロントエンドエコシステムについてRailsが抱える問題に正面から向き合った結果だとも思います。Rails 7.0で、RailsとNode.jsエコシステムの関係がよりよいものになると良いなと思います。

個人的にはWebpackerを入れた上で config/webpack/*.js のデフォルト設定を消して自分で全部設定を書くのが一番正しいような気がしています。

更新履歴

  • 2021-10-26 公開。
  • 2021-10-26 図が誤解を招かないように注釈を追加。Sprocketsが誕生した時代背景がわかるように年情報を追加。
Wantedly, Inc.からお誘い
この話題に共感したら、メンバーと話してみませんか?
Wantedly, Inc.では一緒に働く仲間を募集しています
96 いいね!
96 いいね!

同じタグの記事

今週のランキング

原 将己さんにいいねを伝えよう
原 将己さんや会社があなたに興味を持つかも