Skip to main content

Teleport と Suspense — 描画先の移動と非同期の待ち

12 章の provide と inject は、値の受け渡しをコンポーネントの階層から切り離しました。この章の 2 つは切り離す対象が違います。Teleport は描く場所を DOM ツリーの別の場所へ移し、Suspense は非同期の準備が終わるまでの表示を 1 か所にまとめます。どちらも「本来はコンポーネントの形に縛られるもの」を外へ出す仕組みです。

この章で学ぶこと

  • Teleport が中身を移したあと、元の位置に何が残るか
  • defer が要る場面と、その解決がいつ走るか
  • DOM の位置が動いても変わらないものを言える
  • Suspense の fallback が出る条件と、出ない条件
  • 2 度目以降の描画で fallback へ戻る条件
  • fallback の中が境界の外側として扱われる理由

描く場所を DOM の別の場所へ移す

モーダルやツールチップは、コンポーネントの中に書きたい一方で、DOM 上は祖先の overflowz-index から外したいことがあります。<Teleport> は中身の描画先だけを移します。

components/ModalHost.vue
<script setup lang="ts">
import { ref } from 'vue'

const open = ref(false)
</script>

<template>
<button type="button" @click="open = true">開く</button>

<Teleport to="body">
<div v-if="open" class="overlay">
<div class="dialog">
<p>本文</p>
<button type="button" @click="open = false">閉じる</button>
</div>
</div>
</Teleport>
</template>

to には CSS セレクタか DOM 要素そのものを渡せます。同じターゲットへ複数の <Teleport> を向けると、マウント順に積まれます

元の位置には何も残らないわけではありません。ノードが 2 つ入り、その種類はビルドで変わります。

描き方元の位置ターゲット側
クライアントで新規描画 (開発ビルド)コメント 2 つ (<!--teleport start--> / <!--teleport end-->)空のテキストノード 2 つ
クライアントで新規描画 (本番ビルド)空のテキストノード 2 つ空のテキストノード 2 つ
hydrationコメント 2 つコメント 2 つ (サーバーが出したものをそのまま使う)

hydration の 3 行目は、サーバーが出した中身をターゲットへ差し込んである場合です。差し込まないまま hydrate すると、ターゲット側は空のテキストノードになります。開発ビルドでは Hydration children mismatch が出ますが、本番ビルドの文面は Hydration completed but contains mismatches. だけです。DOM を直接数える処理やスナップショットテストを書くと、この差でビルドと描き方ごとに結果が変わります。

disabled で元の位置へ戻す

disabled を立てている間は、中身が元の位置に描かれます。

<Teleport :disabled="isMobile" to="body">
<div class="dialog"></div>
</Teleport>

行き先が切り替わるとき、要素は作り直されずに移動します。同じノードが動くので、入力中の値のように要素が持っている状態はそのまま残ります (フォーカスがどうなるかはブラウザ依存です)。

disabled にはもう 1 つ効果があります。ターゲットが見つからないときの警告が出なくなります。 見つからない場合の警告はこれです。

[Vue warn]: Failed to locate Teleport target with selector "#not-found".
Note the target element must exist before the component is mounted ...

disabled が立っていると、この警告は出ないまま元の位置に描かれます。切り替えのつもりで disabled を使っていると、ターゲットの綴り違いに気づけません。

ターゲットを Vue が描いているとき

警告文が言うとおり、ターゲットはそのコンポーネントがマウントされる前に存在している必要があります。そのため to="body" のようにアプリの外を指すのが基本です。同じアプリの中にあるターゲットを指すと、まだ描かれていない時点で解決に失敗します。

Vue 3.5 以降は defer でこれを回避できます。

components/DeferredHost.vue
<template>
<Teleport defer to="#sidebar-slot">
<p class="badge">サイドバーへ</p>
</Teleport>

<aside id="sidebar-slot" />
</template>

defer はターゲットの解決を後回しにします。実装では queuePostRenderEffect に積まれるので、そのマウント(または更新)の描画が全部終わってから走ります。同じ描画の中で現れるターゲットまでが射程で、1 秒後に現れる要素には間に合いません。

走る位置は 7 章のフックの間です。どちらもポストフラッシュのキューに入るので、積まれた順で決まります。Teleport を書いたコンポーネント自身の onMounted よりは前ですが、Teleport より前に置いた子コンポーネントの onMounted は解決より先に走ります。ターゲットを子コンポーネントが描いている構成では、その子の onMounted からはまだ中身が見えません。

DOM が動いても親子関係は動かない

移るのは DOM 上の位置だけです。コンポーネントの親子関係は元のままなので、次のものはすべて元の位置に書いたときと同じに振る舞います。

  • 12 章の provide と inject。祖先が provide した値は、飛ばした先の子孫にも届きます
  • 親のスコープを読むテンプレート式と、親へ向けたイベント
  • 1 章<style scoped>。中身には元のコンポーネントの scope 属性が付きます。ただしセレクタが DOM の親子関係に依存していると当たらなくなります.host .dialog のような子孫セレクタは、中身が .host の外へ出た時点で一致しません

アンマウントされない経路には注意が要ります。 <KeepAlive> にキャッシュされて非表示になったコンポーネントの中の Teleport は、中身がターゲットに残ったままです。走るのは 7 章onDeactivated だけなので、モーダルを閉じる処理をそこに書かないと画面に残ります。

サーバー描画では扱いが変わります。中身は本体の HTML に入りません。 renderToString の第 2 引数に渡したコンテキストの teleports に、ターゲットごとの HTML が集まります。

const ctx = {}
const html = await renderToString(app, ctx)
// html に残るのは <!--teleport start--> と <!--teleport end--> だけ
// ctx.teleports['#modal-root'] に中身の HTML が入る

サーバー描画のこのコメントは本番ビルドでも出ます。クライアント側の元の位置が本番では空のテキストノードになるのと対照的です。

disabled を立てているときは話が逆になります。 中身は本体の HTML へ出て、teleports にはアンカーだけが入ります。disabled をレスポンシブの切り替えに使っているなら、サーバー描画の出力先も一緒に変わります。

これを最終的な HTML のどこへ差し込むかは、フレームワーク側の仕事です。9 章の SSR と同じく、自前で組むなら差し込みも自分で書きます。

非同期の準備が終わるまでをまとめる

<Suspense> は、子孫にある非同期の準備を待って、その間の表示を 1 か所で受け持ちます。実験的機能です。

実験的機能

<Suspense> は experimental で、公式ガイドは "not guaranteed to reach stable status and the API may change" と書いています。開発ビルドでは <Suspense> is an experimental feature and its API will likely change. が出ます (本番ビルドにこの文字列はありません)。console.warn ではなく console.info で、アプリを跨いで 1 度だけなので、警告を集めている環境では見落とします。

待てる依存は 2 種類です。非同期の setup() と、非同期コンポーネントです。<script setup> にトップレベルの await があると、そのコンポーネントは自動的に前者になります。

components/UserProfile.vue
<script setup lang="ts">
interface User {
name: string
email: string
}

const props = defineProps<{ id: number }>()

const res = await fetch(`/api/users/${props.id}`)
const user = (await res.json()) as User
</script>

<template>
<section class="profile">
<h2>{{ user.name }}</h2>
<p>{{ user.email }}</p>
</section>
</template>

境界の側は 2 つのスロットを持ちます。どちらも直下の子を 1 つしか受け取りません。

components/UserPanel.vue
<script setup lang="ts">
import UserProfile from './UserProfile.vue'
</script>

<template>
<Suspense>
<UserProfile :id="1" />

<template #fallback>
<p class="loading">読み込み中</p>
</template>
</Suspense>
</template>

子を 2 つ書くと <Suspense> slots expect a single root node. の警告が出て、どちらの子も描かれません。単一のルートを持たないスロットはコメントノードに落ちるので、画面には何も出ません。警告は開発ビルドだけなので、本番では何も言わずに空になります

fallback が出る条件

初回の描画で、#default の中身はまず切り離した <div> の中で描かれます。そこで非同期の依存に当たると pending 状態へ入り、#fallback が本物の DOM へ入ります。依存がすべて解決すると入れ替わります。

イベントは 3 つです。実測した順序を条件と合わせるとこうなります。

場面出るイベント描かれるもの
初回・非同期の依存ありpendingfallback → (解決後) resolvefallback → default
初回・非同期の依存なしresolve だけdefault

依存がゼロでも resolve は出ます。 「読み込みが終わった合図」として使うと、非同期の依存が 1 つも無い経路でも発火します。pending が出たかどうかで区別してください。

hydration はこの表の外です。サーバーが描いた #default をそのまま引き継ぐので、非同期の依存が残っていても pendingfallback は出ません。 依存が解決した時点で resolve だけが出ます。

2 度目以降は戻らない

一度 resolved になった境界が pending へ戻るのは、#default のルートノードが差し替わったときだけです。判定は isSameVNodeType なので、見るのは型と key の両方です。<Profile :key="id" /> のように再取得のため key を振り直す書き方は、型が同じでも差し替え扱いになります。

逆にルートが同じ型・同じ key なら、その中のどれだけ深い位置に新しい非同期の依存が現れても戻りません。イベントも出ません。待っている間その部分は空で、解決してから現れます。

ルートの型が変わったときの見え方は timeout で決まります。実測した 3 通りです。

timeout差し替えの間に出るもの
正の値 (50)50ms は前の中身が残り、過ぎてから fallback
0すぐ fallback へ切り替わる
書かない / 0 未満前の中身がそのまま残る。fallback は一度も出ない

実装の分岐が timeout > 0timeout === 0 の 2 つだけなので、それ以外はすべて 3 行目に落ちます。pending イベントはどの場合でも出ます。出ないのは fallback の方です。既定が「前の中身を出したまま待つ」なので、切り替えを見せたいなら timeout を明示します。

timeout境界を作るときに 1 度だけ読まれます:timeout に ref を渡してあとから変えても、その境界には効きません。

fallback の中は境界の外側

#fallback のツリーは、境界の文脈を持たないまま描かれます。実装は fallback を patch するときに親の Suspense を null で渡していて、コメントにも「fallback tree will not have suspense context」と書かれています。ここから次のことが決まります。

  • fallback の中に非同期の依存を置いても境界は待ちません。 トップレベル await を持つコンポーネントを置くと setup function returned a promise, but no <Suspense> boundary was found の警告が出て描かれません
  • fallback の中の Teleport はすぐ解決します。 一方 #default の中の Teleport は、defer を書かなくても解決を後回しにします

後者が効く条件は「最も近い境界が pending の間にマウントされたこと」です。実装が見ているのはそこだけなので、境界が resolve したあとに v-if で新しく現れた Teleport は待ちません。入れ子の境界で内側だけが resolved なら、その下の Teleport も待ちません。同じ描画の中で現れるターゲットを指すなら defer を明示します。

エラーは境界の外で捕まえる

<Suspense> 自身はエラー処理を持ちません。非同期の setup() が reject したときは、失敗したコンポーネントから親をたどって最初に見つかった onErrorCaptured に届きます。境界の上に置くのが公式ガイドの案内ですが、#default の中の中間ラッパーに置いてもそこで捕まります — 境界は判定に関与しません。

onErrorCaptured((err) => {
message.value = err instanceof Error ? err.message : String(err)
return false
})

そのあとの表示に注意が要ります。実測すると、境界はエラーのあとで resolve します。fallback は消え、setup を途中で中断した #default が描かれます。値が入っていないので、テンプレートが user.name のように参照していれば空になるか、実行時エラーになります。エラー時に何を見せるかは、境界の外側で v-if を切り替えるなど自分で決める必要があります。

非同期コンポーネントとの関係

defineAsyncComponent で作ったコンポーネントは、既定で suspensible です。自分の loadingComponent / delay / timeout が使われないのは、実装の条件どおり 2 つの場合です — 境界の下にいて suspensible のときと、サーバー描画のとき。境界が無ければ、既定のままでも自分の loading が出ます。

const Lazy = defineAsyncComponent({
loader: () => import('./Heavy.vue'),
suspensible: false, // 自分で loading を出したいとき
loadingComponent: Spinner, // 境界の下で suspensible だと使われない
delay: 0, // 省くと 200ms は何も出ない
})

suspensible: false にすると、その境界はこのコンポーネントを待たなくなります。他に未解決の依存が無ければ即 resolve し、コンポーネントが自分の loadingComponent を出します。

errorComponent だけは扱いが別です。 suspensible のままでも読み込み失敗時に使われます。公式ガイドは "loading, error, delay and timeout options will be ignored" と書いていますが、実装に残っているのは error の経路だけです。

入れ子の <Suspense> にも同じ名前の設定があります。こちらは内側の境界を外側の依存にするためのもので、意味が違います。

<Suspense>
<Outer>
<Suspense suspensible>
<Inner />
</Suspense>
</Outer>
</Suspense>

suspensible を書かないと、内側は外側から見て同期のコンポーネントに見えます。内側が自分の fallback を出し、外側は即 resolve します。書くと外側も待ち、DOM に出るのは外側の fallback だけになります (内側もイベントは出します)。どちらの loading を見せたいかで選びます。

効くのは外側がまだ pending の間に内側が生まれたときだけです。外側が resolve したあとに v-if で内側が現れた場合、suspensible を書いていても外側は待たず、内側が自分の fallback を出します。判定が「false でないこと」なので、: を付け忘れた suspensible="false" は文字列として opt-in になります

組み合わせるときの順序

Transition / KeepAlive / RouterView と組むときは入れ子の順序が決まっています。公式ガイドが示す形はこれです。

<RouterView v-slot="{ Component }">
<template v-if="Component">
<Transition mode="out-in">
<KeepAlive>
<Suspense>
<component :is="Component" />
<template #fallback>読み込み中</template>
</Suspense>
</KeepAlive>
</Transition>
</template>
</RouterView>

要らないものを外して使います。なお Vue Router の遅延読み込み(ルート定義の動的 import)は、現時点では <Suspense> を起動しません。 公式ガイドも "currently they will not trigger" と現状の記述にしています。非同期コンポーネントとは別の仕組みだからです。ルートの中に非同期コンポーネントを置けば、そちらは通常どおり境界に効きます。15 章でルーティングを扱います。

まとめ

  • <Teleport> は中身の描画先だけを DOM の別の場所へ移します。to はセレクタか要素で、同じターゲットへ向けた複数の Teleport はマウント順に積まれます
  • 元の位置とターゲット側にそれぞれノードが 2 つ入ります。種類は描き方で変わります — クライアントの新規描画では元の位置が開発ビルドでコメント・本番ビルドで空のテキスト、ターゲット側はどちらも空のテキスト。hydration は、サーバーの中身をターゲットへ差し込んであれば両方ともコメントです
  • disabled の間は元の位置に描かれ、切り替えでは要素が作り直されずに移動しますdisabledターゲットが見つからない警告も止めるので、綴り違いに気づけなくなります
  • ターゲットはマウント前に存在している必要があります。同じアプリが描くターゲットを指すなら defer (3.5+) を使います。射程は同じ描画の中で現れるところまでで、解決の位置はポストフラッシュのキューへ積まれた順です。Teleport を書いた側の onMounted より前ですが、Teleport より前に置いた子の onMounted は解決より先に走ります
  • 移るのは DOM の位置だけなので、provide / inject / 親のスコープ / scope 属性は元の位置と同じです。ただし .host .dialog のような子孫セレクタは当たらなくなります<KeepAlive> にキャッシュされて非表示になった場合は、中身がターゲットに残ったままです
  • サーバー描画では中身が本体の HTML に入らず、renderToString に渡したコンテキストの teleports に集まります。本体に残るコメントは本番ビルドでも出ますdisabled を立てているときは逆で、中身が本体側へ出ます
  • <Suspense>実験的機能です。告知は開発ビルドだけで、console.warn ではなく console.info で 1 度だけ出ます
  • 待てるのは非同期の setup() と非同期コンポーネントの 2 種類です。<script setup> のトップレベル await は前者になります
  • スロットはどちらも直下の子 1 つだけです。2 つ書くとどちらも描かれません。警告は開発ビルドだけなので、本番では何も言わずに空になります
  • #default はまず切り離した <div> の中で描かれます。非同期の依存に当たれば pendingfallback、解決で resolve依存がゼロでも resolve は出ます
  • 一度 resolved になった境界が pending へ戻るのはルートノードが差し替わったときだけです。判定は型と key の両方なので、key を振り直す書き方も差し替え扱いになります。深い位置の新しい依存では戻りません
  • 差し替えのとき、fallback へ切り替わるのは timeout0 (即) か正の値 (その時間だけ待つ) のときだけです。書かない場合と 0 未満では前の中身が残り fallback は出ません。値は境界を作るときに 1 度だけ読まれるので、あとから変えても効きません
  • #fallback のツリーは境界の文脈を持ちません。トップレベル await を持つコンポーネントを置くと警告が出て描かれず、defineAsyncComponent警告なしに自分で読み込んで描かれます。中の Teleport は即解決します。#default の中の Teleport が defer なしで待つのは最も近い境界が pending の間にマウントされたときだけです
  • hydration は別経路です。 サーバーが描いた #default を引き継ぐので、依存が残っていても pendingfallback は出ず、解決時に resolve だけが出ます
  • エラーは失敗したコンポーネントから親をたどって最初の onErrorCaptured に届きます。境界は判定に関与しないので、#default の中に置いてもそこで捕まります境界はエラーのあとで resolve するので、値の入っていない #default が描かれます
  • 非同期コンポーネントの loadingComponent / delay / timeout が使われないのは、境界の下にいて suspensible のときサーバー描画のときの 2 つです。境界が無ければ既定のままでも使われます。errorComponent はどちらでも使われます。自分で loading を出すなら delay を省かないこと — 既定の 200ms は何も描かれません
  • 入れ子の <Suspense>suspensible内側を外側の依存にする設定で、効くのは外側がまだ pending の間に内側が生まれたときだけです。判定が「false でないこと」なので、: を付け忘れた suspensible="false" は opt-in になります
関連リファレンス

次に読む