做了几年前端,我几乎每天都在跟 Vue 打交道。从最开始照着官方文档写 Todo List,到后来维护几十万行代码的后台系统,我发现在 Vue 世界里,组件就是一切的地基。很多开发者学完语法、会写页面,但一到自己动手封装一个基础组件(按钮、弹窗、分页、表格)就卡壳:props 传了页面不更新,事件没反应,样式莫名其妙的串来串去。这篇文章我准备把自己在实际项目里积累的 Vue 基本组件心得完整写出来,从设计思路、注册方式,到 props、emits、插槽、组件通信,最后用一个分页表格组件的封装案例把所有知识点串起来。适合刚接触 Vue 的新手,也适合想系统性整理组件知识、准备从"会用"走向"会设计"的初中级前端。
1. 为什么说组件是 Vue 应用的地基
1.1 没有组件化的项目到底有多痛苦
先聊点实际的。我最早接手一个后台管理项目时,里面有一个订单管理页面,搜索表单、表格、分页、操作按钮全部堆在同一个文件里,一个文件 3000 多行。改一个弹窗的样式要 Ctrl+F 找半天,因为跟这个弹窗无关的表格代码也在同一层作用域里,变量名稍微冲突就出诡异 bug。
这种痛点我相信很多人都经历过。页面越来越大,逻辑越来越纠缠,一个人改起来都费劲,更别说多人协作。后来这个项目用了几年时间勉强重构,第一步就是把页面按功能拆成组件:搜索区抽成一个组件、表格抽成一个组件、分页抽成一个组件,再由页面组件把它们组合起来。拆完之后,每个文件的代码量降到了原来的十分之一,出 bug 的定位时间也从小时级降到了分钟级。
1.2 组件到底解决了什么问题
拆完之后你会发现,组件的价值主要在三件事上:
- 复用性:同一套 UI 和逻辑可以在多个页面复用。比如一个日期范围选择器,订单页用、财务报表页也用,抽成组件之后只维护一份代码。
- 职责隔离:每个组件只干一件事,搜索组件管输入、表格组件管展示、分页组件管页码,各自的作用域互不干扰。
- 协作效率:团队开发时,可以按组件分人,每个人负责一个组件,通过 props 和事件约定好接口,互不阻塞。
所以不要觉得"组件就是一段模板代码"。组件本质上是一种代码组织和协作的单元,在 Vue 里,它的载体通常是单文件组件(SFC),一个.vue文件就是一个独立组件。组件设计得好不好,直接决定项目后期是越改越轻松,还是越改越崩溃。
2. 组件的注册与结构:先把骨架立住
2.1 全局注册还是局部注册,这个选择影响全局
注册组件有两种方式:全局注册和局部注册。很多新手图省事,全部用全局注册,结果项目组件一多,全局命名冲突和性能问题全出来了。
全局注册是这样写的,在入口文件里:
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 />使用。它的好处是方便,不用每次导入,适合真正通用的基础组件,比如按钮、输入框。
局部注册则是在当前组件里用 import 引入:
<template> <div> <Pagination :total="100" @change="handlePageChange" /> </div> </template> <script setup> import Pagination from './Pagination.vue' function handlePageChange(page) { console.log('当前页:', page) } </script>区别我用一个表格给你列清楚:
| 对比项 | 全局注册 | 局部注册 |
|---|---|---|
| 使用方式 | 注册后任意模板直接用,无需导入 | 每个使用组件都要 import |
| 命名冲突 | 多组件同名时后注册的覆盖先注册的 | 按文件引用,冲突概率低 |
| 打包体积 | 全局注册的组件会被打进主包 | 按需加载,配合分包更灵活 |
| 适用场景 | 真正通用的基础组件(Button、Input) | 页面级组件、业务组件 |
我个人建议是:除非是BaseButton、BaseInput这种确定全项目通用的组件,其余全部用局部注册。局部注册最大的好处是依赖关系清楚,你看到这个文件顶部 import 了哪些组件,就知道它用了哪些东西,排查问题省很多事。
2.2 单文件组件的三大块是怎么分工的
一个.vue文件里通常有三块:<template>、<script setup>、<style scoped>。它们各司其职:
template负责结构:声明组件要渲染哪些 DOM,写布局和插值表达式。script setup负责逻辑:定义数据、计算属性、事件处理函数,以及子组件需要用到的 props。style scoped负责样式:scoped的作用是让这里的样式只作用于当前组件的元素,不会污染全局。
这里我想强调一下scoped的机制。很多人以为scoped就是把样式"锁"在组件内部,其实它的实现方式是给组件模板里的元素添加一个><template> <h2>标题</h2> <p>段落</p> </template>
这不是件小事。实际封装组件时,如果你要用 Vue 3 的原子组件(比如把多个 Fragment 组合起来返回),多根节点能省掉一层多余的外层 div,避免布局被破坏。
组件命名方面,文件用 PascalCase(大驼峰),比如PaginationTable.vue,模板里你可以用<PaginationTable />或<pagination-table />,Vue 都认。业务组件建议都统一成 PascalCase,避免跟原生 HTML 元素混淆,也更符合主流习惯。
3. props 与 emits:组件的对外接口
3.1 props 的三种传值方式
组件之间的信息传递,第一步就是 props。父组件往子组件传数据,有三种写法。
第一种,静态传值,直接写字符串:
<Pagination title="订单列表分页" />第二种,动态绑定,用v-bind或它的简写::
<Pagination :total="totalCount" :page-size="pageSize" />这里totalCount是父组件里的一个响应式变量,路由、接口返回的数据变化,子组件里接收到的 prop 也会实时变化。
第三种是传对象、数组这类引用类型,这其实是动态绑定的特殊写法:
<Pagination v-bind="{ total: 100, pageSize: 10, currentPage: 1 }" />这种写法适合你手里已经有一个配置对象时,一次性把多个属性都传给子组件。我在封装组件时也经常用这种写法,能少写几行代码。
3.2 props 的校验与默认值,别偷懒
有些开发者封装组件时defineProps里直接写个数组就完事:
const props = defineProps(['total', 'pageSize'])项目小的时候没问题,项目一复杂,传错类型、传漏参数的问题全来了。组件是对外开放的接口,接口就应该有协议。我习惯写成对象形式,带上类型、默认值和校验:
<script setup> const props = defineProps({ total: { type: Number, required: true, validator: (value) => value >= 0 }, pageSize: { type: Number, default: 10 }, currentPage: { type: Number, default: 1 }, loading: { type: Boolean, default: false } }) </script>required: true表示必填,default指定默认值,validator可以加一个简单的校验函数。校验错误时 Vue 会在控制台给出警告提示,这在开发期能省掉大量 debug 时间。
另外一个很关键的细节:props 是单向数据流。子组件不能直接修改 props 的值,只能通过触发事件让父组件去改。这一点在所有的官方文档里都会强调,但实际项目中总有人不小心写了props.total++这种代码。Vue 会警告你违反了单向数据流,而且这种修改不会同步回父组件,很容易变成隐性 bug。
3.3 emits:把事件交还给父组件
子组件怎么通知父组件?靠的是emit。在单文件组件里定义事件有两个作用,一个是声明这个组件"会发出哪些事件",另一个是方便 Vue 帮你校验。
推荐写法:
<script setup> const emit = defineEmits(['change', 'update:currentPage']) function handlePageChange(page) { emit('change', page) emit('update:currentPage', page) } </script>父组件监听:
<Pagination @change="handlePageChange" />事件的命名要注意,推荐用 kebab-case(短横线)风格,比如page-change,因为 HTML 属性本身不区分大小写,@page-change和@pageChange在某些边缘情况下会踩坑,统一用短横线最稳。
3.4 v-model 的底层机制,封装表单组件关键
v-model在原生输入框上是语法糖,在组件上同样也是。它的完整写法是:
<Pagination :modelValue="currentPage" @update:modelValue="currentPage = $event" />而v-model="currentPage"就是上面写法的简写。这意味着,子组件里如果想实现"v-model 联动",需要接收一个modelValueprop,并在值变化时触发update:modelValue事件。
一个最简单的封装示例,比如一个数字输入框:
<template> <input type="number" :value="modelValue" @input="$emit('update:modelValue', Number($event.target.value))" /> </template> <script setup> defineProps({ modelValue: { type: Number, default: 0 } }) defineEmits(['update:modelValue']) </script>父组件就能直接这样用:
<NumberInput v-model="pageNum" />多个 v-model 的写法是v-model:page="pageNum"对应update:page,这个技巧在实际封装复杂组件时非常有用。比如一个时间范围组件,可以定义v-model:start和v-model:end两个值,父组件用起来就像操作两个普通变量。
4. 插槽:把"坑位"留给父组件
4.1 默认插槽与具名插槽
props 是数据层面的接口,插槽是 DOM 层面的接口。封装一个弹窗或者卡片组件时,外层结构和样式是固定的,但内容区域可能要放什么由使用者决定,这时候就该用插槽。
最简单的方式是默认插槽,在子组件模板里写<slot />,父组件放入的内容会渲染在这个位置:
<!-- Card.vue --> <template> <div class="card"> <div class="card__header"> <slot name="header" /> </div> <div class="card__body"> <slot /> </div> </div> </template>具名插槽就是给<slot>加一个 name 属性,父组件用template配合v-slot指定内容进哪个插槽:
<Card> <template #header> 订单统计 </template> <template #default> 这里显示内容区域 </template> </Card>我在实际项目里总结了一个经验:如果组件里只有静态结构和少量可变位置,优先考虑用插槽而不是 props 传模板内容。插槽可以让父组件完全掌控这块区域的 DOM,传模板字符串会有 XSS 风险和灵活性差的问题,看起来麻烦但长期维护成本最低。
4.2 作用域插槽,让插槽内容拿到子组件的数据
作用域插槽稍微进阶一点,它能让父组件在写插槽内容时访问到子组件里的数据。典型场景是封装表格组件时,想让外部自定义某一列的渲染,但外部需要拿到当前行的数据。
子组件里这样暴露数据:
<template> <slot :row="rowData" :index="rowIndex" /> </template>父组件这样使用:
<Table> <template #row="{ row }"> <span class="highlight">{{ row.status === 1 ? '启用' : '停用' }}</span> </template> </Table>这里的{ row }是解构写法,取的就是子组件 slot 上传出来的rowData。这种模式在封装表格、列表、选择器等组件时几乎是必须掌握的技能,可以说没有作用域插槽,很多通用组件都封不出来。
4.3 插槽的备用内容与使用原则
插槽是可以写默认内容的,父组件不传时显示默认内容,传了就用父组件的内容替换:
<slot name="empty"> <p>暂无数据</p> </slot>关于插槽使用原则,我个人的体会是:能用插槽实现的自定义,就不要用"传一个变量控制 if else"来实现。拿一个弹窗组件举例,如果你用confirmText这个 prop 控制确认按钮文字,那以后是传字符串、还是传按钮、还是传一组带图标的按钮?一旦扩展,props 会越加越多。而如果直接用插槽允许父组件自定义 footer 区域,整个组件的扩展性会好很多。
5. 组件通信:父子、兄弟、跨层级
5.1 父子通信,先掌握这三板斧
父子组件通信是最高频的场景,主要方式有三种:
- props + emits:父传子用 props,子传父用 emit。适用场景:直接父子关系的数据流。
- v-model:适用于父组件要双向控制子组件某个值的场景,本质上是 props + emits 的语法糖。
- ref 调用子组件方法:父组件通过
ref拿到子组件实例,直接调用子组件暴露的方法。
第三种写法要注意,在 Vue 3 组合式 API 里,子组件用defineExpose明确暴露内容,父组件才能拿到:
<!-- 子组件 --> <script setup> function showInfo() { console.log('子组件的方法') } defineExpose({ showInfo }) </script><!-- 父组件 --> <script setup> import { ref } from 'vue' import ChildComp from './ChildComp.vue' const childRef = ref(null) function callChild() { childRef.value.showInfo() } </script> <template> <ChildComp ref="childRef" /> </template>我在早期写 Vue 2 时没有defineExpose,直接this.$refs.child就能拿所有方法。Vue 3 改成显式暴露,其实是对组件内部实现的一种保护,防止外部随意调用内部未公开的东西。刚开始觉得繁琐,后来发现这种约束能倒逼你把组件对外 API 想清楚。
5.2 跨层级通信,provide 和 inject 的妙用
当组件层级很深时,比如页面组件 -> 布局组件 -> 卡片组件 -> 按钮组件,如果每层都靠 props 往下传,中间组件就要写很多跟自己无关的透传代码。Vue 提供了provide和inject来解决这种问题。
父级提供数据:
<script setup> import { provide } from 'vue' const appConfig = { theme: 'dark', locale: 'zh-CN' } provide('appConfig', appConfig) </script>任意后代组件注入使用:
<script setup> import { inject } from 'vue' const appConfig = inject('appConfig', { theme: 'light', locale: 'en' }) console.log(appConfig.theme) // 'dark' </script>inject的第二个参数是默认值,当找不到 provide 时使用。实际使用中有两个坑:
一,如果 provide 的值不是响应式的,后代组件拿到的就是一个静态值,页面不会跟着更新。要注意provide('count', ref(0))要包ref或reactive。
二,滥用 provide 会让数据来源变得不清晰,就像全局变量一样难以追踪。我的经验是:跨两三层用 provide/inject 可以,跨四五层还用它,不如考虑直接引入状态管理工具。
父子通信和跨层级通信的区别,可以看下面这张对照表:
| 通信场景 | 推荐方案 | 注意事项 |
|---|---|---|
| 父传子 | props | 单向数据流,子组件不要直接改 props |
| 子传父 | emits | 事件命名统一 kebab-case |
| 双向同步 | v-model | 底层就是 modelValue + update:modelValue |
| 祖孙/隔代传值 | provide / inject | 传响应式数据,否则不更新 |
| 全局状态共享 | Pinia / Vuex | 按需使用,别把 store 当垃圾桶 |
5.3 状态管理的取舍,别把组件通信搞复杂
不太熟的人以为只要组件之间要共享数据,就得上 Vuex/Pinia。我的经验是反过来:先用组件通信解决,解决不了再上状态管理。
比如一个页面里两个兄弟组件,一个负责筛选条件,一个负责展示列表。它们要共享筛选结果。最简单的方式是把筛选状态提升到它们共同的父组件里,通过 props 传给列表组件,筛选变化时触发事件让父组件更新状态。这种"状态提升"模式完全够用,代码逻辑也透明。
只有当前状态确实需要被多个页面共享(比如用户信息、权限、购物车)时,才应该引入全局状态库。因为全局状态有一个隐形成本:你在一个组件里改了 store 里的数据,完全不知道哪里会受影响。组件通信至少还是沿着组件树的,状态管理则是网络状的,一旦数据链路复杂,排查成本成倍上升。
6. 实战:从零封装一个分页表格组件
6.1 需求分析与接口设计
前面说了这么多理论,现在我们来实际做一个组件。我选择的分页表格组件是后台系统里最典型的基础组件,它同时用到了 props、emits、插槽、作用域插槽、事件定义等多项技术。
需求梳理一下:
- 外层面板:标题栏(可选)+ 表格区域 + 分页区域。
- 表格列:支持自定义,每列一个插槽,外部可以拿到当前行数据和列配置。
- 分页:显示总条数、页码、每页条数,翻页和修改每页条数时通知外部。
- 表格数据来源:由父组件传入数组,组件内部不做接口请求,做纯展示组件。
接口设计如下:
| 名称 | 类型 | 说明 |
|---|---|---|
| columns | Array | 列配置数组,每项包含{ prop, label, width } |
| data | Array | 表格数据数组 |
| total | Number | 总记录数,分页计算用 |
| page-size | Number | 每页条数,默认 10 |
| current-page | Number | 当前页码,默认 1 |
| 事件 update:current-page | 页码变化时触发 | 用于配合 v-model:current-page |
| 事件 update:page-size | 每页条数变化时触发 | 用于配合 v-model:page-size |
| 插槽 默认 | 自定义单元格 | 作用域参数包含{ row, column, index } |
这样的接口设计基本覆盖了后台列表页的常见需求,而且数据流方向清晰:父组件喂数据,子组件只负责展示和输出用户操作,不做任何业务判断。
6.2 具体实现代码
组件完整代码如下:
<template> <div class="pagination-table"> <div class="pagination-table__title" v-if="title"> <slot name="header">{{ title }}</slot> </div> <table class="pagination-table__table"> <thead> <tr> <th v-for="col in columns" :key="col.prop" :style="{ width: col.width }"> {{ col.label }} </th> </tr> </thead> <tbody> <tr v-for="(row, rowIndex) in data" :key="rowIndex"> <td v-for="col in columns" :key="col.prop"> <slot :name="col.prop" :row="row" :column="col" :index="rowIndex"> {{ row[col.prop] }} </slot> </td> </tr> <tr v-if="!data.length"> <td :colspan="columns.length" class="pagination-table__empty"> <slot name="empty">暂无数据</slot> </td> </tr> </tbody> </table> <div class="pagination-table__footer"> <span>共 {{ total }} 条</span> <button :disabled="currentPage <= 1" @click="handlePageChange(currentPage - 1)" > 上一页 </button> <span>第 {{ currentPage }} / {{ totalPages }} 页</span> <button :disabled="currentPage >= totalPages" @click="handlePageChange(currentPage + 1)" > 下一页 </button> <select :value="pageSize" @change="handleSizeChange($event)"> <option v-for="size in pageSizeOptions" :key="size" :value="size"> {{ size }} 条/页 </option> </select> </div> </div> </template> <script setup> import { computed } from 'vue' const props = defineProps({ columns: { type: Array, required: true }, data: { type: Array, default: () => [] }, total: { type: Number, default: 0 }, currentPage: { type: Number, default: 1 }, pageSize: { type: Number, default: 10 }, pageSizeOptions: { type: Array, default: () => [10, 20, 50] }, title: { type: String, default: '' } }) const emit = defineEmits(['update:currentPage', 'update:pageSize']) const totalPages = computed(() => Math.max(1, Math.ceil(props.total / props.pageSize))) function handlePageChange(page) { if (page < 1 || page > totalPages.value) return emit('update:currentPage', page) } function handleSizeChange(event) { const size = Number(event.target.value) emit('update:pageSize', size) // 切页大小后,当前页码如果超出范围,重置到第一页 emit('update:currentPage', 1) } </script> <style scoped> .pagination-table__footer { display: flex; align-items: center; gap: 12px; margin-top: 12px; font-size: 14px; } </style>这里有几个关键点:
第一,data的默认值用了() => [],而不是[]。因为 props 的默认值如果是对象或数组,必须写成函数返回新实例,否则所有组件实例会共享同一个引用,出现 A 组件的修改影响到 B 组件的诡异问题。
第二,分页的totalPages用了computed,它依赖 props 里的total和pageSize,当这两个值变化时,总页数会自动更新。这就是计算属性解决"多数据源联动"的价值。
第三,翻页按钮里我没有直接修改 currentPage,而是通过 emit 通知父组件更新。父组件拿到新页码后,去请求新数据,再通过 props 把新data传回来,整个循环是单向的,非常清晰。
6.3 在项目里如何使用这个组件
假设一个典型的列表页面:
<script setup> import { ref, onMounted } from 'vue' import PaginationTable from './components/PaginationTable.vue' const columns = [ { prop: 'orderNo', label: '订单号', width: '180px' }, { prop: 'amount', label: '金额' }, { prop: 'status', label: '状态' }, { prop: 'actions', label: '操作', width: '200px' } ] const tableData = ref([]) const total = ref(0) const currentPage = ref(1) const pageSize = ref(10) async function fetchData() { const res = await fetchOrderList({ page: currentPage.value, pageSize: pageSize.value }) tableData.value = res.list total.value = res.total } onMounted(fetchData) </script> <template> <PaginationTable v-model:current-page="currentPage" v-model:page-size="pageSize" :columns="columns" :data="tableData" :total="total" @update:current-page="fetchData" @update:page-size="fetchData" > <template #status="{ row }"> <span :class="row.status === 1 ? 'tag-success' : 'tag-default'"> {{ row.status === 1 ? '已支付' : '未支付' }} </span> </template> <template #actions="{ row }"> <button @click="handleView(row)">查看</button> <button @click="handleEdit(row)">编辑</button> </template> </PaginationTable> </template>这个使用方式体现了我在第 3.4 和第 4.2 里讲的两点:通过v-model:current-page实现页码双向同步;通过具名作用域插槽#status、#actions自定义列内容,同时安全地拿到了当前行的row数据。
你可能会问,为什么操作按钮这种明显是当前页面的业务逻辑,要放在插槽里来定义?因为表格组件本身是一个通用基础组件,它不应该知道"订单"应该有查看、编辑按钮。插槽把这样的业务决策权留给了使用方,这才是组件复用的关键逻辑。
6.4 组件完成后一定要过一遍自查清单
组件写完不代表完事,我在实际项目中有一套自查清单:
- props 校验写了吗:所有必要的字段有没有类型和默认值?漏了一个
default,使用方一旦没传,运行起来可能就是undefined。 - 单向数据流守住了吗:组件内部有没有直接改 props?如果有,改成 emit 事件。
- 事件命名统一吗:全部用 kebab-case,避免大小写问题。
- 插槽都有备用内容吗:至少空数据提示要用备用内容兜底。
- style 都加 scoped 了:尤其注意全局样式是否意外泄漏。
这个清单我一般会让组件的使用者也会签一遍,哪怕项目很赶,这个流程基本能过滤掉 80% 的封装问题。
7. 常见问题与排查技巧实录
7.1 props 传了对象,但页面不更新
这个是我被问得最多的问题之一。父组件的 data 明明变了,子组件里拿到的对象却还是旧的。
排查思路是:props 是引用类型时,Vue 的响应式追踪的是引用本身,不是深层次属性。如果父组件里做了类似obj.name = '新名字'这种直接修改已有属性的操作,在某些情况下确实能触发更新,但如果是直接给对象新增/删除一个属性(Vue 3 里用reactive默认能追踪增删,但如果你用的是普通对象配合 ref,就可能失效),或者this.obj = newObj时引用变了,这种则没问题。
更隐蔽的场景是:子组件在onMounted里把 props 拷贝了一份到本地,然后一直用副本渲染。这样父组件每次更新 props,本地副本都不会变。解决方法是改用computed基于 props 计算,或者直接把渲染数据源指向 props。
我自己的习惯是:组件内尽量直接用 props 作为渲染数据源,不到万不得已不做本地副本。
7.2 事件不触发的三个常见原因
如果你发现子组件 emit 了,但父组件监听没反应,按顺序排查这三处:
- 事件名对不对。如果子组件
emit('page-change'),父组件写@pageChange,在 DOM 模板里就会对不上,因为 DOM 模板会把大小写归一化。解决办法是统一用 kebab-case。 - 有没有在正确的组件实例上监听。看起来像废话,但对于使用
v-for渲染多组组件时,经常有人监听了第一个组件,却以为第二个组件的事件会触发。 - 是不是在原生元素上误用了组件事件。比如你在子组件根元素上直接
@click,如果你用的是普通元素包装,那是原生事件,直接绑定即可;如果你把@click加在组件标签上,它会被当作自定义事件,需要子组件 emit('click') 才能触发。
这里有个特别容易踩的坑:给组件标签加@click,期望点组件内部触发。实际上如果不做attr透传或 emit,这个事件是接不到的。在 Vue 3 里,如果你的子组件根节点是单个元素,父组件的v-on监听器会被自动透传给根元素,所以很多场景碰巧能工作,但一旦子组件是多根节点,这种行为就失效了。为了稳定性,组件内部需要触发的主要交互事件,都应该显式 emit,不要依赖透传。
7.3 样式不生效和样式泄漏问题
scoped样式不生效,最常见的场景是你在父组件里想改子组件内部某元素的颜色:
<style scoped> /* 这里的样式不会作用到子组件内部元素 */ .child-content { color: red; } </style>这是因为 scoped 会给选择器加上父组件的 data 属性,而子组件内部元素没有这个属性。解决思路是:
子组件主动提供样式接口,比如通过 props 接收一个headerClass,然后在子组件模板里挂到对应元素上。
或者使用 CSS 变量,父组件通过自定义属性传颜色进去。推荐这个方式,因为 CSS 变量天然能穿透组件边界。
真要暴力解决的话,Vue 3 也支持非 scoped 的样式块,但尽量少用。你会在排查 bug 时知道"全局样式污染"有多难受。
7.4 keep-alive 缓存组件后,数据不刷新
动态组件配合 keep-alive 时,页面可能从缓存恢复,onMounted不会重新执行,导致你预期的"每次进入都拉新数据"失效。
解决办法是用新增的onActivated钩子:
import { onActivated, onMounted } from 'vue' onMounted(() => { // 首次挂载时执行 }) onActivated(() => { // 每次从缓存激活时执行 })这算不上 bug,是设计使然,但如果你不知道这个钩子,排查起来还挺费劲的。
7.5 我建议你养成的组件排查习惯
最后聊点个人习惯。从 Vue 2 转到 Vue 3 之后,我排查组件问题基本固定套路:先看 props 是不是响应式的、再看事件名和监听位置、再看样式隔离、最后看是不是被缓存了。搭配浏览器里的 Vue Devtools,几乎能定位到所有层级问题。
另外我还习惯给每个组件写一个README.md或注释块,把 props、emits、插槽的用途写清楚。别小看这个文件,半年后你回来看自己封装的组件,靠的就是这些记录,不然真的会忘。对于团队协作,这也是一份最小可用的组件文档,比专门写设计文档便宜得多。
组件封装这事,写到后面拼的不是 API 熟练度,而是你对"谁能改什么、数据怎么流、边界在哪里"这些设计问题的把握。多封装几个真实业务里要用的组件,多踩几次样式串扰和数据不同步的坑,你自然就体会到我说的这些经验了。