欢迎光临
我们一直在努力

Vue.js从零到精通系列(三):组件化基础——Props、Emits、插槽与生命周期

摘要: 组件化是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>可以极其简洁地使用:

<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中:

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:

<!– 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>

父组件传入:

<UserCard name="张三" :age="25" :is-active="true" />

注意:非字符串类型的prop需要用v-bind(即冒号)传递,否则会当作字符串。

3.2 使用TypeScript纯类型声明

有了TypeScript,我们可以使用更简洁的纯类型语法:

<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。这确保了数据流的可预测性。

<script setup lang="ts">
const props = defineProps<{ count: number }>()

// ❌ 不要这样做!
function badIncrement() {
 props.count++  // 报错!props是只读的
}
</script>

如果子组件需要基于prop进行计算或转换,有两种正确做法:

使用计算属性做派生:

import { computed } from 'vue'
const doubleCount = computed(() => props.count * 2)

将prop作为本地数据的初始值:

import { ref } from 'vue'
const localCount = ref(props.count)
// 此后操作localCount,不影响父组件

3.4 运行时类型校验

除了TypeScript的编译时检查,Vue还支持运行时校验,提供更详细的警告:

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函数:

<!– 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事件:

<CounterButton @increase="total++" @decrease="total–" />
<p>总计:{{ total }}</p>

4.2 带参数的事件

事件可以携带数据:

<!– 子组件 –>
<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作为事件。

子组件:

<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>

父组件:

<MyInput v-model="username" />
<p>用户名:{{ username }}</p>

Vue 3支持多个v-model绑定,只需指定名字:

<!– 子组件 –>
<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>标签作为占位符:

<!– Panel.vue –>
<template>
 <div class="panel">
   <div class="panel-header">标题栏</div>
   <div class="panel-body">
     <slot />
   </div>
 </div>
</template>

使用:

<Panel>
 <p>这是放入面板的内容</p>
 <button>确认</button>
</Panel>

<Panel>标签之间的所有内容会被投射到<slot />所在位置。

5.2 默认内容

当父组件没有提供内容时,插槽可以显示默认内容:

<slot>
 <span class="placeholder">暂无内容</span>
</slot>

5.3 具名插槽

一个组件可以有多个插槽,用name属性区分:

<!– Card.vue –>
<template>
 <div class="card">
   <header>
     <slot name="header" />
   </header>
   <main>
     <slot />
   </main>
   <footer>
     <slot name="footer" />
   </footer>
 </div>
</template>

使用v-slot指令(简写#)指定内容插入哪个插槽:

<Card>
 <template #header>
   <h2>卡片标题</h2>
 </template>

 <p>这是主体内容,默认插槽</p>

 <template #footer>
   <button>操作按钮</button>
 </template>
</Card>

注意:v-slot只能用在<template>标签或组件标签上。默认插槽的隐式名称是default。

5.4 作用域插槽:插槽中的数据回传

当子组件有一些数据需要暴露给父组件的插槽内容时,可以使用作用域插槽。这解决了“内容由父组件定义,但数据在子组件”的矛盾。

子组件:

<!– 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>

父组件使用:

<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容器)

<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

组件被移除前/后调用。适合做清理工作:

import { onBeforeUnmount } from 'vue'

let timer: number
onMounted(() => {
 timer = window.setInterval(() => {
   console.log('tick')
}, 1000)
})

onBeforeUnmount(() => {
 clearInterval(timer)  // 防止内存泄漏
})

清理操作至关重要,尤其是定时器、事件监听、WebSocket连接等,否则会造成内存泄漏。

onUpdated

组件重新渲染后调用。一般用于需要访问更新后DOM的场景。初次渲染不会触发。

6.3 生命周期的父子和兄弟关系

父子组件的挂载顺序:

父 setup → 父 onBeforeMount
→ 子 setup → 子 onBeforeMount → 子 onMounted
→ 父 onMounted

子组件的mounted在父组件之前完成,确保父组件挂载时子组件已经就位。

更新时:

父 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 组件结构设计

src/
├── types.ts # 统一 TS 类型
├── App.vue # 根组件
└── components/
├── TodoHeader.vue # 标题 + 统计
├── TodoInput.vue # 输入添加
├── TodoList.vue # 列表容器(插槽)
├── TodoItem.vue # 单项
└── TodoFooter.vue # 全选 + 清理已完成

7.2 统一类型文件:types.ts

export interface Todo {
id: number
text: string
done: boolean
}

为什么要用 TS 接口?

答:给待办事项做类型约束,避免传错参数、提高代码可维护性,让编辑器有智能提示。

7.3 TodoHeader.vue

// 接收父组件数据,展示统计信息
<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

<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

<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(使用作用域插槽)

<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

<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(组装)

<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用于清理。

  • 组件树设计应遵循“高内聚、低耦合”,数据单向流动,通过事件反馈。


如果这篇文章帮你解决了实操上的困惑,别忘记点击点赞、分享,也可以留言告诉我你遇到的其它问题,我会尽快回复。动手练习是掌握编程最快的方法,请务必亲手敲一遍本文的所有示例代码,并截图保存你的成果。你的关注是我坚持原创和细节共享的力量来源,谢谢大家。

赞(0)
未经允许不得转载:171主机测评 » Vue.js从零到精通系列(三):组件化基础——Props、Emits、插槽与生命周期
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址