这个系列写到第 10 节了。前面聊过组件通信、状态管理、渲染性能也拆过不少真实项目。今天想谈一个更扎心、也更常见的问题你花两个晚上精心封装的 Vue 组件究竟是随时能搬走的“可复用积木”还是换一个页面就爆炸的“业务雷区”先讲个我经常遇到的场景。某后台项目里有个订单状态标签组件最初只是给订单列表页用的后来运营模块也要展示订单状态于是有人复制了一份改了两行。再后来需求分叉了列表页要求点击标签跳转详情运营模块要求点击弹出操作面板。两边各改各的三个月后这两个组件已经面目全非谁也不敢动因为一动就影响好几个页面。这种故事几乎每天都在前端团队里重演。问题的根源不在于你复制粘贴而在于那个组件从设计之初就没有把“复用”当回事——它被业务逻辑焊死在了某个页面上。今天这篇我想把“可复用组件”和“业务雷区”之间的那条分界线彻底讲清楚并且给出一套能直接落地的诊断和重构方法。1. 先说实话一上来就冲着“通用”去的组件通常活不过三个月很多人对组件复用的理解是错的。以为“通用组件”就是写一个全能的组件把什么功能都塞进去让所有页面拿去用。结果是这个组件越来越重props 越来越多到最后没人看得懂没人敢改变成了最大的业务雷区。反过来也有人从不考虑复用每个页面都从零开始写写着写着发现相似的东西重复了十几遍改一个需求要动十几个文件。这两种极端我都不推荐。真正健康的做法是先让组件在当前场景里好用同时在设计时给它留一个“干净的口子”让未来的复用不需要伤筋动骨。这就像盖房子你不需要一开始就盖成万能体育馆但你得把承重结构、水电接口留好将来改起来才不至于拆墙。那为什么大多数组件会滑向“业务雷区”我观察下来通常不是开发者偷懒而是三个现实原因在推着你走需求来得太快没时间做设计。产品经理早上提需求下午就要看效果你只能在页面里直接写写着写着发现这段逻辑挺独立的顺手就抽了个组件。这种“顺手组件”最容易埋雷。“顺手”加了业务参数。第一次复用的时候你发现两个场景有一点点不同于是给组件加了一个type字段或fromPage参数在组件内部用if/else处理。这个口子一旦打开第三次、第四次复用就会继续往里塞参数组件接口迅速腐化。全局状态太方便了。组件里直接用store、拿$route、调inject当时确实省事但也让组件彻底失去了独立性——它已经和当前项目的上下文绑死了。所以判断你的组件是积木还是雷区第一条标准不是它现在能不能用而是把它丢到一个全新的项目里它能不能在不改内部代码的情况下跑起来如果答案是否定的它就已经是业务雷区了。我见过一个例子某后台的表格组件内部直接调用了一个全局 API 去拉字典数据还根据当前路由判断是哪个页面来决定显示哪些列。听起来很智能是吧实际上这个组件换到任何别的页面都不能用只能继续在原来的页面里不断打补丁。这就是典型的“伪通用”。2. “业务雷区”的四种典型画像你的组件到底被什么焊死了要拆解业务雷区先得认识它的长相。根据我这些年在多个项目里的实践业务雷区通常有四种典型特征。不是每一条都占但占得越多拆分难度越大。2.1 props 里出现了“业务字段”这是最直观的信号。如果你的组件 props 里有orderType、isVIP、fromPage、isInside这种一看就跟某个具体业务页面强相关的字段说明你已经把业务语义灌进了通用组件里。看一个典型的雷区组件!-- OrderStatusBadge.vue雷区版 -- script setup import { computed } from vue import { useStore } from vuex import { useRoute, useRouter } from vue-router const props defineProps({ order: { type: Object, required: true }, showDetail: { type: Boolean, default: false }, fromPage: { type: String, default: } }) const store useStore() const route useRoute() const router useRouter() const statusMap { pending: { text: 待处理, color: orange }, paid: { text: 已支付, color: green }, closed: { text: 已关闭, color: gray } } // 组件内部直接读取全局权限 const isAdmin computed(() store.state.user.role admin) // 组件内部直接依赖当前路由参数 const isBeta computed(() route.query.beta 1) function handleClick() { if (props.showDetail isAdmin.value) { router.push(/order/detail/ props.order.id) } if (props.fromPage operation) { store.dispatch(operation/record, { … }) } } /script这个组件的问题一眼就能看出来order是一个巨大的业务对象组件内部还要拿store判断权限、拿route判断环境、拿fromPage区分来源页。它根本不是“订单状态标签”它是一个跟当前项目路由、权限、埋点体系深度耦合的页面片段。复用它就等于把一整套业务上下文也搬过去。2.2 事件不向外抛而是在组件内部把业务做完了组件的正确工作方式是“展示 通知”。组件负责把状态表现为界面的样子然后把用户操作作为一个事件抛出去由父组件决定“接下来干什么”。但业务雷区组件通常不是这么干的。它在内部直接跳路由、弹确认框、调用接口、更新 store把业务决策全做完了。表面上看省了父组件的代码实际上等于把父组件的职责悄悄转移到了组件内部。下次另一个页面要复用发现点击行为不一样你只有两条路要么改组件内部代码要么加一个更大的if分支。判断方法很简单数一数你的组件有没有对外emit事件。如果一个组件只有 props 没有 emits而它内部又发生了很多事情它大概率已经在替父组件做“不该它做的决定”了。2.3 生命周期里藏着“隐形时序”这不是指onMounted里发请求这种事本身不对而是指组件在生命周期里执行了跟它自身展示无关、却影响外部状态的逻辑。比如某个组件在onMounted里调用了store.dispatch(order/fetchList)理由是“这个组件长在那个页面上组件挂载时顺便拉一下列表数据”。看起来方便但这个组件一旦被挪到别的页面它会在你完全没预期的时候发请求、改全局状态。这就是“隐形时序”的坑。更隐蔽的是用watch监听某个 prop然后在回调里触发别的逻辑。比如监听order.status一旦变成paid就自动调接口发送通知。我见过这样的代码组件本身的职责只是显示订单状态却因为“省事”背负了通知发送的职责。结果测试环境一切正常上线后因为权限问题疯狂告警。2.4 样式和业务视觉绑定过深这个往往被忽略。组件的 DOM 结构、样式类名如果直接写死成业务页面需要的视觉样式比如硬编码间距、颜色、字号甚至为了某个页面把组件结构改成了一个特殊的布局它也就失去了通用性。真正的积木组件在样式上应该只负责自己基础的样子把颜色、间距、圆角这些“审美的差异”交给外部去调整。Vue 里做这件事的手段是 CSS 变量、class透传、style透传而不是在组件内部写死几十个业务专属变量。3. 积木和雷区的分界线接口契约才是复用的地基如果说“业务雷区”是组件设计失败的表现那“可复用积木”的底层逻辑是什么一句话组件可复用的关键不是功能多而是接口干净。在 Vue 里组件的接口由三样东西构成接口要素作用设计原则props入参告诉组件“你在什么状态下展示”只接收跟展示相关的数据结构越扁平越好杜绝业务对象events出参告诉父组件“用户做了什么”只表达意图不替父组件做决策slots扩展点允许外部注入额外的结构用插槽代替在组件内部堆叠大量if/else分支你可以把一个组件理解成函数。一个函数可复用的前提是它的参数、返回值语义清晰且副作用可控。组件也是一样props 是入参emit 是返回值副作用则是它对外部环境路由、store、接口的依赖。很多人写组件的时候搞混了一个概念把“提取公共代码”当成了“设计可复用组件”。提取公共代码是把若干页面里相似的逻辑抽到一处而设计可复用组件是定义一个稳定的接口契约让不同场景可以通过不同参数获得不同表现。一个是“消除重复”一个是“定义抽象”。前者能救急后者才是长久之计。举个例子。你写一个弹窗组件支持“打开”“关闭”“确定”“取消”这是接口契约。但如果你的弹窗组件内部直接绑定了一个“删除订单确认”的文案和确认逻辑那它就不是弹窗是一个“删除订单确认弹窗”。下次你要用弹窗做“批量审批”就只能再写一个。所以每当我看到defineProps里出现业务字段时我都会问自己一个问题如果我把这个字段去掉组件还能不能完成它的本职展示工作能说明这个字段是业务扩展应该放到外层不能说明组件本身就承载了业务职责它根本不是通用组件。另外状态管理同样决定了组件的复用边界。一个行为良好的组件数据应该由父级主动传入事件向父级主动抛出形成一个单向数据流父组件 ──(props 状态)── 子组件 父组件 ──(emit 事件)── 子组件只要把数据流画成一条主线组件就能保持“知道的事情很少但展示得很清楚”的状态。而一旦组件内部出现第三条线——比如直接访问store或route——它就突破了这个边界开始和外部环境悄悄握手。这不是说通用组件绝对不能访问store。但在实际项目中我更倾向于通用的 UI 组件绝不访问 store只有业务组件可以。这个约定一旦立住团队里每个人看到组件里出现useStore()心里就有数——“这是个业务组件不指望它跨项目复用”。4. 五步体检法在动手重构之前先用清单给组件打分知道理论还不够动手之前你得先有个明确的判断。我自己每次接手一个历史组件都会在脑子里过一遍下面这五个检查点。你可以把它当体检表用每个检查点回答“是”或“否”就能大致判断这个组件的健康程度。4.1 检查点一换个页面不加代码、不改代码能用吗这是最直观的一票否决项。想一下如果现在有一个新页面需要完全相同的视觉效果但不希望继承跳转逻辑、不需要埋点、也没有管理员权限判断你的组件塞进去会怎样它会不会报错它会不会擅自去做某些你不想让它做的事它会不会依赖某个不存在的路由参数只要有一个“会”说明它至少已经耦合了页面上下文。这一步就能淘汰一大半历史组件。4.2 检查点二props 里有没有业务字段对象传参深不深翻开组件文件看defineProps的声明。如果出现以下情况就要警惕props 里出现orderType、userLevel、isFromList等业务性明显的字段。props 里有一个巨大的Object类型对象比如detailInfo、sourceData组件内部对这个对象做各种深层次读取。props 数量超过七八个且类型大多是Object。我的经验是props 对象化一个很大的对象传进来是组件腐化的开始。组件为了知道自己该展示什么不得不深入了解那个对象内部的每一个字段于是对象内部结构一变组件就要跟着改。更好的方式是传最小必要数据——比如一个status字符串而不是整个order对象。4.3 检查点三组件内部有没有访问 store、route、inject、localStorage打开script部分搜索useStore、useRoute、inject、localStorage这些关键字。命中任意一个就记一笔。命中 0 项组件大概率是干净的。命中 1 项组件有轻度业务耦合需要看它访问了哪些字段。命中 2 项以上组件基本就是业务雷区建议按第五章的方法做手术。这里要特别提醒访问localStorage也是隐形依赖。组件内部直接跟着本地缓存变化改变状态会让父组件完全失控——你都不知道它什么时候被缓存影响了。4.4 检查点四事件流是否向外开放在模板里看组件内部发生交互时是emit出去了还是直接执行了外部逻辑一个健康组件的模板应该是这样的template button clickemit(action, status)点击/button /template而不健康的组件则是这样template button clickhandleAction点击/button /template然后这个handleAction在内部完成跳转、埋点、接口调用。这一步最容易被忽略因为“内部处理掉”看起来让父组件调用更简单。但这是用“当前页面的便利”换“未来所有页面的麻烦”。4.5 检查点五样式是不是和具体业务视觉绑死看样式部分重点看这些信号组件根元素上写死了margin或极其具体的width/height。组件内部依赖某个业务页面的父级背景色。组件使用了大量全局样式类名类名还带着项目业务前缀。组件没有提供任何让外部微调样式的途径如class透传、CSS 变量。真正可复用的积木样式上应该做到“自带最低限度的默认值但允许外部覆盖”。前阵子我接手一个组件它的根元素写了一行margin-top: 32px后来这个组件被用到另一个列表里间距怎么调都调不掉最后只能给父级写负 margin 硬顶回去。这种雷埋得毫无意义。五个检查点全部过一遍之后我给自己的判断标准是这样的健康0-1 个问题可以在项目内放心复用继续维护。亚健康2-3 个问题能用但尽量不再扩展它新场景宁可新建组件。雷区4-5 个问题找机会按下一章的方式外科手术式改造。5. 分步手术把“一拆就炸”的组件逐步改造成可复用积木排雷难的不是技术而是“一拆就炸”的心理压力——中间还夹着老板的排期、测试的回归、产品经理的下一个需求。所以我强烈建议不要在某个“架构升级月”里大规模重构组件而是在每次经过它的需求中顺手做一次局部手术。积木是一次次打磨出来的不是一次重写出来的。下面是我比较常用的一个五步手术路径每一步都小而可控。5.1 第一步把 store 和 route 的访问从组件里请出去这一步优先做因为它直接影响组件的外部依赖。做法是组件内部不再访问全局状态而是把需要的数据加为 props把需要执行的逻辑改成事件。拿上一章那个订单状态组件举例第一次手术后的效果应该接近这样!-- StatusBadge.vue第一次手术后 -- script setup import { computed } from vue const props defineProps({ status: { type: String, required: true }, label: { type: String, default: }, plain: { type: Boolean, default: false } }) const emit defineEmits([status-click]) const statusMap { pending: { text: 待处理, color: orange }, paid: { text: 已支付, color: green }, closed: { text: 已关闭, color: gray } } /script template span classstatus-badge :class[status-badge--${status}, { is-plain: plain }] clickemit(status-click, status) {{ label || statusMap[status]?.text }} slot / /span /template看到区别了吗组件现在只做三件事接收status、显示文案、点击后发出一个“状态被点击了”的事件。至于点击了之后跳不跳转、弹不弹窗、要不要埋点它一概不管。父组件自己决定。5.2 第二步把业务逻辑下沉到组合式函数门店的 UI 清干净了但业务逻辑总得有个地方放。我的习惯是把跟组件相关的业务逻辑抽成一个组合式函数比如useOrderStatus。组件保持纯粹业务函数负责跟 store、路由、接口打交道。// useOrderStatus.js import { computed } from vue import { useStore } from vuex import { useRouter } from vue-router export function useOrderStatus(orderRef, options {}) { const store useStore() const router useRouter() const { onTrack } options const isAdmin computed(() store.state.user.role admin) function handleStatusClick(status) { if (isAdmin.value) { router.push(/order/detail/ orderRef.value.id) } onTrack?.(status) } return { isAdmin, handleStatusClick } }然后在具体的业务页面里这样用!-- OrderListPage.vue -- script setup import StatusBadge from /components/StatusBadge.vue import { useOrderStatus } from /composables/useOrderStatus const props defineProps([order]) const { handleStatusClick } useOrderStatus(props.order, { onTrack: (status) console.log(埋点, status) }) /script template StatusBadge :statusorder.status status-clickhandleStatusClick / /template经过这一步通用组件从业务上下文里剥离了。原来那个showDetail、fromPage、isAdmin全部消失变成了业务页面侧自己的逻辑。以后运营模块想复用StatusBadge它只需要自己写一个handleStatusClick或者干脆直接用组件默认行为。5.3 第三步用 slot 和 attrs 给“差异”留口子第一次抽取通用组件时你很难预料到未来有多少种变体。与其把所有变体都做成 props不如留出插槽。比如订单状态标签旁边某个页面要显示“去处理”小按钮另一个页面要显示“已加急”小标再一个页面什么都不显示。做法很简单template span classstatus-badge :classstatus-badge--${status} span classstatus-badge__text{{ label || statusMap[status]?.text }}/span slot nameextra / /span /template使用时StatusBadge statuspaid template #extra el-button sizesmall去开票/el-button /template /StatusBadge这样就不需要在组件里堆showButton、buttonText、buttonType这些互不相关的 props 了。多一个需要多一个插槽组件本体完全不需要动。同样需要留口的还有样式覆盖的通道。Vue 3 默认会把非props的属性透传到组件根元素也就是说你在使用组件时写的classcustom已经能覆盖一部分样式了。如果想要更可控的效果可以在组件内部使用 CSS 变量style scoped .status-badge { --badge-bg: var(--status-badge-bg, #f5f5f5); background-color: var(--badge-bg); } /style使用方通过传入内联样式或覆写 CSS 变量就能调整组件核心代码纹丝不动。5.4 第四步用“业务包装组件”弥补纯组件的成本这一步可能和很多人的直觉相反。我建议为了让通用组件保持干净可以专门再写一层“业务包装组件”它内部用组合式函数处理业务逻辑再渲染底层通用组件。这样既有通用组件的复用性又有业务组件的便利性。!-- OrderStatusBadgeWrapper.vue -- script setup import StatusBadge from /components/StatusBadge.vue import { useOrderStatus } from /composables/useOrderStatus const props defineProps({ order: { type: Object, required: true } }) const { handleStatusClick } useOrderStatus(props.order) /script template StatusBadge :statusorder.status status-clickhandleStatusClick / /template业务页面里直接使用OrderStatusBadgeWrapper不用关心底层如何拆。将来如果新页面要求不同的交互直接写一个新的包装组件底层通用组件永远不受影响。这就是经典的“组合优于继承”思路在 Vue 里的落地。很多团队觉得“组件复用难”是因为总想让同一层组件同时满足“通用”和“业务方便”两个目标结果两边都做不到。拆开之后就顺手了底层组件追求干净上层包装追求省事。5.5 第五步渐进式替换别想着“一晚上搞定”历史雷区组件往往牵扯多个页面不是一两天能改完的。我的做法是先复制一份新的干净通用组件命名上明确区分比如BaseStatusBadge。新页面优先使用新组件。旧页面在每次需求变更时顺手切换过去。旧的雷区组件当“瓶子敌人”对待——只能删不能改。之所以不直接改旧组件是因为它在多个页面里已经被隐式依赖了。比如有页面通过ref直接调用组件内部方法有页面依赖它的某个内部事件链。直接改改动面太广一不小心就是线上事故。新增一个干净的组件让新代码“感染”好习惯旧代码慢慢淘汰风险要小得多。6. 写在系列第 10 节的末尾组件设计这件事没有一劳永逸的答案更多是一种持续的取舍。我把这十年写 Vue 组件的体会浓缩成几句话组件复用的核心不是“代码写得聪明”而是“接口守得住边界”。每当你犹豫要不要在组件里加一个业务参数时多想想“这个参数是不是只为一两个页面服务”如果答案是就别加。通用组件是“两个以上真实场景驱动设计出来的”不是“提前设计出来的”。先让它服务好第一个场景等第二个场景出现时再带着对比去抽象——这种抽象才靠谱。不要害怕“底层组件 顶层业务包装”的多层结构。多一层不是坏事反而能让变化被隔离在某一层里不会一改就炸。每次改需求的时候顺手改一次自己维护的组件。把它当成刷牙而不是洗牙。积木是靠日常打磨养出来的不是靠一年一次的大规模重构变出来的。最后分享一个实用小技巧如果你不确定一个组件拆得够不够干净试试在一个新项目里手动复制它然后把所有和当前项目相关的命名store、route、业务字典、专用接口全部删掉看剩下多少代码还能用。剩下得越多说明它越像积木。如果删完之后几乎什么都不剩那恭喜你重新认识了一下自己写的雷区——早点排掉总比线上炸了再抢救要好。
阅读完成 · 觉得有帮助?