リモート開発メインのソフトウェア開発企業のエンジニアブログです

Rails プロジェクトの webpack を Vite に移行した

ある 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 を使った配信は公式ドキュメントに記載があるため、まずはそれを参考にしました。

Backend Integration – Vite

また、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 への移行についての報告でした。なるべく短く書こうとしたら結構色々省略する形になってしまいましたが、移行を検討している方の参考になれば幸いです。

← 前の投稿

次の投稿 →

PythonでChatGPT APIを使ってみる 第2回 Bing Search APIとの連携

コメントを残す