☰
Vue组件复用实战:拆掉业务雷区,打造可搬走的积木组件
2026/10/17 21:58:02 网站建设 项目流程

这个系列写到第 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 @click="emit('action', status)">点击</button> </template>

而不健康的组件则是这样:

<template> <button @click="handleAction">点击</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 class="status-badge" :class="[`status-badge--${status}`, { 'is-plain': plain }]" @click="emit('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 :status="order.status" @status-click="handleStatusClick" /> </template>

经过这一步,通用组件从业务上下文里剥离了。原来那个showDetail、fromPage、isAdmin全部消失,变成了业务页面侧自己的逻辑。以后运营模块想复用StatusBadge,它只需要自己写一个handleStatusClick,或者干脆直接用组件默认行为。

5.3 第三步:用 slot 和 attrs 给“差异”留口子

第一次抽取通用组件时,你很难预料到未来有多少种变体。与其把所有变体都做成 props,不如留出插槽。

比如订单状态标签旁边,某个页面要显示“去处理”小按钮,另一个页面要显示“已加急”小标,再一个页面什么都不显示。做法很简单:

<template> <span class="status-badge" :class="`status-badge--${status}`"> <span class="status-badge__text">{{ label || statusMap[status]?.text }}</span> <slot name="extra" /> </span> </template>

使用时:

<StatusBadge status="paid"> <template #extra> <el-button size="small">去开票</el-button> </template> </StatusBadge>

这样就不需要在组件里堆showButton、buttonText、buttonType这些互不相关的 props 了。多一个需要,多一个插槽,组件本体完全不需要动。

同样需要留口的还有样式覆盖的通道。Vue 3 默认会把非props的属性透传到组件根元素,也就是说你在使用组件时写的class="custom"已经能覆盖一部分样式了。如果想要更可控的效果,可以在组件内部使用 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 :status="order.status" @status-click="handleStatusClick" /> </template>

业务页面里直接使用OrderStatusBadgeWrapper,不用关心底层如何拆。将来如果新页面要求不同的交互,直接写一个新的包装组件,底层通用组件永远不受影响。

这就是经典的“组合优于继承”思路在 Vue 里的落地。很多团队觉得“组件复用难”,是因为总想让同一层组件同时满足“通用”和“业务方便”两个目标,结果两边都做不到。拆开之后就顺手了:底层组件追求干净,上层包装追求省事。

5.5 第五步:渐进式替换,别想着“一晚上搞定”

历史雷区组件往往牵扯多个页面,不是一两天能改完的。我的做法是:

  1. 先复制一份新的干净通用组件,命名上明确区分,比如BaseStatusBadge。
  2. 新页面优先使用新组件。
  3. 旧页面在每次需求变更时,顺手切换过去。
  4. 旧的雷区组件当“瓶子敌人”对待——只能删,不能改。

之所以不直接改旧组件,是因为它在多个页面里已经被隐式依赖了。比如有页面通过ref直接调用组件内部方法,有页面依赖它的某个内部事件链。直接改,改动面太广,一不小心就是线上事故。新增一个干净的组件,让新代码“感染”好习惯,旧代码慢慢淘汰,风险要小得多。

6. 写在系列第 10 节的末尾

组件设计这件事,没有一劳永逸的答案,更多是一种持续的取舍。我把这十年写 Vue 组件的体会浓缩成几句话:

  • 组件复用的核心不是“代码写得聪明”,而是“接口守得住边界”。每当你犹豫要不要在组件里加一个业务参数时,多想想“这个参数是不是只为一两个页面服务”,如果答案是,就别加。
  • 通用组件是“两个以上真实场景驱动设计出来的”,不是“提前设计出来的”。先让它服务好第一个场景,等第二个场景出现时,再带着对比去抽象——这种抽象才靠谱。
  • 不要害怕“底层组件 + 顶层业务包装”的多层结构。多一层不是坏事,反而能让变化被隔离在某一层里,不会一改就炸。
  • 每次改需求的时候,顺手改一次自己维护的组件。把它当成刷牙,而不是洗牙。积木是靠日常打磨养出来的,不是靠一年一次的大规模重构变出来的。

最后分享一个实用小技巧:如果你不确定一个组件拆得够不够干净,试试在一个新项目里手动复制它,然后把所有和当前项目相关的命名(store、route、业务字典、专用接口)全部删掉,看剩下多少代码还能用。剩下得越多,说明它越像积木。如果删完之后几乎什么都不剩,那恭喜你,重新认识了一下自己写的雷区——早点排掉,总比线上炸了再抢救要好。

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

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

立即咨询