【実務・中級編】Webpack vs Vite:『Monorepo環境』における依存関係の重複問題(Hoisting)を解決するためのワークスペース戦略 – ビルド・パッケージ管理ツール生産性向上バイブル

はじめに:Monorepoにおける「依存関係の闇」とバンドル爆発のメカニズム

テックリードとして複数のプロダクトや共通パッケージをTurborepoやNxといったMonorepo環境で統括していると、ある日突然、プロダクトのビルド時間が肥大化し、成果物(Bundle)のサイズが不自然に跳ね上がっているという恐怖に直面する。

その原因の多くは、「依存関係の重複(Dependency Duplication)」 と、パッケージマネージャー(pnpm / Yarn Berry / npm)による 「不完全なホイスティング(Hoisting)」 にある。

特にWebpackやViteを用いたフロントエンド開発において、共通コンポーネントライブラリや内部ユーティリティが、それぞれ独自の`node_modules`を持つか、あるいはビルドツール側がシンボリックリンクやネストされた依存関係を正しく解決できずに、同一ライブラリ(例: `react`, `lodash`, デザインシステム等)の複数バージョンや重複インスタンスを最終バンドルに同梱してしまう現象が後を絶たない。

本記事では、WebpackとViteのモジュール解決メカニズムの差異を紐解きつつ、Monorepo環境における依存関係の重複を根本から断ち切り、バンドルサイズを極限まで最適化するためのワークスペース戦略と具体的な設定アーキテクチャを伝授する。

—

1. なぜ重複が起きるのか? WebpackとViteのモジュール解決の差

現代のパッケージマネージャー(特にハードリンクとシンボルを駆使する `pnpm` や、PnPを採用する `Yarn Berry`)は、ディスク容量の節約と厳格な依存関係の分離を実現している。しかし、これがビルドツールにとっては諸刃の剣となる。

Webpackの挙動と落とし穴

Webpackの `resolve.modules` や `resolve.symlinks` は、デフォルトではシンボルリンクを実体パスに解決する。しかし、Monorepo内の別パッケージ(例: `packages/ui`)が独自の `node_modules` を持っており、かつルートの `node_modules` と異なるバージョンの依存関係(例: `react@18.2.0` と `react@18.3.0`)を要求している場合、Webpackはこれを別物として愚直にバンドルに含める。結果として、Reactのインスタンスが重複し、フックの実行時エラーやバンドルサイズの肥大化を引き起こす。

Vite(esbuild / Rollup)の挙動と落とし穴

Viteは開発時に `esbuild` による超高速な事前バンドル(Pre-bundling)を行う。Viteは内部で `connect` や `resolve` プラグインを用いて依存関係をスキャンするが、Monorepoのワークスペース境界を越えたモジュールインポートにおいて、最適化キャッシュ(`node_modules/.vite`)の不整合が起きやすい。特に `symlinks: true` がデフォルトであるため、ワークスペース内のパッケージがビルド対象外と誤認されたり、依存関係の重複検知が漏れたりする。

—

2. 根本解決のためのアーキテクチャ戦略

この問題を解決するには、以下の3つのアプローチを組み合わせる必要がある。

1. パッケージマネージャー層でのホイスティング制御(シングルトン強制)
2. ビルドツール層でのパス解決の単一化(`resolve.alias` / `dedupe`)
3. ワークスペース間での型・ビルド成果物の参照最適化

—

3. 実践!最適化された設定ファイルのベストプラクティス構成

ここからは、実際に現場で即座に導入できる具体的な設定ファイルのコードを示す。pnpmワークスペースを前提とした、Monorepo環境における決定版だ。

① パッケージマネージャーの制御:`pnpm-workspace.yaml` と `.npmrc`

まずは、依存関係が意図しない階層に散らばるのを防ぎ、依存関係を厳格に制御する。

pnpm-workspace.yaml
モノレポ配下として認識させるディレクトリを定義
packages:

  • ‘apps/’
  • ‘packages/’
  • ‘tooling/’

.npmrc
シンボルリンクの扱いやホイスティングを制御し、幽霊依存(Phantom Dependencies)を防止する
public-hoist-pattern[]=eslint
public-hoist-pattern[]=prettier
public-hoist-pattern[]=typescript

重複する依存関係を可能な限り単一化(シングルトン強制)
shamefully-hoist=false
auto-install-peers=true
strict-peer-dependencies=false

—

② Webpack環境の最適化設定 (`webpack.config.js`)

Webpackを使用しているレガシー、あるいは大規模移行中のアプリケーションにおいて、同一ライブラリの重複を防ぐための決定版設定。ここでは `resolve.alias` と `Symlink` の制御、さらに複数インスタンスを防ぐプラグイン戦略を適用する。

const path = require(‘path’);
const webpack = require(‘webpack’);

module.exports = {
// モノレポのルートディレクトリを特定(__dirnameは apps/web などのアプリ層を想定)
context: __dirname,

resolve: {
// シンボルリンクを実体パスに解決しつつ、モノレポ内の重複を防ぐ
symlinks: true,

// モジュール解決の探索パス。自ホストのnode_modulesだけでなく、モノレポのルートも強制探索させる
modules: [
path.resolve(__dirname, ‘node_modules’),
path.resolve(__dirname, ‘../../node_modules’),
‘node_modules’
],

alias: {
// Reactや主要なライブラリが複数バンドルされるのを防ぐため、ルートのパスに強制固定
‘react’: path.resolve(__dirname, ‘../../node_modules/react’),
‘react-dom’: path.resolve(__dirname, ‘../../node_modules/react-dom’),

// 共通UIパッケージへのショートカット(ビルド済み成果物ではなくソースを直接参照する場合のエイリアス)
‘@repo/ui’: path.resolve(__dirname, ‘../../packages/ui/src’)
},

extensions: [‘.tsx’, ‘.ts’, ‘.js’, ‘.json’]
},

// 開発サーバーやビルドの最適化
optimization: {
// 依存関係のモジュールIDを決定論的に生成し、キャッシュ効率を最大化
moduleIds: ‘deterministic’,

// 共通モジュールをベンダークンクに切り出し、重複を排除
splitChunks: {
chunks: ‘all’,
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name(module) {
// node_modules内のパッケージ名を取得してグループ化
const packageName = module.context.match(/[\\/]node_modules[\\/](.?)([\\/]|$)/)[1];
return `npm.${packageName.replace(‘@’, ”)}`;
},
priority: 10,
},
},
},
},
};

—

③ Vite環境の最適化設定 (`vite.config.ts`)

Vite(およびRolldownを見据えたモダン環境)では、`resolve.dedupe` を用いることで、トランスパイル時およびバンドル時に同一パッケージの重複インスタンスを強制的に排除できる。

import { defineConfig } from ‘vite’;
import react from ‘@vitejs/plugin-react’;
import { resolve } from ‘path’;

export default defineConfig({
plugins: [react()],

resolve: {
// モノレポ環境で最も重要な設定:指定したパッケージは、複数のバージョンが存在しても単一のインスタンスに統合する
dedupe: [‘react’, ‘react-dom’, ‘@tanstack/react-query’, ‘styled-components’],

alias: {
// ワークスペース内の共通パッケージをソースコードから直接マッピング(HMRの追従性を高める)
‘@repo/ui’: resolve(__dirname, ‘../../packages/ui/src’),
‘@repo/utils’: resolve(__dirname, ‘../../packages/utils/src’),
},
},

optimizeDeps: {
// esbuildの事前バンドル対象からワークスペースパッケージを除外せず、正しく依存関係を追跡させる
include: [‘@repo/ui’, ‘@repo/utils’],
// 外部ライブラリのプリバンドルを強制し、ブラウザー側のリクエスト数を削減
exclude: [],
},

build: {
// ターゲット環境の設定
target: ‘esnext’,
// チャンクサイズの警告閾値(KB)
chunkSizeWarningLimit: 1000,
rollupOptions: {
output: {
// ベンダーチャンクの手動分割によるキャッシュ最適化
manualChunks(id) {
if (id.includes(‘node_modules’)) {
if (id.includes(‘react’) || id.includes(‘react-dom’)) {
return ‘vendor-react’;
}
return ‘vendor-core’;
}
},
},
},
},
});

—

4. チーム開発の生産性を劇的に高めるTipsとルール

アーキテクチャや設定ファイルを整備しただけでは、チーム開発において「知らぬ間に依存関係が追加され、再び肥大化する」という技術的負債の再発を防げない。以下のルールと開発効率化テクニックをチーム全体に浸透させよ。

隠れたキーボードショートカット & CLIハック

  • `pnpm -r –filter dev`: モノレポ全体ではなく、依存関係を解決した状態で特定のアプリだけを爆速で立ち上げる。
  • `npx depcheck` または `pnpm dlx depcheck`: 各パッケージの `package.json` に記述されているが実際には使われていない「ゾンビ依存」や、逆に宣言漏れしている「幽霊依存」をCIパイプラインまたはローカルフックで定期的に検出する。

チーム共有のための「依存関係ガードレール」ルール

1. バージョンの完全一致(Syncpackの導入)
モノレポ内のすべての `package.json` で、ReactやTypeScriptなどのコアライブラリのバージョンがバラバラにならないよう、`syncpack` を導入する。

// .syncpackrc.json の例
{
“dependencyTypes”: [“dev”, “prod”, “resolutions”],
“semverGroups”: [
{
“range”: “”,
“dependencies”: [“react”, “react-dom”, “typescript”],
“packages”: [“”]
}
]
}

これにより、`pnpm syncpack fix` を叩くだけで全パッケージの依存バージョンが強制同期され、バージョン違いによるバンドル重複の芽を完全に摘むことができる。

2. CIでのバンドルサイズ監査(Size Limit)
PRごとにバンドルサイズの回帰テストを義務付ける。

// package.json (各アプリ層)
“size-limit”: [
{
“path”: “dist/assets/.js”,
“limit”: “150 KB”
}
]

—

おわりに

Monorepo環境における依存関係の重複問題は、単なる「設定ミスの積み重ね」ではなく、パッケージマネージャーとビルドツールの挙動の不理解から生じる構造的な課題である。

今回紹介した `resolve.alias` によるパスの集約、Viteの `dedupe` や Webpackの `splitChunks` による実体化の防止、そして `syncpack` によるバージョン統制を組み合わせることで、ビルドサイズの劇的な削減と、開発サーバーの起動・HMRスピードの向上を同時に手に入れることができる。

あなたの組織のモノレポも、今日からこのアーキテクチャを取り入れ、真の「クリーンで高速な開発環境」へとアップデートしてほしい。

タイトルとURLをコピーしました