- Vue.js
Vue動的コンポーネントの使い方|component :isでタブ切り替え
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期連続の黒字決算。