ある Rails プロジェクトで、これまで webpack 5 でアセットをビルドしていましたが、今回 Vite に移行しました。移行に伴う経験や学びを紹介します。
移行の動機
個人的に Web UI の規模が小さいこともあって、webpack の運用に大きな問題は感じていませんでした。しかし、設定が複雑で理解に時間がかかる点が課題でした。また、最近では開発の進展が鈍化していることもあり、将来性を考える必要がありました。
移行先として Vite を選んだ理由はいくつかあります。まず、webpack からの移行が容易であり、設定が簡単であること。また、パフォーマンス面など多くのメリットが指摘されており、今後は Vite がある程度主流になると考えたためです。
プロジェクトの構成
移行当時の構成は以下の通りです:
- Ruby: 3.2
- Rails: 7.1
- webpack: 5.9
- webpack-dev-server: 5
- TypeScript: 5.4
アプリは API サーバーと簡単な管理画面 (伝統的な MPA) だけで構成されており、フロントエンドフレームワークは使用していませんでした。
Rails と webpack の統合
ローカルでは webpack-dev-server を使ってアセットを配信し、商用環境では webpack でビルドした静的アセットを CDN で配信していました。
当時、 Webpacker は既に廃止されていたため、Gem や Rails のアセット配信系の機能は使わず、直接 webpack を利用していました。以下はその具体的な連携方法です。
エントリポイント
application.ts
という TypeScript のエントリポイントがあり、そこに必要な CSS やフォント、画像などのファイルをインポートしていました:
// application.ts
import '../stylesheets/application.scss'
import '../images/logo.png'
import Rails from '@rails/ujs'
Rails.start()
webpack では MiniCssExtractPlugin
を使い、CSS はこれで、また画像やフォントなどのアセットは Asset Modules を使って収集していました。
module.exports = {
// ...
module: {
rules: [
{
test: /\.(ts|tsx)$/i,
loader: 'ts-loader',
exclude: ['/node_modules/']
},
{
test: /\.css$/i,
use: [MiniCssExtractPlugin.loader, 'css-loader']
},
{
test: /\.s[ac]ss$/i,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader']
},
{
test: /\.(eot|svg|ttf|woff|woff2|png|jpg|gif)$/i,
type: 'asset',
generator: {
filename: 'resources/[name].[hash][ext]'
}
}
]
},
// ...
}
また Rails からビルドされたアセットのファイル名を解決する為に manifest.json を使用しており、 WebpackManifestPlugin
によって出力していました。上記の設定例では manifest.json は次のようになります:
{
"application.css": "/assets/application.5ee15bd10ecab8c643ca.css",
"application.js": "/assets/application.b2ed66b414fc39a5eae0.js",
"resources/logo.png": "/assets/resources/logo.6d1b81a3eed3538871b4.png"
}
エントリポイントである application.ts
だけでなく、インポートしている application.scss
や logo.png
もバンドルされています。
Rails 側での読み込み
Web UI は伝統的な MPA 構成で、テンプレートエンジンには ERB を使っていたため、先ほどの manifest.json を元にアセットを読み込むヘルパーを作成しました。内容は Webpacker のヘルパー関数を参考にしています:
# ApplicationHelper
def stylesheet_pack_tag(*sources)
sources = sources.map { |s| s.is_a?(String) ? AssetManifest.instance.lookup(s) : s }
stylesheet_link_tag(*sources)
end
def javascript_pack_tag(*sources)
sources = sources.map { |s| s.is_a?(String) ? AssetManifest.instance.lookup(s) : s }
javascript_include_tag(*sources)
end
def image_pack_tag(name, **options)
if options[:srcset] && !options[:srcset].is_a?(String)
options[:srcset] = options[:srcset].map do |src_name, size|
"#{resolve_path_to_image(src_name)} #{size}"
end.join(', ')
end
image_tag(resolve_path_to_image(name), options)
end
private
def resolve_path_to_image(name, **options)
path = name.starts_with?('resources/') ? name : "resources/#{name}"
path_to_asset(AssetManifest.instance.lookup(path), options)
rescue StandardError
path_to_asset(AssetManifest.instance.lookup(name), options)
end
AssetManifest.instance
はシングルトンで、Rails サーバーが一度起動すると同じオブジェクトが使われます。中身は先ほどの manifest.json を読み込んで Hash に入れてあるだけです。ローカルでは manifest.json のタイムスタンプを見て更新があった場合は再読み込みするようにしていました。
ERB では次のように呼び出します:
<%= stylesheet_pack_tag 'application.css' %>
<%= javascript_pack_tag 'application.js', defer: 'defer' %>
<%= image_pack_tag('logo.png', class: 'nav-logo') %>
このヘルパーは Rails の AssetUrlHelper
の機能を使っているので、config.asset_host
の値が使用されます。この設定は application.rb で環境変数を設定しています:
config.asset_host = ENV['RAILS_ASSET_HOST']&.chomp('/')
ローカルでは webpack-dev-server のホスト (//localhost:8080/
) が、商用環境では CDN のホスト (例: //cdn.example.com/
) が使用されます。
<!-- ローカル -->
<link rel="stylesheet" href="//localhost:8080/assets/application.5ee15bd10ecab8c643ca.css" />
<script src="//localhost:8080/assets/application.b2ed66b414fc39a5eae0.js" defer="defer"></script>
<img src="//localhost:8080/assets/resources/logo.6d1b81a3eed3538871b4.png" class="nav-logo" />
<!-- 商用環境 -->
<link rel="stylesheet" href="//cdn.example.com/assets/application.5ee15bd10ecab8c643ca.css" />
<script src="//cdn.example.com/assets/application.b2ed66b414fc39a5eae0.js" defer="defer"></script>
<img src="//cdn.example.com/assets/resources/logo.6d1b81a3eed3538871b4.png" class="nav-logo" />
移行で達成すべきこと
アプリの動作が変わらないこと
これは当然の前提ですが、我々のアプリでは Web UI を使うのが内部のメンバーだけだったため、最優先事項ではありませんでしたが、結果的に動作の変更はありませんでした。
開発者の使用感が変わらないこと
こちらの方を重視していました。当プロジェクトでは docker compose up
一発で開発環境が立ち上がるようにしており、大きな変更があっても docker compose restart
で即更新できるようにしていました。Docker に詳しいメンバーが少なかったこともあり、webpack のメンテナンスは私が主に担当していたため、この部分を重要視しました。
webpack-dev-server に相当する機能が必要ですが、Vite にも同様の機能があり問題ありませんでした。また、webpack で DefinePlugin
を使っていた機能も、Vite に相当する機能があり、スムーズに移行できました。
可能な限り Gem を使わない
webpack の時もそうでしたが、Vite も Rails とは独立したフロントエンドツールであるため、Gem を使用せずに自前で運用することにしました。これにより、問題が発生した際の対応力を高めるとともに、ツールの理解を深めることができました。Vite Ruby という Gem があり、通常はこちらを使うのが早いかと思います。(実際にヘルパーの置き換えで参考にしました)
実際の移行
Vite で manifest.json を使った配信は公式ドキュメントに記載があるため、まずはそれを参考にしました。
また、Rails で MPA を作っているケースが少なかったのですが、以下の記事で CakePHP を使用して同様の取り組みをしている方がいたため、こちらも参考にしました。
CakePHPのMPAにViteを導入して開発を加速させる⚡️
manifest.json の処理
基本的には webpack でやっていたことと同じですが、Vite では manifest.json の構造が若干異なります。
例えば、以下のエントリポイントの例では、Vite では次のように作成されます:
{
"app/assets/entrypoints/application.ts": {
"file": "assets/application.ts-UN13Tmw_.js",
"name": "application.ts",
"src": "app/assets/entrypoints/application.ts",
"isEntry": true,
"imports": [
"_rails-ujs.esm-DLwK8N9E.js"
],
"css": [
"assets/application-C_UhA2bj.css"
]
},
"app/assets/images/logo.png": {
"file": "assets/logo-GqJO7zn9.png",
"src": "app/assets/images/logo.png"
}
}
CSS はエントリポイントではなく、application.ts の中に css として定義されています。ヘルパーはこの構造に合わせて実装する必要があります。実際には、 Vite Ruby の実装を参考にしています:
# ApplicationHelper
def vite_asset_path(name)
path_to_asset vite_manifest.path_for(name)
end
def vite_stylesheet_tag(*names, **)
style_paths = names.map { |name| vite_asset_path(name) }
stylesheet_link_tag(*style_paths, **)
end
def vite_javascript_tag(*names, crossorigin: true, **)
entries = vite_manifest.resolve_entries(*names)
tags = javascript_include_tag(*entries.fetch(:scripts).map { |s| path_to_asset(s) }, crossorigin:, type: :module, extname: false, **)
# preload tag
tags << entries.fetch(:imports).map { |href| tag.link(rel: :modulepreload, href: path_to_asset(href), as: :script, crossorigin:, **) }.join("\n").html_safe
# bundled stylesheets
tags << stylesheet_link_tag(*entries.fetch(:styles).map { |s| path_to_asset(s) }, media: :screen, extname: false, **)
tags
end
def vite_image_tag(name, **)
image_tag(vite_asset_path(name), **)
end
private
def vite_manifest
ViteManifest.instance
end
これにより、エントリポイントが指定している css が自動的に link タグとして生成されるようになります。詳細については Backend Integration – Vite を参照してください。
また、 ViteManifest
ですが、基本は webpack の時と同様ですが、Vite はローカル環境では manifest.json を使わないため、ローカルでは渡されたファイル名をそのまま返すようにしています。例えば、ERB で次のように指定している場合:
<%= vite_javascript_tag 'app/assets/entrypoints/application.ts' %>
ローカル環境では次のようになります:
<!-- //localhost:5173 = Vite のデフォルトポート -->
<script src="//localhost:5173/app/assets/entrypoints/application.ts" type="module" crossorigin="anonymous"></script>
商用環境では次のようになります:
<script src="//cdn.example.com/assets/application.ts-UN13Tmw_.js" type="module" crossorigin="anonymous"></script>
<link rel="stylesheet" href="//cdn.example.com/assets/application-C_UhA2bj.css" media="screen" />
苦労した点
インポートしている CSS がローカルでいい感じに読み込めず、エントリポイントにした
エントリポイントが指定する CSS の取り扱いについて、ローカル環境で問題が発生しました。ローカルでは Vite のサーバーがアセットの配信を行うため、manifest.json を使わず、インポートしている CSS は直接 HTML に動的に style
タグとして埋め込まれます。このため、スタイルの適用が遅れ、最初のロード時に画面が崩れる問題が発生しました。我々のアプリケーションは MPA で都度画面をリロードするため、これは大きな問題でした。
最終的には、インポートしている CSS をエントリポイントとして指定し、 vite_javascript_tag
経由ではなく vite_stylesheet_tag
を使うことで解決しました:
<%= vite_javascript_tag 'app/assets/entrypoints/application.ts' %>
<%= vite_stylesheet_tag 'app/assets/entrypoints/application.scss' %>
移行してみて
Vite に移行したことで、ビルド速度が大幅に向上しました。ローカルでのホットリロードも一瞬で、明らかな速度改善が見られました。運用環境のビルドは CI パイプラインを使用しているため、大きな違いは感じていませんが、開発体験が向上したのは良い点です。
また最も重視していた既存メンバーに大きな負担をかける事なく移行できた点はとても良かったと言えます。
終わりに
以上、Rails アプリケーションにおける webpack から Vite への移行についての報告でした。なるべく短く書こうとしたら結構色々省略する形になってしまいましたが、移行を検討している方の参考になれば幸いです。