Vite v6 + HMR + Full Reload環境でモジュール内の即時実行コードが重複実行される


## 先に結論だけ

以下の条件を満たした場合のみ、モジュール内の即時実行コードが重複実行される問題が発生しました。

- Vite v6
- `npx vite`コマンドで開発サーバーを起動する
- HMR機能を利用
- 循環参照が発生している
- ファイル編集でFull Reloadをトリガー

即時実行コードを使用する場合、そのファイルをexportしてはいけません。

## はじめに

この記事は、[Vite](https://ja.vite.dev/) v6で遭遇した問題の記録と共有のためのものです。

### 想定する読者

- フロントエンド開発者
- Viteを利用している

:::note warn
この記事はすでにViteを使ったことがある人を想定しています。ツールそのものの解説やインストールガイドなどは含まれません。
:::


### 想定する環境

- Vite v6.2.1

:::note warn
この記事は、Vite v6での挙動について記載しています。Vite v5環境ではこの問題は発生しません。また、将来のバージョンでは挙動が変わるかもしれないので、記事を読む前にお手元の環境のバージョンを確認してください。
:::

## 現象の概要

以下のような構成でViteを利用しているとします。

_▼index.html_
```html
<!DOCTYPE html>
<html lang="ja">  
  <head>
    <meta charset="UTF-8">
    <title>Vite App</title>
  </head>
  <body>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>
```

_▼src/main.ts_
```ts
import { ModuleA } from './moduleA';
export class Main {
  static width = 100;
  static height = 60;
  static moduleA:ModuleA;
  static init() {
    this.moduleA = new ModuleA();
  }
}

console.log('main.ts loaded') // 読み込み時に即時実行される
Main.init();
```

_▼src/moduleA.ts_
```ts
import { Main } from './main'; // 循環参照が発生している

export class ModuleA {
  size:{width:number, height:number};
  constructor() {
    console.log('ModuleA constructor');
    this.size = {width: Main.width, height: Main.height};
  }
}
```

_▼vite.config.ts_
```ts
import { defineConfig, Plugin } from 'vite';

const fullReloadAlways: Plugin = {
  handleHotUpdate({ server }) {
    server.ws.send({ type: "full-reload" });
    return [];
  },
};

export default defineConfig(() => {
  return {
    esbuild: {},
    plugins: [
      fullReloadAlways,
    ],
  };
});
```

スクリプトファイルを編集した場合に、フルリロードが発生するように`vite.config.ts`で設定しています。この設定は以下のIssueを参考にしました。

https://github.com/vitejs/vite/issues/9743#issuecomment-1221292059

この構成でViteの開発サーバーを起動します。

```bash
npx vite
```

ブラウザでページを開くと、コンソールに以下のようなログが出力されます。ここまでは問題ありません。

```console
main.ts loaded
ModuleA constructor
```

しかし、ファイルを編集しHMRをトリガーすると、以下のようなログが出力されます。

```console
main.ts loaded
main.ts loaded
ModuleA constructor
ModuleA constructor
```

このように、即時実行コードが重複実行されます。この問題はbuildコマンドを実行した場合には発生しません。

```bash
npx vite build
```

## 原因

この問題は、Viteの[HMR機能](https://ja.vite.dev/guide/features.html#hot-module-replacement)と循環参照が組み合わさった際に発生します。

ViteのHot Module Replacement (HMR) は、開発サーバーから更新されたファイルの差分のみをブラウザに転送する仕組みです。HMRが有効な場合、モジュールの読み込み順序がbuildと変わることがあります。

即時実行コードを含むファイルを外部からimportした場合、読み込み順によっては関数が重複実行されることがあります。

## 対策

### エントリーポイントを専用のファイルにする

この問題を回避するには、即時実行コードを含むファイルからはモジュールをエクスポートせず、エントリーポイントとして専用のファイルを使用します。

_▼index.html_
```html
<!DOCTYPE html>
<html lang="ja">  
  <head>
    <meta charset="UTF-8">
    <title>Vite App</title>
  </head>
  <body>
    <script type="module" src="/src/entry.ts"></script> <!-- ここを変更 -->
  </body>
</html>
```

_▼src/entry.ts_
```ts
// 即時実行コードを記述する専用のファイルを作成する。このファイルはexportしない。
import { Main } from './main';
Main.init();
```

_▼src/main.ts_
```ts
import { ModuleA } from './moduleA';
export class Main {
  static width = 100;
  static height = 60;
  static moduleA:ModuleA;
  static init() {
    this.moduleA = new ModuleA();
  }
} 
// ここでは処理しない
```

### 循環参照を避ける

循環参照はコードの動作を読み解きにくくするだけではなく、再現が難しい問題を引き起こします。しかし循環参照を人力で発見することは簡単ではありません。各種ツールの手助けを受けましょう。

Viteは`vite --debug hmr`というオプションでHMRのデバッグ情報を表示できます。循環参照が発生してViteがフルリロードを実行した場合、コンソールにその旨のメッセージが表示されます。

https://ja.vite.dev/guide/troubleshooting#hmr-%E3%81%A6%E3%82%99%E3%81%AF%E3%81%AA%E3%81%8F%E5%AE%8C%E5%85%A8%E3%81%AA%E3%83%AA%E3%83%AD%E3%83%BC%E3%83%88%E3%82%99%E3%81%8B%E3%82%99%E7%99%BA%E7%94%9F%E3%81%99%E3%82%8B

また、[eslint-plugin-import](https://github.com/import-js/eslint-plugin-import)の`no-cycle`ルールを利用することで、循環参照を検出できます。

https://github.com/import-js/eslint-plugin-import/blob/main/docs/rules/no-cycle.md

以上、ありがとうございました。