Skip to content非 Prop 属性与
自定义事件:
组件上的
动态组件与
组件化:从模板片断到可复用单元
前面的章节覆盖了模板语法和指令,它们解决的是单个页面内部的 DOM 描述与数据绑定。当页面规模扩张,把所有模板、数据和逻辑堆在同一个组件里,状态散落各处,交互互相干扰,改一处可能影响一片——这跟直接操作 DOM 时面临的问题是同构的。把界面拆成独立的组件单元,是 Vue 处理这个问题的核心机制。
组件不是把 HTML 切成几块。组件把模板、逻辑和样式封装成一个自包含的单元,每个单元拥有自己的数据、自己的模板和自己的行为,对外暴露有限的接口。这意味着组件可以像积木一样组合、复用、替换,不需关心内部实现。
在 Vue 开发中,大部分工作落在设计和实现组件上——按钮、表单域、对话框、页面。这些组件层层嵌套,构成一棵组件树。理解组件的定义、注册、传参和通信方式,是进入实际项目的第一步。
单文件组件:template / script / style
最常用的定义方式是把组件写在一个 .vue 文件里,即单文件组件(SFC)。一个 .vue 文件包含三个顶层块:
vue
<template>
<div class="counter">
<p>{{ count }}</p>
<button @click="increment">+1</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const count = ref(0)
function increment() {
count.value++
}
</script>
<style scoped>
.counter {
border: 1px solid #ccc;
padding: 1rem;
}
</style><template> 中是组件最终渲染的 HTML 结构,编译时被预编译为渲染函数。<script> 块作为 ES 模块执行,在使用 <script setup> 时直接运行组合式 API 逻辑,顶层导入的组件在模板中自动可用,无需显式注册。没有构建步骤的环境下,可以通过普通的 <script> 块导出一个选项对象,模板改用 template 选项指定或直接写在 HTML 中。<style scoped> 把样式限制在当前组件范围,不会泄漏到外部。
对于绝大多数现代工程,单文件组件配合构建工具是标准做法。
组件注册:全局与局部
组件定义之后,必须在父组件中注册才能使用。注册方式分为局部注册和全局注册。
局部注册
在父组件的 <script> 中导入子组件。使用 <script setup> 时,导入的组件即可在模板中直接使用:
vue
<!-- Parent.vue -->
<template>
<ChildComponent />
</template>
<script setup>
import ChildComponent from './Child.vue'
</script>局部注册的组件只存在于当前组件的作用范围,不会被暴露到全局。依赖关系显式可控,打包工具也更容易进行 tree-shaking。项目中应优先使用局部注册。
全局注册
对于在页面中大量出现的通用组件——比如统一按钮、图标——逐文件导入注册会显得繁琐。此时可以通过 app.component() 把组件挂载到应用实例上,使其在所有组件中直接可用:
js
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import BaseButton from './components/BaseButton.vue'
const app = createApp(App)
app.component('BaseButton', BaseButton)
app.mount('#app')全局注册之后,任何组件模板中都可以直接写 <BaseButton />。代价是依赖关系不再显式可见,而且全局注册的组件即便不再使用,打包时也难以被剔除。一般只对真正高频出现的组件做全局注册。
Props:类型校验、默认值与单向数据流
组件内部的数据默认是私有的。父组件向子组件传递数据必须通过 props。可以把 props 视为组件的公开接口参数。
子组件使用 defineProps 声明自己接受哪些属性,并可附加类型、默认值与校验规则:
vue
<!-- UserCard.vue -->
<template>
<div class="card">
<p>{{ name }} - {{ age }}</p>
</div>
</template>
<script setup>
defineProps({
name: {
type: String,
required: true
},
age: {
type: Number,
default: 20,
validator(value) {
return value > 0
}
}
})
</script>父组件调用时传入对应属性:
vue
<UserCard name="张三" :age="25" />若传入的类型与声明不匹配,Vue 在开发模式下抛警告。default 在 prop 未被传值时提供初始值;validator 可自定义校验逻辑,返回 false 时同样触发警告。
单向数据流
父组件的更新向下流动到子组件,子组件不应直接修改 props 的值。如果子组件内部试图对 props.name 重新赋值,Vue 会发出警告。这条约束是为了保持数据流动方向清晰,防止父子组件互相影响导致状态难以追踪。
如果子组件确实需要基于 prop 做内部修改,应当将 prop 的值拷贝到本地状态:
js
import { ref } from 'vue'
const props = defineProps(['age'])
const internalAge = ref(props.age)之后修改 internalAge 不会回写父组件的 age。
非 Prop 属性与 $attrs
在父组件中传给子组件的属性,如果子组件没有通过 props 声明接收,就会成为“非 Prop 属性”。这些属性默认自动绑定到子组件的根元素上。
子组件只有一个根元素 <div>:
vue
<template>
<div class="box">Hello</div>
</template>父组件这样调用:
vue
<Child class="outer" id="my-box" />最终渲染出的 HTML 中,额外的 class 与 id 被合并到了根元素:
html
<div class="box outer" id="my-box">Hello</div>如果不需要这些属性透传到根元素,可以设置 inheritAttrs: false,然后通过 $attrs 对象手动控制它们绑定到哪个元素。这在封装基础组件时很实用——比如封装原生 <input> 时,希望 placeholder、type 等属性直接落到内部的 <input> 上,而不是外层容器:
vue
<template>
<div>
<input v-bind="$attrs" />
</div>
</template>
<script setup>
defineOptions({ inheritAttrs: false })
defineProps(['label'])
</script>这样父组件传入的 type="number" placeholder="输入数字" 等属性会直接绑定到 <input> 上。
自定义事件:$emit 与 emits 选项
Props 让数据向下流动。子组件向父组件通知变化时,依靠自定义事件,形成“props down, events up”的模式。
子组件内部通过 $emit 触发事件,父组件用 @ 监听:
vue
<!-- ConfirmButton.vue -->
<template>
<button @click="$emit('confirm', 'ok')">确认</button>
</template>父组件:
vue
<ConfirmButton @confirm="handleConfirm" />点击按钮时,handleConfirm('ok') 被调用,参数 'ok' 是子组件传递的数据。
为了让子组件发出的事件具备约束和文档化,Vue 3 提供了 emits 选项。在 <script setup> 中使用 defineEmits 声明:
vue
<script setup>
const emit = defineEmits(['confirm', 'cancel'])
// 也可对事件携带的载荷进行校验
const emit2 = defineEmits({
confirm: (payload) => typeof payload === 'string',
cancel: null
})
function handleClick() {
emit('confirm', 'ok')
}
</script>如果子组件试图触发一个未声明的事件,或校验函数返回 false,开发环境下会得到警告。此外,声明了 emits 后,这些事件会从 $attrs 中移除,避免被当作非 Prop 属性透传到根元素。
组件上的 v-model
v-model 在原生表单元素上创建双向绑定。放在自定义组件上时,它依赖一套约定:默认情况下绑定一个名为 modelValue 的 prop,并监听 update:modelValue 事件。
自定义输入组件只要接受 modelValue prop 并在值变化时触发 update:modelValue 事件,父组件就可以直接使用 v-model 绑定:
vue
<!-- CustomInput.vue -->
<template>
<input
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
/>
</template>
<script setup>
defineProps(['modelValue'])
defineEmits(['update:modelValue'])
</script>父组件:
vue
<CustomInput v-model="text" />等价于:
vue
<CustomInput :modelValue="text" @update:modelValue="text = $event" />这样就完成了一个支持 v-model 的表单控件封装。在 Vue 3.4 及以上版本,还可以使用 defineModel 简化上述写法。
如果需要同时绑定多个值,可以使用带参数的 v-model:xxx:
vue
<CustomField v-model:title="pageTitle" v-model:content="pageContent" />组件内部对应的 prop 和事件分别为 title / update:title 与 content / update:content。这种方式让组件可以暴露多个双向绑定接口,适合复合型表单组。
插槽:内容分发的三种形式
props 传递的是数据,当父组件需要在子组件内部嵌入不同的模板片段时,数据传递就不够用了。插槽(slot)用来解决这个问题。
默认插槽
子组件在模板中用 <slot></slot> 预留位置,父组件写在组件标签内部的内容会替换掉这个位置。
子组件 Modal.vue:
vue
<template>
<div class="modal">
<slot></slot>
</div>
</template>父组件:
vue
<Modal>
<p>这是模态框的内容</p>
</Modal>渲染后 <p> 出现在 <div class="modal"> 内部。
具名插槽
当子组件有多个区域需要父组件定制时,可以通过 <slot name="xxx"> 定义不同出口。父组件使用 v-slot:xxx(或缩写 #xxx)分发内容。
子组件 Layout.vue:
vue
<template>
<header>
<slot name="header"></slot>
</header>
<main>
<slot></slot>
</main>
<footer>
<slot name="footer"></slot>
</footer>
</template>父组件调用:
vue
<Layout>
<template #header>
<h1>页面标题</h1>
</template>
<p>主体内容</p>
<template #footer>
<small>版权信息</small>
</template>
</Layout>未指定名称的内容全部被放入默认插槽。
作用域插槽
父组件有时需要用子组件内部的数据来决定渲染逻辑。此时子组件可以把数据绑定到 <slot> 上暴露出去,父组件通过 v-slot 接收这些数据。
子组件 List.vue:
vue
<template>
<ul>
<li v-for="item in items" :key="item.id">
<slot :item="item" :index="index"></slot>
</li>
</ul>
</template>
<script setup>
defineProps(['items'])
</script>父组件:
vue
<List :items="products">
<template #default="{ item, index }">
<span>{{ index + 1 }}. {{ item.name }} - {{ item.price }}</span>
</template>
</List>插槽从子组件拿到了 item 和 index,父组件可据此灵活控制每一项的渲染方式,而子组件无需预先知道具体展现逻辑。
动态组件与 <KeepAlive>
有些场景需要根据状态在不同组件之间切换。用 v-if 加多个分支可以实现,但每次切换都会销毁旧组件实例并创建新实例,组件内部的输入状态、滚动位置等全部丢失。
<component :is="..."> 用来动态渲染组件:
vue
<template>
<button @click="currentTab = 'Home'">首页</button>
<button @click="currentTab = 'Profile'">个人</button>
<component :is="currentTab" />
</template>
<script setup>
import { ref } from 'vue'
import Home from './Home.vue'
import Profile from './Profile.vue'
const currentTab = ref('Home')
</script>is 属性可以绑定组件对象或已注册组件的名称字符串。切换时,<component> 内部同样会销毁旧实例并创建新实例。
如需保留被切走的组件状态,可以用 <KeepAlive> 包裹:
vue
<KeepAlive>
<component :is="currentTab" />
</KeepAlive><KeepAlive> 缓存被移除的组件实例,下次切换回来时激活缓存的实例而非重新创建,之前的状态仍然保持。可配合 include / exclude 属性指定哪些组件需要缓存:
vue
<KeepAlive include="Home,Profile">
<component :is="currentTab" />
</KeepAlive>注意 <KeepAlive> 缓存的是组件实例,滥用会导致内存中滞留大量实例。
组件通信模式
从前面各节已经可以看出几种通信路径:
- 父子通信:父组件通过 props 向子组件传递数据,子组件通过
$emit触发事件向父组件上报。这是最直接、最可控的方式。 - 兄弟组件通信:两个不直接关联的组件如果共享状态,需要把状态提升到它们共同的父组件,再由父组件通过 props 向下分发、通过事件向上收集变化。这就是“状态提升”。当共享状态复杂到一定程度,可以引入状态管理库。
- 跨层级通信:组件层级较深时,从根组件层层透传 props 到深层后代组件会很繁琐。此时可用
provide和inject实现依赖注入,祖先组件提供数据,后代组件注入数据,中间层无需逐层传递:
vue
<!-- 祖先组件 -->
<script setup>
import { provide, ref } from 'vue'
const theme = ref('dark')
provide('theme', theme)
</script>vue
<!-- 深层后代组件 -->
<script setup>
import { inject } from 'vue'
const theme = inject('theme', 'light')
</script>provide 的数据本身不是响应式的,但如果提供的是一个 ref 或 reactive 对象,后代组件注入的是同一个引用,因此同样能感知变化。provide/inject 适用于主题、语言环境、配置等应用级上下文,不适合用于复杂的状态管理——大量随意使用会让数据流变得难以追踪。
综合示例:组合 Props、事件与插槽
以下是一个搜索选择器 SearchSelect 组件,综合使用 props、事件和插槽。
组件接收一个选项列表,用户可输入搜索词过滤选项,点击某一项后触发选中事件;未找到结果时通过插槽自定义空状态。
vue
<!-- SearchSelect.vue -->
<template>
<div class="search-select">
<input v-model="keyword" placeholder="搜索..." />
<ul>
<li
v-for="item in filteredList"
:key="item.id"
@click="$emit('select', item)"
>
<slot name="item" :item="item">
{{ item.label }}
</slot>
</li>
<li v-if="filteredList.length === 0">
<slot name="empty">
无结果
</slot>
</li>
</ul>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
options: {
type: Array,
required: true,
validator: (arr) => arr.every((obj) => obj.id && obj.label)
}
})
defineEmits(['select'])
const keyword = ref('')
const filteredList = computed(() =>
props.options.filter((item) =>
item.label.includes(keyword.value)
)
)
</script>父组件调用:
vue
<template>
<SearchSelect :options="users" @select="onSelect">
<template #item="{ item }">
<strong>{{ item.label }}</strong> <small>({{ item.id }})</small>
</template>
<template #empty>
<em>没有匹配的用户</em>
</template>
</SearchSelect>
</template>
<script setup>
import { ref } from 'vue'
import SearchSelect from './SearchSelect.vue'
const users = ref([
{ id: 1, label: 'Alice' },
{ id: 2, label: 'Bob' },
{ id: 3, label: 'Charlie' }
])
function onSelect(user) {
console.log('selected:', user)
}
</script>这个示例集中展示了:
defineProps接收options并做了类型和自定义校验(要求每项都有id和label)。- 组件内部维护搜索关键词
keyword,通过computed过滤选项列表。 $emit('select', item)将选中项传回父组件。- 具名插槽
item和empty让父组件定制展示,并通过作用域插槽传递item数据。 - 父组件使用
#item和#empty进行内容分发。
组件命名注意点
- 在单文件组件中,推荐为子组件使用 PascalCase 文件名和标签名,如
UserCard.vue、<UserCard />,以便与原生 HTML 元素区分。如果在 DOM 中直接书写模板(不经构建步骤),则必须使用 kebab-case 标签名并显式关闭标签,例如<user-card></user-card>。 - 避免巨型组件。单个组件超过几百行或职责混杂时,应当拆分成更小的子组件。
- Props 设计尽量保持简单和扁平,避免把整个对象当作一个 prop 丢进去。接口清晰,单元测试也更好写。
- 自定义事件采用 kebab-case 命名,如
update:modelValue,与原生事件命名习惯一致。 - 不要直接修改 props。遇到需要修改的情况,先考虑是否应通过事件通知父组件修改,或使用本地副本。
