☰
Vue3组件化实践:边界划分、通信契约与响应式避坑
2026/11/2 8:22:22 网站建设 项目流程

上周帮朋友看一个后台管理项目,打开components目录,第一个文件八百多行,template三百行、script setup四百行、style一百行,里面塞着一张表格、一个抽屉、一套搜索表单和两个弹窗。我问他为什么不拆,他说拆过,拆完之后父子组件传参传得头晕,索性又合回去了。这个场景我见过太多次了——问题从来不在"该不该拆",而在于很多人写 Vue3 组件时,脑子里没有一套清晰的边界划分和通信约定。组件化这三个字人人会说,但真正落到代码上,props 该定几个、emits 该叫什么名字、什么时候用插槽什么时候用 props、defineExpose到底该暴露多少东西,这些细节决定了你的组件库是一年后还能维护,还是三个月就没人敢碰。这篇就围绕 Vue3 组件这条主线,把我这些年踩过的坑、做过的取舍,按"粒度划分、契约设计、通信通道、插槽机制、响应式陷阱、性能取舍、故障排查"几个层面拆开讲,适合已经能写组件但想写得更稳的人,也适合刚开始接触组合式 API、还在被各种警告刷屏的同学。

1. 先把组件的边界划对:八百行文件是怎么长出来的

1.1 组件变胖的真实路径,往往不是一次性写坏的

没有谁会在第一天就写出八百行的组件。它是被需求一点点喂胖的:第一版只有列表,产品说加个搜索框,你加在同一个文件里;第二版说搜索要支持高级筛选,你又加了个v-if;第三版说要支持导出,你在script里塞了四十行拼 Excel 的逻辑。每一次改动单看都很合理,因为改动量小、改在本文件里最省事,但累积下来,这个组件就同时承担了数据获取、筛选状态管理、渲染布局、导出逻辑四件互不相关的事。

我在实际项目里总结的判断标准比较土但很好用:一个组件应该只有一个"变化的理由"。如果产品改搜索条件你要动它,改表格列宽你也要动它,改导出格式你还要动它,那它就有至少三个变化的理由,就该拆。反过来,如果两个功能总是在同一时间、因为同一个需求一起改,那它们放一起反而不算错。

有些人会拿行数当标准,说超过三百行就拆。我觉得行数只能当个提示信号,不能当规则。一个纯渲染的复杂图表组件写六百行很正常,拆开反而增加了传参成本。真正该看的是耦合方向:如果两块逻辑之间只通过两三个字段交互,那就是弱耦合,可以拆;如果它们共享同一个状态对象、互相读写十几处,硬拆只会让 props 和 emits 爆炸。

1.2 三个明确的拆分信号,看到就可以动手

我一般会盯这三个信号,出现了基本就该拆:

  • props 数量超过七个。这不是玄学,人的短期记忆大概能同时处理五到七个独立信息块,超过这个数量,调用方每次用组件都要翻文档。
  • v-if嵌套超过三层。三层以上的条件渲染,意味着这个组件在表达多种"形态",而不同形态通常对应不同的数据结构和交互,本质上是多个组件被塞进了一个壳。
  • 出现名为mode、type、variant的 prop 且分支逻辑超过两处。这种"模式参数"是典型的分裂信号——组件内部在用v-if模拟多态。

对应的重构手法也很固定:把不同mode下的模板抽成独立组件,父组件负责根据 mode 决定渲染谁;把和渲染无关的纯逻辑(数据转换、导出拼装、校验规则)抽成独立的组合式函数放在composables目录里。注意后半句——不是所有拆分都要拆成组件,很多时候你需要的只是一个.ts文件,而不是一个新的.vue文件。把纯计算逻辑塞进useXxx()里,组件本身可能从八百行降到两百行,而且这些函数还能被别的组件复用、被单测覆盖。

1.3 过度抽象同样是个坑,而且更难回头

说完拆,得说反面。我见过一个团队,把一个只有三个字段的卡片组件拆成了"容器组件 + 头部组件 + 内容组件 + 底部组件",四个文件,互相靠 props 串起来。后来要加一个右上角的角标,得改三个文件、加两个 prop、新增一个 emits。这就是典型的为了架构好看而牺牲实际效率。

判断要不要抽象,我有个很朴素的问题:这个子组件除了当前这个父组件,还有第二个使用者吗?或者未来半年内可预见地会有吗?如果没有,就先别拆。抽象的成本是双向的:你多写了一个文件、多定义了一层接口、多了一处需要同步修改的地方;收益只有一个——复用。收益不存在的时候,成本就是纯亏损。

提示:拆组件之前先问自己一句"我是在为未来的复用做准备,还是在逃避当前这个文件太乱的事实"。如果是后者,正确的动作是把逻辑抽成函数,而不是把模板切成碎片。

2. props 的契约层:类型、默认值和那个几乎人人踩过的引用共享坑

2.1 用 TS 接口还是运行时校验,其实不是二选一

Vue3 的defineProps支持两种写法:运行时声明(对象形式)和类型声明(泛型形式)。很多人以为用了 TypeScript 就可以不要运行时校验,这是个误解——两者管的根本不是同一件事。

类型声明只在编译期起作用,它保护的是你自己项目里的调用方。但如果这个组件会被非 TS 项目引用,或者 props 的值来自接口返回、来自用户输入、来自 URL 参数,那编译期类型完全帮不上忙,运行时该炸还是炸。所以我的一般做法是:内部业务组件用纯类型声明,对外发布的组件库组件加运行时校验。

<script setup lang="ts"> interface Props { title: string status?: 'idle' | 'loading' | 'error' count?: number } const props = withDefaults(defineProps<Props>(), { status: 'idle', count: 0 }) </script>

需要注意withDefaults只在类型声明模式下可用,运行时声明模式直接在defineProps对象里写default就行。这两个不能混着来,混用会直接编译报错,我见过有人两种都写导致死活编译不过,排查了半天。

2.2 对象和数组的默认值必须用工厂函数,这不是可选项

这是 Vue3 组件里最容易踩、也最容易被忽略的一个坑。看下面这段:

<script setup lang="ts"> // 错误写法 const props = withDefaults(defineProps<{ list?: string[] config?: { size: string } }>(), { list: [], config: { size: 'small' } }) </script>

这段代码在类型上完全合法,但在运行时,所有使用这个组件的实例会共享同一个数组对象和同一个配置对象。也就是说,A 组件里往props.list里推了一个值(虽然在开发环境下 Vue 会警告你不要改 props,但总有各种间接方式绕过去,比如把引用传给别的方法),B 组件里也会看到。这种 bug 极其难查,因为它依赖于组件的挂载顺序和数据流向,本地单测跑不出来,上线后偶发。

正确写法是每个默认值都给一个工厂函数:

<script setup lang="ts"> const props = withDefaults(defineProps<{ list?: string[] config?: { size: 'small' | 'large' } }>(), { list: () => [], config: () => ({ size: 'small' }) }) </script>

原理很简单:withDefaults内部会把非函数类型的默认值原样使用,把函数类型的默认值当作工厂调用。所以哪怕你写的是list: () => [],每次组件实例化都会执行一次,拿到的是全新的数组。这一点和 Vue2 的data()必须返回函数是同一个道理,只是换了个地方。

2.3 props 只读不是"建议",是必须遵守的硬约束

Vue3 在开发环境下会给 props 对象套一层 Proxy,你直接赋值会收到警告。但注意,这个保护是浅层的:props.config.size = 'large'这种写法不会触发任何警告,因为它改的是config对象内部的属性,而不是 props 本身的属性。这就是很多"莫名其妙数据变了"的源头——父组件传了个对象进来,子组件改了内部字段,父组件那边也跟着变了,因为从头到尾就只有一个对象。

我的处理原则很明确:子组件需要对 props 做加工,一律先拷贝或先派生。

<script setup lang="ts"> import { computed } from 'vue' const props = defineProps<{ config: { size: string; theme: string } }>() // 需要派生数据用 computed const displaySize = computed(() => props.config.size === 'large' ? '48px' : '24px') // 需要一份可修改的副本用展开或 structuredClone const localConfig = { ...props.config } </script>

computed适合只读派生,浅展开适合一层结构的可写副本。如果结构嵌套很深,用structuredClone或者专门的深拷贝工具,但要留意拷贝成本和特殊类型(函数、日期、Map 等)的处理,别为了图省事无脑深拷贝,性能敏感的长列表里这么干会明显卡顿。

2.4 一个容易忽略的细节:props 的命名大小写

在模板里写 kebab-case(item-count),在script里写 camelCase(itemCount),Vue 会自动转换。但如果你在模板里写:itemCount="n",在 DOM 编译后的 HTML 属性会变成全小写,某些场景下(尤其是需要透传到原生元素、或者服务端渲染拼接字符串时)会丢信息。所以我的习惯是:模板里一律用 kebab-case,script 里一律用 camelCase,不要混着来。这条不是硬性规定,但统一之后能省掉一类非常隐蔽的问题。

3. emits 与 v-model:把"双向"拆成两条单向数据流来理解

3.1 v-model 在 Vue3 里到底是什么

Vue2 时代的.sync修饰符在 Vue3 里被移除了,取而代之的是v-model支持参数化。很多人改代码的时候只是把:visible.sync换成了v-model:visible,能跑起来就没再深究,结果遇到"子组件改了值父组件没更新"或者"改了父组件的值子组件没动"的时候完全不知道从哪查。

本质上,v-model:xxx就是把两个东西绑定在一起:

  • 一个名为xxx的 prop(默认情况下是modelValue)
  • 一个名为update:xxx的事件
<!-- 父组件 --> <user-drawer v-model:visible="drawerVisible" v-model:keyword="keyword" /> <!-- 等价于 --> <user-drawer :visible="drawerVisible" @update:visible="drawerVisible = $event" :keyword="keyword" @update:keyword="keyword = $event" />

理解了这一点,所有相关问题就都能解释了。子组件里改不了visible(因为它是 prop,只读),它只能emit('update:visible', false),让父组件去改。父组件改了之后,新值再流下来,子组件才看到变化。数据永远是单向的,双向只是语法糖给你造成的错觉。

3.2 多 v-model 绑定的正确写法与命名规范

Vue3 支持在一个组件上绑多个v-model,这在封装表单弹窗、抽屉、复合输入框时非常实用。子组件要接收多个 v-model,得这样声明:

<script setup lang="ts"> const props = defineProps<{ visible: boolean keyword: string }>() const emit = defineEmits<{ (e: 'update:visible', value: boolean): void (e: 'update:keyword', value: string): void }>() function close() { emit('update:visible', false) } </script> <template> <div v-show="visible"> <input :value="keyword" @input="emit('update:keyword', ($event.target as HTMLInputElement).value)" /> <button @click="close">关闭</button> </div> </template>

这里有个极容易犯的错:事件名多写或少写一个冒号、大小写不一致。update:visible写成update:Visible,或者模板里用v-model:Visible,Vue 不会报错,只会默默不生效,然后你对着代码看半天也看不出问题。我的做法是事件名统一小写、参数名和 prop 名保持完全一致的 kebab-case 拼写,改的时候一起改,绝不手改其中一个。

另外,defineEmits的类型声明模式在 3.3 之后还支持更简洁的写法:

const emit = defineEmits<{ 'update:visible': [value: boolean] 'update:keyword': [value: string] }>()

两种写法等价,后者更接近defineProps的语法风格,团队里统一一种就行,别混用,混用会导致类型推导在某些编辑器里失效,明明写错了却不报红。

3.3 事件命名:别用"结果"命名,用"发生了什么"命名

这是个设计层面的经验。假设一个表单组件提交成功,你会emit('success')还是emit('submit-success')?我会选后者,甚至更具体一点emit('submitted')。

原因是success太抽象了,调用方看到这个事件名完全不知道是什么成功了、什么时候触发、带什么参数。而submit系列事件能让调用方一眼看出这是提交动作的反馈。事件名应该描述"组件内部发生了什么",而不是"你希望调用方做什么"。

再补一个实操细节:emits声明里的事件,如果不声明直接用emit调用,Vue3 会把它当作原生事件透传到根元素上(类似attrs),在script setup里这个行为有时候会导致事件触发两次。所以所有自定义事件都必须老老实实在defineEmits里声明,不要图省事直接$emit。

4. 插槽不只是"挖个坑":作用域插槽本质是反向传参

4.1 三种插槽的适用场合,很多人只用了第一种

默认插槽最常见,就是<slot />挖个坑,父组件往里塞内容。具名插槽用来处理多区域布局,比如卡片组件的头部、内容、底部:

<!-- 子组件 CardPanel.vue --> <template> <div class="card"> <header v-if="$slots.header"><slot name="header" /></header> <main><slot /></main> <footer v-if="$slots.footer"><slot name="footer" /></footer> </div> </template>

这里有个小技巧:用$slots.header判断父组件有没有传这个插槽,可以让空区域不渲染多余的 DOM 和样式留白。我以前就吃过亏——底部没传内容,但footer的padding还在,导致卡片底部凭空多出一块空白,查了半天以为是 margin 塌陷。

动态插槽名用得少但很有用,语法是<template #[slotName]>,适合根据数据动态决定渲染哪一块内容的场景,比如一个可配置的详情面板。

4.2 作用域插槽:让子组件出数据、父组件出样式

作用域插槽是 Vue3 组件封装里最有价值的机制之一,但很多人没真正用起来。它的核心价值在于:子组件负责逻辑和数据,父组件负责渲染形态。

典型的例子是列表组件。如果不使用作用域插槽,你只能给列表组件传一个columns配置数组,写死渲染规则;一旦某个列表项需要显示头像、某个需要显示状态标签、某个需要显示进度条,配置数组就会膨胀成一堆type: 'avatar' | 'tag' | 'progress'的枚举,最后组件内部充满v-if分支。这就是我前面说的"模式 prop 爆炸"。

改用作用域插槽之后:

<!-- 子组件 DataList.vue --> <script setup lang="ts"> defineProps<{ items: Array<{ id: number; name: string; status: string }> }>() </script> <template> <ul class="list"> <li v-for="(item, index) in items" :key="item.id" class="list-item"> <slot name="item" :item="item" :index="index"> <!-- 默认渲染,父组件不传时兜底 --> {{ item.name }} </slot> </li> </ul> </template>
<!-- 父组件使用 --> <data-list :items="list"> <template #item="{ item, index }"> <span class="idx">{{ index + 1 }}</span> <strong>{{ item.name }}</strong> <status-tag :value="item.status" /> </template> </data-list>

子组件完全不需要知道每一项长什么样,它只负责循环、key、容器样式、空状态。父组件拿到item之后自由渲染。这样一来,同一个列表组件可以在十个页面里用出十种不同的样子,而组件本身的代码一行都不用改。

4.3 插槽内容的响应式归属,是个必须搞清楚的问题

一个高频困惑:写在插槽里的内容,用的是谁的作用域?

答案是父组件。插槽内容虽然在子组件的<slot>位置渲染,但它的编译作用域和响应式依赖都属于父组件。所以插槽里可以直接访问父组件的ref、reactive,但访问不到子组件内部的变量——除非子组件通过作用域插槽的:item="item"显式传出来。

这个规则直接决定了很多 bug 的排查方向。比如你在父组件里传了个插槽,子组件内部改了数据,插槽内容没更新,你会以为是响应式失效。实际上很可能是因为插槽里引用的那个变量来自父组件,而父组件压根没变,变的只是子组件内部从 props 派生出来的另一份数据。

还有一个衍生问题:插槽内容里的ref获取 DOM 时机。因为插槽内容由父组件渲染,父组件的onMounted触发时,子组件的插槽内容是否已经挂载,取决于子组件的挂载时机。如果子组件是异步加载或条件渲染的,父组件onMounted里拿不到插槽内的 DOM,得用nextTick或者watch加时机判断。这个坑我在封装弹窗组件时踩过——弹窗打开后要聚焦输入框,结果onMounted时弹窗还没渲染,ref是null。

注意:插槽的兜底内容(<slot>标签内部的内容)只在父组件没有提供对应插槽时渲染,它属于子组件作用域。别把需要访问父组件数据的东西写进兜底内容里,会拿不到。

5. 组件通信的四条通道,选错了后面全是坑

5.1 attrs 透传:被低估的"零成本"通道

写一个输入框组件,你要支持placeholder、maxlength、autocomplete、disabled这些原生属性。如果全部用 props 声明一遍,props 数量立刻超标,而且以后原生属性新增了你还得跟着改。

$attrs就是解决这个问题的。在script setup里,根元素会自动接收所有未声明为 props 的属性,你什么都不用写:

<!-- MyInput.vue --> <script setup lang="ts"> defineProps<{ label?: string }>() </script> <template> <label> <span v-if="label">{{ label }}</span> <!-- placeholder、maxlength、disabled 等会自动落到 input 上 --> <input /> </label> </template>

但这里有个细节:自动透传只发生在根元素上,而且只发生在只有一个根元素的时候。如果你的组件有多个根节点(Vue3 支持 fragment),Vue 不知道把属性给谁,就会警告并且不透传。这时候要么手动指定v-bind="$attrs",要么用inheritAttrs: false关掉自动透传再手动绑定。

<script setup lang="ts"> defineOptions({ inheritAttrs: false }) </script> <template> <div class="wrapper"> <input v-bind="$attrs" /> </div> </template>

defineOptions是 3.3 之后提供的,之前得用第二个<script>块写export default { inheritAttrs: false }。这个改动看着小,但确实让script setup的体验好了不少。

5.2 defineExpose:暴露得越少,重构越自由

父组件想调用子组件的方法(比如让表单组件校验一下、让图表组件重新绘制),就得靠defineExpose。但这里的原则很明确:只暴露必要的方法,不暴露内部状态。

<script setup lang="ts"> import { ref } from 'vue' const formRef = ref<HTMLFormElement>() const internalState = ref({ dirty: false }) function validate(): Promise<boolean> { // ... return Promise.resolve(true) } function reset() { internalState.value.dirty = false formRef.value?.reset() } defineExpose({ validate, reset }) </script>

为什么不暴露internalState?因为一旦暴露,父组件就可能去读甚至去写它,你的内部实现就被锁死了——以后想改数据结构、想换成别的实现,都会破坏调用方。而方法是可以稳定保持签名的,内部实现随你怎么换。

还有一个实践细节:defineExpose暴露的方法在类型上是能被父组件感知到的(配合InstanceType<typeof Child>),但如果父组件用ref拿到组件实例并访问暴露的方法,必须确保组件已经挂载。在onMounted之前访问,拿到的是null。我一般会在调用前加个判断,或者用可选链:

const childRef = ref<InstanceType<typeof ChildComp>>() function doValidate() { return childRef.value?.validate() ?? Promise.resolve(false) }

5.3 provide/inject 的边界:解决"跨越五层",不解决"两个兄弟"

provide/inject是用来解决深层嵌套传参问题的,比如主题配置、表单上下文、国际化语言包。一个表单组件下面挂了十几层输入组件,每层都靠 props 传disabled、size,那中间那些纯布局组件就变成了无辜的"传参管道",改一个字段要动五个文件。

用provide之后,顶层注入一次,任意深度的后代都能拿到:

// 顶层 FormProvider import { provide, ref, readonly } from 'vue' const disabled = ref(false) provide('formContext', { disabled: readonly(disabled), setDisabled: (v: boolean) => { disabled.value = v } })

注意我用了readonly包一层。这很重要——如果直接把ref传下去,任何一个后代组件都能改它,出问题时你根本不知道该怪谁。provide 出去的应该是"读接口 + 受控的写入口",而不是一个敞开的可变对象。

但provide/inject有个明确的能力边界:它只对后代组件生效。兄弟组件之间、父组件想主动调用子组件,它都帮不上忙。这种情况下用 props/emits、或者用defineExpose,别硬套 provide。

我把这几条通道的适用场景整理成表格,方便对照:

通道数据方向适用层级典型场景主要代价
props / emits双向(拆成两条单向)直接父子业务组件的显式契约层级深时传参冗长
v-model双向直接父子表单类受控组件多绑定命名易错
attrs 透传父到子(自动)直接父子原生属性、无障碍属性多根节点会失效
defineExpose父调用子直接父子命令式操作(校验、重绘)暴露过多会锁死实现
provide / inject祖先到后代任意深度主题、表单上下文、语言包只能向下,不可向上

5.4 什么时候该引入状态管理,而不是继续传参

这条不谈具体工具,只谈判断标准。如果一份状态同时被三个以上互不相邻的组件使用,而且这些组件之间的层级关系会变动,那就别再用 props 硬串了,抽到一个共享状态里更合理。

但如果只是父子一对一的传递,尤其是子组件只负责展示的场景,硬上状态管理反而让数据流变得不可追踪——你打开组件只看到它从某个全局 store 里读数据,完全不知道这份数据是从哪来的、什么时候被改的。显式的 props 传递虽然啰嗦,但它把数据来源写在了代码上,这是可维护性的一部分。

6. 响应式和生命周期:那些看起来正常但就是不更新的情况

6.1 解构 props 会丢响应性,这个坑至今仍在

在 3.5 之前,const { title } = defineProps<Props>()这样的解构会让title失去响应性——它拿到的只是一个快照,props 后续变化时title不会更新。虽然 3.5 之后编译器对这个场景做了处理,但依赖版本特性写代码是有风险的:你的项目如果有多个子包、不同子包锁了不同版本,行为就会不一致。

比较稳妥的做法是坚持用props.xxx,或者用toRefs:

import { toRefs } from 'vue' const props = defineProps<{ title: string; count: number }>() const { title, count } = toRefs(props) // 现在 title 是 Ref<string>,模板里用 title.value 或者直接 title

toRefs创建的是指向原对象的引用,不会丢响应性,而且解构之后代码更干净。这个习惯在 3.5 之后依然值得保留,因为它不依赖编译器的特殊处理。

6.2 watch 和 watchEffect 的选择,取决于你需不需要知道旧值

这个问题我被问过很多次,我的回答一直很简单:需要旧值、需要精确控制触发时机、需要做异步前的取消判断,用watch;只想"这些依赖变了就跑一下",用watchEffect。

watch的优势是精确——它明确告诉你监听的是谁,依赖关系写在代码上,别人看代码一眼就知道什么变化会触发它。watchEffect是自动收集依赖的,好处是省事,坏处是如果函数体里不小心访问了某个响应式数据(比如打了个日志console.log(someRef.value)),那个数据就变成了隐式依赖,别人根本看不出来。我在维护别人的代码时最怕这种隐式依赖。

还有一个必须注意的点:引用类型的 watch 默认不走深比较。watch(() => props.config, cb)只在config这个引用被整体替换时才触发,修改内部字段不会触发。要监听内部变化,要么加{ deep: true },要么精确指定到字段watch(() => props.config.size, cb)。后者性能好得多,我的建议是能用精确字段就别用 deep,一个大的深监听在数据频繁变动时开销很明显。

6.3 onMounted 里拿不到 DOM 的三种常见情形

onMounted里拿不到ref是最典型的"以为是框架问题"现场。实际原因基本是这三类:

  • 条件渲染还没出现:ref在v-if="false"的分支里,onMounted时 DOM 还没创建。解决办法是把逻辑移到watch里监听条件,或者用nextTick。
  • 异步组件还没加载完:子组件是defineAsyncComponent加载的,父组件onMounted时它还在请求中。这种情况得监听子组件的@load或者用Suspense配合。
  • v-for里的元素,ref在onMounted里还是空数组:Vue3 里v-for的ref收集结果是在渲染后填充的,如果列表数据是异步来的,onMounted时列表根本还没数据,ref数组自然是空的。这个得用watch(list, ..., { flush: 'post' })才能拿到 DOM。

第三种我踩过,而且踩得挺深。当时是想给列表里的每一项做入场动画,onMounted里遍历ref数组,结果是空数组,动画完全没跑。后来改成监听数据源,用flush: 'post'保证 DOM 已经更新,才正常。

7. 性能不是玄学:异步组件、keep-alive 和那些被误用的优化手段

7.1 异步组件的分界点在"首屏是否需要"

defineAsyncComponent的作用是把组件的加载推迟到需要时。适合用的场景是:弹窗、抽屉、富文本编辑器、图表库这类"首屏肯定用不到,但体积很大"的组件。

import { defineAsyncComponent } from 'vue' const RichEditor = defineAsyncComponent(() => import('./RichEditor.vue') )

不适合用的场景是:首屏必然要显示的组件。你把它改成异步,只是多了一次网络往返和一次 Loading 闪烁,收益是负的。我见过有人为了"代码分割"把所有组件都改成异步,结果首屏时间反而变长,因为原来一个请求拿完的东西,现在要串行拿好几个。

真正需要区分的是入口组件和重组件。入口组件同步加载,重组件异步加载,这是比较稳的策略。另外别忘了给异步组件配loadingComponent和errorComponent,否则加载期间就是一片空白,用户会以为页面挂了。

7.2 keep-alive 的 include 匹配,很容易静默失效

keep-alive的include匹配的是组件的 name。但在script setup里,组件默认没有显式 name——3.2.34 之后支持根据文件名推导,但这个推导依赖构建工具和文件路径,在某些打包配置下会失效。

结果就是:你写了<keep-alive include="UserList">,然后切走再切回来,发现组件重新挂载了,数据全丢。查半天不知道为啥。

比较可靠的做法是显式声明 name:

<script setup lang="ts"> defineOptions({ name: 'UserList' }) </script>

这样一来,不管是include匹配、devtools里调试、还是错误堆栈里的组件名,都是确定的。这个成本几乎为零,我建议所有需要被keep-alive缓存的组件都加上。

另外要注意keep-alive缓存的副作用:组件被缓存后,onUnmounted不会触发,onActivated/onDeactivated才会。如果你在onUnmounted里清定时器、取消请求,缓存场景下这些逻辑不会执行,定时器会一直在后台跑。这个坑在带轮询的列表页里特别容易踩。

7.3 shallowRef 和 v-memo,别当成万能药

shallowRef只对.value的赋值做响应式,不代理内部属性。适合的场景是:存一个大型数据结构(比如几千条记录的数组),只在你整体替换时才需要重渲染,内部元素不会被单独修改。这种场景用普通ref会为每个元素都创建 Proxy,初始化的开销很明显。

但如果你以为"用 shallowRef 就更快",然后到处换,很容易出现"数据改了视图不更新"的问题——因为你改的是内部属性,而shallowRef压根不监听这个。我见过有人把它和reactive混用,然后在一个文件里出现了两套响应式系统,调试起来非常痛苦。

v-memo也是类似。它的作用是在依赖数组未变化时跳过子树更新,适合纯静态的长列表。但它的依赖数组是手写的,写错了就会导致该更新的不更新,而且这种 bug 极难发现——因为大部分时候数据是对的,只有特定交互路径下才出问题。如果不是在性能分析工具里实际测出了热点,我不建议提前上这些优化,收益不确定,维护成本确定。

8. 一次完整的排查链路:组件数据变了但视图没动

8.1 从现象到定位,按顺序走这五步

这类问题我处理过太多次,形成了一套固定的排查顺序。倒不是这套顺序多高明,而是它可以避免你在一个方向上钻牛角尖。

第一步,确认数据源本身变了没有。在赋值语句前后打日志,或者在 Vue Devtools 里看对应组件的状态。很多时候你会发现问题根本不在视图层,而是数据压根没变——比如接口返回了但没赋值,或者赋值赋到了另一个变量上。

第二步,确认这个数据是不是响应式的。如果是普通let变量,或者是一个从响应式对象上解构出来的基本类型,那它本来就不会触发更新。这一步能解决掉大概三分之一的问题。

第三步,确认变化有没有被追踪到。用shallowRef、markRaw包裹的对象、或者用Object.freeze处理过的数据,都不会触发更新。特别是markRaw,很多时候是为了避免某些第三方实例被代理而加的,加完之后忘了它会让整个对象脱离响应式。

第四步,确认key有没有问题。如果v-for的key用了索引,而列表项顺序变了,Vue 会复用错误的节点,导致数据和视图对不上。这不是"不更新",而是"更新到了错误的位置",表现上很相似但原因完全不同。判断方法是看内容是否错位——错位就是 key 问题。

第五步,确认更新时机。如果你在修改数据之后立即读取 DOM,那读到的一定是旧值。Vue 的更新是异步批处理的,必须等nextTick。这个坑在"改完数据马上要测量元素高度"的场景里特别常见。

我把这五步对应的典型症状整理一下:

步骤检查点典型症状
一数据源是否真的被赋值Devtools 里状态没变
二变量是否是响应式的用普通变量存数据,改了完全无反应
三是否被 shallowRef / markRaw 包裹整体替换生效,改内部属性无效
四v-for 的 key 是否稳定内容错位、复用了错误的节点
五是否在 DOM 更新前读取拿到旧 DOM、高度测量为 0

8.2 一个具体案例:抽屉组件里的表格永远显示旧数据

前段时间遇到一个很典型的问题:抽屉组件里放了一个表格,每次打开抽屉传不同的id进去,但表格有时候显示新数据,有时候显示上一次的。用户反复开关几次之后,数据就乱了。

按上面的顺序走:第一步,确认接口返回是对的,id也没传错,排除。第二步,表格的数据是ref存的,赋值方式也是.value = res.data,响应式没问题。第三步,没有shallowRef。第四步,表格内部v-for的key用的是item.id,稳定。第五步……似乎也没问题。

到这里五步走完还没找到,那就得往上翻一层看组件生命周期。结果发现抽屉用了keep-alive,也被缓存了。第一次打开时组件挂载,onMounted里发起请求;后续打开时组件被复用,onMounted不再触发,只触发onActivated,而请求逻辑只写在onMounted里。所以只有第一次是真实拉取的,后面全是第一次的缓存数据。

修复方案很直接:把请求逻辑抽成一个函数,onMounted和onActivated里都调一次,同时在watch里监听id变化,变化时重新拉取。三个入口看起来有点冗余,但每一个都对应一种真实场景:首次挂载、缓存后激活、id 变化。

import { onMounted, onActivated, watch } from 'vue' async function load(id: number) { // 拉取并赋值 } onMounted(() => load(props.id)) onActivated(() => load(props.id)) watch(() => props.id, (id) => load(id))

这个案例说明一件事:排查响应式问题,眼睛不能只盯着数据流,还要看组件的生命周期有没有跑通。数据没错、响应式没错,但更新逻辑没被触发,结果是一样的。

8.3 提前避坑:给组件加一个"自检"习惯

我现在写稍微复杂一点的组件,会在开发阶段临时加几行日志,确认三件事:props 每次变化有没有进来、关键方法有没有被调用、组件有没有被意外复用。这些日志上线前删掉,但开发期间能省掉大量"猜"的时间。

还有一个习惯是给所有被keep-alive包裹的组件显式声明名字,并且检查一遍有没有在onMounted里放一次性初始化逻辑。这两个动作能提前规避掉一大类"为什么只有第一次对"的问题。

9. 关于组件封装,我自己的几条取舍

写到最后,分享几条我个人在实际项目里坚持的原则,可能不适合所有团队,但对我确实管用。

第一,props 宁少勿多,能把多个相关 prop 合并成一个对象就合并。比如size、theme、variant三个 prop,如果总是一起变,那就合成一个appearance对象。这不是为了省字符,而是让"这三个参数是一组"这件事体现在类型上,调用方也不容易只传对一半。

第二,组件不主动发起业务请求。组件接收数据、发出事件,请求由使用它的页面层负责。这样做的好处是组件可以被单测、可以在不同数据源下复用、可以脱离后端独立调试。当然这条有例外,比如一些强业务封装的组件(自动分页的表格),但要清楚自己在做例外,而不是默认。

第三,样式隔离想清楚再动手。scoped会加属性选择器,无法影响子组件内部(除非用:deep());module(CSS Modules)能彻底隔离但写法不一样,调试时类名是哈希。我的选择是:组件内部用scoped,需要穿透子组件时用:deep(),全局主题变量用 CSS 自定义属性放在根节点上。混着用会很乱。

第四,给组件写一个最小可运行的示例留在注释里。不是完整文档,就是三五行调用代码。三个月后你自己都会忘记某个 prop 该传什么格式,那几行注释能救你。

第五,命名统一用"业务语义"而不是"外观语义"。叫UserStatusTag而不是GreenTagSmall。外观会变,业务语义相对稳定,改样式的时候不用连带改一片调用点。

这些原则听起来都很朴素,但每一项背后都是一次具体的返工。Vue3 的组件机制本身并不复杂,script setup已经把写法压缩到了很精简的程度,真正难的是在写之前想清楚这个组件的边界在哪、契约是什么、谁会用它。想清楚了再动手,比写完再重构省事得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询