- Vue.js
Vue Teleportの使い方|モーダルをbodyへ移動・defer対応
Vue の Teleport とは、コンポーネントのテンプレートの一部を、DOM上の別の場所(bodyの直下など)に描画するための組み込みコンポーネントです。 コードの上ではモーダルを呼び出し元のすぐ近くに書いたまま、実際のHTMLだけを親要素の外へ出せるので、親の overflow: hidden や z-index の影響を受けずに画面全体へ重ねられます。
この記事では、script setupで書いたモーダルの全文コードと、Teleportで移動する前後のHTMLを実際に出力して並べました。to・disabled・defer の早見表、ターゲットが見つからないときの警告文「Failed to locate Teleport target」の再現と直し方、同じターゲットへ複数置いたときの並び順、Vitestとjsdomでbody側を検証するテストコードまで、すべて実行結果つきで載せています。
確認環境:Ubuntu 24.04 / Node.js 22.22.2 / vue 3.5.43 / vitest 5.0.3 / jsdom 30.1.1 / @vue/test-utils 2.5.1 / @vitejs/plugin-vue 6.0.9(vite 8.3.2)。HTMLの出力とテストはjsdom上で実行しています。
Vue Teleportの早見表
propsの一覧
| prop | 型 | 必須 | 対応バージョン | 働き |
|---|---|---|---|---|
to |
CSSセレクターの文字列、またはDOM要素 | 必須 | 版の注記なし | 移動先のコンテナーを指定する |
disabled |
boolean | 任意 | 版の注記なし | trueの間は移動せず、書いた場所にそのまま描画する。動的に切り替えられる |
defer |
boolean | 任意 | 3.5以降 | アプリの他の部分がマウントされるまで、ターゲット探しを遅らせる |
「対応バージョン」は公式APIリファレンスの記載に基づきます。to と disabled には版の注記がなく、defer だけに「3.5+」と書かれています。
やりたいことから引く
| やりたいこと | 書き方 |
|---|---|
| モーダルをbody直下に出す | <Teleport to="body"> |
| 専用の置き場に出す | <Teleport to="#modals">(index.htmlに <div id="modals"></div> を用意) |
| クラスや属性で指定する | <Teleport to=".some-class">、<Teleport to="[data-teleport]"> |
| 条件によってその場に表示する | <Teleport to="body" :disabled="inline"> |
| 同じコンポーネント内の、後ろに書いた要素へ移動する | <Teleport defer to="#late">(3.5以降) |
| テストで移動後の中身を調べる | document.body.querySelector(...) で調べる |
つまずいたときの対応
| 症状 | 原因 | 直し方 |
|---|---|---|
| Failed to locate Teleport target の警告が出て何も表示されない | マウント時にターゲットがDOMにない | index.htmlに置き場を用意する/to="body" にする/defer を付ける |
wrapper.find() で中身が見つからない |
中身はwrapperの外に移動している | document.body から探す、またはteleportをスタブにする |
| テストをまたいでモーダルが残る | bodyに移動した要素が片付いていない | afterEachで document.body.innerHTML = '' |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Vue Teleportの使い方|モーダルをbodyへ移動・defer対応」
プロジェクトの準備
Vueの単一ファイルコンポーネント(.vueファイル)をそのまま動かし、HTMLの出力とテストの両方に使えるよう、Vitestとjsdomを入れます。
mkdir teleport-demo && cd teleport-demo
npm init -y
npm pkg set type=module
npm i vue
npm i -D vitest jsdom @vue/test-utils @vitejs/plugin-vue
mkdir src tests
vitest.config.js を作ります。
import { defineConfig } from 'vitest/config';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
test: { environment: 'jsdom' },
});
script setupで書くモーダルの全文コード
src/BaseModal.vue
開閉は親から open で受け取り、閉じたいときは close を知らせるだけの、状態を持たないモーダルです。
<script setup>
import { onBeforeUnmount, watch } from 'vue';
const props = defineProps({
open: { type: Boolean, default: false },
title: { type: String, required: true },
inline: { type: Boolean, default: false }, // true にすると移動せず、その場に表示する
});
const emit = defineEmits(['close']);
function onKeydown(event) {
if (event.key === 'Escape') emit('close');
}
// 開いている間だけ Esc キーを受け付ける
watch(
() => props.open,
(isOpen) => {
if (isOpen) document.addEventListener('keydown', onKeydown);
else document.removeEventListener('keydown', onKeydown);
},
{ immediate: true },
);
onBeforeUnmount(() => document.removeEventListener('keydown', onKeydown));
</script>
<template>
<Teleport to="body" :disabled="inline">
<div v-if="open" class="modal-backdrop" @click.self="emit('close')">
<div class="modal" role="dialog" aria-modal="true" :aria-label="title">
<h2>{{ title }}</h2>
<slot />
<button type="button" class="modal-close" @click="emit('close')">閉じる</button>
</div>
</div>
</Teleport>
</template>
<style scoped>
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: grid;
place-items: center;
background: rgb(0 0 0 / 50%);
}
.modal {
min-width: 280px;
padding: 24px;
border-radius: 8px;
background: #fff;
}
</style>
ポイントは3つです。
<Teleport to="body">で、背景と本体をまとめてbody直下へ移動しています。v-ifはTeleportの内側に置いています。:disabled="inline"で、移動するかどうかを呼び出し側が選べます。@click.selfは背景そのものをクリックしたときだけ閉じる指定で、モーダル本体のクリックでは閉じません。
src/App.vue(呼び出す側)
<script setup>
import { ref } from 'vue';
import BaseModal from './BaseModal.vue';
defineProps({ inline: { type: Boolean, default: false } });
const open = ref(false);
</script>
<template>
<main class="card">
<button type="button" class="open" @click="open = true">削除する</button>
<BaseModal :open="open" title="削除の確認" :inline="inline" @close="open = false">
<p>このタスクを削除します。</p>
</BaseModal>
</main>
</template>
呼び出す側は、ボタンのすぐ近くにモーダルを書くだけです。propsを渡し、イベントを受け取る書き方は、Teleportを使わないコンポーネントとまったく同じです。公式ガイドにも、Teleportは描画されるDOM構造を変えるだけで、コンポーネントの論理的な階層には影響しないと書かれています。
Teleport前後のHTMLを比べる
次のテストファイル(tests/demo.test.js)は、各パターンのHTMLと警告文を demo-output.txt に書き出すためのスクリプトです。この後の節の出力はすべてこのファイルで得たものです。
// 記事用:各パターンの HTML と警告文を demo-output.txt に書き出す
import { appendFileSync, writeFileSync } from 'node:fs';
import { createApp, h, nextTick, ref, version } from 'vue';
import { test, vi } from 'vitest';
import App from '../src/App.vue';
import LateTarget from '../src/LateTarget.vue';
import MissingTarget from '../src/MissingTarget.vue';
import Multi from '../src/Multi.vue';
const OUT = 'demo-output.txt';
writeFileSync(OUT, `Vue ${version}\n`);
const say = (...lines) => appendFileSync(OUT, lines.join('\n') + '\n');
// 1行の HTML を、タグごとに改行して字下げする(文字だけの要素は1行にまとめる)
function pretty(html) {
const tokens = html.split(/(<[^>]+>)/).filter((t) => t.trim());
let depth = 0;
const lines = [];
for (let i = 0; i < tokens.length; i++) {
const t = tokens[i];
const isOpen = t.startsWith('<') && !t.startsWith('</') && !t.startsWith('<!--');
if (isOpen && tokens[i + 1]?.startsWith('</')) {
lines.push(' '.repeat(depth) + t + tokens[++i]);
} else if (isOpen && !tokens[i + 1]?.startsWith('<') && tokens[i + 2]?.startsWith('</')) {
lines.push(' '.repeat(depth) + t + tokens[i + 1].trim() + tokens[i + 2]);
i += 2;
} else {
if (t.startsWith('</')) depth--;
lines.push(' '.repeat(depth) + t.trim());
if (isOpen) depth++;
}
}
return lines.join('\n');
}
function setup(bodyHtml) {
document.body.innerHTML = bodyHtml;
vi.restoreAllMocks();
return vi.spyOn(console, 'warn').mockImplementation(() => {});
}
const warnings = (spy) => spy.mock.calls.map((c) => c.map(String).join(' ').trim());
test('1. モーダルを body へ移動', async () => {
for (const inline of [false, true]) {
setup('<div id="app"></div>');
const app = createApp(App, { inline });
app.mount('#app');
say(`\n== 1. inline=${inline}(${inline ? 'Teleport を無効化' : 'body へ移動'})==`, '-- 開く前 --', pretty(document.body.innerHTML));
document.querySelector('button.open').click();
await nextTick();
say('-- 開いた後 --', pretty(document.body.innerHTML));
app.unmount();
}
});
test('2. ターゲットが存在しない', async () => {
const warn = setup('<div id="app"></div>');
const app = createApp(MissingTarget);
app.mount('#app');
say('\n== 2. ターゲットが存在しない ==', ...warnings(warn), '-- HTML --', pretty(document.body.innerHTML));
app.unmount();
const warn2 = setup('<div id="app"></div><div id="modal-root"></div>');
const app2 = createApp(MissingTarget);
app2.mount('#app');
say('-- 直した後(#modal-root を先に用意): 警告 ' + warnings(warn2).length + ' 件 --', pretty(document.body.innerHTML));
app2.unmount();
});
test('3. defer', async () => {
for (const defer of [false, true]) {
const warn = setup('<div id="app"></div>');
const app = createApp(LateTarget, { defer });
app.mount('#app');
await nextTick();
say(`\n== 3. defer=${defer} ==`, `警告 ${warnings(warn).length} 件`, ...warnings(warn).map((w) => w.split('\n')[0]), pretty(document.body.innerHTML));
app.unmount();
}
});
test('4. 同じターゲットへ複数', async () => {
setup('<div id="app"></div><div id="modals"></div>');
const showA = ref(true);
const showB = ref(true);
const app = createApp({ render: () => h(Multi, { showA: showA.value, showB: showB.value }) });
app.mount('#app');
say('\n== 4. 同じターゲットへ複数 ==', '-- A と B を同時に表示 --', document.getElementById('modals').innerHTML);
showA.value = false;
await nextTick();
say('-- A を消す --', document.getElementById('modals').innerHTML);
showA.value = true;
await nextTick();
say('-- A をもう一度表示 --', document.getElementById('modals').innerHTML);
app.unmount();
});
npx vitest run tests/demo.test.js && cat demo-output.txt
移動する場合(inline=false)
== 1. inline=false(body へ移動)==
-- 開く前 --
<div id="app" data-v-app="">
<main class="card">
<button type="button" class="open">削除する</button>
<!--teleport start-->
<!--teleport end-->
</main>
</div>
<!--v-if-->
-- 開いた後 --
<div id="app" data-v-app="">
<main class="card">
<button type="button" class="open">削除する</button>
<!--teleport start-->
<!--teleport end-->
</main>
</div>
<div data-v-7d32fabb="" class="modal-backdrop">
<div data-v-7d32fabb="" class="modal" role="dialog" aria-modal="true" aria-label="削除の確認">
<h2 data-v-7d32fabb="">削除の確認</h2>
<p>このタスクを削除します。</p>
<button data-v-7d32fabb="" type="button" class="modal-close">閉じる</button>
</div>
</div>
開いた後、モーダルの div.modal-backdrop は #app の外、bodyの直下に並んでいます。 元の場所には <!--teleport start--> と <!--teleport end--> という2つのコメントだけが残ります。これはVueが「ここにTeleportがある」と覚えておくための目印です。開く前にbodyの末尾にある <!--v-if--> は、v-if がfalseのときの目印で、これもTeleportの中身として移動しています。
data-v-7d32fabb は <style scoped> のための属性です。移動した後の要素にも同じ属性が付いているので、scopedなスタイルはそのまま当たります。 スロットで渡した p 要素に属性がないのは、呼び出し側(App.vue)の要素だからです。
移動しない場合(inline=true)
== 1. inline=true(Teleport を無効化)==
-- 開く前 --
<div id="app" data-v-app="">
<main class="card">
<button type="button" class="open">削除する</button>
<!--teleport start-->
<!--v-if-->
<!--teleport end-->
</main>
</div>
-- 開いた後 --
<div id="app" data-v-app="">
<main class="card">
<button type="button" class="open">削除する</button>
<!--teleport start-->
<div data-v-7d32fabb="" class="modal-backdrop">
<div data-v-7d32fabb="" class="modal" role="dialog" aria-modal="true" aria-label="削除の確認">
<h2 data-v-7d32fabb="">削除の確認</h2>
<p>このタスクを削除します。</p>
<button data-v-7d32fabb="" type="button" class="modal-close">閉じる</button>
</div>
</div>
<!--teleport end-->
</main>
</div>
disabled がtrueのときは、2つのコメントの間、つまりテンプレートに書いた場所にそのまま描画されます。この「移動しない場合」のHTMLが、Teleportを使わずに書いたときの姿です。main.card に overflow: hidden や transform が指定されていると、この位置では position: fixed のモーダルが切り取られたり、基準位置がずれたりします。body直下へ移せば、親のスタイルと切り離せます。
disabled は動的に切り替えられるので、「広い画面ではモーダル、狭い画面ではページ内にそのまま表示」といった出し分けにも使えます。
toの指定方法と置き場の決め方
| 指定 | 例 | 向いている場面 |
|---|---|---|
| タグ名 | to="body" |
置き場を用意せずにすぐ使いたい |
| ID | to="#modals" |
モーダルやトーストの置き場を1か所にまとめたい |
| クラス・属性 | to=".some-class"、to="[data-teleport]" |
既存のHTMLに合わせたい |
| DOM要素 | :to="element" |
要素を変数で持っている |
公式ガイドには、to のターゲットは、Teleportがマウントされる時点でDOMに存在している必要があると書かれています。body はいつでも存在するので、最初の一歩としては to="body" が確実です。置き場をIDで指定する場合は、index.htmlの #app の隣に書いておきます。
<body>
<div id="app"></div>
<div id="modals"></div>
<script type="module" src="/src/main.js"></script>
</body>
警告「Failed to locate Teleport target」の再現と直し方
存在しない #modal-root を指定したコンポーネント(src/MissingTarget.vue)で再現します。
<template>
<Teleport to="#modal-root">
<p class="msg">お知らせ</p>
</Teleport>
</template>
== 2. ターゲットが存在しない ==
[Vue warn]: Failed to locate Teleport target with selector "#modal-root". Note the target element must exist before the component is mounted - i.e. the target cannot be rendered by the component itself, and ideally should be outside of the entire Vue component tree.
at <MissingTarget>
[Vue warn]: Invalid Teleport target on mount: null (object)
at <MissingTarget>
-- HTML --
<div id="app" data-v-app="">
<!--teleport start-->
<!--teleport end-->
</div>
-- 直した後(#modal-root を先に用意): 警告 0 件 --
<div id="app" data-v-app="">
<!--teleport start-->
<!--teleport end-->
</div>
<div id="modal-root">
<p class="msg">お知らせ</p>
</div>
警告は2件出ます。 1件目がセレクターで要素を見つけられなかったこと、2件目がマウント時のターゲットがnullだったことを伝えています。HTMLを見ると、目印のコメントだけがあり、中身の p 要素はどこにも描画されていません。画面には何も出ないので、「Teleportで書いた部分が表示されない」ときは、まずコンソールにこの警告がないかを確認します。
出力の後半は、#modal-root を先にbodyへ用意してからマウントした結果で、警告は0件になり、中身が #modal-root の中に入りました。直し方をまとめます。
| 原因 | 直し方 |
|---|---|
| セレクターの書き間違い、置き場をHTMLに書いていない | index.htmlに置き場を追加する。または to="body" にする |
| 置き場を同じコンポーネントのテンプレート内で、Teleportより後ろに書いている | defer を付ける(3.5以降)。または置き場をindex.htmlへ移す |
置き場が v-if などで後から現れる |
置き場を常に存在する要素にし、中身のほうを v-if で切り替える |
| テストで置き場がない | beforeEachで置き場の要素をbodyに追加する |
deferで後から現れるターゲットに移動する(3.5以降)
警告文にもあるとおり、ターゲットは「そのコンポーネント自身が描画する要素」であってはならない、というのが従来の決まりでした。Vue 3.5で加わった defer を付けると、アプリの他の部分がマウントされるまでターゲット探しを遅らせるので、同じテンプレートの後ろに書いた要素にも移動できます。src/LateTarget.vue で、defer の有無を切り替えて比べます。
<script setup>
defineProps({ defer: { type: Boolean, default: false } });
</script>
<template>
<section>
<Teleport to="#late" :defer="defer">
<p class="msg">後から現れる場所へ移動</p>
</Teleport>
<!-- ターゲットが Teleport より後ろに書かれている -->
<div id="late"></div>
</section>
</template>
== 3. defer=false ==
警告 2 件
[Vue warn]: Failed to locate Teleport target with selector "#late". Note the target element must exist before the component is mounted - i.e. the target cannot be rendered by the component itself, and ideally should be outside of the entire Vue component tree.
[Vue warn]: Invalid Teleport target on mount: null (object)
<div id="app" data-v-app="">
<section>
<!--teleport start-->
<!--teleport end-->
<!-- ターゲットが Teleport より後ろに書かれている -->
<div id="late"></div>
</section>
</div>
== 3. defer=true ==
警告 0 件
<div id="app" data-v-app="">
<section>
<!--teleport start-->
<!--teleport end-->
<!-- ターゲットが Teleport より後ろに書かれている -->
<div id="late">
<p class="msg">後から現れる場所へ移動</p>
</div>
</section>
</div>
defer なしでは警告が2件出て #late は空のまま、defer ありでは警告0件で #late の中に移動しました。 レイアウト用のコンポーネントが置き場を持ち、その内側のページが中身を送り込む、といった構成で役立ちます。
defer を付けても、ターゲットはTeleportと同じタイミングでマウントされる必要があります。ずっと後になって v-if で現れる要素を待ち続ける機能ではないので、置き場そのものは最初から描画しておきます。
同じターゲットに複数のTeleportを置いたときの並び順
2つのTeleportを同じ #modals に向けたコンポーネント(src/Multi.vue)です。
<script setup>
defineProps({ showA: Boolean, showB: Boolean });
</script>
<template>
<Teleport v-if="showA" to="#modals"><div class="a">A</div></Teleport>
<Teleport v-if="showB" to="#modals"><div class="b">B</div></Teleport>
</template>
== 4. 同じターゲットへ複数 ==
-- A と B を同時に表示 --
<div class="a">A</div><div class="b">B</div>
-- A を消す --
<div class="b">B</div>
-- A をもう一度表示 --
<div class="b">B</div><div class="a">A</div>
並び順は「マウントされた順に末尾へ追加」です。 同時に表示すればテンプレートに書いた順(A、B)になり、Aをいったん消してもう一度表示すると、Bの後ろに入ります。公式ガイドの「後のマウントは前のマウントの後に配置される」という説明どおりの結果です。
モーダルを重ねる場合、後から開いたものがDOM上でも後ろに来るので、同じ z-index なら後から開いたモーダルが手前に表示されます。通知のトーストを積む場合も、新しいものが下(末尾)に追加されていく並びになります。
Vitestとjsdomでbody側を検証するテスト
Teleportの中身は、テスト対象のコンポーネントのルート要素の外に描画されます。そのため wrapper.find('.modal') では見つからず、document.body から探すのが基本です。tests/BaseModal.test.js の全文です。
import { afterEach, describe, expect, it } from 'vitest';
import { mount } from '@vue/test-utils';
import App from '../src/App.vue';
import BaseModal from '../src/BaseModal.vue';
afterEach(() => {
document.body.innerHTML = ''; // 移動先に残った要素を片付ける
});
describe('BaseModal(Teleport to="body")', () => {
it('閉じている間は body にモーダルがない', () => {
mount(BaseModal, { props: { open: false, title: '確認' } });
expect(document.body.querySelector('.modal')).toBeNull();
});
it('開くと wrapper の外(body 直下)に描画される', () => {
const wrapper = mount(BaseModal, {
props: { open: true, title: '確認' },
slots: { default: '<p>本文</p>' },
});
// wrapper.html() には移動後の中身が含まれない
expect(wrapper.find('.modal').exists()).toBe(false);
// body 側を直接調べる
const modal = document.body.querySelector('.modal');
expect(modal.getAttribute('aria-label')).toBe('確認');
expect(modal.textContent).toContain('本文');
expect(modal.parentElement.parentElement).toBe(document.body);
});
it('閉じるボタンで close が emit される', async () => {
const wrapper = mount(BaseModal, { props: { open: true, title: '確認' } });
document.body.querySelector('.modal-close').click();
expect(wrapper.emitted('close')).toHaveLength(1);
});
it('Esc キーで close が emit される', () => {
const wrapper = mount(BaseModal, { props: { open: true, title: '確認' } });
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
expect(wrapper.emitted('close')).toHaveLength(1);
});
it('App から開いて閉じる(findComponent で仮想 DOM 側からたどる)', async () => {
const wrapper = mount(App, { attachTo: document.body });
await wrapper.find('button.open').trigger('click');
expect(document.body.querySelector('.modal h2').textContent).toBe('削除の確認');
wrapper.findComponent(BaseModal).vm.$emit('close');
await wrapper.vm.$nextTick();
expect(document.body.querySelector('.modal')).toBeNull();
wrapper.unmount();
});
it('teleport をスタブにすると wrapper の中で調べられる', () => {
const wrapper = mount(BaseModal, {
props: { open: true, title: '確認' },
global: { stubs: { teleport: true } },
});
expect(wrapper.find('.modal h2').text()).toBe('確認');
expect(document.body.querySelector('.modal')).toBeNull();
});
});
npx vitest run tests/BaseModal.test.js --reporter=verbose
RUN v5.0.3 /home/taro/teleport-demo
✓ tests/BaseModal.test.js > BaseModal(Teleport to="body") > 閉じている間は body にモーダルがない 24ms
✓ tests/BaseModal.test.js > BaseModal(Teleport to="body") > 開くと wrapper の外(body 直下)に描画される 15ms
✓ tests/BaseModal.test.js > BaseModal(Teleport to="body") > 閉じるボタンで close が emit される 6ms
✓ tests/BaseModal.test.js > BaseModal(Teleport to="body") > Esc キーで close が emit される 4ms
✓ tests/BaseModal.test.js > BaseModal(Teleport to="body") > App から開いて閉じる(findComponent で仮想 DOM 側からたどる) 16ms
✓ tests/BaseModal.test.js > BaseModal(Teleport to="body") > teleport をスタブにすると wrapper の中で調べられる 4ms
Test Files 1 passed (1)
Tests 6 passed (6)
Start at 23:22:23
Duration 1.10s (environment 67%, import 14%, transform 11%, tests 7%)
(パスの表示は環境に合わせて置き換えています。)
テストの書き方を整理します。
| 調べ方 | コード | 向いている場面 |
|---|---|---|
| body側のDOMを直接調べる | document.body.querySelector('.modal') |
実際に移動したことまで確かめたい |
| 仮想DOM側からたどる | wrapper.findComponent(BaseModal) |
子コンポーネントのpropsやemitを調べたい |
| Teleportをスタブにする | global: { stubs: { teleport: true } } |
移動を無視して中身だけ調べたい |
- 2つ目のテストが、移動の確認そのものです。
wrapper.find('.modal').exists()がfalse、document.body.querySelector('.modal')で見つかる、という組み合わせで「wrapperの外に出ている」ことを確かめています。 - 3つ目と4つ目では、body側のボタンやキー操作から、
wrapper.emitted('close')にイベントが届いています。DOM上は離れていても、コンポーネントとしての親子関係は保たれていることがわかります。 - 5つ目の
findComponentは、Vue Test Utilsの公式ガイドで紹介されている方法です。HTMLは外へ移動していても、仮想DOMは元のコンポーネントへの参照を保っているので、コンポーネント単位でたどれます。 - afterEachでbodyを空にする処理は必須です。移動した要素はwrapperを破棄するまでbodyに残るため、片付けないと次のテストの
querySelectorが前のテストの要素を拾います。
使いどころの判断表
| 部品 | Teleportを使うか | 理由 |
|---|---|---|
| モーダル、確認ダイアログ | 使う | 親の overflow や重なり順と切り離したい |
| トースト通知 | 使う | 画面の端に、どのページからでも同じ場所へ出したい |
| 画面全体を覆うローディング表示 | 使う | 最前面に固定したい |
| ドロップダウン、ツールチップ | 親で切り取られるなら使う | 位置計算が必要になるので、切り取られない場合はそのままでよい |
| ページ内の通常の部品 | 使わない | 書いた場所に描画すれば十分 |
よくある質問
Q. Teleportで移動した中身に、propsやイベントは届きますか?
A. 届きます。Teleportが変えるのは描画先のDOMだけで、コンポーネントの親子関係は変わりません。今回のテストでも、bodyへ移動したボタンのクリックで親に close が届きました。
Q. scopedなスタイルは移動後も効きますか?
A. 効きます。移動後のHTMLにも data-v-7d32fabb のようなscoped用の属性が付いたままでした(「Teleport前後のHTMLを比べる」の出力)。
Q. to="body" と専用の置き場(#modals)はどちらがよいですか?
A. どちらでも動きます。body は置き場の用意が不要で、警告が出る心配がありません。専用の置き場は、モーダルやトーストをまとめて管理したいとき、スタイルを一括で当てたいときに向いています。
Q. defer はいつ付ければよいですか?
A. ターゲットが、同じコンポーネントやアプリ内の別のコンポーネントによって描画される場合です。index.htmlに最初から書いてある要素や body が相手なら不要です。Vue 3.5以降で使えます。
まとめ
- Teleportは、テンプレートの一部をDOM上の別の場所に描画する組み込みコンポーネント。モーダルは
to="body"でbody直下に出せます。 - 元の場所には
<!--teleport start-->と<!--teleport end-->のコメントだけが残り、中身はターゲットの末尾に追加されます。 disabledがtrueなら移動せずその場に描画。動的に切り替えられます。- ターゲットがないと「Failed to locate Teleport target」の警告が出て、中身は描画されない。置き場を先に用意するか、
to="body"にします。 defer(3.5以降)で、同じテンプレートの後ろに書いたターゲットにも移動できるようになりました。- 同じターゲットへの複数Teleportは、マウントされた順に末尾へ追加されます。
- テストは
document.bodyから探し、afterEachでbodyを空にする。中身だけ調べたいときはteleportをスタブにします。
参考・出典
- Vue.js公式ガイド「Teleport」 https://ja.vuejs.org/guide/built-ins/teleport
- Vue.js公式ガイド(英語版)「Teleport」 https://vuejs.org/guide/built-ins/teleport.html
- Vue.js公式APIリファレンス「Built-in Components(Teleport)」 https://vuejs.org/api/built-in-components.html
- Vue Test Utils公式ガイド「Testing Teleport」 https://test-utils.vuejs.org/guide/advanced/teleport
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Vue Teleportの使い方|モーダルをbodyへ移動・defer対応」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。