摘要组件化是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 langts 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 langts const props defineProps({ name: String, age: Number, isActive: Boolean }) /script template div classuser-card :class{ active: isActive } h3{{ name }}/h3 p年龄{{ age }}/p /div /template父组件传入UserCard name张三 :age25 :is-activetrue /注意非字符串类型的prop需要用v-bind即冒号传递否则会当作字符串。3.2 使用TypeScript纯类型声明有了TypeScript我们可以使用更简洁的纯类型语法script setup langts interface Props { name: string age?: number // 可选 isActive?: boolean tags?: string[] // 数组类型 config?: { // 对象类型 showAvatar: boolean size: small | medium | large } } const props withDefaults(definePropsProps(), { age: 0, isActive: false, tags: () [] // 数组/对象默认值必须用工厂函数 }) /scriptdefineProps加上泛型PropsTypeScript会在编译时检查父组件传入的prop类型是否匹配。withDefaults为可选prop设置默认值对于数组和对象必须使用箭头函数返回避免共享引用。这就是静态类型的威力当你传入错误的类型开发环境会立刻报错减少运行时bug。3.3 Props的单向数据流Vue强调单向数据流数据从父组件流向子组件子组件不应直接修改props。这确保了数据流的可预测性。script setup langts 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 PropTypeactive | inactive | pending, required: true, validator: (value: string) [active, inactive, pending].includes(value) } })如果父组件传入非法值控制台会输出明确的警告信息帮助调试。四、子传父 Emits让组件“会说话”Props解决父→子通信Emits解决子→父通信。子组件通过触发事件向父组件发送消息就像孩子喊“爸爸按钮被点了”4.1 基本用法在子组件中使用defineEmits声明事件然后调用emit函数!-- CounterButton.vue -- script setup langts const emit defineEmits([increase, decrease]) function handleIncrease() { emit(increase) } function handleDecrease() { emit(decrease) } /script template div button clickhandleDecrease-/button button clickhandleIncrease/button /div /template父组件监听事件就像处理原生DOM事件CounterButton increasetotal decreasetotal-- / p总计{{ total }}/p4.2 带参数的事件事件可以携带数据!-- 子组件 -- script setup langts const emit defineEmits{ (e: submit, value: string, id: number): void }() function onSubmit() { emit(submit, hello, 123) } /script !-- 父组件 -- ChildComponent submithandleSubmit / script setup langts function handleSubmit(value: string, id: number) { console.log(value, id) } /script使用TypeScript泛型声明事件父组件的回调参数类型会自动推断获得完整的智能提示。4.3 v-model在组件上的双向绑定v-model本质是props emits的语法糖。默认情况下组件上的v-model使用modelValue作为propupdate:modelValue作为事件。子组件script setup langts 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 :valuemodelValue inputupdateValue / /template父组件MyInput v-modelusername / p用户名{{ username }}/pVue 3支持多个v-model绑定只需指定名字!-- 子组件 -- script setup langts defineProps{ firstName: string; lastName: string }() defineEmits{ (e: update:firstName, val: string): void (e: update:lastName, val: string): void }() /script !-- 父组件 -- UserForm v-model:first-namefirst v-model:last-namelast /这种设计让自定义组件可以像原生表单元素一样使用v-model极大简化了表单开发。五、插槽 Slots内容分发魔法有时一个组件不仅需要数据还需要“内容”——父组件想往子组件里塞一段HTML。插槽就是为此而生。5.1 默认插槽最简单的插槽在子组件中使用slot标签作为占位符!-- Panel.vue -- template div classpanel div classpanel-header标题栏/div div classpanel-body slot / /div /div /template使用Panel p这是放入面板的内容/p button确认/button /PanelPanel标签之间的所有内容会被投射到slot /所在位置。5.2 默认内容当父组件没有提供内容时插槽可以显示默认内容slot span classplaceholder暂无内容/span /slot5.3 具名插槽一个组件可以有多个插槽用name属性区分!-- Card.vue -- template div classcard header slot nameheader / /header main slot / /main footer slot namefooter / /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 langts import { ref } from vue interface Item { id: number name: string score: number } const items refItem[]([ { id: 1, name: 张三, score: 95 }, { id: 2, name: 李四, score: 82 } ]) /script template ul li v-foritem in items :keyitem.id slot nameitem :itemitem :indexitem.id / /li /ul /template父组件使用List template #item{ item, index } span classrank{{ 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 langts import { ref, onMounted } from vue const canvasRef refHTMLCanvasElement | null(null) onMounted(() { // canvasRef.value 现在可以安全使用 const ctx canvasRef.value?.getContext(2d) // ... }) /script template canvas refcanvasRef / /templateonBeforeUnmount / 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 → 父 onUpdated6.4 与Vue 2的差异如果你看过Vue 2的文档会发现钩子名称变了beforeCreate/created→ 被setup()替代beforeMount→onBeforeMountmounted→onMountedbeforeUpdate→onBeforeUpdateupdated→onUpdatedbeforeDestroy→onBeforeUnmountdestroyed→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.tsexport interface Todo { id: number text: string done: boolean }为什么要用 TS 接口答给待办事项做类型约束避免传错参数、提高代码可维护性让编辑器有智能提示。7.3 TodoHeader.vue// 接收父组件数据展示统计信息 script setup langts defineProps{ activeCount: number total: number }() /script template div classtodo-header h1 Vue3 Todo 组件树案例/h1 p classstats 总计 {{ 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; } /style7.4 TodoInput.vuescript setup langts 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 classtodo-input input v-modeltext keyup.entersubmit placeholder输入新任务回车添加 / button clicksubmit添加/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; } /style7.5 TodoItem.vuescript setup langts 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 classtodo-item :class{ done: todo.done } input typecheckbox :checkedtodo.done changeemit(toggle, todo.id) / span classtext{{ todo.text }}/span button classremove-btn clickemit(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; } /style7.6 TodoList.vue使用作用域插槽script setup langts import type { Todo } from ../types defineProps{ todos: Todo[] }() /script // 作用域插槽只负责循环不负责渲染父组件决定如何渲染每一项高复用、解耦 template ul classtodo-list v-iftodos.length 0 slot nameitem v-fortodo in todos :keytodo.id :todotodo / /ul div classempty 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.vuescript setup langts // 全选加清理实现 自定义 v-model固定语法modelValue update:modelValue defineProps{ modelValue: boolean }() const emit defineEmits{ (e: update:modelValue, value: boolean): void (e: clear-completed): void }() /script template div classtodo-footer label classtoggle-all input typecheckbox :checkedmodelValue changeemit(update:modelValue, $event.target.checked) / 全部{{ modelValue ? 未完成 : 完成 }} /label button classclear-btn clickemit(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; } /style7.8 App.vue组装script setup langts 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 refTodo[]([]) // 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 classapp div classtodo-card TodoHeader :active-countactiveCount :totaltodos.length / TodoInput addaddTodo / TodoList :todostodos template #item{ todo } TodoItem :todotodo toggletoggleTodo removeremoveTodo / /template /TodoList TodoFooter v-iftodos.length 0 v-modelallDone clear-completedclearCompleted / /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); } /style7.9 组件树价值总结重构后每个组件职责单一TodoInput只管输入与添加。TodoItem只管单条待办的展示和事件触发。TodoList负责列表循环通过插槽提供灵活性。TodoFooter管理全选和清理。App作为总调度持有数据协调通信。修改任何一个组件的样式或逻辑不会影响其他部分。这就是组件化的核心价值。7.10 功能测试7.10.1 基础展示测试页面加载 → 显示标题、统计、输入框无任务时 → 显示「暂无任务」7.10.2 添加任务在输入框输入文字点击【添加】或按回车任务出现在列表7.10.3 切换完成 / 未完成点击任务复选框文字变灰 加删除线顶部未完成数量自动变化7.10.4 删除单条任务点击某条任务的【删除】该条消失数量同步更新7.10.5 全选 / 取消全选点击底部「全部完成 / 未完成」所有任务一键切换状态未完成数量变为 07.10.6 本地存储持久化添加 / 删除 / 切换一些任务刷新页面数据依然存在不会丢失7.10.7 清理已完成点击「清理已完成」所有已完成任务被删除7.11 问题解答为什么要用 ref 而不是 reactive因为删除任务时会直接替换整个数组reactive 包裹数组不能直接赋值会丢失响应式而 ref 更适合数组和基本类型。什么是作用域插槽子组件把数据通过插槽抛给父组件父组件决定如何渲染实现了解耦和复用。自定义组件如何实现 v-model子组件接收modelValue并触发update:modelValue事件。为什么要用 computed有缓存依赖不变不会重复计算性能更好代码更简洁。组件通信方式有哪些Props 父传子Emits 子传父插槽provide/injectpinia/vuex八、总结本文我们正式进入了Vue组件化的世界从组件注册、Props定义与TypeScript类型、Emits事件通信、v-model双向绑定、三种插槽机制到生命周期的各个关键钩子构成了组件化开发的完整知识拼图。组件是独立可复用的UI单元.vue文件将模板、逻辑、样式内聚在一起。defineProps接收父组件数据单向数据流withDefaults设置默认值TypeScript泛型提供编译时类型安全。defineEmits向父组件发送事件v-model是propsemits的语法糖支持多绑定。插槽分为默认、具名和作用域三种作用域插槽实现“数据在子组件渲染在父组件”的灵活模式。生命周期钩子让你在组件的不同阶段执行代码onMounted适合DOM操作和API请求onBeforeUnmount用于清理。组件树设计应遵循“高内聚、低耦合”数据单向流动通过事件反馈。如果这篇文章帮你解决了实操上的困惑别忘记点击点赞、分享也可以留言告诉我你遇到的其它问题我会尽快回复。动手练习是掌握编程最快的方法请务必亲手敲一遍本文的所有示例代码并截图保存你的成果。你的关注是我坚持原创和细节共享的力量来源谢谢大家。
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 langts 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 langts const props defineProps({ name: String, age: Number, isActive: Boolean }) /script template div classuser-card :class{ active: isActive } h3{{ name }}/h3 p年龄{{ age }}/p /div /template父组件传入UserCard name张三 :age25 :is-activetrue /注意非字符串类型的prop需要用v-bind即冒号传递否则会当作字符串。3.2 使用TypeScript纯类型声明有了TypeScript我们可以使用更简洁的纯类型语法script setup langts interface Props { name: string age?: number // 可选 isActive?: boolean tags?: string[] // 数组类型 config?: { // 对象类型 showAvatar: boolean size: small | medium | large } } const props withDefaults(definePropsProps(), { age: 0, isActive: false, tags: () [] // 数组/对象默认值必须用工厂函数 }) /scriptdefineProps加上泛型PropsTypeScript会在编译时检查父组件传入的prop类型是否匹配。withDefaults为可选prop设置默认值对于数组和对象必须使用箭头函数返回避免共享引用。这就是静态类型的威力当你传入错误的类型开发环境会立刻报错减少运行时bug。3.3 Props的单向数据流Vue强调单向数据流数据从父组件流向子组件子组件不应直接修改props。这确保了数据流的可预测性。script setup langts 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 PropTypeactive | inactive | pending, required: true, validator: (value: string) [active, inactive, pending].includes(value) } })如果父组件传入非法值控制台会输出明确的警告信息帮助调试。四、子传父 Emits让组件“会说话”Props解决父→子通信Emits解决子→父通信。子组件通过触发事件向父组件发送消息就像孩子喊“爸爸按钮被点了”4.1 基本用法在子组件中使用defineEmits声明事件然后调用emit函数!-- CounterButton.vue -- script setup langts const emit defineEmits([increase, decrease]) function handleIncrease() { emit(increase) } function handleDecrease() { emit(decrease) } /script template div button clickhandleDecrease-/button button clickhandleIncrease/button /div /template父组件监听事件就像处理原生DOM事件CounterButton increasetotal decreasetotal-- / p总计{{ total }}/p4.2 带参数的事件事件可以携带数据!-- 子组件 -- script setup langts const emit defineEmits{ (e: submit, value: string, id: number): void }() function onSubmit() { emit(submit, hello, 123) } /script !-- 父组件 -- ChildComponent submithandleSubmit / script setup langts function handleSubmit(value: string, id: number) { console.log(value, id) } /script使用TypeScript泛型声明事件父组件的回调参数类型会自动推断获得完整的智能提示。4.3 v-model在组件上的双向绑定v-model本质是props emits的语法糖。默认情况下组件上的v-model使用modelValue作为propupdate:modelValue作为事件。子组件script setup langts 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 :valuemodelValue inputupdateValue / /template父组件MyInput v-modelusername / p用户名{{ username }}/pVue 3支持多个v-model绑定只需指定名字!-- 子组件 -- script setup langts defineProps{ firstName: string; lastName: string }() defineEmits{ (e: update:firstName, val: string): void (e: update:lastName, val: string): void }() /script !-- 父组件 -- UserForm v-model:first-namefirst v-model:last-namelast /这种设计让自定义组件可以像原生表单元素一样使用v-model极大简化了表单开发。五、插槽 Slots内容分发魔法有时一个组件不仅需要数据还需要“内容”——父组件想往子组件里塞一段HTML。插槽就是为此而生。5.1 默认插槽最简单的插槽在子组件中使用slot标签作为占位符!-- Panel.vue -- template div classpanel div classpanel-header标题栏/div div classpanel-body slot / /div /div /template使用Panel p这是放入面板的内容/p button确认/button /PanelPanel标签之间的所有内容会被投射到slot /所在位置。5.2 默认内容当父组件没有提供内容时插槽可以显示默认内容slot span classplaceholder暂无内容/span /slot5.3 具名插槽一个组件可以有多个插槽用name属性区分!-- Card.vue -- template div classcard header slot nameheader / /header main slot / /main footer slot namefooter / /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 langts import { ref } from vue interface Item { id: number name: string score: number } const items refItem[]([ { id: 1, name: 张三, score: 95 }, { id: 2, name: 李四, score: 82 } ]) /script template ul li v-foritem in items :keyitem.id slot nameitem :itemitem :indexitem.id / /li /ul /template父组件使用List template #item{ item, index } span classrank{{ 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 langts import { ref, onMounted } from vue const canvasRef refHTMLCanvasElement | null(null) onMounted(() { // canvasRef.value 现在可以安全使用 const ctx canvasRef.value?.getContext(2d) // ... }) /script template canvas refcanvasRef / /templateonBeforeUnmount / 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 → 父 onUpdated6.4 与Vue 2的差异如果你看过Vue 2的文档会发现钩子名称变了beforeCreate/created→ 被setup()替代beforeMount→onBeforeMountmounted→onMountedbeforeUpdate→onBeforeUpdateupdated→onUpdatedbeforeDestroy→onBeforeUnmountdestroyed→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.tsexport interface Todo { id: number text: string done: boolean }为什么要用 TS 接口答给待办事项做类型约束避免传错参数、提高代码可维护性让编辑器有智能提示。7.3 TodoHeader.vue// 接收父组件数据展示统计信息 script setup langts defineProps{ activeCount: number total: number }() /script template div classtodo-header h1 Vue3 Todo 组件树案例/h1 p classstats 总计 {{ 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; } /style7.4 TodoInput.vuescript setup langts 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 classtodo-input input v-modeltext keyup.entersubmit placeholder输入新任务回车添加 / button clicksubmit添加/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; } /style7.5 TodoItem.vuescript setup langts 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 classtodo-item :class{ done: todo.done } input typecheckbox :checkedtodo.done changeemit(toggle, todo.id) / span classtext{{ todo.text }}/span button classremove-btn clickemit(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; } /style7.6 TodoList.vue使用作用域插槽script setup langts import type { Todo } from ../types defineProps{ todos: Todo[] }() /script // 作用域插槽只负责循环不负责渲染父组件决定如何渲染每一项高复用、解耦 template ul classtodo-list v-iftodos.length 0 slot nameitem v-fortodo in todos :keytodo.id :todotodo / /ul div classempty 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.vuescript setup langts // 全选加清理实现 自定义 v-model固定语法modelValue update:modelValue defineProps{ modelValue: boolean }() const emit defineEmits{ (e: update:modelValue, value: boolean): void (e: clear-completed): void }() /script template div classtodo-footer label classtoggle-all input typecheckbox :checkedmodelValue changeemit(update:modelValue, $event.target.checked) / 全部{{ modelValue ? 未完成 : 完成 }} /label button classclear-btn clickemit(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; } /style7.8 App.vue组装script setup langts 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 refTodo[]([]) // 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 classapp div classtodo-card TodoHeader :active-countactiveCount :totaltodos.length / TodoInput addaddTodo / TodoList :todostodos template #item{ todo } TodoItem :todotodo toggletoggleTodo removeremoveTodo / /template /TodoList TodoFooter v-iftodos.length 0 v-modelallDone clear-completedclearCompleted / /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); } /style7.9 组件树价值总结重构后每个组件职责单一TodoInput只管输入与添加。TodoItem只管单条待办的展示和事件触发。TodoList负责列表循环通过插槽提供灵活性。TodoFooter管理全选和清理。App作为总调度持有数据协调通信。修改任何一个组件的样式或逻辑不会影响其他部分。这就是组件化的核心价值。7.10 功能测试7.10.1 基础展示测试页面加载 → 显示标题、统计、输入框无任务时 → 显示「暂无任务」7.10.2 添加任务在输入框输入文字点击【添加】或按回车任务出现在列表7.10.3 切换完成 / 未完成点击任务复选框文字变灰 加删除线顶部未完成数量自动变化7.10.4 删除单条任务点击某条任务的【删除】该条消失数量同步更新7.10.5 全选 / 取消全选点击底部「全部完成 / 未完成」所有任务一键切换状态未完成数量变为 07.10.6 本地存储持久化添加 / 删除 / 切换一些任务刷新页面数据依然存在不会丢失7.10.7 清理已完成点击「清理已完成」所有已完成任务被删除7.11 问题解答为什么要用 ref 而不是 reactive因为删除任务时会直接替换整个数组reactive 包裹数组不能直接赋值会丢失响应式而 ref 更适合数组和基本类型。什么是作用域插槽子组件把数据通过插槽抛给父组件父组件决定如何渲染实现了解耦和复用。自定义组件如何实现 v-model子组件接收modelValue并触发update:modelValue事件。为什么要用 computed有缓存依赖不变不会重复计算性能更好代码更简洁。组件通信方式有哪些Props 父传子Emits 子传父插槽provide/injectpinia/vuex八、总结本文我们正式进入了Vue组件化的世界从组件注册、Props定义与TypeScript类型、Emits事件通信、v-model双向绑定、三种插槽机制到生命周期的各个关键钩子构成了组件化开发的完整知识拼图。组件是独立可复用的UI单元.vue文件将模板、逻辑、样式内聚在一起。defineProps接收父组件数据单向数据流withDefaults设置默认值TypeScript泛型提供编译时类型安全。defineEmits向父组件发送事件v-model是propsemits的语法糖支持多绑定。插槽分为默认、具名和作用域三种作用域插槽实现“数据在子组件渲染在父组件”的灵活模式。生命周期钩子让你在组件的不同阶段执行代码onMounted适合DOM操作和API请求onBeforeUnmount用于清理。组件树设计应遵循“高内聚、低耦合”数据单向流动通过事件反馈。如果这篇文章帮你解决了实操上的困惑别忘记点击点赞、分享也可以留言告诉我你遇到的其它问题我会尽快回复。动手练习是掌握编程最快的方法请务必亲手敲一遍本文的所有示例代码并截图保存你的成果。你的关注是我坚持原创和细节共享的力量来源谢谢大家。