1763 字
9 分钟
Vue 3 的动态组件、可选链与空值合并

JavaScript 的可选链与空值合并#

?. 和 ?? 是 JavaScript 的运算符,不是 Vue 的 API。模板里读嵌套数据几乎离不开它们。

安全读取嵌套属性#

读链式属性时,链上任意一环是 null 或 undefined,整个表达式就短路返回 undefined,不再往后读,也不会抛错。

嵌套数据长这样:

const user = {
info: {
address: {
city: '北京'
}
}
}

不加 ?.,链上某一环没值就直接报错,后面的代码不再执行:

// TypeError: Cannot read properties of undefined
const city = user.info.address.city

补上之后可以安全读到最后一层:

const city = user?.info?.address?.city

规则只有两条:前面是 null 或 undefined 就停止读取,返回 undefined;前面有值就继续往后取。

四种写法:

obj?.a?.b?.c // 读深层属性
arr?.[0] // 读数组元素
fn?.() // 调用方法前先判断
obj?.[key] // 读动态 key

递归组件里判断子节点是常见用法:

<TreeItem v-if="item?.children?.length" />

item 不存在时表达式返回 undefined,v-if 为假,不渲染;item 存在但 children 是空数组时 length 为 0,同样不渲染。

这段判断等价于 if (item && item.children && item.children.length)。

默认值兜底#

|| 会把 0、空字符串和 false 一起当成空值:

const num = 0
console.log(num || 100) // 100,0 被当成空值
console.log(num ?? 100) // 0,只判断 null 和 undefined

?? 只把 null 和 undefined 当成空值,其他值原样返回,0、空字符串和 false 都是有效值。

组合使用#

两者一起用是最常见的兜底写法:能安全读到值就用它,读到 undefined 就补默认值。

const city = user?.info?.address?.city ?? '未知城市'
const name = data?.user?.name ?? '匿名用户'

组件里给 prop 补默认值用的是 withDefaults,它只处理 prop 没有传的情况:

const props = withDefaults(defineProps<{
count?: number
}>(), {
count: 0
})

动态组件#

动态组件让多个组件共用同一个挂载点,由数据决定当前渲染哪一个,靠内置的 <component> 标签和 is 属性实现。官方文档见 动态组件。

基本写法#

App.vue
<script setup>
import { shallowRef } from 'vue'
import A from './A.vue'
import B from './B.vue'
const currentComp = shallowRef(A)
</script>
<template>
<component :is="currentComp" />
</template>
  • <component> 是内置标签,本身不渲染内容,渲染谁由 is 决定。
  • is 的值可以是组件对象、注册过的组件名字符串,也可以是一个 ref,模板里会自动解包。
  • 换掉 currentComp.value,挂载点里的组件跟着换。

这里用 shallowRef 而不是 ref,原因见“组件不要放进响应式代理”一节。

典型场景#

  • Tab 切换
  • 步骤表单
  • 低代码平台和动态仪表盘
  • 权限控制下的动态视图

组件名与组件对象#

is 接受两种值:组件对象,或者字符串。字符串既可以是注册过的组件名,也可以是原生 HTML 标签名,例如 :is="'div'"。Vue 2 和 Vue 3 在这点上一致。

在 <script setup> 里通常传组件对象,原因是 import 进来的组件只是当前作用域里的变量,没有注册到任何名字下,用名字找不到它。

// 选项式 API:组件注册过名字,可以传名字
export default {
components: { A, B },
data() {
return { compName: 'A' }
},
}
<!-- script setup:没有注册名,直接传 import 进来的对象 -->
<component :is="A" />
<table>
<tr is="vue:my-row"></tr>
</table>

组件不要放进响应式代理#

组件定义是静态对象,不需要响应式。把它交给 reactive 或 ref,Vue 会多做一层深层代理,开发环境还会打印一条警告:

这条消息来自 packages/runtime-core/src/vnode.ts 里的 createVNode。开发构建里,Vue 会顺带调用 toRaw(type) 把它还原成原始对象;生产构建没有这一步,组件仍然带着这层代理,多出来的代理就是额外的开销。

markRaw#

markRaw 把一个对象标记成永远不转为响应式,并返回这个对象本身。放在集合里的组件都用它包一层:

import { markRaw, reactive } from 'vue'
import A from './A.vue'
import B from './B.vue'
const tabList = reactive([
{ name: 'A 组件', comp: markRaw(A) },
{ name: 'B 组件', comp: markRaw(B) },
])

标记跟着对象本身走,之后 reactive 遇到它就直接原样返回。

shallowRef#

shallowRef 只监听 .value 有没有被替换,不把内部对象转成代理,适合单独存一个当前组件:

import { shallowRef } from 'vue'
import A from './A.vue'
import B from './B.vue'
const currentComp = shallowRef(A)
currentComp.value = B // 触发更新,挂载点换成 B

要放进集合的组件用 markRaw,只存单个引用时用 shallowRef。

完整示例#

三个文件构成一个可以运行的例子:两个标签页子组件,一个父组件负责切换、传参和缓存。

src/
├── components/
│ ├── TabA.vue
│ └── TabB.vue
└── App.vue

TabA 有一个输入框和一个按钮,按钮向上抛出事件:

components/TabA.vue
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{
msg: string
}>()
const emit = defineEmits<{
(e: 'send', val: string): void
}>()
const inputVal = ref('')
const emitData = () => {
emit('send', '我是 A 组件的数据')
}
</script>
<template>
<div class="box">
<h3>组件 A</h3>
<p>父组件传值:{{ msg }}</p>
<input v-model="inputVal" placeholder="缓存测试" />
<button @click="emitData">向父传值</button>
</div>
</template>
<style scoped>
.box { padding: 20px; border: 1px solid #ccc; }
</style>

TabB 只有一个 textarea,用来验证切换后输入的内容还在:

components/TabB.vue
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{
msg: string
}>()
const text = ref('')
</script>
<template>
<div class="box">
<h3>组件 B</h3>
<p>父组件传值:{{ msg }}</p>
<textarea v-model="text" placeholder="切换不丢失内容"></textarea>
</div>
</template>
<style scoped>
.box { padding: 20px; border: 1px solid #ccc; margin-top: 10px; }
</style>

父组件用 markRaw 存列表、用 shallowRef 存当前组件,<KeepAlive> 包住挂载点:

App.vue
<script setup lang="ts">
import { markRaw, reactive, shallowRef } from 'vue'
import TabA from './components/TabA.vue'
import TabB from './components/TabB.vue'
// 组件放在列表里,用 markRaw 挡住 reactive 的深层代理
const tabList = reactive([
{ name: 'A 页面', comp: markRaw(TabA) },
{ name: 'B 页面', comp: markRaw(TabB) },
])
// 只存当前组件,用 shallowRef 监听 .value 的替换
const currentComp = shallowRef(TabA)
const handleReceive = (val: string) => {
console.log('父组件收到:', val)
}
</script>
<template>
<button
v-for="item in tabList"
:key="item.name"
:class="{ active: currentComp === item.comp }"
@click="currentComp = item.comp"
>
{{ item.name }}
</button>
<KeepAlive>
<component
:is="currentComp"
msg="父组件下发的文本"
@send="handleReceive"
/>
</KeepAlive>
</template>
<style>
button.active { background: #409eff; color: #fff; border: none; padding: 4px 8px; margin: 0 4px; }
</style>

用 KeepAlive 缓存状态#

动态组件切换时默认销毁旧组件,再切回来会重新创建。用 <KeepAlive> 包住挂载点,Vue 会缓存组件实例:

<KeepAlive>
<component :is="currentComp" />
</KeepAlive>

缓存下来的是组件实例和它内部的状态,输入框里已经输入的内容和组件里的 ref 都会留着,切回来直接复用。

组件被缓存和重新激活时会触发 onActivated 和 onDeactivated。include 和 exclude 按组件名决定哪些组件进入缓存,max 限制缓存数量,超出上限时淘汰最久没有访问的实例。

官方文档见 KeepAlive。

第 12 篇,共 19 篇本文属于系列

Vue 3 的动态组件、可选链与空值合并
https://www.choria.top/posts/vue3-components-2/
作者
乔瑞雅·克林威尔
发布于
2026-04-14
许可协议
CC BY-NC-SA 4.0

分享文章

生成精美分享图或复制链接,与更多人分享本文。

继续阅读

沿着主题读

基于共同的标签与分类

换条路线

从其他文章中稳定抽取

最后更新于 ,距今已过 208 天

部分内容可能已过时