- Vue.js
Vue Suspenseと非同期コンポーネントの使い方|読み込み中表示
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期連続の黒字決算。