Nuxtで開発中、ページ遷移時に強制リロードされる事象に立ち向かう
TL;DR
✨ new dependencies optimized: <package-name>
✨ optimized dependencies changed. reloading
Nuxtでページ遷移時にこのようなログが出る場合、nuxt.config.tsに以下の設定を入れると解消します。
export default defineNuxtConfig({
  vite: {
    optimizeDeps: {
      entries: [
        // 実用的なスコープならこの3つくらい。もし必要なら.tsも。
        "pages/**/*.vue",
        "layouts/**/*.vue",
        "components/**/*.vue",
        // または、面倒なら全て
        "**/*.vue",
      ]
    }
  }
})
はじめに
遭遇した事象
このサンプルでhttp://localhost:3000を開くとリロードが発生し、ターミナルのコンソールにこのようなログが出ます。また、ページ遷移時にもリロードが発生する場合があります。
✨ new dependencies optimized: <package-name>
✨ optimized dependencies changed. reloading
開発中に意図せぬリロードが発生するのは開発者体験を損なうので、一体何が起きているのかを調べてみました。
Viteによる依存関係の事前バンドル
NuxtはデフォルトではViteを使ってアプリケーションをビルドします。そのため、この事象はViteが深く関わっています。
Viteはビルドする際に、依存関係(≒npmパッケージ)を事前バンドルします。事前バンドルについては長くなるのでドキュメントを読んでください。
Nuxtでは、実際に事前バンドルされているファイルはnode_modules/.cache/vite/で確認できます。

この依存関係の事前バンドルですが、アプリケーションすべての依存関係を事前バンドルするわけではなく、必要最低限な依存関係を自動的に検出して都度事前バンドルするようです。
つまり、アプリケーションの開発中に事前バンドルが発生することがあり得ます。それが今回の事象だと考えられます。
検証してみる
では色んなパターンの事前バンドルの挙動を確認してみましょう。ずらっとパターンを列挙してみます。それぞれのパターンごとに別々のnpmパッケージを読み込む必要があるので、lodashを使ってlodashのモジュールが事前バンドルされるかどうかを検証していきます。
わかりやすくするために、各パターンA〜Hとlodashのモジュールの頭文字を対応させています。
| パターン | 内容 | 依存関係 | 
|---|---|---|
| A | app.vueでimportする依存関係 | 
lodash.after | 
| B | app.vueでimportするコンポーネントが持つ依存関係 | 
lodash.before | 
| C | app.vueで動的importする依存関係 | 
lodash.compact | 
| D | app.vueで動的importするコンポーネントが持つ依存関係 | 
lodash.debounce | 
| E | pages/index.vueが持つ依存関係 | lodash.every | 
| F | 
NuxtLink(prefetchあり)の遷移先のpagesコンポーネントが持つ依存関係 | 
lodash.flip | 
| G | 
NuxtLink(prefetchなし)の遷移先のpagesコンポーネントが持つ依存関係 | 
lodash.get | 
| H | layoutsコンポーネントが持つ依存関係 | lodash.head | 
また。app.vueはこのようになっています。
これでサンプルリポジトリでnpm run devしましょう。
ここで注意するのは、ビルドするたびにnode_modules/.cache/viteを削除することです。サンプルリポジトリではnpm run devすると勝手に消すようになっています。
 1. npm run devの直後(http://localhost:3000はまだ表示しない)
node_modules/.cache/viteを見てみると、このようになっており、パターンA・B・Cが事前バンドルされている事がわかります。

_metadata.jsonも覗いてみましょう。ここにはいろんなハッシュ値が保存されています。
{
  "hash": "5d03508b",
  "configHash": "7e260cbf",
  "lockfileHash": "9443f8d2",
  "browserHash": "19d7d7db",
  "optimized": {
    "lodash.after": {
      "src": "../../../../lodash.after/index.js",
      "file": "lodash__after.js",
      "fileHash": "732b2acb",
      "needsInterop": true
    },
    // ...
}
 2. http://localhost:3000を表示した直後
パターンD・E・Fから依存関係が新たに検出され、再バンドルが行われてページリロードが発生します。


_metadata.jsonも見てみると少し差分が出ています。
{
  "hash": "5d03508b",
  "configHash": "7e260cbf",
  "lockfileHash": "9443f8d2",
- "browserHash": "19d7d7db",
+ "browserHash": "ab389ed8",
  "optimized": {
    "lodash.after": {
      "src": "../../../../lodash.after/index.js",
      "file": "lodash__after.js",
-     "fileHash": "732b2acb",
+     "fileHash": "988c2045",
      "needsInterop": true
    },
    // ...
}
 3. http://localhost:3000/prefetch に遷移した後
すでに先程パターンFの依存関係はバンドルされていたので、/prefetchに遷移しても再バンドルは発生しません。
 4. http://localhost:3000/no-prefetch に遷移した後
プリフェッチされてないパターンGで新たな依存関係が検出され、再バンドルが発生します。

{
  "hash": "5d03508b",
  "configHash": "7e260cbf",
  "lockfileHash": "9443f8d2",
- "browserHash": "ab389ed8",
+ "browserHash": "011a43dd",
  "optimized": {
    "lodash.after": {
      "src": "../../../../lodash.after/index.js",
      "file": "lodash__after.js",
-     "fileHash": "988c2045",
+     "fileHash": "4ad82157",
      "needsInterop": true
    },
    // ...
}
 5. http://localhost:3000/layout に遷移した後
レイアウトコンポーネントを使うパターンHから新たな依存関係が検出され、再バンドルが発生します。

{
  "hash": "5d03508b",
  "configHash": "7e260cbf",
  "lockfileHash": "9443f8d2",
- "browserHash": "011a43dd",
+ "browserHash": "3c84cb7d",
  "optimized": {
    "lodash.after": {
      "src": "../../../../lodash.after/index.js",
      "file": "lodash__after.js",
-     "fileHash": "4ad82157",
+     "fileHash": "9baad5cf",
      "needsInterop": true
    },
    // ...
}
ページリロードを防ぐには
一度バンドルされれば、node_modules/.cache/viteのファイルが残っているので、再バンドルされない限りはリロードが起きません。
ただし、開発でブランチを切り替える際などで依存関係をリセットするためにnpm ciのようなコマンドを実行すると、node_modulesが空になり、事前バンドルされたキャッシュファイルは消えます。
そうすると、Nuxtアプリケーションを立ち上げてページ遷移するたびに依存関係の再バンドルに遭遇してしまいます。
できれば最初の事前バンドルで、必要な分の依存関係を事前バンドルしたいですよね。
先ほど検証したパターンで、事前バンドルされなかったパターンD〜Hまでは、それぞれ以下のパスのコンポーネントでした。
| パターン | ディレクトリ | 
|---|---|
| D | components/ | 
| E・F・G | pages/ | 
| H | layouts/ | 
これらのパスのコンポーネントの依存関係を事前バンドルしてもらうために、今回はoptimizeDeps.entriesを使って対策します。tinyglobby(glob)パターンを指定できます。
NuxtでViteの設定をするにはnuxt.config.tsでviteプロパティを使います。
export default defineNuxtConfig({
  vite: {
    optimizeDeps: {
      entries: [
        "pages/**/*.vue",
        "layouts/**/*.vue",
        "components/**/*.vue",
      ]
    }
  }
})
これでもう一度サンプルリポジトリで、node_modules/.cache/vite削除してnpm run devした直後にnode_modules/.cache/viteを見てみると、事前バンドルされる事がわかります。
これで依存関係の再バンドル時のページリロードを防ぐことができました🥳
ちなみに①
NuxtのIssueコメントによると、この解決方法はRemixやSvelteKitでも採用されているようでした。
ちなみに②
依存関係が事前バンドルされてない状態でnpm run devすると、事前バンドルがある2回目以降のビルドに比べ、事前バンドルがない初回のビルドが遅くなりそうです。
そう思って試してみましたが、サンプルリポジトリでは初回のビルドと2回目のビルド時間がほぼ同じでした。

著者が実際に業務で開発している事前バンドルされる依存関係が300ファイル程度のアプリケーションで同じような設定にしても、初回ビルドと2回目以降のビルドで3200ms程度の差でした。初回だけですし、この程度なら受け入れられるのではないでしょうか。
少なくともページ遷移のたびにリロードが発生するよりは良いと思います。
ちなみに③
optimizeDeps.includeを使う方法も先程のIssueに上がっていましたが、すべての依存関係をここに並べるのでかなり冗長になります。
export default defineNuxtConfig({
  vite: {
    optimizeDeps: {
      include: [
        "lodash.after",
        "lodash.before",
        "lodash.compact",
        "lodash.debounce",
        "lodash.every",
        "lodash.flip",
        "lodash.get",
        "lodash.head",
      ]
    }
  }
}) 
また、この方法はメンテナー曰くおすすめできないそうです。(もしかしたらoptimizeDeps.entriesも同じ問題を抱えているかもしれませんが。。。)
余談:Nuxtを使わず、VueとViteでビルドするときとの違い
冒頭にも少し書きましたが、ここまで書いてきた内容はNuxt固有の事象です。
Nuxtを使わずに、VueをViteでビルドすると全く異なる結果になります。
↑のサンプルではNuxtLinkのprefetch(パターンG)やNuxtのlayoutコンポーネント(パターンH)などは無いので省略しています。その他いくつか違う点があります。
おわりに
書き始めは、よくわからないけどとりあえずoptimizeDeps.entriesを使えば対策できるぞ!という内容で記事を書こうとしていましたが、ちょっと掘り下げてみたら色々なパターンがあり大きめなボリュームになりました。
Viteの事前バンドルの処理はかなり難しく、むやみに立ち向かっても理解するまでとても時間がかかりそうでした。有識者の解説記事を心待ちにしています。
Discussion