Muddy WebはMuddy = 泥臭いとして、Webフロントエンドの開発現場における話やケーススタディなど泥臭さのある話から、学びを得ることを目的として開催しています。

現場で遭遇した具体的な体験を元に、実際に明日から使えるかもしれないWebフロントエンドの技術や知識を、参加者の皆さまと共有し合うことを通して、フロントエンド開発の糧になれればと思います。

第11回はゲストにサイボウズ株式会社様をお招きし、Webフロントエンドの現場から明日使えそうな技術や事例をトークしました。

本記事は、2025年03月21(金)に開催した「Muddy Web #11 ~Special Edition~ 【ゲスト: Cybozu】」において発表された「大規模プロジェクトにおける段階的な技術刷新について」に対して、社内の生成AI議事録ツール「コエログ」を活用して書き起こし、登壇者本人が監修役として加筆修正しました。


竹内 実 (どら)

2021年新卒入社。WINTICKETのフロントエンドエンジニア。最近Model Context Protocolが自分の中でアツいです。

X: https://x.com/d0ra1998


こんにちは。まず私から、本日最初のセッションとして「爆速スッキリ! Rspack以降の成果と道のり」というタイトルで発表させていただきます。よろしくお願いします。

最初に軽く自己紹介をさせていただきます。どらと言います。所属はWINTICKETのWebチームです。最近のトピックとしては、少しお休みをいただいてアメリカのグランドキャニオン周辺を回ってきました。

まずは、今回お話しする内容についてです。WINTICKETのWebでは、WebpackとBabelを使った構成から、Rspackへの移行と、タイトルには記載していなかったのですが、SWCにも移行を行いました。その話がメインになります。移行に至った経緯や、本番環境に実際に適用していくまでのプロセス、さらにその中で大変だったことや、どのようにして高速化を実現したかといった内容をお伝えできればと思います。

まずWINTICKETについて、軽くご紹介します。ご存じない方もいらっしゃるかと思いますが、WINTICKETは競輪とオートレースのインターネット投票サービスです。Web版とアプリ版を提供しており、アプリはFlutterで開発されています。

WINTICKET Webは、SSRを行うIsomorphicなWebアプリケーションになっています。構成としては、WebpackやBabel、TypeScriptにReactと、よくある構成を採用しています。その他の周辺ライブラリとしては、loadable-componentsやstyled-components、Storybookなどを使用しています。Next.jsのようなフレームワークを使った開発ではないのですが、少し前によく見かけた構成かなと思います。

規模感でいうと、ファイル数がだいたい7000ファイルほどあり、TypeScriptやTSXのコードが約50万行あります。コードベースがかなり大きくなってきていることもあり、ローカル環境での起動に約50秒ほどかかるというのが現状でした。

そこで今回登場するRspackについて、軽く説明しておきます。Rspackは、ByteDance社が開発しているRust製のJavaScriptバンドラです。最大の特徴は、Webpack v5と互換性がある点で、ConfigやPlugin、LoaderといったWebpackのエコシステムや設定が基本的にそのまま動作するという点が大きな特徴です。

正式な安定版がリリースされたのは比較的最近で、昨年の8月になります。

そもそもなぜ既存の構成を剥がしたかったのかというと、大きく2つの理由があります。1つ目は、先ほども触れたようにローカルの開発環境での起動に約50秒かかっていた点です。特にUIを修正してすぐに挙動を確認する、といったイテレーションを繰り返す開発フローにおいて、この遅さは大きなボトルネックでした。開発サイクルを高速化して、開発生産性を上げたいというのが一つの目的です。

もう一つは、ちょうどBabelやWebpackの設定を見直すタイミングだったという背景があります。例えば、以前はレガシーブラウザ向けに別のビルドを提供する仕組みを用意していたのですが、現在はそれが不要になっていたり、browserslistの更新が滞っていたりと課題がありました。こうした見直しのタイミングに合わせて、使用するツール自体も移行することにしました。

一旦移行してみようということで、実際に試してみました。基本的な手順としては、公式にマイグレーションガイドが用意されているので、それに従って進めていく形になります。まず最初に行ったのは、config内のwebpackと記述されている箇所をRspackに書き換えることです。ファイル名やビルドコマンドなどもRspack向けに変更していきました。

あとは、一部のプラグインについても対応が必要でした。よく使っているところで言うと、copy-webpack-pluginのようなものがありますが、これらはRspack側から提供されているものがあるので、そちらに置き換えていきました。これらの置き換えを行わないとビルドが正しく動作しないため、地味に注意が必要なポイントです。

あとは、細かい部分を何度もビルドを回しながら調整していくような作業になりました。たとえば細かいところでは、configの一部のフィールド、具体的にはキャッシュの設定がWebpackではオブジェクト形式で指定できていたのに対し、Rspackではbooleanしか受け付けなかったり、チャンクの名前を関数ベースで返す設定において、渡ってくる引数の型が微妙に異なっていたりと、そういった細かな違いがありました。ただ、そうした差分はあるものの、全体としてはそれほど大きな変更なく移行を進めることができました。

一旦立ち上げてみたところ、起動時間が50秒から41秒程度になりました。これでもおおよそ2割ほどは高速化されていて、確かに以前よりは早くなっています。ただ、それでも劇的な改善とは言えないため、さらに何か工夫できないかを模索しました。

そこで、トランスパイラをBabelからSWCに置き換えました。SWCはRust製で、Rspackにおいてはビルトインと表現されていると思いますが、実際にRspackが実装しているloaderが用意されています。それを使うことで、RustとJavaScript間のやり取りが減るため、より高速に動作するという説明がされています。

この部分を実際に置き換えてみて、どうなるかを試してみました。

SWCについて簡単に説明すると、Rust製のJavaScriptやTypeScriptのcompilerおよびbundlerです。Next.jsのcompilerにも使われているので、馴染みがある方も多いかと思います。特徴としては、プラグインの仕組みが比較的充実していて、一部のBabelプラグインに対応する代替実装が存在するという点が大きな特徴になっています。

ここまで対応を進めると、一気にパフォーマンスが向上しました。Rspackに移行しただけの段階ではビルド時間が約40秒程度だったのですが、SWCへの置き換えを行うことで、一気に20秒程度まで短縮されました。

まとめると、もともとビルド時間が約50秒かかっていたところから、Rspackへ移行しただけで約10秒短縮され、さらにSWCへ移行することで20秒程度まで高速化するという結果になりました。

「SWCに移行するだけでも、Webpackのままで十分速くなるのでは?」という見方もあるかと思います。実際に比較用にWebpack+SWCの構成も試してみましたが、その場合は約36秒と、確かにある程度の高速化は見られました。ただ、それでもやはりRspackとSWCをRustベースでフルに組み合わせた構成のほうが、圧倒的に高速になるという結果になりました。

それでは、実際に本番環境への移行を進めていくフェーズを説明します。移行については、3段階に分けて段階的に進める方針を取りました。

まず1つ目は、現在のWebpackとBabelを使った構成から、Webpackはそのまま維持しつつ、transpilerをSWCに置き換えるというステップです。次に、ローカル開発環境のみRspackへ移行します。そして最終的には、本番環境も含めてすべてをRspackに移行していくという流れで進めることにしました。

第一段階として、まずWebpack構成を維持したまま、SWCへの移行を実施しました。transpilerの移行に加えて、minimizerも含めてSWCに置き換えを行い、対応するサポート対象に合わせて設定を整理しました。当初のモチベーションのひとつでもあった、古い設定の見直しもこのタイミングで実施しています。

これで一旦リリースに踏み切ろうという流れになったのですが、テスターの方々に検証していただいた中で、少し厄介なバグが見つかり、うまくリリースできない状況になってしまいました。

原因については深掘りしきれなかったのですが、一部のページ遷移時に “Cannot access uninitialized variable” というエラーが発生していました。これがどうやら iOS の15系の一部マイナーバージョンでのみ起きるという、かなり特殊なバグに遭遇してしまいました。

興味深いことに、Rspackにそのまま設定を移行した場合には、このエラーが再現しないという結果も出ていて、挙動としても少し不思議なものでした。最終的に、このステップを踏まずに直接Rspackに移行することに決めました。というのも、このバグは将来的に不要になる見込みがあり、またWINTICKETとしてもiOS 15系のサポートは近々終了する予定だったためです。

次に本番適用前にローカル開発環境へRspackを導入しました。というのも、もともとのモチベーションのひとつに、ローカルでの開発時のビルドが非常に遅いという課題があって、そこを改善したいという狙いが大きかったからです。そのため、まずはローカルから移行を進め、開発体験の向上を優先しました。

本番環境はWebpackのままで、ローカルはRspackという一時的に異なる構成になってしまいますが、これは一時的な差分として許容する判断をしました。この構成での開発ビルドを通じて、本番に出す前段階でもRspackでの動作に基本的な問題がないことを確認できました。

その後、本番環境も含めて実際に移行を完了することができました。ここまでの流れだけを見ると、わりとスムーズに進んだように見えるかもしれませんが、実際のところはそう簡単ではありませんでした。このあとのセクションでは、表には見えにくいような、もう少し泥臭い部分の話や、移行の裏側で直面した細かい課題などについても触れていければと思います。

振り返ってみると、大変だったことは大きく分けて4つほどありました。実際には「Rspackへの移行」と言いつつも、その中でSWCへの移行によって発生した課題のほうが多かった印象があります。ここからは、それぞれのポイントについて一つずつ紹介していきたいと思います。

一つ目は、BabelからSWCへの置き換えです。移行前の段階では、Babelに14個ほどのプラグインがインストールされていたため、それらを一つずつ確認しながらSWCへ置き換えていきました。

多くのプラグインはpolyfillの目的で使われており、たとえば plugin-proposal 系のプラグインが含まれていましたが、Babelでいうところの preset-env に相当するものがSWCには env という形で用意されているので、そちらに切り替えることでpolyfillを適用するようにしました。

また、plugin-react-remove-properties というBabelプラグインを使用していたのですが、これは本番ビルド時に data-testid を除去するためのものでした。ただ、WINTICKETでは data-testid 自体を多用しておらず、付与されている値も外部に漏れても特に問題のない内容だったこと、さらに他のサービスでも削除せずに運用しているケースが多いことから、今回はこのプラグインの使用をやめる判断をしました。

先ほども触れたように、SWCの大きな特徴のひとつにBabelプラグイン相当のプラグイン機構があるという点があります。今回、WINTICKETで使用していた loadable components や styled-components など、Babelプラグインを必要とするライブラリについても、SWCに対応したプラグインが存在していたため、移行を進める大きな後押しとなりました。

ただ、このSWCのプラグインについては注意点もあります。SWCのプラグインはWASMで実装されているのですが、このWASMプラグインはSWC本体とバージョンの整合性が非常に重要で、SWC Coreと呼ばれている部分とバージョンが合っていないとエラーになります。

しかも、このエラーメッセージがあまり親切ではないため、原因の特定に少し手間取ることもあります。なので、プラグイン導入時にはしっかりとバージョンの確認を行う必要があります。

じゃあどうやってバージョンを合わせるのかというと、SWCの公式が用意している「SWC Plugins」というページが参考になります。このページでは、例えばRspackやSWC Coreのバージョンを入力すると、どのバージョンのプラグインが対応しているかを一覧で確認できるようになっています。少し手間ではありますが、これを使えば安全なバージョンの組み合わせがわかるようになっています。

導入時はハマりづらいポイントかと思いますが、Renovateなどで自動的にPRが上がってきたときに、うっかりプラグイだけをアップデートしてしまうとすぐに壊れてしまうことがあるので、そこは特に注意が必要なポイントになっています。

もう一つは、少し面白いケースのデグレについてです。実際に移行を進めていく中で、Storybookを使ったVisual Regression Testing (VRT)に明確な差分が1件発生しました。ちょっと画像だと遠目でわかりづらいかもしれませんが、アップにしてみるとこのような感じで、見た目に変化が出ているのが確認できました。

この差分についてですが、左半分がSWCに移行したあとの表示で、右半分が移行前、つまりBabelを使っていたときの表示になります。Babel使用時には「○月」という文字列が正常に描画されていたのに対して、SWCに移行後は文字の位置がずれて、描画が埋もれてしまっているような状態になっていました。

この現象がなぜ発生しているのかを調査していくことにしました。

よく見てみると、SVG内でテキストの位置を制御している dy 属性に違いがありました。もともとは 1em のような具体的な値が入っていたのですが、SWCに移行すると calc 関数がそのまま文字列として入ってしまっており、それが原因で正しく描画されず、テキストが埋まってしまうという状態になっていました。この挙動がなぜ起きているのかは一見わかりづらかったので、さらに深掘りして調査を進めることにしました。

この実装の中を追っていくと、`reduce-css-calc` という処理が呼ばれていることがわかりました。SWCに移行する前までは、この中で `calc(1 * -1em)` のような式が正しく評価されて、結果として `-1em` に変換されていました。ところが、SWCに移行したあとは、この計算が評価されずに `calc(1 * -1em)` のような形でそのまま出力されてしまっており、これが原因でブラウザ側で正しく描画されず、テキストが崩れてしまうという状態になっていました。

では、なぜ値が置き換えられなくなったのかをさらに調べていくと、reduce-css-calc の内部で例外処理が入っていて、何かしらのエラーが発生すると元の値をそのまま返すという実装になっていました。今回のケースでは、どうやらその例外処理に引っかかっていたようです。

この挙動を検証するために、try-catch の中身を切り出して実行してみたところ、”i is not defined” というエラーが発生しました。詳しく見てみると、for文の中で i を使っているのに対して、var や let による宣言がされていないという問題がありました。

BabelやTerserを使っていたときには、たまたま他の関数でも同じ i を使っていたことで、関数がminifyされた際にスコープがうまくかみ合い、偶然として正常に動いていたという状態でした。ただ、実際には変数宣言が抜けているという、元のコード自体に問題があったということになります。

調べてみると、このコードはすでに修正されていて、新しいバージョンでは正しい形で定義されていました。

そのため、reduce-css-calc のバージョンを上げることで、今回の問題は解決することができました。今回はバージョンを上げるために override を使って上書きしました。

本来であれば、override を使わずに済むのが理想だったのですが、内部で依存している dependencies のバージョン指定などの関係で、直接のアップデートがうまくいかず、今回は override による上書き対応を取ることになりました。

このようにしてSWCへの移行は進められたのですが、先ほど話したStorybookの対応も同様に必要になりました。Storybookでは、これまでWebpackのビルダーを使っていたため、こちらも移行に合わせて対応する必要がありました。ただ、ここが少し特殊な部分になります。

Webpackを使っている場合、Storybook側もそのままWebpackをビルダーとして利用できます。しかし、Rspackの場合は専用のビルダーが用意されておらず、代わりにRsbuildというビルドツールが用意されています。

Rsbuildは、Rspackをラップしたビルドツールになっており、公式サイトでもその位置づけが説明されています。ViteとRollupのような関係に近く、RspackをラップしたVite風のツールという立ち位置になっています。設定インターフェースなどもViteのようなスタイルで書けるようになっているのが特徴です。

そのため、Rspackに移行した場合、基本的にはStorybookでもこのRsbuildを使う必要があります。一応、Rspackそのものを直接使わせてほしいという要望もあるようですが、現時点ではStorybookでRspackを使うには、storybook-react-rsbuildを利用する必要があるという状況です。

基本的に、RsbuildにはReact向けの設定が用意されたパッケージがあり、これは@rsbuild/plugin-reactという名前になっています。これを使うことで、Reactに必要な設定は大体網羅できるようになっています。今回もこのパッケージをベースに設定を組みました。

また、Rsbuildの中にはtools.rspackという設定項目があり、ここにRspackの設定をそのまま記述することができます。つまり、Rsbuildを使ってはいるのですが、既存でRspack向けに書かれていた設定がある場合は、それをtools.rspackの中に移すことで同様の動作が可能になります。

そのため、設定ファイルとしては別で用意する必要はあるものの、Storybook向けのRsbuildの設定は比較的シンプルにすることができました。

ここまで対応を進めて移行を完了した結果を改めて振り返ります。ローカルサーバーの起動時間については、以前は約50秒かかっていたものが、先ほどお話ししたように約20秒程度まで短縮されました。

さらに、本番ビルドの速度も大幅に向上しました。これまでは1分半、具体的には79秒ほどかかっていたのですが、移行後は16秒程度で完了するようになり、体感でもはっきりと違いがわかるほど高速化されました。

また、このエコシステム自体がどんどん進化しているという点も、大きな恩恵のひとつだと感じています。Rspackは現時点でもかなり高速ですが、マイナーバージョンのアップデートごとにさらにビルド速度が改善されていて、今もなお進化が続いています。

SWCについても同様に、パフォーマンス改善に関するプルリクエストが次々と取り込まれている状況で、バージョンを上げていくだけでビルドの高速化が期待できるというのは、非常にありがたい点です。

こういった継続的な改善の波に乗れるという意味でも、WebpackからRspackへの移行はやって良かったなと感じています。

最後にまとめになりますが、RspackはWebpackから比較的手軽に移行できるのが大きな特徴です。設定周りで多少の差分はありますが、基本的には数行単位で修正すれば済む程度なので、大きな障壁にはなりませんでした。

今回苦労したポイントは、どちらかというとBabelからSWCへの移行部分です。Webpackの設定そのままにRspackを導入するだけであれば、かなりスムーズに進められますが、本格的な高速化を目指す場合、Babelのままでは限界があり、周辺の構成も含めて見直しが必要になるという点は意識しておくべきポイントです。

また、先ほど紹介した描画のバグのように、移行には予期せぬデグレードが付きものです。こういった問題を事前に検知できるように、VRTやE2E、手動でのQCなど、品質を担保する手段の重要性を改めて感じました。

以上になります。ありがとうございました。