脱Node.jsにてRails 7.1(Propshaft + Import Maps + DartSass)でBootstrapを利用する
March 22, 2024
はじめに
Railsにてアセット(画像、CSS、JavaScriptなど)を管理するアセットパイプライン機能、こちらは、Railsのバージョンが上がると共に色々と変わっていって、それに対応するのが大変なところです。
Rails 5 までは Sprockets、Rails 6 からは Webpacker、そして、Rails 7 ではその Webpackerも開発がストップしてしまい、標準では組み込まれなくなりました。 なので、Rails 7.1 では、また Sprockets が標準的アセット管理 gem として復活しています。
これまで、JavaScriptのモジュールを管理するために、別途 npm や yarn を入れて、そのために Node.js も動かさなくてはならないなど、Ruby on Rails なのに、なんで Node.js 必要なん、てな感じで、ちょっと Webpacker を毛嫌っていたところがあったのです。
アセットパイプラインの役目としては、大きく以下の4つになると思います。
- ファイル名へのダイジェストハッシュの付加
- 複数のJSやCSSファイルのバンドリング(連結)及び圧縮(最小化)
- JS及びCSS上位言語のコンパイル(トランスパイル)
- 公開フォルダ public への配置
Rails 7 より、importmap-rails が標準になったことにより、複数のJSやCSSファイルをバンドリングして1つのファイルにする必要がなくなりました。背景としては、HTTP/2、ES6/ESM があるのですが、これにより、アセットパイプラインがよりシンプルになったと思います。
今回は、Sprokets に代わる新しいアセットパイプラインとして、今後標準的な存在になるであろう Propshaft を使用し、Node.js に依存しない環境を構築してみたいと思います。
Propshaft は、Sprokets よりもシンプルで、トランスパイルなどの機能がありません。なので、TypeScript や JSX を使用される場合は、別途ビルドツールが必要であり、その際は、importmap-rails ではなく、jsbundling-rails gem (esbuild) を導入することになりますが、そうなると Node.js も導入しなければならなくなってしまいます。
私は、Bootstrap や JQuery があれば良いだけのレガシー指向なので、React や Vue でバリバリフロントエンド(クライアントサイド)を構築したい場合は、いっそのことアセットパイプラインは排除して、APIモード、つまりバックエンド専用でRailsを動かした方が良いかと思います。
前置きが長くなりましたが、scss をコンパイルする Sass は使用したいので、これも Node.js を必要としない Dartで書かれた dartsass-rails を採用したいと思います。
それでは、Node.js に依存しない、次の gem ファイルを使用して、Bootstrap が利用できるまでやってみたいと思います。
- dartsass-rails … scss のトランスパイル(Sass)として
- importmap-rails … CSSやJSのバンドリング
- propshaft … ファイル名のダイジェストハッシュ付加、公開ディレクトリへの配置等
【参考】 Rails 7: importmap-rails gem README(翻訳)|TechRacho by BPS株式会社
最初の環境構築
Rails アプリプロジェクト用のディレクトリを作成し、そこに cd しておきます。ここでは、例として「sample_app
」としています。
$ mkdir sample_app
$ cd sample_app
$ touch Gemfile
当方の bundler の環境はこちらです。今回 Ruby の Docker イメージコンテナ上で構築しますので、BUNDLE_PATH が、/usr/local/bundle
になります。別に、vendor/bundle
でも良いと思います。
$ bundle config
Settings are listed in order of priority. The top value will be used.
app_config
Set via BUNDLE_APP_CONFIG: "/usr/local/bundle"
:
いつものように、Gemfile に rails gem を指定しておきます。
【 Gemfile 】
source "https://rubygems.org"
gem "rails", "~> 7.1.1"
「rails new
」コマンドで特にオプションを指定しないと、
- sprockets gem
- sprockets-rails gem
の gem が入り、アセットパイプラインとして、Sprockets が採用されます。
今回は、Propshaft を採用しますので、オプションとして「-a propshaft
」を加えます。
$ bundle exec rails new . -a propshaft
そうすると、sprockets、sprockets-rails などの gem は導入されず、代わりに propshaft gem が導入されます。
次に、DartSass (dartsass-rails) と、foreman を導入させるために、Gemfileに記述します。dartsass-rails は foreman を必要とします。
【 Gemfile 】
:
gem "dartsass-rails"
gem "foreman"
:
では、「bundle install
」を実行します。
$ bundle install
続いて、dartsass 環境を導入するために、「bin/rails dartsass:install
」を実行します。
$ bin/rails dartsass:install
実行すると、次のようなことが行われます。
- ディレクトリ
app/assets/builds
が生成 app/assets/stylesheets/application.scss
が生成Procfile.dev
が生成
続いて、importmap 環境を導入するために、「bin/rails importmap:install
」を実行します。
$ bin/rails importmap:install
実行すると、次のようなことが行われます。
app/views/layouts/application.html.erb
に、タグ「<%= javascript_importmap_tags %>
」が挿入app/javascript/application.js
が生成- pin によってダウンロードしたJSが格納されるディレクトリ
vendor/javascript
が生成 config/importmap.rb
が生成
では、プロジェクトディレクトリに生成された Procfile.dev にちょっと修正を入れます。
【 Procfile.dev 】
web: bin/rails server -b 0.0.0.0 -p 3000
css: bin/rails dartsass:watch
ブラウザで「http://localhost:3000」でアクセスした際に、「localhost からデータが送信されませんでした。(ERR_EMPTY_RESPONSE)」とエラー画面になってしまう場合は、「-b 0.0.0.0
」を追記を追記しておきます。
Rails用データベース(SQLite)の作成のため、「bin/rails db:create
」を実行します。
$ bin/rails db:create
Created database 'storage/development.sqlite3'
Created database 'storage/test.sqlite3'
では、やっと Rails の起動です。
$ bin/dev
そこで、ログを確認すると。。。
:
Cannot render console from 10.0.13.1! Allowed networks: 127.0.0.0/127.255.255.255, ::1
:
ログの中に、Cannot render console from 10.0.13.1! Allowed networks: 127.0.0.0/127.255.255.255, ::1
と出ていますが、これは単に、127.0.0.0/8
以外の許可されていないネットワークからアクセスされたということなので、このクライアントのIPアドレス 10.0.13.1
を許可してあげれば良いだけです。
【 config/environments/development.rb 】
Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.
:
config.web_console.allowed_ips = '10.0.0.0/8'
end
railsの再起動、「bin/dev
」の実行が必要です。
とりあえず、この画面が表示されたらひとまずOKです。
画像の掲載
Webページに任意の画像を掲載してみましょう。
$ bin/rails g controller hello index
とりあえず、「rails generator controller
」コマンドにて、適当にコントローラクラス「HelloController (hello_controller.rb)
」とそれに紐づくビュー「Index (index.html.erb)
」を作成してみます。
$ bin/rails g controller hello index
create app/controllers/hello_controller.rb
route get 'hello/index'
invoke erb
create app/views/hello
create app/views/hello/index.html.erb
invoke test_unit
create test/controllers/hello_controller_test.rb
invoke helper
create app/helpers/hello_helper.rb
invoke test_unit
ブラウザにて「http://localhost:3000/hello/index」にアクセスすると、次のような画面が出てきます。
例えば、この殺風景なWebページに、猫ちゃんの写真を掲載してみたいと思います。
画像ファイル(ここでは「cat_m.jpg
」)を、app/assets/images
にアップロードしておきます。
$ ls app/assets/images/
cat_m.jpg
そして、ビューファイル app/views/hello/index.html.erb
に、image_tag
にて画像ファイルを指定します。
【 app/views/hello/index.html.erb 】
<h1>Hello#index</h1>
<p>Find me in app/views/hello/index.html.erb</p>
<div><%= image_tag 'cat_m.jpg', :width => '200' %></div>
ここで、「http://localhost:3000/hello/index」にアクセスすると、画像が表示されるのが確認できます。
ブラウザにてページのソースを確認するとこんな感じ。
:
<body>
<h1>Hello#index</h1>
<p>Find me in app/views/hello/index.html.erb</p>
<div><img width="300" src="/assets/cat_m-c2d44131b7f1ae479fd3a90ff694376df3b103b6.jpg" /></div>
:
アセットである画像ファイル名がダイジェストハッシュ付きになっているのが確認できます。
CSS (application.css) の変更
一番大元のCSSである「application.css」を変更すると、それがRailsアプリのすべてのWebページに適用されます。
といっても「application.css」ファイルを直接編集するのではなく、.scss
ファイルの方「application.scss」を編集し、DartSass によって、.scss
から .css
に変換します。ここでは、任意の名前の .scss
ファイル「main.scss」を作成して、それを @use
にて「application.scss」に読み込ませるようにします。
例えば、その「main.scss」にて、こんな感じで H1
タグに対し、少し緑かかった色 (#33aa99
) を設定してみます。
【 app/assets/stylesheets/main.scss 】
h1 {
color: #33aa99;
}
そして、その「main.scss」を「application.scss」に読み込ませる設定をします。
【 app/assets/stylesheets/application.scss 】
// Sassy
@use "main";
では、再度「http://localhost:3000/hello/index」にアクセスしてみますと。。。
反映されました!
実際に、「application.css」の中を見てみますと、ちょっと圧縮された形のCSSファイルになっているのがわかります。
【 app/assets/builds/application.css 】
h1{color:#3a9}
Bootstrap (CSS) の適用
Rails で Bootstrap と言えば、bootstrap gem がありますが、この gem を bundle install した場合、今回の環境だと、「ExecJS::RuntimeUnavailable: Could not find a JavaScript runtime.
」とエラーが出て失敗します。ExceJS が必要、つまり Node.js が必要になってきます。
まずは、Bootstrap も何も導入しない状態で、index.html.erb ファイルの方に、次のサイトからBootstrapを使ったサンプルソースを拝借して追記してみます。
【引用元】 Dropdowns (ドロップダウン) · Bootstrap v5.0
【 app/views/hello/index.html.erb 】
<div class="container">
<h1>Hello#index</h1>
<p>Find me in app/views/hello/index.html.erb</p>
<div><%= image_tag 'cat_m.jpg', :width => '300' %></div>
<br />
<div class="dropdown">
<button class="btn btn-primary dropdown-toggle" type="button" id="dropdownMenuButton1" data-bs-toggle="dropdown" aria-expanded="false">
Dropdown button
</button>
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton1">
<li><a class="dropdown-item" href="#">Action</a></li>
<li><a class="dropdown-item" href="#">Another action</a></li>
<li><a class="dropdown-item" href="#">Something else here</a></li>
</ul>
</div>
</div>
ブラウザにて「http://localhost:3000/hello/index」にアクセスしてみますと。。。
単に、普通のボタンに普通のリンクが作成されています。
まずは、Bootstrap の CSS (bootstrap.min.css) をサイトからダウンロードしてきて、これを先程「main.scss」ファイルを置いた同じ場所 app/assets/stylesheets
に置きましょう。今回ダウンロードは wget コマンドで行ってみます。
$ cd app/assets/stylesheets
$ wget -nv https://raw.githubusercontent.com/twbs/bootstrap/main/dist/css/bootstrap.min.css
$ ls
application.css application.scss bootstrap.min.css main.scss
そして、この「bootstrap.min.css」を「application.scss」に読み込ませるために、「application.scss」に次のように追記します。
【 app/assets/stylesheets/application.scss 】
// Sassy
@use "bootstrap.min";
@use "main";
ここで、ブラウザで確認してみます。
CSSは問題なく適用されましたが、JSが無いのでボタンをクリックしてもドロップダウンが有効になっていません。
ブラウザにて、ページのソースを確認してみますと、CSSファイルは、「/assets/application-5bc445f7...37737dd2a41.css
」というようなダイジェストハッシュ付のファイル名になっています。
このCSSファイルのリンクをクリックすると、application.css の中身を確認することができます。
/*!
* Bootstrap v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/:root,[data-bs-theme=light]{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple
:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow
:#ffc107;--bs-gr
:
flex{display:flex !important}.d-print-inline-flex{display:inline-flex !important}
.d-print-none{display:none !important}}h1{color:#3a9}
↑「bootstrap.min.css」と「main.scss」の内容が結合されているのが分かります。
Bootstrap (JS) の適用
JS(JavaScript)モジュールファイルについては、次のような流れ、
-
bin/importmap pin
コマンドにて、必要なJSモジュールをvendor/javascript
配下にダウンロードする -
config/importmap.rb
ファイルに、ダウンロードしたファイル名と、ソースで利用する名前とをマッピングする -
app/javascript/application.js
ファイルに、import
文にて、上の2で設定した名前を指定する
で適用させる形が良いのですが、どうもこの「importmap pin
」コマンドで取ってくるファイルが何を基準で取ってくるのかが良く分からないです。
上手くいった方法としては、上の1で、「importmap pin
」コマンドではなく、別途 wget とかでCDNサイトからダウンロードしたファイルを vendor/javascript
に置くというやり方です。
$ cd vendor/javascript
$ wget -nv https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js
$ wget -nv https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js.map
$ wget -nv https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js
$ wget -nv https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js.map
$ ls
bootstrap.min.js bootstrap.min.js.map popper.min.js popper.min.js.map
次に、 config/importmap.rb
を編集します。
【 config/importmap.rb】
# Pin npm packages by running ./bin/importmap
pin "application"
pin "@popperjs/core", to: "popper.min.js"
pin "bootstrap", to: "bootstrap.min.js"
ここで、「to: "bootstrap.min.js"
」のファイル名の部分をCDNのURLにすると、ダウンロード済みのJSファイルではなく、CDNサイトを直接参照するようになります。
最後、 app/javascript/application.js
を編集します。
【 app/javascript/application.js 】
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@popperjs/core";
import "bootstrap";
では、ブラウザで確認してみます。
うまくドロップダウンできましたね!
ページのソースを見るとこんな感じ。ちゃんとJSファイルが読み込まれてますね。
:
<script type="importmap" data-turbo-track="reload">{
"imports": {
"application": "/assets/application-98b46e9272...c35a810dbef48b91e.js",
"@popperjs/core": "/assets/popper.min-b85c89d...c27ad6caf0f7c986a7c95.js",
"bootstrap": "/assets/bootstrap.min-e3d895...fc460489eed2adfa64b4.js"
}
}</script>
<link rel="modulepreload" href="/assets/application-98b46e927...c35a810dbef48b91e.js">
<link rel="modulepreload" href="/assets/popper.min-b85c89d...c27ad6caf0f7c986a7c95.js">
<link rel="modulepreload" href="/assets/bootstrap.min-e3d895...fc460489eed2adfa64b4.js">
<script type="module">import "application"</script>
:
補足1: bin/importmap コマンド
今回は使用しないで行いましたが、「bin/importmap
」コマンドについても触れたいと思います。
「bin/importmap pin
」コマンドにて、bootstrap.js をダウンロードするには次のようなコマンドをたたきます。
$ bin/importmap pin bootstrap@5.3.3
結果は次のとおり、bootstrap と一緒に @popperjs/core もダウンロードされます。
$ bin/importmap pin bootstrap@5.3.3
Pinning "bootstrap" to vendor/javascript/bootstrap.js via download from https://ga.jspm.io/npm:bootstrap@5.3.3/dist/js/bootstrap.esm.js
Pinning "@popperjs/core" to vendor/javascript/@popperjs/core.js via download from https://ga.jspm.io/npm:@popperjs/core@2.11.8/lib/index.js
ちなみに、「bin/importmap pin bootstrap
」で実行した場合、「Couldn't find any packages in ["bootstrap"] on jspm
」と出てダウンロードされない場合があります。その場合は、パッケージの指定を「bootstrap@5.3.3
」のようにバージョンも含めた形で行うとダウンロードしてくれると思います。または、from
オプションにて「--from unpkg
」や「--from jsdelivr
」などとCDNサイトを指定してみると良いです。
また、あるサイトには、「--download
」オプションを付ければ良いみたいなことが書いてあったのですが、2024年3月時点では、このオプションを付加した場合、「--download
」自体をパッケージ名として認識されてしまうようです。help を見てみます。
$ bin/importmap --help
Commands:
importmap audit # Run a security audit
importmap help [COMMAND] # Describe available commands or one specific command
importmap json # Show the full importmap in json
importmap outdated # Check for outdated packages
importmap packages # Print out packages with version numbers
importmap pin [*PACKAGES] # Pin new packages
importmap unpin [*PACKAGES] # Unpin existing packages
importmap update # Update outdated package pins
このコマンドによって、JSファイルは、 vendor/javascript/
配下に保存されます。
$ ls vendor/javascript/
@popperjs--core.js bootstrap.js
併せて、 config/importmap.rb
にも自動的に追記されています。
【 config/importmap.rb 】
# Pin npm packages by running ./bin/importmap
pin "application"
pin "bootstrap" # @5.3.3
pin "@popperjs/core", to: "@popperjs--core.js" # @2.11.8
「bin/importmap json
」コマンドにて、<script type="importmap">
タグ内に記載されるJSONも確認できます。きちんとファイル名にダイジェストが付加されています。
$ bin/importmap json
{
"imports": {
"application": "/assets/application-d8a8613a4adb2b058d6ae5ddc9d0114a5ab5dc2e.js",
"bootstrap": "/assets/bootstrap-e25ad8d55dd45b21bf9f74f9cebdb21d9680d9a0.js",
"@popperjs/core": "/assets/@popperjs--core-d968fa6b3a2fc168fa27f46456beea72c2c02cec.js"
}
}
しかし、「@popperjs/core
」が参照しているソース「vendor/javascript/@popperjs--core.js
」の中身をみると、「from"../_/a0ba12d2.js"
」などの記述があり、別の場所のJSを参照しているようで、これではダメダメ。。。
【 vendor/javascript/@popperjs–core.js 】
export{afterMain,afterRead,afterWrite,auto,basePlacements,
beforeMain,beforeRead,beforeWrite,bottom,clippingParents,
end,left,main,modifierPhases,placements,popper,read,reference,
right,start,top,variationPlacements,viewport,write}from"./enums.js";
import"./modifiers/index.js";export{c as createPopperBase,
p as popperGenerator}from"../_/a0ba12d2.js";export{createPopper}from"./popper.js";
export{createPopper as createPopperLite}from"./popper-lite.js";
export{default as detectOverflow}from"./utils/detectOverflow.js";
export{default as applyStyles}from"./modifiers/applyStyles.js";
export{default as arrow}from"./modifiers/arrow.js";
export{default as computeStyles}from"./modifiers/computeStyles.js";
export{default as eventListeners}from"./modifiers/eventListeners.js";
export{default as flip}from"./modifiers/flip.js";export{default as hide}from"./modifiers/hide.js";e...
:
やっぱりなんか上手くいかないんです。。。
なので、今回は直接CDNサイトから参照し、wget でダウンロードしてきたというわけです。
補足2: assets:precompile の影響
Propshaft を使用した場合の注意として、development 環境では、「assets:precompile
」を実行してしまうと、それ以降アセットの変更の反映がされなくなるようです。
【参考】 Rails: Sprockets->Propshaftアップグレードガイド(翻訳)|TechRacho by BPS株式会社
Propshaftは、developmentモードで動的アセットリゾルバを使います。ただしローカルでassets:precompileを実行すると、Propshaftが静的アセットリゾルバを使うように切り替わります。そうなるとアセットの変更が反映されなくなるので、アセットを変更するたびにアセットのプリコンパイルを実行しなければならなくなります。ここがSprocketsと異なる点です。
動的なアセットリゾルバを再度有効にしたい場合は、ターゲットフォルダ(通常はpublic/assets)を削除する必要があります。これにより、propshaftがソースからの動的コンテンツの配信を開始するようになります。
「assets:precompile
」を実行してしまうと、Rails.application.config.assets.paths
で参照されるすべてのアセットが、「public/assets
」に、ダイジェストハッシュを付与したファイル名でコピーされます。そうなると、それ以降、こちらのファイルを参照してしまいますので、元のJSファイルをいじったところで変化がありません。
その場合の回避策として、 public/assets
の中身をクリアすると良いです。次のコマンドを実行します。
$ bin/rails assets:clobber
【参考】 【Tips】ローカルで rails assets:precompile
を実行してしまい以後アセットの更新が反映されなくなってしまった場合の対応 #Ruby - Qiita
そして、その後 bin/dev
で再起動です。
どうでしょうか、アセットパイプライン辺りがシンプルになって見通しがよくなって、しかも動作が軽くなったような気がするのは私だけでしょうか。これからもなるべく Ruby on Rails の流儀に従って開発していきたいです。