Vue の動的コンポーネントとは、component という特別なタグの is に渡した値によって、その場所に表示するコンポーネントを差し替えるしくみです。 v-if を何段も並べる代わりに「いま表示するもの」を1つの値で表せるので、タブ・ウィザード・ダッシュボードの部品の出し分けがすっきり書けます。

この記事では、Vue 3.5.43 の <script setup> でタブ切り替えの全文コードを動かし、出力された HTML をそのまま載せています。 :is に渡せる値の一覧表、ref にコンポーネントを入れたときの警告「Vue received a Component that was made a reactive object」の再現と shallowRef・markRaw での直し方、defineAsyncComponent の loadingComponent・errorComponent・delay・timeout の早見表と時間ごとの表示の移り変わり、KeepAlive と組み合わせて入力を残す例まで、実行結果つきでまとめました。

確認環境: Linux(x64)/Node.js 22.22.2/Vue 3.5.43/Vite 8.3.2+@vitejs/plugin-vue 6.0.9/Vitest 5.0.3+jsdom 30.1.1+@vue/test-utils 2.5.1/Chromium 141(Playwright 1.56.0)。出力中のパスは /home/taro/ に置き換えています。

動的コンポーネントの早見表

:is に渡せる値の一覧

IsValue.vue(テンプレートは component :is="value" の中に「中身」という文字を置いただけ)に、いろいろな値を渡して出力 HTML を確かめた結果です。

:is に渡す値 例 出力された HTML メモ
コンポーネントオブジェクト TabHome(import したもの) <p>ホーム:お知らせはありません。</p> <script setup> での基本形
登録名の文字列(全体登録) 'TabHome'(app.component で登録済み) <p>ホーム:お知らせはありません。</p> 登録が必要
登録名の文字列(ケバブケース) 'tab-home'(同上) <p>ホーム:お知らせはありません。</p> 登録名はどちらの表記でも引ける
登録していない名前の文字列 'TabHome'(import しただけ) <tabhome>中身</tabhome> その名前のタグとして出力される
HTML タグ名 'a'・'h2' <a>中身</a>・<h2>中身</h2> 見出しレベルやリンク/ボタンの出し分けに
関数コンポーネント (props, { slots }) => h('strong', slots.default?.()) <strong>中身</strong> 描画関数だけの小さな部品
null・undefined ― (何も出力されない) 「何も表示しない」状態に使える
空文字 '' (何も出力されない) 警告 Invalid vnode type when creating vnode が出る
非同期コンポーネント defineAsyncComponent(() => import('./TabArchive.vue')) 読み込み後に中身が表示される 必要になってから読み込む

「いま表示するもの」の持ち方

持ち方 書き方 向いている場面
文字列のキー+対応表 const currentKey = ref('home') と配列・オブジェクトの対応表 おすすめ。URL や保存データと相性がよい
コンポーネントそのもの const current = shallowRef(TabHome) 対応表を作るほどでもない小さな切り替え
リアクティブな配列の中 ref([{ component: markRaw(TabHome) }]) タブを追加・削除・並べ替えする
ref(TabHome) ― 警告が出るので shallowRef に置き換える

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

(記載例)出典:株式会社RJC「Vue動的コンポーネントの使い方|component :isでタブ切り替え」

タブ切り替えの全文コード(script setup)

まず、そのまま貼って動く完成形です。タブの中身3つと、切り替える本体 Tabs.vue の4ファイルです。

<!-- TabHome.vue -->
<template>
  <p>ホーム:お知らせはありません。</p>
</template>
<!-- TabPosts.vue -->
<script setup>
import { ref } from 'vue'
const draft = ref('')
</script>

<template>
  <div>
    <p>投稿:下書きを書いてください。</p>
    <input v-model="draft" placeholder="下書き" />
  </div>
</template>
<!-- TabArchive.vue -->
<template>
  <p>アーカイブ:3件の記事があります。</p>
</template>
<!-- Tabs.vue -->
<script setup>
import { ref, computed } from 'vue'
import TabHome from './TabHome.vue'
import TabPosts from './TabPosts.vue'
import TabArchive from './TabArchive.vue'

// タブの定義(表示名とコンポーネントの対応表)
const tabs = [
  { key: 'home', label: 'ホーム', component: TabHome },
  { key: 'posts', label: '投稿', component: TabPosts },
  { key: 'archive', label: 'アーカイブ', component: TabArchive },
]

// 状態として持つのは「どのタブか」を表す文字列だけ
const currentKey = ref('home')
const currentTab = computed(() => tabs.find((t) => t.key === currentKey.value))
</script>

<template>
  <div class="tabs">
    <div role="tablist">
      <button
        v-for="tab in tabs"
        :key="tab.key"
        role="tab"
        :aria-selected="tab.key === currentKey"
        :class="{ active: tab.key === currentKey }"
        @click="currentKey = tab.key"
      >
        {{ tab.label }}
      </button>
    </div>
    <div role="tabpanel">
      <component :is="currentTab.component" />
    </div>
  </div>
</template>

<style scoped>
.active {
  font-weight: bold;
  border-bottom: 2px solid currentColor;
}
</style>

初期表示の HTML です(data-v-... は scoped スタイル用に付く属性で、見やすさのために省いています)。

<div class="tabs">
  <div role="tablist"><button role="tab" aria-selected="true" class="active">ホーム</button><button role="tab" aria-selected="false" class="">投稿</button><button role="tab" aria-selected="false" class="">アーカイブ</button></div>
  <div role="tabpanel">
    <p>ホーム:お知らせはありません。</p>
  </div>
</div>

「投稿」「アーカイブ」を順にクリックしたときの tabpanel の中身です。

--- 「投稿」をクリック
<div role="tabpanel">
  <div>
    <p>投稿:下書きを書いてください。</p><input placeholder="下書き">
  </div>
</div>
aria-selected: false, true, false
--- 「アーカイブ」をクリック
<div role="tabpanel">
  <p>アーカイブ:3件の記事があります。</p>
</div>

このコードのポイント

ポイント 理由
状態は文字列 currentKey だけ コンポーネント自体をリアクティブにしないので、後述の警告と無縁になる
tabs はふつうの配列(ref で包まない) 中身が変わらない定義は、リアクティブにする必要がない
computed で「いまのタブ」を引く テンプレートが currentTab.component と短くなる
role="tab"・aria-selected 読み上げソフトにも「どのタブが選ばれているか」が伝わる

v-if を3つ並べる書き方と比べると、タブが増えても tabs に1行足すだけで済むのが動的コンポーネントの良さです。

:is に渡せる値をくわしく見る

コンポーネントオブジェクトを渡す(script setup の基本)

<script setup> では、import したコンポーネントを変数としてそのままテンプレートで使えます。:is にも、その変数(コンポーネントオブジェクト)を渡します。Vue 公式 API リファレンスの component の項にも、is にはコンポーネントの定義を直接バインドできると書かれています。

文字列で渡すなら「登録」が必要

:is に文字列を渡すと、Vue は「HTML タグ名」か「登録済みコンポーネントの名前」として扱います。Options API でローカル登録(components オプション)した場合は、文字列で切り替えられます。

<!-- OptionsTabs.vue -->
<script>
import TabHome from './TabHome.vue'
import TabPosts from './TabPosts.vue'

export default {
  components: { TabHome, TabPosts }, // ローカル登録
  data() {
    return { current: 'TabHome' } // 登録名の文字列
  },
}
</script>

<template>
  <button @click="current = 'TabPosts'">投稿</button>
  <component :is="current" />
</template>
<button>投稿</button><p>ホーム:お知らせはありません。</p>
<!-- クリック後 -->
<button>投稿</button><div><p>投稿:下書きを書いてください。</p><input placeholder="下書き"></div>

script setup で文字列を渡すと、その名前のタグになる

Options API の書き方に慣れていると、<script setup> でも文字列で渡したくなります。import しただけ(登録なし)の状態で試しました。

<!-- StaticIs.vue -->
<script setup>
import TabHome from './TabHome.vue'
const name = 'TabHome'
</script>

<template>
  <section data-case="static"><component is="TabHome" /></section>
  <section data-case="dynamic"><component :is="name" /></section>
</template>
<section data-case="static">
  <tabhome></tabhome>
</section>
<section data-case="dynamic">
  <tabhome></tabhome>
</section>

コロンなしの is="TabHome" も、文字列の変数を渡す :is="name" も、TabHome コンポーネントではなく tabhome という名前のタグとして出力されました。 Chromium で開いた場合も同じ HTML で、警告は出ません。画面に何も見えないのに警告もない、という状態になるので、原因に気づきにくいところです。

直し方:名前からコンポーネントを引く対応表を作る

API のレスポンスや URL から「表示する部品の名前」が文字列で届く場合は、名前をキーにしたオブジェクトを作り、そこからコンポーネントを引いて渡します。

<!-- MapTabs.vue -->
<script setup>
import { ref } from 'vue'
import TabHome from './TabHome.vue'
import TabPosts from './TabPosts.vue'

// 名前(文字列)からコンポーネントを引く対応表
const components = { TabHome, TabPosts }
const currentName = ref('TabHome') // API やURLから来た文字列を想定
</script>

<template>
  <button @click="currentName = 'TabPosts'">投稿</button>
  <component :is="components[currentName]" />
</template>
<button>投稿</button><p>ホーム:お知らせはありません。</p>
<!-- クリック後(警告 0 件) -->
<button>投稿</button><div><p>投稿:下書きを書いてください。</p><input placeholder="下書き"></div>

対応表にない名前が来たときは components[currentName] が undefined になり、何も表示されません(一覧表の undefined の行)。components[currentName] ?? NotFound のように、見つからないとき用の部品を用意しておくと親切です。

HTML タグ名を渡す

文字列が HTML のタグ名なら、そのタグが出力されます。「見出しレベルを props で変える」「リンクなら a、そうでなければ button にする」といった出し分けに使えます。

<!-- 見出しレベルを props で切り替える例 -->
<script setup>
defineProps({ level: { type: Number, default: 2 } })
</script>

<template>
  <component :is="`h${level}`"><slot /></component>
</template>

一覧表のとおり、'h2' を渡すと h2 タグ、'a' を渡すと a タグが出力されました。

組み込みコンポーネントを渡す

KeepAlive や Transition などの組み込みコンポーネントも :is に渡せます。Vue 公式 API リファレンスによると、名前(文字列)で渡したい場合は登録が必要です。登録なしで 'KeepAlive' という文字列を渡すと、keepalive という名前のタグが出力されました。import した値をそのまま渡すと、組み込みコンポーネントとして働きます。

refに入れると出る警告と、shallowRef・markRawでの直し方

警告の再現

「いまのコンポーネント」を ref に入れると、開発中のコンソールに警告が出ます。

<!-- RefTabs.vue -->
<script setup>
import { ref } from 'vue'
import TabHome from './TabHome.vue'
import TabPosts from './TabPosts.vue'

const current = ref(TabHome) // コンポーネントを ref に入れている
</script>

<template>
  <button @click="current = TabPosts">投稿</button>
  <component :is="current" />
</template>

開発モードでビルドして Chromium で開き、ボタンを押したときのコンソール出力です。

[Vue warn]: Vue received a Component that was made a reactive object. This can lead to unnecessary performance overhead and should be avoided by marking the component with `markRaw` or using `shallowRef` instead of `ref`. 
Component that was made reactive:  {render: , __file: /home/taro/app/src/TabHome.vue} 
  at <RefTabs>
[Vue warn]: Vue received a Component that was made a reactive object. This can lead to unnecessary performance overhead and should be avoided by marking the component with `markRaw` or using `shallowRef` instead of `ref`. 
Component that was made reactive:  {__name: TabPosts, setup: , render: , __file: /home/taro/app/src/TabPosts.vue} 
  at <RefTabs>

初期表示で TabHome について1回、クリック後に TabPosts について1回、合計2回出ました。表示そのものは切り替わりますが、警告文にあるとおり、余分な処理が増えます。

なぜ警告になるのか

ref にオブジェクトを入れると、Vue はその中のプロパティまで追跡できるように包みます(深いリアクティブ化)。コンポーネントオブジェクトは setup や render などを持つ「定義」で、中身が変わることを追跡する必要がありません。追跡しなくてよいものまで包むと手間が増えるので、Vue が知らせてくれている、というのがこの警告です。

直し方1:shallowRef に置き換える

<!-- ShallowTabs.vue -->
<script setup>
import { shallowRef } from 'vue'
import TabHome from './TabHome.vue'
import TabPosts from './TabPosts.vue'

const current = shallowRef(TabHome) // 中身までは監視しない
</script>

<template>
  <button @click="current = TabPosts">投稿</button>
  <component :is="current" />
</template>
ShallowTabs.vue 警告 0 件 / 表示: <div><p>投稿:下書きを書いてください。</p><input placeholder="下書き"></div>

shallowRef は「.value が差し替わったこと」だけを追跡します。current = TabPosts のように丸ごと入れ替える使い方なら、これで十分です。

直し方2:配列やオブジェクトの中では markRaw

タブの一覧を ref の配列で持つ場合も、中の component がリアクティブ化されて同じ警告が出ます。

<!-- RefListTabs.vue(警告が出る) -->
<script setup>
import { ref } from 'vue'
import TabHome from './TabHome.vue'
import TabPosts from './TabPosts.vue'

// 配列ごと ref に入れると、中の component もリアクティブ化される
const tabs = ref([
  { label: 'ホーム', component: TabHome },
  { label: '投稿', component: TabPosts },
])
const index = ref(0)
</script>

<template>
  <button v-for="(tab, i) in tabs" :key="tab.label" @click="index = i">{{ tab.label }}</button>
  <component :is="tabs[index].component" />
</template>

この場合は、コンポーネントを markRaw で包みます。

<!-- MarkRawTabs.vue(警告 0 件) -->
<script setup>
import { ref, markRaw } from 'vue'
import TabHome from './TabHome.vue'
import TabPosts from './TabPosts.vue'

// markRaw を付けたオブジェクトはリアクティブ化の対象から外れる
const tabs = ref([
  { label: 'ホーム', component: markRaw(TabHome) },
  { label: '投稿', component: markRaw(TabPosts) },
])
const index = ref(0)
</script>

<template>
  <button v-for="(tab, i) in tabs" :key="tab.label" @click="index = i">{{ tab.label }}</button>
  <component :is="tabs[index].component" />
</template>
RefListTabs.vue 警告 1 件
MarkRawTabs.vue 警告 0 件 / 表示: <button>ホーム</button><button>投稿</button><p>ホーム:お知らせはありません。</p>

どれを選ぶかの判断表

状況 選ぶもの 理由
「いまのコンポーネント」を1つ持つ shallowRef(TabHome) 入れ替えだけ追跡できれば足りる
タブ一覧をあとから増減・並べ替えする ref([...]) の中で markRaw(TabHome) 配列の変化は追跡し、コンポーネントは追跡しない
タブ一覧が固定 ふつうの配列+文字列キーの ref リアクティブにするものが最小で済む(冒頭の全文コード)
reactive({ component: TabHome }) 中で markRaw reactive も深く包むので同じ警告になる

props・イベント・スロットはそのまま渡せる

component タグに書いた props・イベントリスナー・スロットは、is で選ばれたコンポーネントにそのまま渡ります。

<!-- Panel.vue -->
<script setup>
defineProps({ title: String })
defineEmits(['close'])
</script>

<template>
  <section>
    <h3>{{ title }}</h3>
    <slot />
    <button @click="$emit('close', title)">閉じる</button>
  </section>
</template>
<!-- PassProps.vue -->
<script setup>
import { ref, shallowRef } from 'vue'
import Panel from './Panel.vue'

const current = shallowRef(Panel)
const message = ref('')
</script>

<template>
  <component :is="current" title="お知らせ" @close="(t) => (message = `${t} を閉じました`)">
    <p>スロットの中身</p>
  </component>
  <p class="message">{{ message }}</p>
</template>
<section>
  <h3>お知らせ</h3>
  <p>スロットの中身</p><button>閉じる</button>
</section>
<p class="message"></p>
--- 「閉じる」をクリック
<p class="message">お知らせ を閉じました</p>

切り替える候補どうしで props とイベントの名前をそろえておくと、component タグ側の書き方が1つで済みます。候補ごとに渡す値が違うときは、対応表に props も持たせて v-bind="currentTab.props" でまとめて渡す形が扱いやすいです。

動的なネイティブ要素に v-model を付けるとき

Vue 公式 API リファレンスには、component タグの v-model は modelValue props と update:modelValue イベントに展開されるため、ネイティブの HTML 要素とは互換性がないと書かれています。:is="'input'" で確かめました。

<!-- DynamicInput.vue -->
<script setup>
import { ref } from 'vue'
const tag = ref('input')
const text = ref('初期値')
const text2 = ref('初期値')
</script>

<template>
  <!-- v-model は modelValue / update:modelValue に展開される -->
  <component :is="tag" v-model="text" class="a" />
  <!-- ネイティブ要素には value と input イベントを自分で書く -->
  <component :is="tag" :value="text2" @input="text2 = $event.target.value" class="b" />
  <p>{{ text }} / {{ text2 }}</p>
</template>
a の初期表示: "" / b の初期表示: "初期値"
(a に「Aに入力」、b に「Bに入力」と入力)
<p>初期値 / Bに入力</p>

v-model を付けた a は、初期値が入力欄に出ず、入力しても text が変わりませんでした。:value と @input に分けて書いた b は、どちらも期待どおりです。

KeepAliveと併用して入力状態を残す

動的コンポーネントは、切り替えのたびに前のコンポーネントを破棄して新しく作ります。入力途中の文字を残したいときは、KeepAlive で囲みます。

<!-- TabsKeep.vue -->
<script setup>
import { shallowRef } from 'vue'
import TabHome from './TabHome.vue'
import TabPosts from './TabPosts.vue'

defineProps({ keep: Boolean })
const current = shallowRef(TabPosts)
</script>

<template>
  <button data-t="home" @click="current = TabHome">ホーム</button>
  <button data-t="posts" @click="current = TabPosts">投稿</button>
  <KeepAlive v-if="keep">
    <component :is="current" />
  </KeepAlive>
  <component v-else :is="current" />
</template>

「投稿」タブの入力欄に「あとで書く」と入れ、「ホーム」に切り替えてから「投稿」に戻ったときの入力値です。

keep=false: 戻ったあとの入力値 = ""
keep=true: 戻ったあとの入力値 = "あとで書く"

KeepAlive で囲むと、表示から外れたコンポーネントが破棄されずに取っておかれ、戻ったときに入力がそのまま残ります。 取っておかれている間は onMounted が再び呼ばれないので、表示のたびに行いたい処理は onActivated に書きます。

defineAsyncComponentで必要になってから読み込む

基本の書き方

タブの数が多いときや、中身が重いときは、開かれたときに初めてそのコードを読み込むようにできます。defineAsyncComponent に、import() を返す関数を渡します。

import { defineAsyncComponent } from 'vue'

const TabArchive = defineAsyncComponent(() => import('./TabArchive.vue'))

こうして作ったものは、ふつうのコンポーネントと同じように :is に渡せます。

オプションの早見表

読み込み中やエラー時の表示を付けたいときは、オブジェクトで渡します。既定値は Vue 公式ガイド「非同期コンポーネント」の記載です。

オプション 役割 既定値 実行して確かめた動き
loader import() を返す関数 (必須) 完了するまで中身は表示されない
loadingComponent 読み込み中に表示する部品 なし delay を過ぎてから表示された
delay loadingComponent を出すまでの待ち時間(ミリ秒) 200 199ms では空、200ms で表示
errorComponent 読み込めなかったときに表示する部品 なし error という props でエラーを受け取れた
timeout この時間を超えたらエラー扱いにする(ミリ秒) Infinity(制限なし) 3000 を指定すると 3000ms でエラー表示に切り替わった
onError エラー時に呼ばれる関数。retry() でやり直せる なし attempts が 1、2 と増え、3回目の読み込みで表示できた

時間ごとの表示の移り変わり

通信の代わりに「指定ミリ秒後に読み込みが終わる関数」を loader に渡し、テスト用の時計を進めながら HTML を記録しました。Loading.vue と LoadError.vue は次のとおりです。

<!-- Loading.vue -->
<template><p class="loading">読み込み中...</p></template>
<!-- LoadError.vue -->
<script setup>
defineProps({ error: Error })
</script>

<template>
  <p class="error">表示できませんでした:{{ error.message }}</p>
</template>
// async.test.js
import { test, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { defineAsyncComponent, h } from 'vue'
import Loading from './Loading.vue'
import LoadError from './LoadError.vue'
import TabArchive from './TabArchive.vue'

// ms ミリ秒後に読み込みが終わる loader(通信の代わり)
const slow = (ms) => () => new Promise((resolve) => setTimeout(() => resolve(TabArchive), ms))

async function timeline(title, options, marks) {
  const Async = defineAsyncComponent(options)
  const wrapper = mount({ render: () => h('div', [h(Async)]) })
  let now = 0
  const lines = [`### ${title}`]
  for (const mark of marks) {
    await vi.advanceTimersByTimeAsync(mark - now)
    now = mark
    await flushPromises()
    lines.push(`${String(mark).padStart(5)}ms: ${wrapper.html().replace(/\n\s*/g, '')}`)
  }
  process.stdout.write(lines.join('\n') + '\n')
}

test('defineAsyncComponent の表示の移り変わり', async () => {
  vi.useFakeTimers()
  await timeline('delay 既定(200ms)・読み込み 1000ms',
    { loader: slow(1000), loadingComponent: Loading }, [0, 199, 200, 999, 1000])
  await timeline('読み込み 150ms',
    { loader: slow(150), loadingComponent: Loading }, [0, 100, 150])
  await timeline('timeout: 3000・読み込み 5000ms',
    { loader: slow(5000), loadingComponent: Loading, errorComponent: LoadError, timeout: 3000 },
    [0, 200, 2999, 3000])
})
### delay 既定(200ms)・読み込み 1000ms
    0ms: <div><!----></div>
  199ms: <div><!----></div>
  200ms: <div><p class="loading">読み込み中...</p></div>
  999ms: <div><p class="loading">読み込み中...</p></div>
 1000ms: <div><p>アーカイブ:3件の記事があります。</p></div>
### 読み込み 150ms
    0ms: <div><!----></div>
  100ms: <div><!----></div>
  150ms: <div><p>アーカイブ:3件の記事があります。</p></div>
### timeout: 3000・読み込み 5000ms
    0ms: <div><!----></div>
  200ms: <div><p class="loading">読み込み中...</p></div>
 2999ms: <div><p class="loading">読み込み中...</p></div>
 3000ms: <div><p class="error">表示できませんでした:Async component timed out after 3000ms.</p></div>

読み取れることは3つです。

  • delay の 200ms を過ぎるまでは、読み込み中の表示は出ない(空のコメントノードだけ)。
  • 150ms で読み込みが終わる場合、読み込み中の表示は一度も出ない。速い回線で一瞬だけちらつくのを防ぐための待ち時間です。
  • timeout を過ぎると errorComponent に切り替わり、error.message は Async component timed out after 3000ms. になる。

delay: 0 にすると、0ms の時点から「読み込み中...」が表示されました。すぐに出したい場合は 0 を指定します。

読み込めなかったとき(errorComponent・onError)

loader がエラーを返した場合(ネットワークが切れて import() が完了しなかった場合など)の表示です。

### loader がエラー・errorComponent あり
    0ms: <div><p class="error">表示できませんでした:Failed to fetch dynamically imported module</p></div>

errorComponent を指定しておくと、エラーの内容を error props で受け取って表示できます。 指定しなかった場合は、コンソールに [Vue warn]: Unhandled error during execution of async component loader が出て、画面は「読み込み中...」のままになりました。loadingComponent を付けるなら errorComponent も組で用意するのがおすすめです。

一時的な通信の乱れに備えて、何回かやり直すこともできます。onError の引数は (error, retry, fail, attempts) です。

let count = 0
const Async = defineAsyncComponent({
  // 2回目まではエラー、3回目で読み込めるという状況を再現
  loader: () => {
    count++
    console.log(`loader 呼び出し ${count} 回目`)
    return count < 3 ? Promise.reject(new Error('一時的なエラー')) : Promise.resolve(TabArchive)
  },
  errorComponent: LoadError,
  onError(error, retry, fail, attempts) {
    console.log(`onError: attempts=${attempts} message=${error.message}`)
    if (attempts <= 2) retry() // 2回まではやり直す
    else fail() // それ以上は errorComponent を表示
  },
})
loader 呼び出し 1 回目
onError: attempts=1 message=一時的なエラー
loader 呼び出し 2 回目
onError: attempts=2 message=一時的なエラー
loader 呼び出し 3 回目
表示: <div><p>アーカイブ:3件の記事があります。</p></div>

Vue 2 の書き方との対応

以前の書き方から移すときの対応です(Vue 2 公式ガイド「動的 & 非同期コンポーネント」と Vue 3 公式ガイド「非同期コンポーネント」の記載による)。

項目 Vue 2 Vue 3
定義のしかた () => import('./X.vue') をそのまま登録 defineAsyncComponent(() => import('./X.vue'))
読み込む関数 component loader
読み込み中の部品 loading loadingComponent
エラー時の部品 error errorComponent
待ち時間・制限時間 delay(既定 200)・timeout delay(既定 200)・timeout(既定 Infinity)

Vue 3.5 以降では、サーバーサイドレンダリングと組み合わせる場合に向けて、hydrateOnIdle・hydrateOnVisible・hydrateOnMediaQuery・hydrateOnInteraction という遅延ハイドレーションの指定も公式ガイドに載っています。また、非同期コンポーネントは組み込みの Suspense と一緒に使うこともできます。

全部入り:遅延読み込み+KeepAliveのタブ

ここまでの内容をまとめた、応用版のタブです。最初のタブだけ通常の import にし、残りは開かれたときに読み込みます。

<!-- LazyTabs.vue -->
<script setup>
import { ref, computed, defineAsyncComponent } from 'vue'
import TabHome from './TabHome.vue'
import Loading from './Loading.vue'
import LoadError from './LoadError.vue'

// 最初に見えるタブは通常の import、あとから開くタブは必要になってから読み込む
const lazy = (loader) =>
  defineAsyncComponent({
    loader,
    loadingComponent: Loading,
    errorComponent: LoadError,
    delay: 200,
    timeout: 10000,
  })

const tabs = [
  { key: 'home', label: 'ホーム', component: TabHome },
  { key: 'posts', label: '投稿', component: lazy(() => import('./TabPosts.vue')) },
  { key: 'archive', label: 'アーカイブ', component: lazy(() => import('./TabArchive.vue')) },
]
const currentKey = ref('home')
const currentTab = computed(() => tabs.find((t) => t.key === currentKey.value))
</script>

<template>
  <div role="tablist">
    <button
      v-for="tab in tabs"
      :key="tab.key"
      role="tab"
      :aria-selected="tab.key === currentKey"
      @click="currentKey = tab.key"
    >
      {{ tab.label }}
    </button>
  </div>
  <div role="tabpanel">
    <KeepAlive>
      <component :is="currentTab.component" :key="currentKey" />
    </KeepAlive>
  </div>
</template>

ビルド結果:タブごとにファイルが分かれる

vite build の出力です。import() で書いたタブは、別のファイルに分けて出力されています。

vite v8.3.2 building client environment for production...
✓ 17 modules transformed.
dist/index.html                      0.21 kB │ gzip:  0.16 kB
dist/assets/TabArchive-CGGGUDJJ.js   0.20 kB │ gzip:  0.20 kB
dist/assets/TabPosts-BF-D9uw4.js     0.34 kB │ gzip:  0.30 kB
dist/assets/index-IC_9Srsp.js       70.48 kB │ gzip: 27.95 kB

Chromiumで開いたときの読み込みと表示

ビルドしたものを Chromium で開き、タブを順にクリックして、読み込まれた JavaScript ファイルと tabpanel の中身を記録しました。

初期表示: <p>ホーム:お知らせはありません。</p>
--- 「投稿」をクリック
  読み込み: /assets/TabPosts-[hash].js
表示: <div><p>投稿:下書きを書いてください。</p><input placeholder="下書き"></div>
--- 「アーカイブ」をクリック
  読み込み: /assets/TabArchive-[hash].js
表示: <p>アーカイブ:3件の記事があります。</p>
--- 「投稿」に戻る
入力値 = "あとで書く"

タブを押した時点で初めてそのタブのファイルが読み込まれ、「投稿」に戻ったときは再読み込みなしで、入力した「あとで書く」も残っています。 非同期コンポーネントと KeepAlive は、そのまま組み合わせて使えます。

同じファイルを通常の import と import() の両方で読むと分かれない

確認の途中で、TabPosts.vue を別のファイルから通常の import でも読み込んでいたところ、ビルド時に次のメッセージが出て、TabPosts のファイルは分かれませんでした。

[INEFFECTIVE_DYNAMIC_IMPORT] src/TabPosts.vue is dynamically imported by src/LazyTabs.vue but also statically imported by src/RefTabs.vue, dynamic import will not move module into another chunk.

遅延読み込みにしたいコンポーネントは、ほかの場所で通常の import をしていないかを確かめておくと、意図どおりに分割されます。

v-ifと動的コンポーネントの使い分け

状況 向いている書き方 理由
切り替える候補が2つ v-if/v-else テンプレートを読めば何が出るかすぐわかる
候補が3つ以上、または増える見込み component :is 対応表に1行足すだけで増やせる
候補ごとに props やスロットが大きく違う v-if を並べる それぞれに合った書き方ができる
表示する部品がデータ(API・設定)で決まる component :is+対応表 名前からコンポーネントを引ける
表示・非表示を頻繁に切り替えるだけ v-show DOM を残したまま見た目だけ切り替える

つまずきやすい点の早見表

症状 理由 直し方
何も表示されず、警告もない <script setup> で文字列を渡していて、その名前のタグになっている コンポーネントオブジェクトを渡す。文字列なら対応表で引く
Vue received a Component that was made a reactive object コンポーネントを ref/reactive に入れた shallowRef か markRaw
タブを戻ると入力が消える 切り替えのたびに作り直している KeepAlive で囲む
動的な input に v-model が効かない modelValue に展開される :value と @input に分ける
読み込み中の表示が出ない delay(既定 200ms)より早く読み込めている そのままで問題なし。すぐ出したいなら delay: 0
エラー時に「読み込み中」のままになる errorComponent がない errorComponent を指定する
ビルドしてもファイルが分かれない 同じファイルを通常の import でも読んでいる 通常の import をなくす

よくある質問

Q. :is には文字列とコンポーネントのどちらを渡すのがよいですか? A. <script setup> では、import したコンポーネントオブジェクトを渡すのが基本です。文字列で渡して働くのは、app.component や components オプションで登録した名前と、HTML のタグ名です。名前の文字列しか手元にない場合は、名前をキーにしたオブジェクトからコンポーネントを引いて渡します。

Q. shallowRef と markRaw はどう使い分けますか? A. 「いまのコンポーネント」を1つ入れ替えるだけなら shallowRef です。タブ一覧のように、リアクティブな配列やオブジェクトの中にコンポーネントを入れる場合は、そのコンポーネントを markRaw で包みます。タブ一覧が固定なら、ふつうの配列にして文字列のキーだけを ref にする方法がいちばん手軽です。

Q. 動的コンポーネントに切り替えるとき、前のコンポーネントはどうなりますか? A. 破棄されて、次に表示するときに新しく作られます。そのため入力値などの状態は初期値に戻ります。状態を残したいときは KeepAlive で囲んでください。この記事の例では、囲んだ場合だけ「あとで書く」という入力が残りました。

Q. defineAsyncComponent の読み込み中の表示が一瞬も出ません。 A. delay の既定値が 200ms のため、それより早く読み込みが終わると表示されません。150ms で読み込みが終わる例では、読み込み中の表示は一度も出ませんでした。必ず出したい場合は delay: 0 を指定します。

まとめ

  • 動的コンポーネントは component タグの :is に渡した値で表示を差し替えるしくみ。タブは「文字列のキー+対応表」で持つと書きやすくなります。
  • <script setup> ではコンポーネントオブジェクトを渡す。登録していない名前の文字列を渡すと、その名前のタグとして出力され、警告も出ません。
  • ref にコンポーネントを入れると警告が出るので、入れ替えるだけなら shallowRef、配列やオブジェクトの中では markRaw を使います。
  • props・イベント・スロットは component タグに書けばそのまま渡る。動的なネイティブ要素の v-model は :value と @input に分けます。
  • KeepAlive で囲むと、切り替えても入力状態が残る。非同期コンポーネントとも一緒に使えます。
  • defineAsyncComponent は開かれたときに読み込む。delay は既定 200ms、timeout は既定で制限なし。loadingComponent と errorComponent は組で用意します。

参考・出典

  • Vue.js 公式 API リファレンス「ビルトインの特別な要素」component の項(ja.vuejs.org/api/built-in-special-elements)
  • Vue.js 公式ガイド「非同期コンポーネント」(ja.vuejs.org/guide/components/async)
  • Vue.js 公式ガイド「KeepAlive」(ja.vuejs.org/guide/built-ins/keep-alive)
  • Vue 2 公式ガイド「動的 & 非同期コンポーネント」(v2.ja.vuejs.org/v2/guide/components-dynamic-async)

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

(記載例)出典:株式会社RJC「Vue動的コンポーネントの使い方|component :isでタブ切り替え」

株式会社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で一緒に開発しながら、 成長を楽しみませんか?