シンクロ・フード エンジニアブログ

飲食店ドットコムを運営する、株式会社シンクロ・フードの技術ブログです

Rails アプリケーションのフロントエンドを webpack から Rspack に移行しました

はじめに

こんにちは。開発部の竹内です。
弊社のプロダクトの1つである モビマル におけるフロントエンドビルドツール刷新の取り組みについてご紹介します。具体的には webpack から Rspack への移行を行いましたので、手順や結果をお伝えしたいと思います。

既存のプロジェクトの構成

モビマルはRailsアプリケーションとして構築されており、Reactで書かれたフロントエンド部分はwebpackでバンドルされ、app/assets/ディレクトリに出力されていました。その後、アセットパイプラインを通して利用するというワークフローになっていました。

ですが、この構成には以下の課題がありました。

  • webpack とアセットパイプラインの役割が重複し無駄になっている
  • 開発環境で Source Map が二重に出力されてブラウザから利用できなくなっている
  • 開発環境で HMR(Hot Module Replacement) ができない

特に HMR ができない課題は、変更のたびにページのリロードが必要になり、開発者体験を落とす結果に繋がっていました。

そこでフロントエンドビルドツールのモダン化を行い、開発効率・開発者体験の向上を図りました。

  • 脱アセットパイプライン
  • webpack-dev-server の導入(HMR の実現)
  • Rspack への移行

の三段階に分けて対応を進めました。
具体的な対応内容について以降のセクションで紹介していきます。

脱アセットパイプライン

webpack でビルドした bundle ファイルはアセットパイプラインを通さず直接 view から読み込むことを目標とします。
client/ ディレクトリにあるフロントエンドのコードをビルドして public/packs/ に出力し、 view ではそこから script タグを用いて読み込むように変更します。
ここで問題になるのがキャッシュコントロールのため必要となるフィンガープリントです。アセットパイプラインを使えば javascript_include_tag でフィンガープリントが自動で付与されますが、webpack で出力した場合は javascript_include_tag を使用できません。
そこで以下の記事を参考にwebpack-manifest-plugin を用い manifest.json を出力し、独自にフィンガープリント付きのパスで script タグを出力する helper を作成しました。

webpack-dev-server の導入

参考記事 と同様に webpack-dev-server の設定を行いました。これにより React の HMR が可能になり、コード変更時のページリロードが不要になります。具体的な変更内容は以下です。

  • webpack.config.js の設定
  • helper の変更
  • プロキシの設定
  • react-refresh-webpack-plugin の導入

これらの設定により HMR を実現できましたが、筆者の環境では webpack での HMR に10〜15秒程度の時間を要し、まだ改善の余地があると感じました。
上記を含めたコード例は次のセクションでまとめて掲載します。

Rspack への移行

フロントエンドビルド環境のモダン化を進めるにあたり、高速ビルドツールとしてViteRspackの二つの選択肢を検討しました。両者ともに十分なビルド速度を備えていましたが、違いを比較した結果僅差ではありますが Rspack を選択しました。

Vite Rspack
設定がシンプル。 設定は webpack と互換性が高い(やや複雑になる可能性がある)。
HMR 対応のためプロダクションコードの変更が必要になる。 現在のプロダクションコードで HMR 対応可能。
webpack から移行する場合の(Railsに統合するための)修正が多め。 webpack から移行する場合の修正が少ない。
採用実績は多め。 1.0が出たのが2024年8月なのでまだ採用実績は少ない。

当初は Rails から使用するための参考資料が多めの Vite への移行を想定していました。
ですが現在 webpack を使用しているため移行コストの低い Rspack を選択するメリットは多いと考え、こちらの採用を決定しました。

まずは必要なライブラリをインストールします

yarn add -D @rspack/core @rspack/cli @rspack/dev-server @rspack/plugin-react-refresh @swc/helpers react-refresh rspack-manifest-plugin
yarn add postcss

webpack.config.js の代わりに rspack.config.js を設定します。

const path = require('path')
const { rspack } = require('@rspack/core')
const { RspackManifestPlugin } = require('rspack-manifest-plugin')
const ReactRefreshPlugin = require('@rspack/plugin-react-refresh') // HMR を可能にする

module.exports = (env, argv) => {
  const isDevelopment = argv.mode === 'development'

  return {
    output: {
      path: path.resolve(__dirname, '../public/packs/'),
      filename: '[name]/webpack/bundle-[contenthash].js', // フィンガープリントをファイル名に含める
      publicPath: '/packs/',
    },
    entry: {
      'managers/offer_projects/opening_shifts':
        './src/entryPoints/Managers/OfferProjects/OpeningShift/index.tsx',
      // ...
    },
    resolve: {
      extensions: ['.js', '.ts', '.jsx', '.tsx'],
      modules: [path.resolve(__dirname, 'src'), 'node_modules'],
    },
    devtool: argv.mode === 'development' ? 'source-map' : false,
    module: {
      rules: [
        {
          test: /\.(j|t)sx?$/,
          exclude: [/[\\/]node_modules[\\/]/],
          loader: 'builtin:swc-loader', // 高速化のため babel-loader ではなく、推奨されている builtin:swc-loader を使用
          options: {
            jsc: {
              parser: {
                syntax: 'typescript',
                tsx: true,
              },
              externalHelpers: true,
              transform: {
                react: {
                  runtime: 'automatic',
                  development: isDevelopment,
                  refresh: isDevelopment,
                },
              },
            },
            env: {
              targets: 'defaults',
            },
          },
        },
        {
          test: /\.css$/,
          use: ['style-loader', 'css-loader'],
          type: 'javascript/auto',
        },
      ],
    },
    plugins: [
      // 無駄な locale を読み込まないようにする
      new rspack.IgnorePlugin({
        resourceRegExp: /^\.\/locale$/,
        contextRegExp: /moment$/,
      }),
      new RspackManifestPlugin({ // manifest.json を出力
        fileName: 'manifest.json',
        publicPath: '/packs/',
        writeToFileEmit: true,
      }),
    ].concat(isDevelopment ? [new ReactRefreshPlugin()] : []),
    devServer: {
      host: '0.0.0.0',
      port: 21201,
      headers: {
        'Access-Control-Allow-Origin': '*',
      },
      allowedHosts: [
        'localhost',
        'client', // docker-compose のサービス名が client なので、コンテナ内からアクセスできるようにする
      ],
    },
  }
}

script タグを生成する helper(javascript_bundle_tag) を作成します。

module GlobalHelpers
  module WebpackBundleHelper
    class BundleNotFound < StandardError; end

    # manifest.json を読み込んで、エントリーポイント名からファイルパス(client でビルドしたハッシュ付きファイル名)を取得する
    # @see https://inside.pixiv.blog/subal/4615#%E3%83%93%E3%83%A5%E3%83%BC%E3%83%98%E3%83%AB%E3%83%91%E3%83%BC%E3%81%AE%E5%AE%9F%E8%A3%85
    def asset_bundle_path(entry, **options)
      return '' if Rails.env.test? # テスト環境では manifest.json がない可能性があるので空文字列を返す

      raise BundleNotFound, "Could not find bundle with name #{entry}" unless webpack_manifest.key? entry

      asset_path(webpack_manifest.fetch(entry), **options)
    end

    def javascript_bundle_tag(entry, **options)
      path = asset_bundle_path("#{entry}.js")

      options = {
        src: path,
      }.merge(options)

      javascript_include_tag '', **options
    end

    private

      MANIFEST_PATH = 'public/packs/manifest.json'
      # 開発環境・テスト環境以外では manifest.json を1度だけ読み込む
      MANIFEST_CONTENT = Rails.env.development? || Rails.env.test? ? nil : JSON.parse(File.read(MANIFEST_PATH))

      def webpack_manifest
        if Rails.env.development?
          # dev-serverから取得する
          JSON.parse(OpenURI.open_uri("http://#{Settings.dev_server.host}/packs/manifest.json").read)
        else
          MANIFEST_CONTENT
        end
      end
  end
end

dev-serverからのアセット取得のプロキシ設定です。参考記事 ほぼそのままです。

require 'rack/proxy'

# dev-serverからのアセット取得をプロキシする -> localhost以外からもdev環境を見れるようにするため
# @see https://studist.tech/goodbye-webpacker-183155a942f6
class DevServerProxy < Rack::Proxy
  def perform_request(env)
    if env['PATH_INFO'].start_with?('/packs/')
      env['HTTP_HOST'] = dev_server_host
      env['HTTP_X_FORWARDED_HOST'] = dev_server_host
      env['HTTP_X_FORWARDED_SERVER'] = dev_server_host
      super
    else
      @app.call(env)
    end
  end

  private

    def dev_server_host
      Settings.dev_server.host
    end
end

Rails.application.configure do
  # ...
  config.middleware.use DevServerProxy, ssl_verify_none: true
end

package.json のビルドコマンドを rspack に変更します。

{
  ...
  "scripts": {
-   "build": "webpack --mode=development",
-   "build:prod": "webpack --mode=production",
-   "watch": "webpack --mode=development --watch"
+   "build": "rspack --mode=development",
+   "build:prod": "rspack --mode=production",
+   "dev-server": "rspack serve --hot --mode development"
  },
  ...

あとは各 view で javascript_bundle_tag を使うように書き換えれば対応完了です。 HMR を試したところ1秒以内に完了するため快適になりました。

まとめ

Rspack の導入で高速な HMR が可能になり、開発効率・開発者体験が向上しました。
今はモビマルのみ Rspack を使用していますが、他のプロダクトでも展開を検討しています。
Rails アプリケーションにおける Rspack の導入事例はまだウェブ上でも多く見られなかったため、本記事が参考になれば幸いです。