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期連続の黒字決算。

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

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