Vueの非同期コンポーネントとは、defineAsyncComponentで包み、必要になったときにだけ読み込む部品のことで、Suspenseとは、その読み込みやasync setupの完了を待つ間に代わりの表示(fallback)を出す組み込みコンポーネントです。 defineAsyncComponentは「部品のコードを後から取ってくる」しくみ、Suspenseは「待っている間の表示を1か所にまとめる」しくみで、役割が違います。

この記事では、defineAsyncComponent単体、Suspenseとasync setup、両者の併用の3パターンを実際に動かし、何ミリ秒後に何が表示されたかを時系列で記録しました。delayとtimeoutの関係、併用したときにloadingComponentが使われなくなる様子、vite build が出力するチャンク、エラー表示と再試行、Vue 3.5の遅延ハイドレーションまで、貼って動くコードと出力で確認できます。

確認環境:Ubuntu 24.04、Node.js 22.22.2、Vue 3.5.43、Vite 8.3.2、@vitejs/plugin-vue 6.0.9。ブラウザはPlaywright 1.56.0から起動したChromium 141です。時系列は本番ビルド(vite build)で記録し、開発サーバーでも同じ順序になることを確かめています。

Suspenseと非同期コンポーネントの早見表

3パターンの比較(読み込みに1000msかかる場合の実行結果)

パターン 0ms 200ms 1000ms 読み込み中表示を決めるもの
A. defineAsyncComponent単体(delay 200) 何も表示なし loadingComponent 本体 loadingComponentとdelay
B. Suspense+async setup fallback fallback 本体 Suspenseのfallbackスロット
C. 併用(Suspenseの中に非同期コンポーネント) fallback fallback 本体 Suspenseのfallback(loadingComponentとdelayは使われない)
C'. 併用+suspensible: false 何も表示なし loadingComponent 本体 非同期コンポーネント自身の設定

defineAsyncComponentのオプション

オプション 既定値 意味
loader (必須) コンポーネントを返すPromiseを返す関数。通常は () => import('./X.vue')
loadingComponent なし 読み込み中に表示するコンポーネント
delay 200 loadingComponentを出すまでの待ち時間(ms)
errorComponent なし 読み込めなかったときに表示するコンポーネント。propsの error でエラーを受け取る
timeout 指定なし(時間切れなし) この時間を過ぎたらerrorComponentを表示(ms)
suspensible true 上位のSuspenseに待ち時間の表示を任せるか
onError なし (error, retry, fail, attempts) を受け取り、再試行するかを決める
hydrate なし 遅延ハイドレーションの戦略(SSRのとき。Vue 3.5以降)

どれを使うかの判断表

やりたいこと 使うもの
大きな部品のコードを、表示するときまで読み込まない defineAsyncComponent
その部品だけに読み込み中表示を出す defineAsyncComponentのloadingComponent
setupの中でawaitしてデータを取り、そろってから表示する Suspense+async setup
複数の部品の読み込み中表示を1つにまとめる Suspenseの中に並べる
ページを切り替えるとき、前の内容を出したまま待つ Suspense(timeoutを指定しない)
読み込めなかったときに案内と再試行を出す errorComponent+onError、またはonErrorCaptured

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Vue Suspenseと非同期コンポーネントの使い方|読み込み中表示」

確かめるための共通ファイル

Viteの最小構成(vueとvite、@vitejs/plugin-vue)に、次のファイルを置いて動かしました。時間を数えやすいように、待ち時間をつくる関数を1つ用意しています。

// src/wait.js
export const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
<!-- src/Spinner.vue -->
<template>
  <p class="spinner">読み込み中…(loadingComponent)</p>
</template>
<!-- src/LoadError.vue -->
<script setup>
defineProps({ error: Error })
</script>

<template>
  <p class="error">読み込めませんでした(errorComponent):{{ error?.message }}</p>
</template>
<!-- src/Heavy.vue:後から読み込む部品 -->
<template>
  <p class="heavy">グラフ本体(Heavy.vue)</p>
</template>

時系列は、マウントの直前を0msとして、MutationObserverで画面のp要素の文字が変わるたびに記録しました。

// src/main.js
import { createApp } from 'vue'
import PatternA from './PatternA.vue'
import PatternB from './PatternB.vue'
import PatternC from './PatternC.vue'
import RetryLoader from './RetryLoader.vue'
import SuspenseError from './SuspenseError.vue'
import OnDemand from './OnDemand.vue'

const roots = { a: PatternA, b: PatternB, c: PatternC, retry: RetryLoader, serr: SuspenseError, demand: OnDemand }
const root = roots[new URLSearchParams(location.search).get('p') ?? 'a']

// 何ミリ秒後に何が表示されたかを記録する
const el = document.getElementById('app')
const t0 = performance.now()
window.__timeline = []
window.__mark = (text) => window.__timeline.push({ t: Math.round(performance.now() - t0), text })
const shot = () => {
  const text = [...el.querySelectorAll('p')].map((p) => p.textContent.trim()).join(' / ') || '(何も表示なし)'
  const last = window.__timeline.filter((x) => !x.text.startsWith('event') && !x.text.startsWith('onError')).at(-1)
  if (!last || last.text !== text) window.__mark(text)
}
new MutationObserver(shot).observe(el, { childList: true, subtree: true, characterData: true })

createApp(root).mount(el)
shot()

URLのクエリ(?p=a&load=1000 など)で、パターンと待ち時間を切り替えます。

パターンA:defineAsyncComponent単体

<!-- src/PatternA.vue -->
<script setup>
import { defineAsyncComponent } from 'vue'
import Spinner from './Spinner.vue'
import LoadError from './LoadError.vue'
import { wait } from './wait.js'

const q = new URLSearchParams(location.search)
const num = (k, d) => (q.has(k) ? Number(q.get(k)) : d)
const loadMs = num('load', 1000)      // 読み込みにかかる時間(確認用に遅らせる)

const Heavy = defineAsyncComponent({
  loader: async () => {
    await wait(loadMs)
    if (q.get('reject') === '1') throw new Error('チャンクを取得できませんでした')
    return import('./Heavy.vue')
  },
  loadingComponent: Spinner,
  errorComponent: LoadError,
  delay: num('delay', 200),           // 既定値 200
  timeout: q.has('timeout') ? Number(q.get('timeout')) : undefined,  // 書かなければ時間切れなし
})
</script>

<template>
  <h1>パターンA</h1>
  <Heavy />
</template>

loaderの中で wait(loadMs) を挟み、読み込みにかかる時間を再現しています。通常のコードでは loader: () => import('./Heavy.vue') だけで構いません。

delayとtimeoutの時系列

[A1 load=100 delay=200]
      4ms  (何も表示なし)
    111ms  グラフ本体(Heavy.vue)

[A2 load=1000 delay=200]
      5ms  (何も表示なし)
    206ms  読み込み中…(loadingComponent)
   1011ms  グラフ本体(Heavy.vue)

[A3 load=1000 delay=0]
      4ms  読み込み中…(loadingComponent)
   1020ms  グラフ本体(Heavy.vue)

[A4 load=5000 delay=200 timeout=600]
      4ms  (何も表示なし)
    205ms  読み込み中…(loadingComponent)
    606ms  読み込めませんでした(errorComponent):Async component timed out after 600ms.
  (console) Error: Async component timed out after 600ms.

[A5 load=1000 で reject]
      5ms  (何も表示なし)
    206ms  読み込み中…(loadingComponent)
   1008ms  読み込めませんでした(errorComponent):チャンクを取得できませんでした
  (console) Error: チャンクを取得できませんでした

表にまとめます。

条件 0ms〜 200ms〜 終わり 読み取れること
読み込み100ms・delay 200 何も表示なし ― 111msで本体 delayより早く終われば、loadingComponentは一度も出ない
読み込み1000ms・delay 200 何も表示なし loadingComponent 1011msで本体 206msで読み込み中表示が出た
読み込み1000ms・delay 0 loadingComponent loadingComponent 1020msで本体 最初から読み込み中表示が出る
読み込み5000ms・timeout 600 何も表示なし loadingComponent 606msでerrorComponent timeoutを過ぎるとエラー表示に切り替わる
1000ms後にloaderがreject 何も表示なし loadingComponent 1008msでerrorComponent rejectした時点でエラー表示になる

公式ドキュメントは、delayの既定値が200msである理由を、速い回線では読み込み中表示がすぐに置き換わり、ちらつきのように見えるおそれがあるためと説明しています。1行目の結果がその動きです。delayは「短い待ちには何も出さない」ための設定で、0〜200msの間は部品の場所に何も表示されません。レイアウトが動くのを避けたいときは、親の側で高さを確保しておきます。

時間切れのときにerrorComponentが受け取るエラーのメッセージは、次のとおりでした。

Async component timed out after 600ms.

timeoutにInfinityを書くとすぐエラー表示になる

「時間切れなし」を明示するつもりで timeout: Infinity と書くと、次の結果になりました。

[A6 timeout=Infinity を明示]
      4ms  (何も表示なし)
     10ms  読み込めませんでした(errorComponent):Async component timed out after Infinityms.
   1011ms  グラフ本体(Heavy.vue)
  (console) Error: Async component timed out after Infinityms.

読み込みが始まった直後にエラー表示へ切り替わっています。時間切れを設けないときは、timeoutを書かない(またはundefinedにする)のが確実です。上のPatternA.vueでも、クエリで指定がないときはundefinedを渡しています。

必要になるまでチャンクを取得しない

defineAsyncComponentに関数だけを渡す、いちばん短い書き方です。

<!-- src/OnDemand.vue -->
<script setup>
import { ref, defineAsyncComponent } from 'vue'

const show = ref(false)
const Heavy = defineAsyncComponent(() => import('./Heavy.vue'))
</script>

<template>
  <h1>必要になったら読み込む</h1>
  <button id="open" @click="show = true">グラフを開く</button>
  <Heavy v-if="show" />
</template>

本番ビルドを開き、ブラウザが取得したJavaScriptファイルを記録しました。

表示直後に取得した JS: index-Yu354OZN.js
ボタンを押した後までに取得した JS: index-Yu354OZN.js, Heavy-X4BlaALh.js

Heavyのチャンクは、v-ifがtrueになって初めて取得されています。モーダル、タブの2枚目以降、グラフやエディターのように、最初の表示に要らない部品が非同期コンポーネントに向いています。

vite buildのチャンク出力

import('./Heavy.vue') のように動的importで書いたファイルは、ビルド時に別のファイル(チャンク)に分かれます。今回の一式を npx vite build したときの出力です。

✓ 23 modules transformed.
dist/index.html                         0.22 kB │ gzip:  0.18 kB
dist/assets/Heavy-X4BlaALh.js           0.19 kB │ gzip:  0.20 kB
dist/assets/HeavyWithData-B6C3Kvlj.js   0.26 kB │ gzip:  0.25 kB
dist/assets/index-Yu354OZN.js          74.58 kB │ gzip: 29.03 kB
ファイル 中身 取得されるとき
index-….js Vue本体と、通常のimportで読み込んだコンポーネント ページを開いたとき
Heavy-….js 動的importしたHeavy.vue Heavyが初めて描画されるとき
HeavyWithData-….js 動的importしたHeavyWithData.vue 同上

SpinnerやLoadErrorは通常のimportなので、index側に入ります。読み込み中表示そのものを遅延させると表示が間に合わないため、この2つは通常のimportのままにしておきます。

同じファイルを別の場所で通常のimportでも読み込むと、チャンクは分かれません。main.jsに import Heavy from './Heavy.vue' を1行足してビルドし直すと、Heavyのチャンクがなくなり、index側に入りました。

✓ 24 modules transformed.
dist2/index.html                         0.22 kB │ gzip:  0.18 kB
dist2/assets/HeavyWithData-CSWDk12Z.js   0.26 kB │ gzip:  0.25 kB
dist2/assets/index-CDL0MGJE.js          75.07 kB │ gzip: 29.17 kB

パターンB:Suspense+async setup

script setupのトップレベルにawaitを書くと、そのコンポーネントのsetupはasyncになります。これをSuspenseの中に置くと、awaitが終わるまでfallbackスロットが表示されます。

<!-- src/AsyncData.vue -->
<script setup>
import { wait } from './wait.js'

const props = defineProps({ ms: { type: Number, default: 1000 }, label: { type: String, default: '注文一覧' }, fail: Boolean })

// script setup のトップレベル await = async setup
await wait(props.ms)
if (props.fail && !window.__retried) throw new Error('データを取得できませんでした')
const rows = ['A-001', 'A-002']
</script>

<template>
  <p class="data">{{ label }}:{{ rows.join('、') }}</p>
</template>
<!-- src/PatternB.vue -->
<script setup>
import { ref } from 'vue'
import AsyncData from './AsyncData.vue'

const q = new URLSearchParams(location.search)
const two = q.get('two') === '1'
const timeout = q.has('stimeout') ? Number(q.get('stimeout')) : undefined
const page = ref(1)
const ev = (name) => window.__mark(`event: ${name}`)
</script>

<template>
  <h1>パターンB</h1>
  <button id="next" @click="page++">次のページ</button>
  <Suspense :timeout="timeout" @pending="ev('pending')" @fallback="ev('fallback')" @resolve="ev('resolve')">
    <div :key="page">
      <AsyncData :ms="1000" :label="`注文一覧 ${page}ページ`" />
      <AsyncData v-if="two" :ms="300" label="お知らせ" />
    </div>
    <template #fallback>
      <p class="fallback">読み込み中…(Suspense の fallback)</p>
    </template>
  </Suspense>
</template>

最初の表示

[B1 async setup 1000]
      4ms  event: pending
      4ms  event: fallback
      5ms  読み込み中…(Suspense の fallback)
   1005ms  event: resolve
   1005ms  注文一覧 1ページ:A-001、A-002

[B2 async setup 1000 と 300]
      6ms  event: pending
      6ms  event: fallback
      6ms  読み込み中…(Suspense の fallback)
   1010ms  event: resolve
   1010ms  注文一覧 1ページ:A-001、A-002 / お知らせ:A-001、A-002
  • fallbackは0msから出ます。 Suspenseにはdelayにあたる待ち時間がなく、最初の表示ではすぐにfallbackになります。
  • 300msで終わる部品も、1000msの部品がそろうまで待ちます。 Suspenseは、中にある非同期の依存がすべて解決してから、まとめて表示を切り替えます。「一部だけ先に出る」ことを避けたいときに向いた動きです。
  • イベントは、pending → fallback → resolveの順に発火しました。

ページを切り替えたとき(再度の待ち)

「次のページ」ボタンでkeyを変えると、AsyncDataが作り直され、もう一度待ちが発生します。このときの動きはSuspenseのtimeout属性で変わります。

[B3 再読み込み(timeout 指定なし)]
      5ms  event: pending
      5ms  event: fallback
      5ms  読み込み中…(Suspense の fallback)
   1006ms  event: resolve
   1006ms  注文一覧 1ページ:A-001、A-002
   1418ms  --- ボタンを押す ---
   1463ms  event: pending
   2468ms  event: resolve
   2468ms  注文一覧 2ページ:A-001、A-002

[B4 再読み込み(timeout=0)]
      4ms  event: pending
      4ms  event: fallback
      5ms  読み込み中…(Suspense の fallback)
   1005ms  event: resolve
   1005ms  注文一覧 1ページ:A-001、A-002
   1410ms  --- ボタンを押す ---
   1467ms  event: pending
   1467ms  event: fallback
   1467ms  読み込み中…(Suspense の fallback)
   2467ms  event: resolve
   2467ms  注文一覧 2ページ:A-001、A-002

[B5 再読み込み(timeout=300)]
      6ms  event: pending
      6ms  event: fallback
      6ms  読み込み中…(Suspense の fallback)
   1007ms  event: resolve
   1007ms  注文一覧 1ページ:A-001、A-002
   1412ms  --- ボタンを押す ---
   1467ms  event: pending
   1768ms  event: fallback
   1769ms  読み込み中…(Suspense の fallback)
   2468ms  event: resolve
   2468ms  注文一覧 2ページ:A-001、A-002

ボタンを押してからの経過で整理します。

Suspenseのtimeout 押した直後 300ms後 1000ms後 発火したイベント
指定なし 前のページのまま 前のページのまま 次のページ pending → resolve
0 fallback fallback 次のページ pending → fallback → resolve
300 前のページのまま fallback 次のページ pending → fallback → resolve

timeoutを指定しないSuspenseは、2回目以降の待ちでは前の内容を表示したまま待ちます。 画面が一瞬空になるのを避けられる、Suspenseならではの動きです。待っていることを伝えたい場合は、pendingイベントで進行状況のバーを出す、またはtimeoutに数百msを指定して長いときだけfallbackに切り替える、という使い分けができます。

async setupの部品はSuspenseの中に置く

AsyncData.vueをSuspenseで包まずに置くと、開発サーバーで次の警告が出て、画面には親の見出しだけが表示されました。

[Vue warn]: Component <AsyncData>: setup function returned a promise, but no <Suspense> boundary was found in the parent component tree. A component with async setup() must be nested in a <Suspense> in order to be rendered.

awaitを使う部品を作ったら、親のどこかにSuspenseがあるかを合わせて確かめます。

パターンC:Suspenseと非同期コンポーネントの併用

Suspenseの中にdefineAsyncComponentの部品を置くと、読み込みの待ちもSuspenseが受け持ちます。

<!-- src/PatternC.vue -->
<script setup>
import { defineAsyncComponent } from 'vue'
import Spinner from './Spinner.vue'
import LoadError from './LoadError.vue'
import { wait } from './wait.js'

const q = new URLSearchParams(location.search)
const inner = q.get('inner') === '1'

const Heavy = defineAsyncComponent({
  loader: async () => {
    await wait(inner ? 300 : 1000)
    if (q.get('reject') === '1') throw new Error('チャンクを取得できませんでした')
    return inner ? import('./HeavyWithData.vue') : import('./Heavy.vue')
  },
  loadingComponent: Spinner,
  errorComponent: LoadError,
  delay: 200,
  timeout: q.has('timeout') ? Number(q.get('timeout')) : undefined,
  suspensible: q.get('suspensible') !== '0',   // 既定値 true
})
</script>

<template>
  <h1>パターンC</h1>
  <Suspense>
    <Heavy />
    <template #fallback>
      <p class="fallback">読み込み中…(Suspense の fallback)</p>
    </template>
  </Suspense>
</template>
<!-- src/HeavyWithData.vue:読み込んだ後に、さらに async setup で待つ部品 -->
<script setup>
import { wait } from './wait.js'
await wait(500)
</script>

<template>
  <p class="heavy">グラフ本体+データ(HeavyWithData.vue)</p>
</template>
[C1 併用 load=1000]
      5ms  読み込み中…(Suspense の fallback)
   1012ms  グラフ本体(Heavy.vue)

[C2 併用 suspensible=false]
      4ms  (何も表示なし)
    205ms  読み込み中…(loadingComponent)
   1010ms  グラフ本体(Heavy.vue)

[C3 併用 load=300 + async setup 500]
      4ms  読み込み中…(Suspense の fallback)
    810ms  グラフ本体+データ(HeavyWithData.vue)

[C4 併用 load=1000 で reject]
      5ms  読み込み中…(Suspense の fallback)
   1007ms  読み込めませんでした(errorComponent):チャンクを取得できませんでした
  (console) Error: チャンクを取得できませんでした

[C5 併用 load=1000 timeout=600]
      5ms  読み込み中…(Suspense の fallback)
   1011ms  グラフ本体(Heavy.vue)

[C6 suspensible=false load=1000 timeout=600]
      4ms  (何も表示なし)
    205ms  読み込み中…(loadingComponent)
    607ms  読み込めませんでした(errorComponent):Async component timed out after 600ms.
   1010ms  グラフ本体(Heavy.vue)
  (console) Error: Async component timed out after 600ms.
条件 結果 読み取れること
併用(既定) 0msからSuspenseのfallback。loadingComponentは出ない loadingComponentとdelayは使われず、Suspenseのfallbackに一本化される
suspensible: false 205msからloadingComponent。Suspenseのfallbackは出ない 非同期コンポーネントが自分の設定で表示する
チャンク300ms+async setup 500ms 810msまでfallback チャンクの読み込みと、その中のasync setupの両方を合わせて待つ
併用でloaderがreject 1007msでerrorComponent 併用時もerrorComponentは表示された
併用でtimeout 600 時間切れにならず1011msで本体 併用時はtimeoutが使われない
suspensible: false でtimeout 600 607msでerrorComponent、1010msで本体 時間切れの後でも、読み込みが終われば本体に切り替わる

併用するときの設計は、次のどちらかにそろえると見通しがよくなります。

  • Suspenseにまとめる:読み込み中表示はfallbackスロットだけに書き、非同期コンポーネント側はloaderとerrorComponentだけにする。
  • 部品ごとに出す:suspensible: false を付け、loadingComponent・delay・timeoutを部品側で決める。

うまく読み込めないときの表示と再試行

onErrorで自動的にやり直す

defineAsyncComponentのonErrorは、loaderがrejectするたびに呼ばれます。retry()でやり直し、fail()でerrorComponentの表示に進みます。attemptsは何回目の試行かを表します。

<!-- src/RetryLoader.vue -->
<script setup>
import { defineAsyncComponent } from 'vue'
import Spinner from './Spinner.vue'
import LoadError from './LoadError.vue'
import { wait } from './wait.js'

const okAt = Number(new URLSearchParams(location.search).get('okAt') ?? 3)
let calls = 0

const Heavy = defineAsyncComponent({
  loader: async () => {
    calls++
    await wait(100)
    if (calls < okAt) throw new Error(`${calls}回目の読み込みでエラー`)
    return import('./Heavy.vue')
  },
  loadingComponent: Spinner,
  errorComponent: LoadError,
  delay: 0,
  onError(error, retry, fail, attempts) {
    window.__mark(`onError: attempts=${attempts} ${error.message}`)
    if (attempts <= 2) retry()   // 2回まで自動でやり直す
    else fail()                  // あきらめて errorComponent を表示
  },
})
</script>

<template>
  <h1>自動の再試行</h1>
  <Heavy />
</template>

3回目で読み込める場合と、読み込めないままの場合の記録です。

[R1 onError で再試行(3回目に成功)]
      4ms  読み込み中…(loadingComponent)
    104ms  onError: attempts=1 1回目の読み込みでエラー
    204ms  onError: attempts=2 2回目の読み込みでエラー
    311ms  グラフ本体(Heavy.vue)

[R2 onError で再試行(成功しない)]
      5ms  読み込み中…(loadingComponent)
    105ms  onError: attempts=1 1回目の読み込みでエラー
    205ms  onError: attempts=2 2回目の読み込みでエラー
    306ms  onError: attempts=3 3回目の読み込みでエラー
    310ms  読み込めませんでした(errorComponent):3回目の読み込みでエラー
  (console) Error: 3回目の読み込みでエラー

2回まで自動でやり直し、3回目もエラーならerrorComponentを表示しています。再試行の間はloadingComponentが出たままなので、利用者からは「少し長めの読み込み」に見えます。

Suspenseの中のエラーはonErrorCapturedで受ける

Suspense自体にはエラー用のスロットがありません。公式ドキュメントは、親コンポーネントでerrorCapturedオプションまたはonErrorCapturedフックを使って非同期エラーを受け取る方法を案内しています。async setupの中で起きたエラーを受け、案内と再試行ボタンを出す例です。

<!-- src/SuspenseError.vue -->
<script setup>
import { ref, onErrorCaptured } from 'vue'
import AsyncData from './AsyncData.vue'

const error = ref(null)
const tryCount = ref(0)

onErrorCaptured((err) => {
  error.value = err
  return false
})

function retry() {
  window.__retried = true   // 確認用:2回目は成功させる
  error.value = null
  tryCount.value++
}
</script>

<template>
  <h1>Suspense のエラー表示</h1>
  <div v-if="error" role="alert">
    <p>表示できませんでした:{{ error.message }}</p>
    <button id="retry" @click="retry">もう一度読み込む</button>
  </div>
  <Suspense v-else :key="tryCount">
    <AsyncData :ms="300" fail />
    <template #fallback>
      <p class="fallback">読み込み中…(Suspense の fallback)</p>
    </template>
  </Suspense>
</template>
[S1 Suspense + onErrorCaptured]
      4ms  読み込み中…(Suspense の fallback)
    306ms  表示できませんでした:データを取得できませんでした
    708ms  --- ボタンを押す ---
    747ms  読み込み中…(Suspense の fallback)
   1048ms  注文一覧:A-001、A-002
  • 306msでasync setupのエラーをonErrorCapturedが受け取り、案内に切り替わりました。
  • 再試行ではSuspenseのkeyを変えて作り直しています。作り直したSuspenseは「最初の表示」になるので、fallbackがすぐに出ます。
  • return falseで伝播を止めているため、このエラーはここで処理を終えています。記録を残したい場合は、止める前に送信処理を呼びます。
うまくいかない原因 受け取る場所 表示
チャンクを取得できない(単体) errorComponent、onError errorComponent
チャンクを取得できない(Suspenseと併用) errorComponent、onError errorComponent(今回の実行結果)
読み込みが長い(単体・timeout指定あり) errorComponent errorComponent
async setupの中でエラー 親のonErrorCaptured 自分で用意した案内

Vue 3.5の遅延ハイドレーション

遅延ハイドレーションは、サーバーサイドレンダリング(SSR)で出力済みのHTMLに対して、クライアント側でイベントなどを結び付ける処理(ハイドレーション)を、部品ごとに後回しにする機能です。公式ドキュメントには、SSRを使っている場合にのみ適用されること、Vue 3.5以降で使えることが書かれています。defineAsyncComponentのhydrateオプションに戦略を渡します。

組み込みの戦略一覧

戦略 ハイドレーションするとき 使うしくみ・指定できるもの 向いている部品
hydrateOnIdle() ブラウザの手が空いたとき requestIdleCallback。タイムアウトの上限を指定できる 急がないが早めに使えるようにしたい部品
hydrateOnVisible() 要素が画面に入ったとき IntersectionObserver。rootMarginなどのオプションを渡せる ページ下部のコメント欄やフッター
hydrateOnMediaQuery(query) メディアクエリに一致したとき 引数にメディアクエリの文字列 特定の画面幅でだけ使うメニュー
hydrateOnInteraction(event) 指定したイベントが起きたとき イベント名、またはイベント名の配列 押されるまで動かなくてよいボタン類
独自の戦略 自分で決めた条件 HydrationStrategy型の関数を書く 上の4つに当てはまらない条件

SSRで動かして確かめる

サーバー側でHTMLを作り、クライアントでハイドレーションする最小の構成を、h関数で書いた部品で動かしました。

// ssr/Counter.js
import { h, ref, onMounted } from 'vue'

// name ごとのカウンターボタンを作る
export const makeCounter = (name) => ({
  setup() {
    const count = ref(0)
    onMounted(() => window.__mark(`ハイドレーション完了: ${name}`))
    return () => h('button', { id: name, onClick: () => count.value++ }, `${name}: ${count.value}`)
  },
})
// ssr/app.js
import {
  h, defineAsyncComponent,
  hydrateOnIdle, hydrateOnVisible, hydrateOnMediaQuery, hydrateOnInteraction,
} from 'vue'

const lazy = (name, hydrate) =>
  defineAsyncComponent({
    loader: () => import('./Counter.js').then((m) => m.makeCounter(name)),
    hydrate,
  })

const Plain = lazy('plain')                                             // 指定なし
const Idle = lazy('idle', hydrateOnIdle())                              // ブラウザの手が空いたら
const Interaction = lazy('interaction', hydrateOnInteraction('click'))  // クリックされたら
const Media = lazy('media', hydrateOnMediaQuery('(max-width: 500px)'))  // 幅 500px 以下になったら
const Visible = lazy('visible', hydrateOnVisible())                     // 画面に入ったら

export const App = {
  render: () =>
    h('div', [
      h(Plain), h(Idle), h(Interaction), h(Media),
      h('div', { style: 'height: 3000px' }),
      h(Visible),
    ]),
}
// ssr/client.js
import { createSSRApp } from 'vue'
import { App } from './app.js'

const t0 = performance.now()
window.__timeline = []
window.__mark = (text) => window.__timeline.push({ t: Math.round(performance.now() - t0), text })

createSSRApp(App).mount('#app')
// ssr/server.mjs:HTML を作って ssr.html に書き出す
import { writeFileSync } from 'node:fs'
import { createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { App } from './app.js'

const html = await renderToString(createSSRApp(App))
console.log(html.replace(/<div style="height:\s?3000px;?"><\/div>/, '…'))
writeFileSync(new URL('../ssr.html', import.meta.url), `<!doctype html>
<html lang="ja">
  <head><meta charset="utf-8"><title>lazy hydration</title></head>
  <body>
    <div id="app">${html}</div>
    <script type="module" src="/ssr/client.js"></script>
  </body>
</html>
`)

node ssr/server.mjs が出力したHTML(3000pxの空白は … に置き換えて表示)です。5つのボタンは、サーバー側ですべて描画されています。

<div><button id="plain">plain: 0</button><button id="idle">idle: 0</button><button id="interaction">interaction: 0</button><button id="media">media: 0</button>…<button id="visible">visible: 0</button></div>

このHTMLを幅800pxのChromiumで開き、操作しながらハイドレーションの完了(onMounted)を記録しました。

[表示から 500ms 後]
   11ms ハイドレーション完了: plain
   19ms ハイドレーション完了: idle
[plain と media をクリック]
   ボタン: plain: 1 | idle: 0 | interaction: 0 | media: 0 | visible: 0
[interaction を1回クリック]
   688ms ハイドレーション完了: interaction
   ボタン: plain: 1 | idle: 0 | interaction: 1 | media: 0 | visible: 0
[interaction をもう1回クリック]
   ボタン: plain: 1 | idle: 0 | interaction: 2 | media: 0 | visible: 0
[幅を 400px に変更]
   1115ms ハイドレーション完了: media
[一番下までスクロール]
   1424ms ハイドレーション完了: visible
部品 結果
plain(指定なし) 11msでハイドレーション。クリックで数が増える
idle 19msでハイドレーション
media 幅800pxではハイドレーションされず、クリックしても0のまま。幅を400pxにした時点でハイドレーション
interaction 最初のクリックでハイドレーションされ、そのクリックで数も1に増えた
visible 一番下までスクロールして画面に入った時点でハイドレーション

ハイドレーション前でもHTMLは表示されていますが、クリックなどの操作は効きません。 mediaのボタンが0のままだったのがその例です。押せるように見える部品には、hydrateOnInteractionのように「操作そのものをきっかけにする」戦略を選ぶと、最初のクリックから反応します。

Suspenseを使うときに知っておくこと

公式ドキュメントのSuspenseのページから、設計に関わる点をまとめます。

項目 内容
位置づけ 実験的な機能と明記されており、APIが変わる可能性がある
待つ対象 async setupを持つコンポーネント(script setupのトップレベルawaitを含む)と、非同期コンポーネント
スロット defaultとfallbackの2つ
イベント pending、resolve、fallback
エラー処理 Suspense自体は行わない。errorCapturedオプションやonErrorCapturedで受ける
ほかの組み込みとの入れ子 RouterView → Transition → KeepAlive → Suspense の順に包む
入れ子のSuspense Vue 3.3以降、内側のSuspenseにsuspensible属性を付けると、待ちの扱いを外側に任せられる

実験的な機能である点を踏まえると、まずはdefineAsyncComponentのloadingComponentで足りるかを考え、複数の待ちをまとめたい・前の内容を出したまま待ちたい、という要件が出たときにSuspenseを選ぶ進め方が取り組みやすいです。

よくあるつまずきと直し方

症状 原因 直し方
loadingComponentが出ない 上位にSuspenseがあり、待ちの表示がfallbackに移っている fallbackスロットに書く。部品側で出したいなら suspensible: false
読み込み中表示が一瞬も出ない delay(既定200ms)より早く読み込みが終わっている 仕様どおり。常に出したいなら delay: 0
すぐにエラー表示になる timeout: Infinity と書いている timeoutを書かない
timeoutが効かない Suspenseと併用している suspensible: false にする、または待ちの上限を別の方法で設ける
async setupの部品が表示されない Suspenseの外で使っている 親でSuspenseの中に置く
ページ切り替えでfallbackが出ない Suspenseのtimeoutを指定していない 前の内容を出したまま待つ動き。切り替えたいなら timeout="0"
速く終わる部品が先に出ない Suspenseは中の待ちがすべて終わるまで切り替えない 先に出したい部品はSuspenseの外に置く、またはSuspenseを分ける
チャンクが分かれない 同じファイルを通常のimportでも読み込んでいる 動的importだけにする

よくある質問

Q. defineAsyncComponentとSuspenseは、どちらを使えばよいですか? A. 目的が違います。部品のコードを後から読み込みたいならdefineAsyncComponent、setupでawaitしたデータがそろうまで待ちたい、または複数の待ちを1つの読み込み中表示にまとめたいならSuspenseです。併用するとSuspenseのfallbackに一本化され、loadingComponentとdelayは使われません。

Q. delayとtimeoutはどういう関係ですか? A. どちらも読み込み開始からの経過時間です。delay(既定200ms)を過ぎるとloadingComponentが出て、timeoutを過ぎるとerrorComponentに切り替わります。今回の実行では、delay 200・timeout 600のとき、205msで読み込み中表示、606msでエラー表示になりました。

Q. Suspenseのfallbackが2回目から出ないのはなぜですか? A. Suspenseは、いったん表示できた後の待ちでは、前の内容を出したまま待つためです。timeout属性を指定すると、その時間を過ぎた時点でfallbackに切り替わります。0を指定すれば毎回すぐにfallbackになります。

Q. 遅延ハイドレーションはSSRを使っていなくても効果がありますか? A. 公式ドキュメントでは、SSRを使っている場合にのみ適用されると説明されています。ブラウザだけで描画するアプリでは、defineAsyncComponentによるチャンクの遅延読み込みが、同じ「必要になるまで後回しにする」役割を担います。

まとめ

  • defineAsyncComponentは「コードを後から読み込む」、Suspenseは「待ちの表示をまとめる」しくみ
  • delay(既定200ms)より早く終われば読み込み中表示は出ない。 timeoutを過ぎるとerrorComponentに切り替わる
  • 時間切れなしにしたいときはtimeoutを書かない。 Infinityを書くとすぐにエラー表示になった
  • Suspenseと併用すると、loadingComponent・delay・timeoutは使われず、fallbackに一本化される。 部品側で出したいときは suspensible: false
  • Suspenseは中の待ちがすべて終わってから切り替える。 2回目以降は前の内容を出したまま待ち、timeout属性でfallbackへの切り替えを決められる
  • エラーは、チャンクの読み込みならerrorComponentとonError、async setupなら親のonErrorCapturedで受ける
  • 動的importしたファイルは vite build で別チャンクになり、描画されるときに初めて取得される

参考・出典

  • Vue.js 公式ドキュメント「非同期コンポーネント」(https://ja.vuejs.org/guide/components/async)
  • Vue.js 公式ドキュメント「Suspense」(https://ja.vuejs.org/guide/built-ins/suspense)
  • Vue.js 公式ドキュメント「グローバル API: 汎用」defineAsyncComponent(https://ja.vuejs.org/api/general#defineasynccomponent)
  • Vue.js 公式ドキュメント「ビルトインのコンポーネント」Suspense(https://ja.vuejs.org/api/built-in-components#suspense)

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Vue Suspenseと非同期コンポーネントの使い方|読み込み中表示」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?