摘要: 组件化是Vue的灵魂,也是现代前端开发的基石。前两篇我们掌握了响应式数据和模板语法,本篇将正式踏入组件化的世界。我们将从"为什么需要组件"讲起,逐步拆解父子组件通信的两大支柱------Props(父传子)和Emits(子传父),并学习如何使用TypeScript为它们添加编译时的安全保障。随后深入Vue强大的插槽系统(默认插槽、具名插槽、作用域插槽),理解内容分发与作用域穿透的精妙设计。最后,我们将全面掌握组件生命周期的各个阶段,知道"什么时候该做什么事"。通过升级版的待办事项应用,你将亲手把这些知识整合成一个结构清晰、职责分明的组件树。
一、组件:前端开发的"乐高积木"
1.1 什么是组件?
如果把网页比作一栋房子,组件就是砖、门窗和家具。组件是一个独立、可复用、可组合的UI单元,它封装了自己的模板、逻辑和样式。当你需要一扇窗户时,不是重新画一遍,而是直接拿一个"窗户组件"装上去,并告诉它尺寸、颜色(Props),它也能告诉你何时被打开(Emits)。
1.2 为什么需要组件化?
你已经写过一个Todo应用,所有代码挤在一个App.vue里。当应用扩大到几十个页面、数百个功能时,单文件将变得不可维护。组件化带来三大好处:
-
复用性:一个按钮组件可以在多处使用,一处修改全局更新。
-
隔离性:每个组件的样式和逻辑互不污染,出了问题容易定位。
-
可测试性:独立的小组件单元测试更容易编写。
1.3 Vue单文件组件的三件套
上一篇我们已经见过.vue文件,它由三个块组成:
<template> ← 结构(HTML)
<script> ← 逻辑(JS/TS)
<style> ← 样式(CSS)
这种高内聚的设计让你在修改一个组件时,所有相关内容都在一个文件里,不再在HTML/JS/CSS三个文件间来回跳转。
二、组件注册:全局 vs 局部
在Vue 3中,组件有两种注册方式。我们先从项目结构入手,创建一个components文件夹。
2.1 局部注册:按需引入
局部注册是推荐做法,配合<script setup>可以极其简洁地使用:
javascript
<script setup lang="ts">
import MyButton from './components/MyButton.vue'
import MyInput from './components/MyInput.vue'
</script>
<template>
<MyButton />
<MyInput />
</template>
导入后,直接在模板中使用,无需任何额外注册步骤。<script setup>的编译魔法会在背后处理一切。如果未使用setup语法糖,则需要在components选项中显式注册。
组件命名规范:
-
在模板中,我们使用PascalCase(大驼峰)
<MyButton>或kebab-case(短横线)<my-button>都可以。 -
在JavaScript/TypeScript中导入时,通常用PascalCase与构造函数区分,也符合ES模块的命名习惯。
2.2 全局注册:全局可用
某些高频使用的组件(如通用图标、模态框)可以全局注册。在main.ts中:
javascript
import { createApp } from 'vue'
import App from './App.vue'
import GlobalIcon from './components/GlobalIcon.vue'
const app = createApp(App)
app.component('GlobalIcon', GlobalIcon)
app.mount('#app')
现在所有组件中都可以直接使用<GlobalIcon />,无需导入。但全局注册有副作用:即使不用该组件,它也会被打包进来,影响Tree Shaking。所以非必要不全局注册。
三、父传子 Props:让组件"可配置"
Props是组件的输入。它定义了从父组件传递数据给子组件的接口。使用Props,同一个组件可以根据不同输入展现出不同形态。
3.1 Props的基础定义
在子组件中,使用defineProps定义props:
javascript
<!-- UserCard.vue -->
<script setup lang="ts">
const props = defineProps({
name: String,
age: Number,
isActive: Boolean
})
</script>
<template>
<div class="user-card" :class="{ active: isActive }">
<h3>{{ name }}</h3>
<p>年龄:{{ age }}</p>
</div>
</template>
父组件传入:
javascript
<UserCard name="张三" :age="25" :is-active="true" />
注意:非字符串类型的prop需要用v-bind(即冒号)传递,否则会当作字符串。
3.2 使用TypeScript纯类型声明
有了TypeScript,我们可以使用更简洁的纯类型语法:
javascript
<script setup lang="ts">
interface Props {
name: string
age?: number // 可选
isActive?: boolean
tags?: string[] // 数组类型
config?: { // 对象类型
showAvatar: boolean
size: 'small' | 'medium' | 'large'
}
}
const props = withDefaults(defineProps<Props>(), {
age: 0,
isActive: false,
tags: () => [] // 数组/对象默认值必须用工厂函数
})
</script>
defineProps加上泛型<Props>,TypeScript会在编译时检查父组件传入的prop类型是否匹配。withDefaults为可选prop设置默认值,对于数组和对象,必须使用箭头函数返回,避免共享引用。
这就是静态类型的威力:当你传入错误的类型,开发环境会立刻报错,减少运行时bug。
3.3 Props的单向数据流
Vue强调单向数据流:数据从父组件流向子组件,子组件不应直接修改props。这确保了数据流的可预测性。
javascript
<script setup lang="ts">
const props = defineProps<{ count: number }>()
// ❌ 不要这样做!
function badIncrement() {
props.count++ // 报错!props是只读的
}
</script>
如果子组件需要基于prop进行计算或转换,有两种正确做法:
使用计算属性做派生:
javascript
import { computed } from 'vue'
const doubleCount = computed(() => props.count * 2)
将prop作为本地数据的初始值:
javascript
import { ref } from 'vue'
const localCount = ref(props.count)
// 此后操作localCount,不影响父组件
3.4 运行时类型校验
除了TypeScript的编译时检查,Vue还支持运行时校验,提供更详细的警告:
javascript
defineProps({
status: {
type: String as PropType<'active' | 'inactive' | 'pending'>,
required: true,
validator: (value: string) => ['active', 'inactive', 'pending'].includes(value)
}
})
如果父组件传入非法值,控制台会输出明确的警告信息,帮助调试。
四、子传父 Emits:让组件"会说话"
Props解决父→子通信,Emits解决子→父通信。子组件通过触发事件向父组件发送消息,就像孩子喊"爸爸,按钮被点了!"
4.1 基本用法
在子组件中,使用defineEmits声明事件,然后调用emit函数:
javascript
<!-- CounterButton.vue -->
<script setup lang="ts">
const emit = defineEmits(['increase', 'decrease'])
function handleIncrease() {
emit('increase')
}
function handleDecrease() {
emit('decrease')
}
</script>
<template>
<div>
<button @click="handleDecrease">-</button>
<button @click="handleIncrease">+</button>
</div>
</template>
父组件监听事件,就像处理原生DOM事件:
javascript
<CounterButton @increase="total++" @decrease="total--" />
<p>总计:{{ total }}</p>
4.2 带参数的事件
事件可以携带数据:
javascript
<!-- 子组件 -->
<script setup lang="ts">
const emit = defineEmits<{
(e: 'submit', value: string, id: number): void
}>()
function onSubmit() {
emit('submit', 'hello', 123)
}
</script>
<!-- 父组件 -->
<ChildComponent @submit="handleSubmit" />
<script setup lang="ts">
function handleSubmit(value: string, id: number) {
console.log(value, id)
}
</script>
使用TypeScript泛型声明事件,父组件的回调参数类型会自动推断,获得完整的智能提示。
4.3 v-model在组件上的双向绑定
v-model本质是props + emits的语法糖。默认情况下,组件上的v-model使用modelValue作为prop,update:modelValue作为事件。
子组件:
javascript
<script setup lang="ts">
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ (e: 'update:modelValue', value: string): void }>()
function updateValue(event: Event) {
const target = event.target as HTMLInputElement
emit('update:modelValue', target.value)
}
</script>
<template>
<input :value="modelValue" @input="updateValue" />
</template>
父组件:
javascript
<MyInput v-model="username" />
<p>用户名:{{ username }}</p>
Vue 3支持多个v-model绑定,只需指定名字:
javascript
<!-- 子组件 -->
<script setup lang="ts">
defineProps<{ firstName: string; lastName: string }>()
defineEmits<{
(e: 'update:firstName', val: string): void
(e: 'update:lastName', val: string): void
}>()
</script>
<!-- 父组件 -->
<UserForm v-model:first-name="first" v-model:last-name="last" />
这种设计让自定义组件可以像原生表单元素一样使用v-model,极大简化了表单开发。
五、插槽 Slots:内容分发魔法
有时,一个组件不仅需要数据,还需要"内容"------父组件想往子组件里塞一段HTML。插槽就是为此而生。
5.1 默认插槽
最简单的插槽,在子组件中使用<slot>标签作为占位符:
javascript
<!-- Panel.vue -->
<template>
<div class="panel">
<div class="panel-header">标题栏</div>
<div class="panel-body">
<slot />
</div>
</div>
</template>
使用:
javascript
<Panel>
<p>这是放入面板的内容</p>
<button>确认</button>
</Panel>
<Panel>标签之间的所有内容会被投射到<slot />所在位置。
5.2 默认内容
当父组件没有提供内容时,插槽可以显示默认内容:
javascript
<slot>
<span class="placeholder">暂无内容</span>
</slot>
5.3 具名插槽
一个组件可以有多个插槽,用name属性区分:
javascript
<!-- Card.vue -->
<template>
<div class="card">
<header>
<slot name="header" />
</header>
<main>
<slot />
</main>
<footer>
<slot name="footer" />
</footer>
</div>
</template>
使用v-slot指令(简写#)指定内容插入哪个插槽:
javascript
<Card>
<template #header>
<h2>卡片标题</h2>
</template>
<p>这是主体内容,默认插槽</p>
<template #footer>
<button>操作按钮</button>
</template>
</Card>
注意 :v-slot只能用在<template>标签或组件标签上。默认插槽的隐式名称是default。
5.4 作用域插槽:插槽中的数据回传
当子组件有一些数据需要暴露给父组件的插槽内容时,可以使用作用域插槽。这解决了"内容由父组件定义,但数据在子组件"的矛盾。
子组件:
javascript
<!-- List.vue -->
<script setup lang="ts">
import { ref } from 'vue'
interface Item {
id: number
name: string
score: number
}
const items = ref<Item[]>([
{ id: 1, name: '张三', score: 95 },
{ id: 2, name: '李四', score: 82 }
])
</script>
<template>
<ul>
<li v-for="item in items" :key="item.id">
<slot name="item" :item="item" :index="item.id" />
</li>
</ul>
</template>
父组件使用:
javascript
<List>
<template #item="{ item, index }">
<span class="rank">{{ index }}</span>
<span>{{ item.name }}</span>
<span :class="{ excellent: item.score >= 90 }">
{{ item.score }}分
</span>
</template>
</List>
子组件通过slot的属性绑定向父组件传递数据,父组件用解构语法接收。这种模式是无渲染组件 的基础------只提供逻辑,不提供UI,渲染完全由父组件决定。像Vue Router的<router-link>和Element Plus的表格自定义列都大量使用了作用域插槽。
六、生命周期:组件的生老病死
每个Vue组件实例从创建到销毁,会经历一系列预定义的步骤,称为生命周期 。在这些步骤的间隙,Vue暴露了生命周期钩子函数,让你可以在特定时刻注入自己的代码。
6.1 生命周期的几个阶段
用人的一生类比:
创建期(setup):组件实例初始化,响应式数据建立。
挂载期(onBeforeMount → onMounted):组件被插入到DOM树。
更新期(onBeforeUpdate → onUpdated):响应式数据变化导致重新渲染。
销毁期(onBeforeUnmount → onUnmounted):组件从DOM移除并被销毁。
6.2 常用钩子详解
setup()
组合式API的入口,在组件创建之前执行。此时组件实例尚未完全创建,无法访问this(也没有this)。这是初始化响应式数据、计算属性、侦听器的地方。由于<script setup>就是整个脚本块运行在此阶段,你通常不需要显式写setup()。
onMounted
组件挂载到DOM后调用。此时可以:
-
访问DOM元素(通过
ref模板引用) -
发起API请求
-
初始化第三方库(如echarts图表需要DOM容器)
javascript
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const canvasRef = ref<HTMLCanvasElement | null>(null)
onMounted(() => {
// canvasRef.value 现在可以安全使用
const ctx = canvasRef.value?.getContext('2d')
// ...
})
</script>
<template>
<canvas ref="canvasRef" />
</template>
onBeforeUnmount / onUnmounted
组件被移除前/后调用。适合做清理工作:
javascript
import { onBeforeUnmount } from 'vue'
let timer: number
onMounted(() => {
timer = window.setInterval(() => {
console.log('tick')
}, 1000)
})
onBeforeUnmount(() => {
clearInterval(timer) // 防止内存泄漏
})
清理操作至关重要,尤其是定时器、事件监听、WebSocket连接等,否则会造成内存泄漏。
onUpdated
组件重新渲染后调用。一般用于需要访问更新后DOM的场景。初次渲染不会触发。
6.3 生命周期的父子和兄弟关系
父子组件的挂载顺序:
javascript
父 setup → 父 onBeforeMount
→ 子 setup → 子 onBeforeMount → 子 onMounted
→ 父 onMounted
子组件的mounted在父组件之前完成,确保父组件挂载时子组件已经就位。
更新时:
javascript
父 onBeforeUpdate
→ 子 onBeforeUpdate → 子 onUpdated
→ 父 onUpdated
6.4 与Vue 2的差异
如果你看过Vue 2的文档,会发现钩子名称变了:
-
beforeCreate/created→ 被setup()替代 -
beforeMount→onBeforeMount -
mounted→onMounted -
beforeUpdate→onBeforeUpdate -
updated→onUpdated -
beforeDestroy→onBeforeUnmount -
destroyed→onUnmounted
统一加了on前缀,更清晰,都是从vue中导入的函数。
七、综合案例:升级待办事项组件树
让我们将之前的Todo应用重构为组件树,实践Props、Emits、插槽和生命周期。
7.1 组件结构设计
javascript
src/
├── types.ts # 统一 TS 类型
├── App.vue # 根组件
└── components/
├── TodoHeader.vue # 标题 + 统计
├── TodoInput.vue # 输入添加
├── TodoList.vue # 列表容器(插槽)
├── TodoItem.vue # 单项
└── TodoFooter.vue # 全选 + 清理已完成
7.2 统一类型文件:types.ts
javascript
export interface Todo {
id: number
text: string
done: boolean
}
为什么要用 TS 接口?
答:给待办事项做类型约束 ,避免传错参数、提高代码可维护性,让编辑器有智能提示。
7.3 TodoHeader.vue
javascript
// 接收父组件数据,展示统计信息
<script setup lang="ts">
defineProps<{
activeCount: number
total: number
}>()
</script>
<template>
<div class="todo-header">
<h1>📋 Vue3 Todo 组件树案例</h1>
<p class="stats">
总计 {{ total }} 项 · 未完成 {{ activeCount }} 项
</p>
</div>
</template>
<style scoped>
.todo-header {
text-align: center;
margin-bottom: 24px;
}
.todo-header h1 {
font-size: 26px;
font-weight: 600;
color: #2d3748;
margin: 0 0 8px 0;
}
.stats {
font-size: 14px;
color: #718096;
margin: 0;
}
</style>
7.4 TodoInput.vue
javascript
<script setup lang="ts">
import { ref } from 'vue'
// 输入框,定义自定义事件,输入完成后,向父组件发送数据
const text = ref('')
const emit = defineEmits<{
(e: 'add', text: string): void
}>()
function submit() {
const trimmed = text.value.trim()
if (trimmed) {
emit('add', trimmed)
text.value = ''
}
}
</script>
<template>
<div class="todo-input">
<input
v-model="text"
@keyup.enter="submit"
placeholder="输入新任务,回车添加"
/>
<button @click="submit">添加</button>
</div>
</template>
<style scoped>
.todo-input {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.todo-input input {
flex: 1;
padding: 12px 16px;
border: 1px solid #e2e8f0;
border-radius: 10px;
outline: none;
font-size: 15px;
}
.todo-input input:focus {
border-color: #4299e1;
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.15);
}
.todo-input button {
padding: 12px 20px;
background: #4299e1;
color: white;
border: none;
border-radius: 10px;
cursor: pointer;
font-weight: 500;
}
.todo-input button:hover {
background: #3182ce;
}
</style>
7.5 TodoItem.vue
javascript
<script setup lang="ts">
import type { Todo } from '../types'
// 单个任务,接收单个任务数据,发出切换 / 删除事件
const props = defineProps<{
todo: Todo
}>()
const emit = defineEmits<{
(e: 'toggle', id: number): void
(e: 'remove', id: number): void
}>()
</script>
<template>
<li class="todo-item" :class="{ done: todo.done }">
<input
type="checkbox"
:checked="todo.done"
@change="emit('toggle', todo.id)"
/>
<span class="text">{{ todo.text }}</span>
<button class="remove-btn" @click="emit('remove', todo.id)">删除</button>
</li>
</template>
<style scoped>
.todo-item {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: #f7fafc;
border-radius: 10px;
}
.todo-item input {
width: 18px;
height: 18px;
accent-color: #48bb78;
}
.text {
flex: 1;
font-size: 15px;
color: #2d3748;
word-break: break-all;
}
.done .text {
text-decoration: line-through;
color: #a0aec0;
}
.remove-btn {
padding: 6px 10px;
background: #fef2f2;
color: #dc2626;
border: none;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
}
.remove-btn:hover {
background: #fecaca;
}
</style>
7.6 TodoList.vue(使用作用域插槽)
javascript
<script setup lang="ts">
import type { Todo } from '../types'
defineProps<{
todos: Todo[]
}>()
</script>
// 作用域插槽,只负责循环,不负责渲染,父组件决定如何渲染每一项,高复用、解耦
<template>
<ul class="todo-list" v-if="todos.length > 0">
<slot
name="item"
v-for="todo in todos"
:key="todo.id"
:todo="todo"
/>
</ul>
<div class="empty" v-else>暂无任务,添加一个吧 🎯</div>
</template>
<style scoped>
.todo-list {
list-style: none;
padding: 0;
margin: 0 0 20px 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.empty {
text-align: center;
padding: 30px 0;
color: #aaa;
font-size: 14px;
}
</style>
注意:这里使用作用域插槽将每个todo数据抛出。这种方式让TodoList成为"无渲染组件"的趋势------它只负责循环逻辑,每个项的渲染由父组件决定。当然,直接使用v-for渲染TodoItem也可以,此处为了演示插槽的高级用法。
7.7 TodoFooter.vue
javascript
<script setup lang="ts">
// 全选加清理,实现 自定义 v-model,固定语法:modelValue + update:modelValue
defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'clear-completed'): void
}>()
</script>
<template>
<div class="todo-footer">
<label class="toggle-all">
<input
type="checkbox"
:checked="modelValue"
@change="emit('update:modelValue', $event.target.checked)"
/>
全部{{ modelValue ? '未完成' : '完成' }}
</label>
<button class="clear-btn" @click="emit('clear-completed')">
清理已完成
</button>
</div>
</template>
<style scoped>
.todo-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 16px;
border-top: 1px solid #e2e8f0;
font-size: 14px;
color: #718096;
}
.toggle-all {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.toggle-all input {
cursor: pointer;
accent-color: #4299e1;
}
.clear-btn {
background: none;
border: none;
color: #718096;
cursor: pointer;
font-size: 14px;
}
.clear-btn:hover {
color: #dc2626;
}
</style>
7.8 App.vue(组装)
javascript
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import type { Todo } from './types'
import TodoHeader from './components/TodoHeader.vue'
import TodoInput from './components/TodoInput.vue'
import TodoList from './components/TodoList.vue'
import TodoItem from './components/TodoItem.vue'
import TodoFooter from './components/TodoFooter.vue'
// 数据
// todos:存储所有任务,ref 包裹数组
const todos = ref<Todo[]>([])
// nextId:自增 ID,无需响应式
let nextId = 1
// 计算
// 计算未完成任务数量,具有缓存,依赖变化才重新计算
const activeCount = computed(() =>
todos.value.filter(t => !t.done).length
)
// 可写计算属性,get:获取全选状态,set:批量设置任务状态,用于实现 v-model 全选
const allDone = computed({
get: () => todos.value.length > 0 && activeCount.value === 0,
set: (val: boolean) => {
todos.value.forEach(t => t.done = val)
}
})
// 方法
// 添加任务,接收子组件 emit 的数据,push 新增任务,ID 自增
function addTodo(text: string) {
todos.value.push({ id: nextId++, text, done: false })
}
// 切换完成状态,根据 ID 找到任务,取反 done 状态
function toggleTodo(id: number) {
const todo = todos.value.find(t => t.id === id)
if (todo) todo.done = !todo.done
}
// 删除任务,filter 返回新数组,直接替换整个数组 → 必须用 ref
function removeTodo(id: number) {
todos.value = todos.value.filter(t => t.id !== id)
}
// 清理已完成
function clearCompleted() {
todos.value = todos.value.filter(t => !t.done)
}
// 生命周期 + 本地存储
// 页面加载后从 localStorage 恢复数据
onMounted(() => {
const saved = localStorage.getItem('vue3-todos')
if (saved) {
try {
const list = JSON.parse(saved) as Todo[]
todos.value = list
nextId = list.length > 0
? list.reduce((max, item) => Math.max(max, item.id), 0) + 1
: 1
} catch {}
}
})
// 自动保存
// 深度监听 todos,数据变化自动保存到本地,刷新不丢失
watch(todos, (val) => {
localStorage.setItem('vue3-todos', JSON.stringify(val))
}, { deep: true })
</script>
// Props 父传子,Emits 子传父,作用域插槽:子组件把数据抛给父组件,自定义 v-model
<template>
<div class="app">
<div class="todo-card">
<TodoHeader :active-count="activeCount" :total="todos.length" />
<TodoInput @add="addTodo" />
<TodoList :todos="todos">
<template #item="{ todo }">
<TodoItem
:todo="todo"
@toggle="toggleTodo"
@remove="removeTodo"
/>
</template>
</TodoList>
<TodoFooter
v-if="todos.length > 0"
v-model="allDone"
@clear-completed="clearCompleted"
/>
</div>
</div>
</template>
<style scoped>
.app {
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background: linear-gradient(135deg, #f5f7fa 0%, #e4eaf5 100%);
padding: 20px;
}
.todo-card {
width: 100%;
max-width: 520px;
background: white;
border-radius: 16px;
padding: 32px;
box-shadow: 0 8px 24px rgba(0,0,0,0.08);
}
</style>
7.9 组件树价值总结
重构后,每个组件职责单一:
-
TodoInput只管输入与添加。 -
TodoItem只管单条待办的展示和事件触发。 -
TodoList负责列表循环,通过插槽提供灵活性。 -
TodoFooter管理全选和清理。 -
App作为总调度,持有数据,协调通信。
修改任何一个组件的样式或逻辑,不会影响其他部分。这就是组件化的核心价值。
7.10 功能测试
7.10.1 基础展示测试
页面加载 → 显示标题、统计、输入框,无任务时 → 显示「暂无任务」

7.10.2 添加任务
在输入框输入文字,点击【添加】或按回车,任务出现在列表

7.10.3 切换完成 / 未完成
点击任务复选框,文字变灰 + 加删除线,顶部未完成数量自动变化

7.10.4 删除单条任务
点击某条任务的【删除】,该条消失,数量同步更新

7.10.5 全选 / 取消全选
点击底部「全部完成 / 未完成」,所有任务一键切换状态,未完成数量变为 0

7.10.6 本地存储持久化
添加 / 删除 / 切换一些任务,刷新页面,数据依然存在,不会丢失

7.10.7 清理已完成
点击「清理已完成」,所有已完成任务被删除


7.11 问题解答
为什么要用 ref 而不是 reactive?
因为删除任务时会直接替换整个数组,reactive 包裹数组不能直接赋值,会丢失响应式,而 ref 更适合数组和基本类型。
什么是作用域插槽?
子组件把数据通过插槽抛给父组件,父组件决定如何渲染,实现了解耦和复用。
自定义组件如何实现 v-model?
子组件接收 modelValue,并触发 update:modelValue 事件。
为什么要用 computed?
有缓存,依赖不变不会重复计算,性能更好,代码更简洁。
组件通信方式有哪些?
-
Props 父传子
-
Emits 子传父
-
插槽
-
provide/inject
-
pinia/vuex
八、总结
本文我们正式进入了Vue组件化的世界,从组件注册、Props定义与TypeScript类型、Emits事件通信、v-model双向绑定、三种插槽机制,到生命周期的各个关键钩子,构成了组件化开发的完整知识拼图。
-
组件是独立可复用的UI单元,
.vue文件将模板、逻辑、样式内聚在一起。 -
defineProps接收父组件数据(单向数据流),withDefaults设置默认值;TypeScript泛型提供编译时类型安全。 -
defineEmits向父组件发送事件;v-model是props+emits的语法糖,支持多绑定。 -
插槽分为默认、具名和作用域三种,作用域插槽实现"数据在子组件,渲染在父组件"的灵活模式。
-
生命周期钩子让你在组件的不同阶段执行代码,
onMounted适合DOM操作和API请求,onBeforeUnmount用于清理。 -
组件树设计应遵循"高内聚、低耦合",数据单向流动,通过事件反馈。
如果这篇文章帮你解决了实操上的困惑,别忘记点击点赞、分享 ,也可以留言告诉我你遇到的其它问题,我会尽快回复。动手练习是掌握编程最快的方法,请务必亲手敲一遍本文的所有示例代码,并截图保存你的成果。你的关注是我坚持原创和细节共享的力量来源,谢谢大家。