
接手第一个需要长期维护的中后台项目时我一度以为复用就是把长得像的代码复制一份再改改。直到某天一个用户选择器组件在三个页面里出现了四种行为——有的要清空、有的要单选、有的要带部门树而我维护的是四份几乎相同的模板时我才意识到把代码复制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]); /script2.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-modelmodelValue/update:modelValue本质是props emits语法糖透传原生属性class、style、自定义事件$attrs直接继承到根节点如果你在设计接口时拿不准先问一句这个能力是状态、是信号、还是内容问题立刻变清晰。3. 从零封装一个异步搜索选择器完整的实操案例理论讲再多不如完整拆一个组件。这里我以一个非常常见、适合做可复用案例的场景来讲——异步搜索选择器输入关键字、远程搜索、下拉选择。这个组件几乎每个中后台项目都有而且十个项目里有九个是每个页面写一份。3.1 第一步明确组件边界可复用设计的第一步就是把组件该管的和使用方该管的划分清楚组件该管输入框的状态、下拉展开/收起、键盘导航、选中后的展示、清空、加载中状态、防抖调用搜索。使用方该管远程接口的URL、查询参数处理、选项数据映射、选中后的业务动作。这个边界划分一旦确定接口设计就顺理成章了。3.2 第二步设计props、emits、slots我给出的第一版接口如下script setup langts 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 classasync-select refrootRef input v-modelkeyword typetext :placeholderplaceholder :disableddisabled focushandleFocus inputhandleInput keydown.down.preventmoveHighlight(1) keydown.up.preventmoveHighlight(-1) keydown.enter.preventselectHighlighted keydown.esccloseDropdown / div v-ifloading classasync-select__loading加载中.../div ul v-else-ifisOpen classasync-select__dropdown li v-for(option, index) in props.options :keyoption.value :class{ async-select__option--selected: option.value modelValue?.value, async-select__option--highlighted: index highlightIndex, } mousedownselectOption(option) slot nameoption :optionoption {{ 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-modelcurrentUser :optionssearchResults :loadingloading placeholder搜用户名或邮箱 clearable searchonSearch / /template script setup langts 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是组件的另一个维度有些逻辑不属于任何视觉界面却需要在多个组件里共用比如点击外部关闭防抖表单校验规则。这些场景最好的载体不是组件而是组合式函数。以点击外部关闭为例老老实实写onMounteddocument.addEventListener也能用但不优雅。封装成useClickOutside之后任何需要弹出层点击外部关闭的组件下拉、弹窗、气泡卡片都能一键接入export function useClickOutside(containerRef: RefHTMLElement | 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引用不变或者使用toRefsconst 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; } /style5.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 classcustom-select-wrapper a-select v-bind$attrs changehandleChange 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?keywordxxx客服的搜索/api/agents?keywordxxx那组件本身应该没有任何区别仅父组件不同这就合格了。6.3 什么时候不应该追求可复用最后必须补一个反向提醒不是所有组件都值得花大力气做成可复用组件。可复用设计是有成本的。你花三天设计接口、写文档、做示例如果这个组件只有一个人在一个页面里用一次那这些成本纯属浪费。我的判断标准是如果这个组件生命周期很短比如活动页、一次性海报页优先选择够用就好。如果这个组件会出现在3个以上页面或者已经出现了复制代码后改两行的情况那才是值得抽出来认真设计的好时机。如果组件本身充满了业务不确定性需求方自己也说不清楚那先不要抽象用具体实现跑通流程等需求稳定后再沉淀。复用是手段不是目的。为了看起来高级而强行抽象出来的组件往往会变成团队里最没人敢碰的一坨代码。6.4 文档和示例可复用组件最后一块拼图很多工程师设计好组件之后觉得代码写得这么清楚还要文档干嘛。但可复用组件是给团队用的代码只能表达做了什么文档才表达什么时候用它、别踩哪些坑。我在项目里会强制给每个可复用组件配一段最小demo放在一个单独的页面里或者配合vitepress做组件库文档。demo至少包含三种状态基础用法、自定义插槽用法、完整业务用法。这样后来者不需要读源码就能知道组件的全部能力边界这个组件能干什么和这个组件不能干什么一样重要。所有版本管理方面也要给组件定一个稳定后再破坏的规则新增功能优先通过新增可选的props实现不破坏已有接口必须破坏接口时至少在两个版本前给出deprecated警告。这是很多内部组件库做不好的地方但恰恰是它能持续被信任的前提。我在团队里推行这套组件设计规范之后最大的感受不是代码变漂亮了而是跨页面、跨项目的协作成本明显降低了。以前看到一个下拉框得先翻半天代码才知道它内部请求了哪个接口现在看到组件的props和emits整个能力地图一目了然。真正可复用的组件应该像一个靠谱的同事——你不需要了解他所有的私事只要知道他答应你什么、在什么情况下能帮你搞定什么就够了。如果你也正在整理团队里的组件我建议先从手头已经复制了两次的那个组件开始按这篇文章的验收清单走一遍。大概率你会发现问题从来不是这个组件长什么样而是它的边界到底划在哪。先把边界划清楚了代码怎么写都是顺理成章的事。