起初写 Vue 的那会儿,我和多数新人一样,心想:“哇,组件也太优雅了吧”!三年回头望,组件目录却像一处填埋场:维护这里改坏那里、props 漫天飞、事件横冲直撞、复用基本靠复制粘贴。那时我才真正意识到——在 Vue 项目里,组件设计才是名副其实的灾难地带。
1. “抽组件”≠“新建个文件夹”
很多初学者对于“组件化”的理解很直接:“页面上有重复 UI?好,抽出来做成组件。”
于是你很快得到这样一个组件:
<!-- TextInput.vue -->
<template>
<input :value="value" @input="$emit('update:value', $event.target.value)" />
</template>
接着,需要一个带图标的输入框,于是复制第一份:
<!-- IconTextInput.vue -->
<template>
<div class="icon-text-input">
<i class="icon" :class="icon" />
<input :value="value" @input="$emit('update:value', $event.target.value)" />
</div>
</template>
随后又要校验、加载态、提示……结果目录里长这样:
TextInput.vue
IconTextInput.vue
ValidatableInput.vue
LoadingInput.vue
FormInput.vue
组件数量指数膨胀,却都只是“刚好能用”,彼此难以复用。因此,规模一大就维护崩盘。
2. 过度抽象:为“复用”而复用,最后谁也不敢用
设想你做了一个“超复杂”的表格组件:
<CustomTable
:columns="columns"
:data="tableData"
:show-expand="true"
:enable-pagination="true"
:custom-actions="['edit', 'delete']"
/>
你自豪地称它为“通用组件”,可当同事尝试落地时,却发现——
.某页面不需要操作列,但你的配置无法移除;
.另一页需要自定义排序,你的实现却写死在内部;
.有的场景遵循 element-plus 的样式,而你做了另一套 UI;
.报错后控制台红警刷屏,根本不知道源头在哪。
于是,大家的选择是:无视“通用组件”,各自复制一份代码再改。结果反而更多“平行宇宙”。
3. 数据向下流、事件向上冒:你真的吃透了 props 与 emit 吗?
理论很清晰:**父传子用 props,子通知父用 emit**。然而,现实往往是——
.props层层下钻 7 级,你已分不清数据从哪来;
.子组件触发两个事件,父组件再把回调倒回去;
.开发者“悄悄”用 provide/inject、ref 或 eventBus 打通旁路通信。
示例看起来没毛病:
<!-- Grandparent Component -->
<template>
<PageWrapper>
<ChildComponent :formData="form" @submit="handleSubmit" />
</PageWrapper>
</template>
<!-- Child Component -->
<template>
<Form :model="formData" />
<button @click="$emit('submit', formData)">Submit</button>
</template>
然而,当 ChildComponent 被 FormWrapper 包一层、内部再嵌 InputList 时,你会发现:
.到底谁在真正控制formData ?
.submit 事件在多层之间被包装/防抖/节流/拦截;
.想改一个按钮逻辑,你要翻四个文件。
.因此,越到后期,通信成本越像迷宫;最终,团队对“抽组件”开始本能抗拒。
4. 技术债的主力来源:不敢删、不敢动
目录结构也许看似井井有条,但多数组件共有这些特征:
.有 10 个 props + 3 个事件,却没人知道谁在用;
.注释写着“给 A 页面用”,实际 B/C/D 也悄悄依赖;
.轻轻一动,蝴蝶效应引爆全局。
于是你只能复制一份、加个 V2 后缀,旧的也不敢删:
components/
├── Input.vue
├── InputV2.vue
├── InputWithTooltip.vue
├── InputWithValidation.vue
├── InputWithValidationV2.vue
└── ...
“为了让别人能维护我的代码,我决定——我自己先别动它。”
5. 组件设计的核心,其实是抽象能力
三年里我学到的一点:难点不在语法,也不在封装,而在“如何抽象问题”。例如,需要做一个“搜索区”组件:包含输入框、日期区间、搜索按钮。新手的写法:
<SearchHeader
:keyword="keyword"
:startDate="start"
:endDate="end"
@search="handleSearch"
/>
但是,当需求变成下拉 + 单选时,你要再造一个组件吗?更好的做法:结构交给组件,内容交给页面——靠 slot / scoped slot。
<!-- SearchHeader.vue -->
<template>
<div class="search-header">
<slot name="form" />
<button @click="$emit('search')">Search</button>
</div>
</template>
<!-- 堆代码 duidaima.com -->
<!-- 使用 -->
<SearchHeader @search="search">
<template #form>
<el-input v-model="keyword" placeholder="Enter keywords" />
<el-date-picker v-model="range" type="daterange" />
</template>
</SearchHeader>
因此,组件不必“包办一切”,而是提供骨架、与页面协作。与此同时,你也保留了最大灵活度。
6. 那么,组件该怎么“对”地设计?
我把经验归结为三条朴素但有效的建议:
1) 先厘清职责边界:UI?交互?还是业务逻辑?
UI 组件:只管展示(Button/Tag/Card)。
交互组件:只封装人机动作(Input/Select/Uploader)。
逻辑组件:收拢业务规则(筛选区、分页器)。
不要让一个组件同时负责渲染 + 业务 + 请求——那是明确的反模式。
2) **收敛 props 与 emit**:只暴露“必要接口”
一个组件的 props 超过 6 个,就需要警惕;
事件名若缺乏业务含义(例如 click),考虑抽象为语义事件(如 confirm/submit);
避免用 ref 去操纵子组件内部逻辑——这会让耦合飙升。
3) 能用 Slot,就别用“超级定制的 Props”
当你发现组件 props 长这样:
<SuperButton
:label="'Submit'"
:icon="'plus'"
:iconPosition="'left'"
:styleType="'primary'"
:loading="true"
/>
是时候换成 slot 了:
<SuperButton>
<template #icon><PlusIcon /></template>
Submit
</SuperButton>
因此,用 slot 换灵活度、用语义事件降复杂度、用职责拆分控风险;最终,你会发现维护成本陡降。
结语:从“最简单”到“最难的坑”
三年前,我以为组件化是 Vue 里最容易的部分; 三年后,我才懂它其实是最深、最难、坑最多的一环。
如果你也踩过这些坑——.
.写得越多,“复用”反而越复杂,同事不敢用;
.props 与事件像迷宫,维护成本居高不下;
.UI 与逻辑紧紧捆绑,牵一发而动全身;
后期组件数量雪球滚大,技术债堆成山——
请别让组件成为你项目的“债务黑洞”。你是否也遇到过类似问题?欢迎分享你的“灾后重建”经验。