npm workspace + Viteのモノレポ環境でHMRを有効にする

:::note info
viviONグループでは、DLsiteやcomipoなど、二次元コンテンツを世の中に届けるためのサービスを運営しています。
ともに働く仲間を募集していますので、興味のある方は[こちら](#一緒に二次元業界を盛り上げていきませんか)まで。
:::

## はじめに

npm workspace + Viteのモノレポ環境で、パッケージを更新した際にアプリケーション側でHMRする方法を検証してみました。
今回は2つの方法を紹介します。

## 環境

- node 20+
- npm 10.2.4
- vite@6.0.3

## サンプルのプロジェクト構成

以下はプロジェクト構成です。
appsディレクトリにアプリケーション、packagesディレクトリにパッケージを配置しています。
```
.
├── package.json
├── apps
│   └── my-app
│   │   └── vite.config.mts
    │   └── src/index.ts
└── packages/
    ├── a/
    │   └── package.json
    │   └── vite.config.mts
    │   └── src/index.ts
    ├── b/
    │   └── package.json
    │   └── vite.config.mts
    │   └── src/index.ts
```

各パッケージの名前は次のとおりです。

| ディレクトリ     | パッケージ名   |
|------------------|----------------|
| `packages/a`     | `@my-org/a`    |
| `packages/b`     | `@my-org/b`    | 

## aliasを使用する

アプリケーションが依存するパッケージのソースを直接aliasに指定します。

```typescript
import { defineConfig } from "vite";
import path from "node:path";

export default defineConfig({
  resolve: {
    alias: {
      "@my-org/a": path.resolve(__dirname, "../../packages/a/src"),
    },
  },
});
```

全てのパッケージでHMRが有効になる一方、パッケージに更に依存するパッケージはビルド済みのファイルを参照しようとするためHMRの対象になりません。
そのため、HMRの対象にしたい全ての依存パッケージをaliasに設定する必要があります。
例えば、パッケージaがパッケージbに依存していた場合、アプリケーション側のvite.confit.mtsは以下になります。

```typescript
import { defineConfig } from "vite";
import path from "node:path";

export default defineConfig({
  resolve: {
    alias: {
      "@my-org/a": path.resolve(__dirname, "../../packages/a/src"),
      "@my-org/b": path.resolve(__dirname, "../../packages/b/src"),
    },
  },
});
```

### メリットとデメリット

**メリット**
- HMRがシンプルに動作する
- 依存関係が少ない場合は設定が簡単
- パッケージのビルドが不要

**デメリット**
- パッケージ間の依存関係を手動管理する必要がある
- ビルドされていないソースを参照するため、初回読み込み時、ページリロード時のパフォーマンスを低下させる可能性がある
    - Viteは依存関係にあるファイルを全てブラウザで読み込むため

## build --watchを使用する

次に紹介する方法は、aliasを使用せずに`vite build --watch`を利用するものです。
この方法では、パッケージの変更を検知して自動的に再ビルドを行い、その結果をアプリケーション側に反映します。

:::note warn
アプリケーション側はHMRが有効ですが、`vite build --watch`を使用しているパッケージ側はファイル変更時にフルビルドが走ります。
また、パッケージ数だけwatchのプロセスを立ち上げる必要があります。
:::

### 問題点と解決策

`vite build --watch`は再ビルド時に一度出力先のファイルを削除するため、HMRでwatchしていたファイルが存在しなくなり、アプリケーション側で以下のエラーが発生します。

```
GET https://localhost:3000/@fs/my-app/packages/a/dist/index.js?t=1734664538544 net::ERR_ABORTED 404 (Not Found)
```

この問題に対応するためには、vite.config.mtsの[`build.emptyOutDir`](https://ja.vite.dev/config/build-options#build-emptyoutdir)を無効にする必要があります。
emptyOutDirを無効にすることで、再ビルド時にファイルが削除されず、404エラーを回避できます。
modeがdevelopmmentの場合のみ、emptyOutDirを無効にします。

```typescript
import { defineConfig } from "vite";

export default defineConfig(({ mode }) => {
  return {
    build: {
      emptyOutDir: mode !== 'development',
    },
  }
});
```

これにより、再ビルド時に出力先ファイルが削除されなくなり、404エラーを回避できます。

### メリットとデメリット

**メリット**
- アプリケーション側がパッケージ間の依存関係を意識する必要がなくなる
- 複雑なalias設定が不要

**デメリット**
- パッケージごとにwatchプロセスが必要
- 再ビルド時にフルビルドが走るため、ビルド時間が増加する

## まとめ

npm workspaceのモノレポ構成 + Viteを使用する環境で、HMRを有効にして開発する2つの方法を紹介しました。

基本的にはaliasを使うことをお勧めしますが、選択肢の一つとして `vite build --watch`を使用する方法も覚えておくと役に立つ時が来るかもしれません。

## 参考
- https://github.com/vitejs/vite/discussions/7155
- https://www.reddit.com/r/vuejs/comments/ypsju8/vite_hmr_monorepo_any_way_to_improve_this/

# 一緒に二次元業界を盛り上げていきませんか？
株式会社viviONでは、フロントエンドエンジニアを募集しています。

https://vivion.jp/recruit/

また、フロントエンドエンジニアに限らず、バックエンド・SRE・スマホアプリなど様々なエンジニア職を募集していますので、ぜひ採用情報をご覧ください。