接手第一个需要长期维护的中后台项目时,我一度以为"复用"就是把长得像的代码复制一份再改改。直到某天,一个用户选择器组件在三个页面里出现了四种行为——有的要清空、有的要单选、有的要带部门树,而我维护的是四份几乎相同的模板时,我才意识到:把代码复制N份,不叫复用,那叫维护噩梦。后来我花了很长时间去理解Vue组件设计的真实逻辑:为什么同样的一个组件,有的团队越拆越省力,有的团队越封装越僵。
这篇内容不打算从"什么是组件"的入门概念讲起,而是围绕一个核心问题展开:什么样的Vue组件才算"可复用",以及怎么一步一步把一个随处可见的业务需求封装成真正能沉淀下来的组件。适合已经写过不少props和emits、但总觉得自己封装的组件"换个场景就要改源码"的前端开发,也适合想在团队里推动组件规范但不知道怎么落地的人。
1. 从"能用"到"可复用":差的不只是代码
先泼一盆冷水:可复用组件和你平时写的业务页面组件,差的不只是"有没有人用",而是设计时的思维起点完全不同。
1.1 两种组件的本质区别
业务页面组件的核心是"解决这一个页面的问题"。今天需求方的弹窗长这样,明天可能就要完全重写,组件内部可以直接写死接口地址、写死样式、写死交互,没问题,因为它的生命周期就是一次性的。
可复用组件恰恰相反,它的核心不是"帮谁解决问题",而是"在未知场景下依然成立"。设计它的时候,你面对的不是一个明确需求,而是无数个潜在需求。
举个例子,同样是下拉选择框:
- 业务页面里这样写:接口地址写死在组件里,选中后直接跳转路由,样式按照当前设计稿精确到像素。
- 可复用组件里这样写:组件只维护"选中了哪一项""有哪些选项""是否展开",至于选项数据从哪来、选中之后要干什么,全部通过接口交回给使用方。
这两种写法的差距,在实践中直接表现为:业务页面组件改了数据源,就得改组件本身;可复用组件改了数据源,只是换了个地方传不同的props而已。
1.2 判断组件是否可复用的三个检验标准
我自己的做法是,写完一个组件后先问三个问题:
- 这个组件的"行为"是否能被完全替换?比如点击按钮后触发的事件,是否依赖某个特定页面里的路由跳转或者存储逻辑?
- 这个组件的"视觉"是否独立于父级裁剪?如果把组件单独放进一个空白页面,不加任何外部样式,它是否还长得基本正常?
- 这个组件的"状态"是否职责清晰?组件内部维护的状态,和使用方关心的状态,是否边界分明?
如果有一个答案是"否",那这个组件大概率还是一次性组件。很多团队会直接用第三方的element-plus、ant-design-vue之类的组件库,就是因为这些库里的组件天然通过了这些检验。自己封装的时候,其实就是在重复这套检验逻辑。
这也是为什么我在正文里反复强调:"可复用"不是一个技术名词,它是一个设计标准。接下来所有章节,都是在讲如何达到这个标准。
2. 组件的接口设计:props、事件、插槽各管一摊事
很多初学者封装组件的思路是"把页面里用到的东西都堆到props里",结果组件越写越臃肿。实际上,Vue组件的三种对外接口——props、emits、slots——承担着完全不同的职责。把它们的分工搞明白,组件设计就成功了一半。
2.1 props负责描述"它是什么"
props是组件的外部输入,应该只描述状态,不应该描述行为。
什么叫状态?比如一个弹窗组件的visible、一个按钮组件的type、一个表格组件的columns,这些都是状态——它们决定了组件在某个时刻"长什么样""显示什么"。
什么叫行为?比如"点击后跳转到详情页"、"请求某个接口拿到数据",这些都是行为。如果你把行为也设计成了props,比如beforeJump、fetchDataUrl,会导致一个典型问题:组件和使用方之间的耦合变成隐性的。一旦行为逻辑变了,使用方得去改组件里的非props代码路。
很常见的反模式是这样:
<!-- 反模式:组件内部做数据处理逻辑 --> <script setup> const props = defineProps({ userId: String, fetchUrl: String, }); // 组件内部自己请求接口 onMounted(() => { const data = await fetch(props.fetchUrl, { params: { userId: props.userId }}); // ... 然后在组件内部处理 data }); </script>这种写法在"只需要一个页面用"的时候效率极高,但一旦第二个页面要用,接入成本会迅速拉高——你需要先读懂组件内部到底怎么处理数据的,才能知道传什么参数。
正确做法是:组件只接收数据和回调,把"数据从哪来"和"事件到哪去"全部交给使用方。
<script setup> const props = defineProps({ // 只关心数据是什么,不关心数据从哪来 options: { type: Array, required: true }, modelValue: { type: [String, Number], default: "" }, }); const emit = defineEmits(["update:modelValue", "change"]); </script>2.2 emits负责描述"它发生了什么"
和props相对,emits是组件向外广播的事件信号,它只应该描述"发生了什么",而不应该代替使用方做决定。比如change、select、clear、search,这些都是事件;而"选择后自动保存"这种一定不能写在组件里,应该让使用方监听change后自己处理。
这里有一个设计细节:事件命名要动词化、结果化。好的命名让人一看就知道什么时候触发,比如update:modelValue、select、clear、remove;差的命名往往是"状态名词",比如visibleChange、dataChanged,这类命名会让人分不清它是"变化前"还是"变化后"。
2.3 插槽解决"开放扩展点"的问题
就算props给得再全面,也不可能覆盖所有使用场景。比如一个按钮组件,你可能需要在左侧加一个图标、在右侧加一段说明文字,这种位置灵活的内容,就应该通过slots开放出去。
可复用组件的设计里,我习惯的原则是:凡是不确定的内容区域,优先给插槽,而不是给props。因为插槽是HTML结构级的扩展,天然可以满足复杂内容需求;而props只能解决"文字/数字/布尔"这类简单数据。比如卡片组件的title,我一般会同时提供title这个prop和一个title插槽,使用者可以传字符串,也可以传一段带图标的复杂HTML。
2.4 用决策表格快速判断接口该怎么开
为了让你在封装时不纠结,我整理了一个简单的决策表:
| 想要实现的外露能力 | 优先选择的接口 | 说明 |
|---|---|---|
| 控制组件显隐/值/数据源 | props | 简单状态下传 |
| 通知父级"值变了/被点击了/加载完成" | emits | 事件单向向上传递 |
| 支持任意结构的内容占位 | slots | 比如头部图标、底部操作区 |
| 双向绑定某个值 | v-model(modelValue/update:modelValue) | 本质是props + emits语法糖 |
| 透传原生属性(class、style、自定义事件) | $attrs | 直接继承到根节点 |
如果你在设计接口时拿不准,先问一句:"这个能力是状态、是信号、还是内容?"问题立刻变清晰。
3. 从零封装一个异步搜索选择器:完整的实操案例
理论讲再多,不如完整拆一个组件。这里我以一个非常常见、适合做"可复用"案例的场景来讲——异步搜索选择器(输入关键字、远程搜索、下拉选择)。这个组件几乎每个中后台项目都有,而且十个项目里有九个是"每个页面写一份"。
3.1 第一步:明确组件边界
可复用设计的第一步,就是把"组件该管的"和"使用方该管的"划分清楚:
- 组件该管:输入框的状态、下拉展开/收起、键盘导航、选中后的展示、清空、加载中状态、防抖调用搜索。
- 使用方该管:远程接口的URL、查询参数处理、选项数据映射、选中后的业务动作。
这个边界划分一旦确定,接口设计就顺理成章了。
3.2 第二步:设计props、emits、slots
我给出的第一版接口如下:
<script setup lang="ts"> interface SelectOption { label: string; value: string | number; disabled?: boolean; [key: string]: any; // 允许额外的数据字段,比如图标、分组信息等 } const props = defineProps<{ modelValue: SelectOption | null; // 受控值:当前选中项 options: SelectOption[]; // 外部传入的搜索结果 loading?: boolean; // 是否处于加载中 placeholder?: string; clearable?: boolean; // 是否允许清空 disabled?: boolean; }>(); const emit = defineEmits<{ (e: "update:modelValue", value: SelectOption | null): void; (e: "change", value: SelectOption | null): void; (e: "search", keyword: string): void; // 向父组件请求搜索 (e: "clear"): void; }>(); </script>这里有几个设计决策需要解释:
- modelValue 为什么是一个对象而不是 value 字符串?因为许多业务场景里,选中项除了value还需要label、头像、分组信息等。组件只负责"存和你传给我的当前项",如果你只传字符串,其他信息又要另外用props传,容易对不齐。
- search 事件用来请求父组件,组件内部不做请求。这样同一个组件既可以用
fetch,也可以用axios,甚至可以接WebSocket,完全不受数据源技术栈限制。 - options 完全由父组件控制,组件不做任何"自己拿到keyword后请求"的自动行为。这是刻意为之——让使用方决定"什么条件下触发搜索",组件本身不越权。
3.3 第三步:内部实现的关键细节
模板部分的核心结构如下:
<template> <div class="async-select" ref="rootRef"> <input v-model="keyword" type="text" :placeholder="placeholder" :disabled="disabled" @focus="handleFocus" @input="handleInput" @keydown.down.prevent="moveHighlight(1)" @keydown.up.prevent="moveHighlight(-1)" @keydown.enter.prevent="selectHighlighted" @keydown.esc="closeDropdown" /> <div v-if="loading" class="async-select__loading">加载中...</div> <ul v-else-if="isOpen" class="async-select__dropdown"> <li v-for="(option, index) in props.options" :key="option.value" :class="{ 'async-select__option--selected': option.value === modelValue?.value, 'async-select__option--highlighted': index === highlightIndex, }" @mousedown="selectOption(option)" > <slot name="option" :option="option"> {{ option.label }} </slot> </li> </ul> </div> </template>接下来是两个最常见的内部逻辑坑,也是这个组件能否"可复用"的关键:
坑一:关键词的防抖应该放在哪?
组件内部维护一个keyword作为输入框的显示值,并在handleInput里调用带防抖的搜索逻辑:
const keyword = ref(props.modelValue?.label ?? ""); // 防抖函数,简单实现,正式项目建议用工具库 let timer: number | undefined; function handleInput() { window.clearTimeout(timer); timer = window.setTimeout(() => { emit("search", keyword.value); }, 300); }防抖放在组件内部,是为了让任何使用方都不用重复写一套防抖逻辑,这是"交互细节"的一部分,属于组件的职责范围。但"300ms"这个值如果可以配置会更好,比如加一个debounceDelayprop,默认300。
坑二:外部加载状态和内部展开状态的协同
父组件拿到search事件后发起请求,请求结束后把结果通过options传回来,同时把loading状态作为prop传入。组件内部不做请求,所以它必须确保一点:当loading变化时,下拉框不会闪断。这里最简单的策略是,只要isOpen且loading为true,就显示加载占位。
function handleFocus() { isOpen.value = true; if (props.options.length === 0) { // 聚焦时立即触发一次搜索,让父组件有机会初始化数据 emit("search", keyword.value); } }坑三:点击外部关闭的监听
这不是一个显而易见的坑。对付不需要的关闭事件,很多人直接在document上监听mousedown,结果发现点组件内部也会触发。原因是绑定时机不对:监听应该放在onMounted里,并且触发判定要排除组件自身根节点及其子节点。
function onClickOutside(e: MouseEvent) { const root = rootRef.value; if (root && e.target && !root.contains(e.target as Node)) { isOpen.value = false; } } onMounted(() => document.addEventListener("mousedown", onClickOutside)); onBeforeUnmount(() => document.removeEventListener("mousedown", onClickOutside));3.4 组件的最小可运行示例
一个完整的最小调用方式如下,父组件里负责请求远程数据:
<template> <AsyncSelect v-model="currentUser" :options="searchResults" :loading="loading" placeholder="搜用户名或邮箱" clearable @search="onSearch" /> </template> <script setup lang="ts"> import { ref } from "vue"; import AsyncSelect from "@/components/AsyncSelect.vue"; const currentUser = ref(null); const searchResults = ref([]); const loading = ref(false); async function onSearch(keyword: string) { loading.value = true; try { const res = await fetch(`/api/users?keyword=${encodeURIComponent(keyword)}`); searchResults.value = await res.json(); } finally { loading.value = false; } } </script>这样设计之后,同一个AsyncSelect组件可以放进用户管理、订单审核、消息推送等多个场景,只要父组件里改掉搜索事件的具体实现即可,组件源码一个字都不用动。
4. 组件分层:不是所有组件都值得"可复用"
提到可复用组件,很多团队容易陷入一个误区:"我们把页面拆成很多很多小组件,这样复用率肯定高了吧?"实际上,过度拆分导致的props层层传递、状态管理混乱,比不拆分更可怕。
4.1 三层组件结构
我在项目里习惯把组件分成三个层级,各自有不同的"复用"策略:
| 层级 | 代表 | 复用策略 | 设计重点 |
|---|---|---|---|
| 基础组件 | 按钮、输入框、弹窗、表单校验组件 | 高度复用,跨项目通用 | 接口稳定、样式独立、支持主题定制 |
| 业务组件 | 用户选择器、部门树、表格操作栏 | 项目内复用,或业务域内沉淀 | 绑定业务模型,但不绑定具体接口 |
| 页面组件 | 用户管理页、订单详情页 | 基本不复用 | 聚合业务组件,专注页面编排 |
一个很常见的问题是:把本应属于页面组件的内容强行下沉成业务组件。
比如"用户管理页",它需要一个搜索栏、一个表格、一个弹窗。有些人会把"搜索栏+表格+弹窗"整合成一个"用户管理大组件",说这样可以复用。结果呢?另一个需求方要的表格字段不一样,弹窗大小不一样,搜索项也不一样,你只能给这个"大组件"加无数个showNameInput、showAgeInput这类开关。用不了多久,这个组件的props会膨胀到20多个,比拆开写更痛苦。
4.2 组件的"粒度"应该怎么定
我的经验是,判断能否封装成可复用组件,要看它是不是满足**"单一业务语义"**:
- "用户选择器"——业务语义单一:"选一个用户",可复用。
- "用户管理页"——业务语义复杂:"展示用户列表、编辑用户信息、删除用户、分配角色",不可复用。
组件的职责应该尽量贴近"一个名词"而不是"一个场景"。名词是稳定的,场景是变化的。比如"用户选择器"是一个名词,它在用户管理页、订单指派、群发起人三个场景都能用;而"用户管理页"是一个场景,无论你怎么抽象,它都很难在别的地方复用。
4.3 组合式函数(composables)是组件的另一个维度
有些逻辑不属于任何视觉界面,却需要在多个组件里共用,比如"点击外部关闭""防抖""表单校验规则"。这些场景最好的载体不是组件,而是组合式函数。
以"点击外部关闭"为例,老老实实写onMounted+document.addEventListener也能用,但不优雅。封装成useClickOutside之后,任何需要"弹出层点击外部关闭"的组件(下拉、弹窗、气泡卡片)都能一键接入:
export function useClickOutside(containerRef: Ref<HTMLElement | null>, callback: () => void) { function onClick(e: MouseEvent) { const container = containerRef.value; if (container && e.target && !container.contains(e.target as Node)) { callback(); } } onMounted(() => document.addEventListener("mousedown", onClick)); onBeforeUnmount(() => document.removeEventListener("mousedown", onClick)); return onClick; }这是可复用设计里很容易被忽略的一点:复用不仅发生在组件层,也发生在逻辑层。有些东西做成组件会显得很重(比如一个小小的debounce),做成组合式函数反而恰到好处。
5. 可复用的细节工程:响应式、样式与类型
一个组件接口设计得再好,如果内部实现里埋了几个响应式或样式的坑,一旦在其他页面里复用,就会以非常隐晦的方式爆出来。这一章全是实操里最常见的"暗雷"。
5.1 props的响应式丢失:解构的陷阱
在<script setup>里,很多人喜欢直接把props解构使用:
const { modelValue, options } = defineProps<{ modelValue: string; options: string[] }>();然后在模板里用modelValue。如果是readonly用途还好,但如果你在watch里用解构出来的变量,就会遇到响应式丢失的问题——watch(modelValue)其实观察的是解构时刻的快照值,而不是实时值。
正确做法是保持props引用不变,或者使用toRefs:
const props = defineProps<{ modelValue: string }>(); watch(() => props.modelValue, (val) => { ... });可复用组件天然会被多个父级使用,父级对modelValue的更新可能发生在任意时刻,一旦你在这个环节丢响应式,组件会在特定交互下出现"界面没跟着外部状态更新"的诡异bug。
5.2 组件内部绝不直接修改props
这个属于Vue的常识,但在封装组件时尤其重要。有些新人会在组件内部写props.modelValue = newVal,虽然Vue会在控制台报警,但更隐蔽的是通过defineModel或者v-model时处理不当。
以我们之前的AsyncSelect为例,当用户点击下拉选项时,正确的"改值"方式是:
function selectOption(option: SelectOption) { emit("update:modelValue", option); // 通知父组件更新 emit("change", option); keyword.value = option.label; // 组件内部只更新展示用状态 isOpen.value = false; }组件内部绝不能让modelValue变成"自己修改自己的全局变量",而是要把修改动作提升给父级。这是单向数据流在组件封装中最基本的体现。否则,一旦父组件也想改这个值,内部和外部就会打架,产生"我明明选了A,界面却显示B"的错乱问题。
5.3 样式自包含:不依赖父级上下文
可复用组件最常见的样式坑是:样式写得不完整,需要在父级页面补一堆覆盖代码。
比如下拉选项的li,如果组件里没有设置基础margin和padding,父页面通常会做一些重置;但如果父页面重置掉list-style,不同地方的效果就会不一样。
我的做法是,可复用组件的最外层样式必须自包含,同时用一段统一的类名前缀避免冲突。简单来说:
- 所有类名统一加
async-select这种有辨识度的前缀,避免和业务全局类名撞车。 scoped样式里,如果确实需要穿透子组件(比如封装第三方组件时改内部样式),用:deep(),但不要满屏都用。
<style scoped> .async-select__input { width: 100%; height: 36px; padding: 0 12px; border: 1px solid #ddd; border-radius: 6px; outline: none; } .async-select__input:deep(.inner-icon) { /* 对第三方组件内部节点做调整 */ color: #999; } </style>5.4 类型:可复用组件必须支持"用Typescript的人"
在Vue 3 + TS项目里,如果组件没有完善的类型,使用方的v-model绑定、事件参数都会被推断成any,开发体验极差。好的可复用组件应该有完整的类型声明:
defineProps用泛型 +withDefaults提供默认值;defineEmits用带函数类型的泛型,这样事件回调参数也能被推导;- 对外暴露的插槽建议用
defineSlots声明(Vue 3.3+),让插槽props也有类型检查。
const props = withDefaults(defineProps<{ options: SelectOption[]; loading?: boolean; }>(), { options: () => [], loading: false, });这里特别提醒options: () => []这个细节:对象的默认值必须写成函数,不能直接写options: [],否则重置事件触发时会共享同一个数组引用,在多个组件实例之间互相污染。
5.5 组件实例的暴露:defineExpose的使用边界
有些场景需要父组件调用子组件的方法,比如"让表单组件触发表单校验"。这时可以用defineExpose暴露特定方法。
但要克制:能靠props和emits表达的状态交互,就不要靠暴露方法来解决。方法调用是命令式的,一旦形成依赖,组件的复用方必须知道"调哪个方法、在什么时机调",耦合会增加不少。
5.6 继承属性:inheritAttrs与事件透传
封装第三方组件库(比如在ant-design-vue的a-select上二次封装)时,一个非常隐蔽的坑是inheritAttrs。
默认情况下,未在props里定义的属性会直接落在组件根节点上。但如果你封装的根节点是一个div,使用方传过来的@change事件就不会自动落在内部的a-select上。此时你需要手动绑定$attrs到正确的目标节点,并设置inheritAttrs: false关掉默认透传:
<template> <div class="custom-select-wrapper"> <a-select v-bind="$attrs" @change="handleChange"> <slot /> </a-select> </div> </template> <script setup> defineOptions({ inheritAttrs: false }); </script>这一步经常被忽略,而忽略的后果是:使用方给组件传placeholder、@focus、@blur全都不生效,排查半天也找不到原因。
6. 可复用组件的验收清单:到底怎么判断设计合格了
组件写完了,接口也定了,怎么判断"这玩意儿到底行不行"?我在团队里推行过一份验收清单,每一条都是踩过坑之后总结出来的,分享出来供你直接用。
6.1 功能验收清单
用五条最基本的标准来审视组件:
- 接口闭环:所有外部可修改的状态,都有对应的
props、emits或v-model通道,不存在"外部改了状态但组件不知情"的死角。 - 行为隔离:组件内没有直接调用业务API、路由跳转、store操作的代码。如果有,立刻把它迁移到事件回调或插槽里。
- 样式自包含:组件单独挂载在一个空白页面时,视觉效果和功能不受外部样式影响;类名前缀不污染全局样式。
- 默认值完整:所有props都有合理的默认值,对象的默认值用工厂函数返回,避免共享引用。
- 类型完整:TS项目里props、emits、插槽都有类型推导,使用方不写
any也能获得完整的代码提示。
6.2 实操中的"可复用"检验技巧
有一个我特别推荐的小技巧:每封装一个组件,先用"两个完全不同的页面场景"来同时试验。如果两个场景都要在组件源码里改东西才能适配,说明接口边界没切对。如果两个场景都只靠不同props和插槽就能适配,恭喜你,这个组件大概率是能沉淀下来的。
举个例子,还是在AsyncSelect场景里。第一个页面是"用户管理里选择负责人",第二个页面是"订单分配里选择客服"。如果两个场景在父组件里做的事分别是:
- 负责人的搜索:
/api/managers?keyword=xxx - 客服的搜索:
/api/agents?keyword=xxx
那组件本身应该没有任何区别,仅父组件不同,这就合格了。
6.3 什么时候不应该追求可复用
最后必须补一个"反向提醒":不是所有组件都值得花大力气做成可复用组件。
可复用设计是有成本的。你花三天设计接口、写文档、做示例,如果这个组件只有一个人在一个页面里用一次,那这些成本纯属浪费。我的判断标准是:
- 如果这个组件生命周期很短(比如活动页、一次性海报页),优先选择"够用就好"。
- 如果这个组件会出现在3个以上页面,或者已经出现了"复制代码后改两行"的情况,那才是值得抽出来认真设计的好时机。
- 如果组件本身充满了业务不确定性(需求方自己也说不清楚),那先不要抽象,用具体实现跑通流程,等需求稳定后再沉淀。
复用是手段,不是目的。为了"看起来高级"而强行抽象出来的组件,往往会变成团队里最没人敢碰的一坨代码。
6.4 文档和示例:可复用组件最后一块拼图
很多工程师设计好组件之后,觉得"代码写得这么清楚,还要文档干嘛"。但可复用组件是给团队用的,代码只能表达"做了什么",文档才表达"什么时候用它、别踩哪些坑"。
我在项目里会强制给每个可复用组件配一段最小demo,放在一个单独的页面里,或者配合vitepress做组件库文档。demo至少包含三种状态:基础用法、自定义插槽用法、完整业务用法。这样后来者不需要读源码就能知道组件的全部能力边界,"这个组件能干什么"和"这个组件不能干什么"一样重要。
所有版本管理方面,也要给组件定一个"稳定后再破坏"的规则:新增功能优先通过新增可选的props实现,不破坏已有接口;必须破坏接口时,至少在两个版本前给出deprecated警告。这是很多内部组件库做不好的地方,但恰恰是它能持续被信任的前提。
我在团队里推行这套组件设计规范之后,最大的感受不是"代码变漂亮了",而是跨页面、跨项目的协作成本明显降低了。以前看到一个下拉框,得先翻半天代码才知道它内部请求了哪个接口;现在看到组件的props和emits,整个能力地图一目了然。真正可复用的组件,应该像一个靠谱的同事——你不需要了解他所有的私事,只要知道他答应你什么、在什么情况下能帮你搞定什么,就够了。
如果你也正在整理团队里的组件,我建议先从手头"已经复制了两次"的那个组件开始,按这篇文章的验收清单走一遍。大概率你会发现,问题从来不是"这个组件长什么样",而是"它的边界到底划在哪"。先把边界划清楚了,代码怎么写都是顺理成章的事。