1. 先把Vue3组件这件事重新捋一遍
这几年带团队做前端项目,组件库更新了一茬又一茬,框架也换过几轮,但真正让我反复回头研究的,还是Vue3的组件体系。原因很简单:组件是Vue这个框架里最基础、也最容易被写歪的东西。基础到几乎每个人都会写,容易歪到很多人写了三四年还停留在"能跑就行"的阶段。Vue3组件到底指的是什么?说白了,它就是把一段视图、一套状态、一组交互逻辑封装成一个独立单元,对外暴露清晰的输入输出接口,对内维护自己的响应式数据与生命周期。它能做的事包括但不限于:拆分页面结构、复用业务逻辑、隔离样式作用域、承载通信协议。解决的问题是让一个大前端项目不至于变成几千行一个文件的泥潭。
这篇文章适合谁看?如果你刚接触Vue3,正在纠结setup到底怎么用、ref和reactive该选哪个,那这里的基础拆解能帮你少走弯路;如果你已经写过不少组件,但总觉得自己的组件"复用起来别扭""改一处崩一片",那后面关于粒度划分、透传属性、通信选型的内容应该能戳到你的痛点。我尽量不堆概念,多讲我实际踩过的坑和验证过的写法。Vue3组件这个主题听起来朴素,但真正把它写扎实,项目质量的差距会非常明显。
2. 组件设计思路:从"能跑"到"好维护"的分水岭
2.1 组合式API改变了什么心智
Vue2时代写组件,大家的思维定式是按选项分块:data放数据、methods放方法、computed放计算属性、watch放监听。一个组件的逻辑如果涉及三四个功能点,相关代码就被打散到四五个选项里,改一个功能得上下翻半天。Vue3的组合式API把这件事彻底翻过来了——它按"功能"组织代码,而不是按"选项类型"组织。我写一个带搜索、分页、导出的表格组件时,搜索相关的ref、方法、watch可以挨在一起,分页的挨在一起,导出逻辑再单独一块,阅读时是一条线走下来的。
这个转变的价值不在于语法新旧,而在于逻辑聚合。举个生活化的类比:Vue2的写法像把厨房里所有刀具放一个抽屉、所有调料放一个柜子、所有锅具放一个架子,做饭时来回跑;Vue3的写法像按"做一道菜"把所有要用的东西摆在一个托盘上,端起来就能开工。所以我在设计组件时,会先问自己:这个组件内部有哪几个独立的功能域?每个功能域用一个useXxx的抽离思路去组织,哪怕最后不抽成单独文件,代码分段也要按功能域来。
注意:组合式API不等于必须用
<script setup>。<script setup>是语法糖,编译期会展开成setup()函数,它在性能上有轻微优势(更少的样板代码、更好的类型推导),但也牺牲了一些灵活性,比如无法在运行时动态决定暴露哪些内容。团队里如果有成员对编译产物不熟,建议先在中小型组件里过渡使用。
2.2 组件粒度划分的实战判断标准
组件拆得太粗,一个文件上千行,改起来心惊胆战;拆得太细,满屏都是只包了一层div的"俄罗斯套娃组件", props一路往下传,维护成本反而更高。我判断粒度的标准有三条:单一职责、可独立测试、复用频次。单一职责指的是这个组件只对一类变化负责,比如"用户信息卡片"只负责展示用户信息,不负责拉取数据;可独立测试指的是我能给它喂几组props就验证它的渲染结果;复用频次指的是同样的结构在项目里出现两次以上才值得抽。
还有一条容易被忽略的:状态归属。如果一个子组件需要知道父组件的很多内部状态才能工作,那说明这个子组件拆早了,或者它的职责边界没划清。我见过一个"按钮组件"接收了十几个props来控制禁用、加载、图标位置、颜色主题、点击防抖……这种组件表面复用性强,实际上是把自己的职责膨胀成了"万能按钮",最后谁都不敢改。合理的做法是把基础按钮做薄,把业务语义(比如"提交按钮""删除按钮")放到组合层去封装。
2.3 命名与目录约定,别小看这一层
组件命名我坚持两条:文件名用大驼峰或中划线统一一种(团队内统一即可,别混用),组件名至少两个单词。单单词组件名(比如Item.vue、Card.vue)在大型项目里几乎必然撞名,而且和原生HTML标签容易混淆。目录上我一般按"基础组件 / 业务组件 / 布局组件"三层分:基础组件放components/base,不依赖任何业务状态;业务组件放components/business,可以依赖store和接口;布局组件放components/layout,只负责骨架和插槽。这个分层不是为了好看,而是为了约束依赖方向——基础组件绝对不能反向依赖业务组件,否则打包和复用都会出问题。
3. 核心机制拆解:组件内部到底发生了什么
3.1 setup的执行时机与响应式建立
setup在组件实例创建之前执行,这个"之前"很关键。此时this是undefined,你拿不到组件实例,也拿不到props的解构响应性(解构会丢失响应式)。所以我在setup里访问props时,要么用props.xxx,要么用toRefs(props)转一下。响应式数据的建立也在这个阶段,ref和reactive的差异经常被新手问:ref适合包装基本类型和需要整体替换的对象,访问时要用.value;reactive适合包装对象,但整体替换会丢失响应性,而且解构同样会丢。
我的经验是:默认用ref,需要聚合一组相关字段且不做整体替换时用reactive。原因是ref的.value虽然写起来多一个字符,但它对"替换整个值"这种操作是安全的,而reactive一旦被整体赋值(state = {...}),原来的代理就断了。团队里因为reactive整体替换导致视图不更新的bug,我至少处理过五六次,全是同一个坑。
import { ref, reactive, toRefs } from 'vue' // 推荐:ref 承载会被整体替换的数据 const list = ref([]) const loading = ref(false) // reactive 用于字段多、结构稳定的表单 const form = reactive({ name: '', age: 0 }) // 解构时务必用 toRefs,否则丢失响应式 const { name, age } = toRefs(form)3.2 props、emit、slot三件套的正确姿势
props是组件对外的输入契约,我一贯主张显式声明类型、默认值、校验。Vue3的props声明支持对象写法,type、default、required、validator都能写清楚。别嫌麻烦,这层校验在多人协作时是免费的文档,也是运行时的一道防线。emit是输出,事件名建议用中划线(update:modelValue这种除外),并且在emits选项里显式声明,否则会有警告,也不利于类型推导。
slot是最容易被低估的部分。很多组件的"复用性差",本质是插槽设计没做好。我设计插槽的原则是:能通过插槽让使用方决定的,就不要用props硬编码。比如一个卡片组件,头部、主体、底部都留具名插槽,使用方想放标题放标题,想放操作按钮放操作按钮;同时提供作用域插槽把内部数据(比如当前项、索引)暴露出去,使用方就能自定义每一行的渲染。
<script setup> const props = defineProps({ title: { type: String, default: '' }, items: { type: Array, default: () => [] } }) const emit = defineEmits(['select']) </script> <template> <div class="card"> <header><slot name="header">{{ title }}</slot></header> <ul> <li v-for="(item, i) in items" :key="i" @click="emit('select', item)"> <!-- 作用域插槽,把 item 和索引交给使用方 --> <slot name="item" :item="item" :index="i">{{ item.label }}</slot> </li> </ul> <footer><slot name="footer" /></footer> </div> </template>提示:
default: () => []这种写法不是多此一举。对象和数组类型的props默认值必须用工厂函数返回,否则所有组件实例会共享同一个引用,一个实例改了,其他实例跟着变,这个bug极难排查。
3.3 生命周期钩子与副作用清理
Vue3的生命周期在组合式API里变成了注册式的onMounted、onUnmounted等函数。我特别想强调onUnmounted(或onBeforeUnmount)里的清理工作:定时器、事件监听、WebSocket连接、第三方实例(比如图表实例、地图实例)都要在卸载时销毁。我接手过一个项目,页面切换十几次后明显卡顿,最后定位到一个组件里setInterval没清理,十几个定时器在后台跑。
还有一个细节:onMounted在服务端渲染时不会执行,如果组件需要兼容SSR,访问window、document的代码要放到onMounted里或者做好环境判断。另外watch和watchEffect返回的停止函数,在组件卸载时会自动停止(如果在setup同步作用域内创建),但如果你是异步创建的watch,记得手动调用停止函数。
4. 手写一个可复用的业务组件:完整实操
4.1 需求拆解与接口设计
假设我们要做一个"带搜索和分页的用户选择器"组件,用在表单里选人。先把需求拆开:对外需要接收已选中的用户、是否多选、每页条数;对内需要维护搜索关键词、当前页、列表数据、加载状态、选中集合。接口设计上,我倾向用v-model承载选中值(Vue3里默认是modelValue+update:modelValue),这样使用方写起来最自然。
设计接口时我会先在纸上把这个组件"当黑盒用一遍":使用方需要传什么、需要监听什么、需要拿到什么内部状态来自定义渲染。这一步做完,props和emits的清单基本就出来了。不要一边写实现一边加接口,那样最后接口一定是围绕实现长的,而不是围绕使用场景长的。
4.2 核心代码实现与逐段说明
<script setup> import { ref, watch, computed, onMounted, onUnmounted } from 'vue' const props = defineProps({ modelValue: { type: [Array, Object], default: null }, multiple: { type: Boolean, default: false }, pageSize: { type: Number, default: 10 } }) const emit = defineEmits(['update:modelValue', 'change']) const keyword = ref('') const page = ref(1) const total = ref(0) const list = ref([]) const loading = ref(false) const selected = ref(props.multiple ? [] : null) // 已选中的 id 集合,用于列表里高亮 const selectedIds = computed(() => { if (props.multiple) return new Set((selected.value || []).map(u => u.id)) return new Set(selected.value ? [selected.value.id] : []) }) let timer = null // 搜索输入防抖,避免每敲一个字就请求 watch(keyword, () => { clearTimeout(timer) timer = setTimeout(() => { page.value = 1 fetchList() }, 300) }) watch(page, fetchList) async function fetchList() { loading.value = true try { // 这里换成真实接口,参数按后端约定传 const res = await mockRequest({ keyword: keyword.value, page: page.value, size: props.pageSize }) list.value = res.rows total.value = res.total } finally { loading.value = false } } function toggle(user) { if (props.multiple) { const idx = selected.value.findIndex(u => u.id === user.id) idx > -1 ? selected.value.splice(idx, 1) : selected.value.push(user) } else { selected.value = selected.value?.id === user.id ? null : user } emit('update:modelValue', selected.value) emit('change', selected.value) } onMounted(fetchList) onUnmounted(() => clearTimeout(timer)) </script>这段代码里有几个我特意处理的点。第一,keyword的watch用了防抖,300毫秒是实测下来比较舒服的值,太短请求太密,太长用户觉得卡。第二,selected用ref而不是直接改props.modelValue,遵循单向数据流,改完通过emit通知父级。第三,selectedIds用computed生成Set,列表渲染时高亮判断是O(1),比每次都some遍历一遍快得多。第四,onUnmounted清理定时器,前面讲过的坑这里就落实了。
4.3 样式隔离与透传属性的处理
样式隔离我一般用<style scoped>,但它不是万能的。scoped会给元素加>