大規模モノレポでVite 8(Rolldown)移行に挑戦中!
こんにちは!ナレッジワークのソフトウェアエンジニアのはぎはらです。
この記事は、KNOWLEDGE WORK Blog Sprint 2026 Spring の一発目の記事です!
はじめに
私たちのプロダクト「ナレッジワーク」では、複数のフロントエンドアプリケーションとそれらが依存するnpmパッケージ群をpnpm Workspaceによるモノレポで管理しており、25のnpmパッケージをViteでビルドしています。現在、Vite 7(esbuild/Rollupベース)からVite 8(Rolldownベース)への移行に取り組んでいます。
本記事の前半では Vite 7 → 8 の一般的なマイグレーション内容をまとめ、後半ではナレッジワークの大規模モノレポ構成で実際に起きた問題とその解決策を共有します。先に結論だけお伝えすると、Rolldownの preserveModules オプションが私たちの構成における移行の鍵でした!
移行の背景とモチベーション
最大のモチベーションは ビルド速度の改善 です。普段のプロダクト開発では pnpm dev でdevサーバーを起動し、ブラウザでUIを確認しながら実装を進めるのですが、パッケージ数の増加に伴いdevサーバーがどんどん重くなっていて、ホットリロードの遅延やビルド待ちが日常茶飯事でした。開発効率の低下はもちろん、開発者のストレスも相当なもので、なんとかしたい……!という思いが常にありました。
そんな中、タイミングよく Vite 8 beta がリリースされたので、藁にもすがる思いで Vite 8 へのアップデート対応に着手しました。
Vite 8で何が変わるのか
まず、Vite 8 の主な変更点を整理しておきます。まだベータ版ということもあり、日本語の情報が少ないので参考になれば幸いです。
ビルドエンジンの統一
Vite 8最大の変更点は、ビルドエンジンがRolldownに統一されたことです。
Vite 7まではdev時にesbuild、本番ビルド時にRollupという2つのバンドラーを使い分けていました。この構成は高速なDXを実現する一方で、devとproductionで変換パイプラインやプラグインシステムが異なるという一貫性の課題を抱えていました。
Vite 8では、VoidZeroチームが開発したRust製バンドラー Rolldown がこれらを置き換えます。
- パフォーマンス: Rustによるネイティブ速度で動作し、Rollupの10〜30倍高速
- 一貫性: devとproductionで同一のバンドラーを使用するため、環境差異による問題が減少
- 互換性: RollupおよびViteのプラグインAPIと互換性があり、多くのプラグインがそのまま動作
- 将来性: Full Bundle Mode、モジュールレベルの永続キャッシュ、Module Federationなど、より高度な機能が利用可能に
また、Rolldownの内部ではパーサー・リゾルバ・トランスフォーマーとして同じくVoidZeroチームが開発する Oxc が使われており、Vite → Rolldown → Oxcというエンドツーエンドのツールチェーンが同一チームで一貫して開発されている点も大きな特徴です。
その他の主な変更点
-
ビルトインの tsconfig
pathsサポート:resolve.tsconfigPaths: trueで有効化でき、vite-tsconfig-pathsプラグインが不要に -
emitDecoratorMetadataのビルトインサポート: TypeScriptのデコレータメタデータを自動的に処理
Vite 7 → 8 マイグレーションガイド
ここでは、公式マイグレーションガイドの内容をベースに、Vite 7 → 8 で必要な主な変更をまとめます。
内部ツールチェーンの置き換え
Vite 8最大の変更は、内部で使われるツールが一新されたことです。
| 役割 | Vite 7 | Vite 8 |
|---|---|---|
| バンドラー(dev) | esbuild | Rolldown |
| バンドラー(build) | Rollup | Rolldown |
| JS変換(トランスパイル) | esbuild | Oxc |
| JSミニファイ | esbuild | Oxc Minifier |
| CSSミニファイ | esbuild | Lightning CSS |
esbuildとRollupがそれぞれRolldownとOxcに置き換わっていますが、設定の互換性レイヤーが用意されているため、多くの場合は大きな変更なしに移行できます。
設定オプションの移行
esbuild / Rollupベースの設定オプションは非推奨となり、Rolldown / Oxcベースのオプションへの移行が推奨されます。現時点では互換性レイヤーにより自動変換されますが、将来削除される予定です。
| 旧オプション | 新オプション |
|---|---|
build.rollupOptions |
build.rolldownOptions |
worker.rollupOptions |
worker.rolldownOptions |
optimizeDeps.esbuildOptions |
optimizeDeps.rolldownOptions |
esbuild(JS変換オプション) |
oxc |
build.minify: 'esbuild' |
Oxc Minifierがデフォルト |
build.cssMinify: 'esbuild' |
Lightning CSSがデフォルト |
外部化されたモジュールの require() 呼び出し
Vite 7では外部モジュールの require() は import 文に変換されていましたが、Vite 8では require() のまま保持されるようになりました。詳細は Rolldownのドキュメント を参照してください。ESM環境でこれがエラーになる場合は、Vite 8が提供する esmExternalRequirePlugin で対処できます。
import { defineConfig, esmExternalRequirePlugin } from 'vite'
export default defineConfig({
plugins: [
esmExternalRequirePlugin({
external: ['react', 'react-dom', /^node:/],
}),
],
})
CommonJS 相互運用の変更
CJSモジュールからの default インポートの挙動が統一されました。Vite 7ではdevとbuildで挙動が微妙に異なっていましたが、Vite 8では一貫した処理になります。詳細は Rolldownのドキュメント を参照してください。既存コードが壊れる場合は legacy.inconsistentCjsInterop: true で一時的に旧挙動に戻せます。
Reactプロジェクトでの変更
Vite 8ではOXCが内部で使われるため、SWCプラグインではなく標準のReactプラグインが推奨されます。
// Before
import react from '@vitejs/plugin-react-swc'
// After
import react from '@vitejs/plugin-react'
tsconfig paths のビルトイン化
vite-tsconfig-paths プラグインが不要になり、ビルトインオプションで対応できるようになりました。
// Before: プラグインで対応
import tsconfigPaths from 'vite-tsconfig-paths'
plugins: [tsconfigPaths()]
// After: ビルトインオプション
resolve: {
tsconfigPaths: true
}
段階的な移行パス
公式は2つの移行パスを提示しています。
-
直接アップグレード:
viteのバージョンを8に上げる -
段階的移行(推奨): まずVite 7のまま
rolldown-viteパッケージに切り替え、Rolldown起因の問題を先に解決してからVite 8に上げる
大規模プロジェクトでは段階的移行が推奨されています。私たちは直接アップグレードを選びましたが、問題の切り分けという意味では段階的移行の方が安全だったかもしれません。
ここまでが一般的な Vite 7 → 8 マイグレーションの内容です。ここからは、ナレッジワークの大規模モノレポ構成で実際に起きた問題と、その解決策を紹介します。
ナレッジワークのビルドパイプライン
まず、私たちのビルドパイプラインの特徴を説明します。この構成が後述する問題の原因に深く関わっています。
- npmパッケージ群(25個): Viteのライブラリモードでビルド
- アプリケーション群(11個): Next.js(webpack)でバンドル
各npmパッケージは src/**/*.{ts,tsx} をすべてエントリポイントとして個別にES Modulesとして出力し、利用側のNext.jsアプリケーションが持つnext.config.mtsの transpilePackages で取り込む構成です。つまり、ViteでビルドしたnpmパッケージをNext.js(webpack)が再バンドルするという2段階のパイプラインになっています。
最大の課題: 自動コード分割によるdist構造の崩壊
問題の発見
前述のマイグレーションガイド通りに設定を書き換えて「よし、ビルドしてみよう!」と意気込んだところ、dist出力に大幅な悪化 が発生しました。
| 項目 | Vite 7 | Vite 8(素の移行) | 変化 |
|---|---|---|---|
| JSファイル数 | 5,667 | 8,821 | +55% |
| 空ファイル(0バイト) | 0 | 1,033 | +1,033 |
| .css.jsファイル数 | 345 | 704 | 約2倍 |
| kwlib/coreルートレベルのJSファイル | 5 | 767 | +762 |
根本原因: ルートレベルへのホイスティング
Rolldownの Automatic Code Splitting(自動コード分割) が原因でした。src/**/*.{ts,tsx} を全てエントリポイントにしている構成では、複数エントリ間で共有されるモジュールが自動的に「共有チャンク」として分離され、ルートレベルにホイスト されます。
# Vite 8 素の移行 - ルートにホイストされた例
dist/
├── SideBar.js ← ルートにホイストされた実装 (export { SideBar as t })
├── SideBar.css.js ← ルートにホイストされたCSS
├── component/layout/SideBar/
│ ├── SideBar.js ← re-exportのみ (70以上のimport文)
│ └── SideBar.css.js ← re-exportのみ
元のファイルの場所には大量の import 文と re-export だけが残り、実装はルートディレクトリに移動してしまいます。さらに、別ディレクトリに同名のファイルが複数存在する場合(例: component/editor/Editor.tsx と component/richEditor/Editor.tsx)、ルートにホイストされる際に Editor.js, Editor2.js, Editor3.js ... と連番で区別されます。
なぜ問題なのか
ポイントは、私たちのビルドパイプラインがViteの後にもう1段バンドラー(webpack)を通す構成であることです。
Vite (Rolldown) → dist/*.js → Next.js (webpack) → 最終成果物
Vite側の共有チャンク分割はwebpackが再バンドルする時点で上書きされるため、最終成果物には効果がありません。 つまり、Rolldownの自動コード分割は私たちの構成では完全に無駄な処理であり、dist構造を複雑化させるだけだったのです。これは困った……!
解決策: preserveModules の導入
preserveModulesとは
Rolldownの preserveModules オプションは、自動コード分割を無効化し、各モジュールを元のファイル構造のまま個別に出力するオプションです。
rolldownOptions: {
output: {
preserveModules: true,
preserveModulesRoot: 'src',
},
}
preserveModulesRoot: 'src' を指定することで、src/ プレフィックスを除去して dist/ 以下に直接マッピングします。
- 入力:
src/component/Button/Button.tsx - 出力:
dist/component/Button/Button.js
globによるinput指定との併用
Rolldownのドキュメントには以下の注意があります。
It is therefore not recommended to blindly use this option to transform an entire file structure to another format if you directly want to import from those files as expected exports may be missing.
要約すると、preserveModules を単独で使用した場合、エントリポイントとして指定されていないファイルのexportがtree-shakingによって消える可能性がある、という警告です。私たちの構成では glob で全ファイルをエントリポイントに指定しているため、この問題は発生しません。既存の input 設定を維持することが重要です。
三者比較: 改善効果
| 項目 | Vite 7 | Vite 8(素) | Vite 8 + preserveModules |
|---|---|---|---|
| JSファイル数 | 5,667 | 8,821 | 6,022 |
| 空ファイル | 0 | 1,033 | 514 |
| .css.jsファイル数 | 345 | 704 | 356 |
| ルートレベルへのホイスト | なし | 大量発生 | 解消 |
| dist構造 | ソースと対応 | 大きく乖離 | ソースと対応 |
preserveModules により、Vite 8素の移行で発生していた主要な問題がすべて解消されました!
ビルドパフォーマンスの実測
npmパッケージのビルド時間
25パッケージの全量ビルド(pnpm build:package --force、turboキャッシュ無効)で計測しました。
| 指標 | Vite 7 | Vite 8 + preserveModules | 変化 |
|---|---|---|---|
| Wall time(実時間) | 4分48秒 | 4分57秒 | +3%(ほぼ同等) |
| CPU time(user) | 8分01秒 | 6分58秒 | -13% |
……正直に言うと、Wall time(体感の待ち時間)はほとんど変わりませんでした。 一方で、CPUが実際に処理に費やした時間は13%減少しています。
なぜ Wall time が速くならないのか?
Vite 8 のビルドログにヒントがありました。
[PLUGIN_TIMINGS] vite:dts (81%)
ビルド時間の 81%がTypeScript型定義の生成(vite:dts プラグイン) に費やされていたのです。DTSプラグインはTypeScript Compilerを使うJavaScriptの処理であり、Rolldownの高速化の恩恵を受けません。
つまり、こういう構図です。
- Rolldownによる JSバンドリング自体は確実に高速化 している(CPU time -13%が証拠)
- しかし、ビルド時間の大部分を占めるDTS生成がボトルネックになっていて、JSバンドリングの改善分がWall timeに反映されない
- さらに、JSバンドリングが速くなった結果、turboの並列ビルドで「DTSの完了待ち」が支配的になり、CPU並列効率が低下(Vite 7: 1.66x → Vite 8: 1.39x)
DTSを除いた純粋なJSバンドリングだけで比較すれば、Rolldownの速度改善は体感できるはずです。今後、DTSの生成が高速化されれば(例えば tsgo のようなGo製の高速TypeScriptコンパイラの登場)、Rolldownの真価がWall timeにも反映されることを期待しています。
Next.jsバンドルサイズ
最終成果物であるNext.jsのバンドルサイズも確認しました。
| 条件 | チャンク合計サイズ | 変化 |
|---|---|---|
| Vite 7(ベースライン) | 8.7 MB | — |
| Vite 8 + preserveModules | 8.3 MB | -4.95% |
バンドルサイズが増加していないことを確認できました。むしろヘルパー関数の共有化により微減しており、一安心です。
トランスパイル出力の変化
ビルドエンジンがesbuild/SWCからRolldown/OXCに変わったことで、私たちのパッケージのdist出力にも面白い変化がありました。
ヘルパー関数の共有化
Vite 7: 各ファイルにスプレッド構文やasync/awaitのヘルパーがインラインで重複定義される
// 170以上のファイルそれぞれに21行のヘルパーが重複
var __spreadValues = (a, b) => {
/* ... */
}
var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b))
Vite 8 + preserveModules: _virtual/ ディレクトリに共有モジュールとして1つだけ出力
// _virtual/_@oxc-project_runtime/helpers/objectSpread2.js(1ファイルのみ)
function _objectSpread2(e) {
/* ... */
}
export { _objectSpread2 }
// 使用側(import 1行のみ)
import { _objectSpread2 } from '../_virtual/_@oxc-project_runtime/helpers/objectSpread2.js'
Vite 7では 21行 × 170ファイル = 3,570行 もの重複が、Vite 8ではたった23行に集約されます。これは地味に嬉しい改善ですね! 前述のバンドルサイズ微減にもこのヘルパー共有化が寄与しています。
import/exportの可読性向上
Vite 7(minified):
import { jsxs as n, jsx as e } from 'react/jsx-runtime'
export { SideBar as b }
Vite 8 + preserveModules(元の名前を維持):
import { jsxs, jsx } from 'react/jsx-runtime'
export { SideBar }
同一パッケージ内のモジュール参照も、外部パッケージ形式ではなく相対パスで参照されるようになり、デバッグ時の追跡性が向上しました。
devサーバーの開発体験
冒頭で「devサーバーが重い!」という話をしたので、ここについても触れておきます。今回の Vite 8 移行ではnpmパッケージの本番ビルドがRolldownに切り替わりましたが、pnpm dev で起動するNext.jsのdevサーバー自体はwebpackベースなので、Vite のバージョンが直接影響するのはnpmパッケージのrebuild部分に限られます。
ファイル変更時のHMR(ホットリロード)パイプラインは「Vite rebuild → Next.js recompile → ブラウザ反映」という2段階構成です。
現時点ではNext.js側のrecompileがボトルネックになっていて、正直なところ Vite のバージョン差だけで体感速度が劇的に変わるわけではありません。ただ、Vite 公式が今後リリースを予定している Full Bundle Mode では、devサーバーの起動が3倍高速化、フルリロードが40%改善、ネットワークリクエストが1/10に削減されるとの初期結果が報告されています。これが実現すれば開発体験の本丸の改善になるはずなので、期待大です!
sideEffects の検討と見送り
ここからは、移行の必須作業ではなく追加の最適化として検討したものの、最終的に見送った事項を紹介します。
preserveModules を導入すると、dist内の各ファイルが元のモジュール構造を保持するため、package.json の sideEffects フィールドでwebpackのtree-shakingを最適化できる可能性がありました。
検証結果
| 条件 | _appチャンクサイズ | First Load JS |
|---|---|---|
| sideEffects未設定 | 3.9 MB | 1.26 MB |
sideEffects: ["*.css.js"] |
3.0 MB (-25%) | 944 KB (-28%) |
数値上は劇的な改善ですが……残念ながら CSSが壊れる ことが判明しました。
CSSが壊れる理由
Vite 8のビルド出力では、コンポーネントのファイルがCSS変数定義への import をside-effect importとして含んでいます。sideEffects: ["*.css.js"] を指定すると、*.css.js ファイル自体はside-effectfulとしてマークされますが、それを import する中間のJSファイルがside-effect-freeと判定され、webpackが CSS変数定義へのimportごと除去 してしまいます。
// dist/component/SideBar/SideBar.js — side-effect-free と判定される
import '../../../global.css.js' // ← このside-effect importがwebpackに除去される
import { SideBar } from '../../../SideBar.js'
export { SideBar }
結果として :root にCSS変数が定義されず、var(--color-general-border) 等を参照する全スタイルが壊れます。
sideEffects 未設定でもバンドルサイズはVite 7から増加していない(むしろ微減)ので、今回は無理せず見送りとしました。Rolldownが将来的に sideEffects を考慮した出力を生成するようになれば再チャレンジしたいところです。
まとめ
移行で得られた成果
| 指標 | Vite 7 | Vite 8 + preserveModules | 評価 |
|---|---|---|---|
| ビルド Wall time | 4分48秒 | 4分57秒 | ほぼ同等(DTS生成がボトルネック) |
| ビルド CPU time | 8分01秒 | 6分58秒 | -13%(JS処理は高速化) |
| Next.jsバンドルサイズ | 8.7 MB | 8.3 MB | 微減(-5%) |
| dist構造 | ソースと対応 | ソースと対応 | 同等 |
| ヘルパー重複 | 全ファイルにインライン | 共有モジュール | 改善 |
| import/export可読性 | minified | 元の名前を維持 | 改善 |
移行のポイント
-
preserveModulesは必須: npmパッケージをライブラリモードでビルドし、別のバンドラーで再バンドルする構成では、Rolldownの自動コード分割は害になる。preserveModules: trueで無効化すべき -
sideEffectsは慎重に: Rolldownの出力構造とwebpackのtree-shaking挙動の相性を十分に検証してから導入すべき -
段階的な検証が重要: dist出力の比較分析 →
preserveModulesの導入 → Next.js バンドルサイズの確認、と段階的に進めることで問題の切り分けがしやすくなる
今後の展望
- Vite 8 の正式リリース後に安定版への追従
- Full Bundle Mode によるdevサーバーの開発体験の改善
- TypeScriptコンパイラを tsgo へ移行し、DTS生成のボトルネックを解消(現在計画中)
大規模モノレポでの Vite 8 移行は一筋縄ではいきませんでした。ビルドのWall timeはDTS生成のボトルネックにより期待ほどの改善が見られなかったものの、preserveModules という鍵を見つけたことでdist構造の健全性を確保でき、CPU処理効率の向上やトランスパイル出力の改善など確実な前進がありました。Vite 8とRolldownはまだベータ段階であり、今後のDTS高速化やFull Bundle Modeの登場でさらなる改善が期待できます。同様の構成で Vite 8 移行を検討している方の参考になれば嬉しいです!
Discussion