一开始写 Vue 的时候,谁不是觉得:"哇,组件好优雅!"三年后再回头一看,组件目录像垃圾堆,维护一处改三处,props 乱飞、事件满天飞,复用全靠 copy paste。于是我终于明白 ------ 组件设计,才是 Vue 项目的重灾区。
1. 抽组件 ≠ 拆文件夹
很多初学 Vue 的人对"组件化"的理解就是:"页面上出现重复的 UI?好,抽个组件。"
于是你会看到这样的组件:
html
<!-- TextInput.vue -->
<template>
<input :value="value" @input="$emit('update:value', $event.target.value)" />
</template>
接着你又遇到需要加图标的输入框,于是复制一份:
html
<!-- 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>
再后来你需要加验证、loading、tooltip......结果就变成了:
TextInput.vue
IconTextInput.vue
ValidatableInput.vue
LoadingInput.vue
FormInput.vue
组件爆炸式增长,但每一个都只是"刚好凑合",共用不了。
2. 抽象失控:为了复用而复用,结果没人敢用
比如下面这个场景:
你封装了一个超级复杂的表格组件:
html
<CustomTable
:columns="columns"
:data="tableData"
:show-expand="true"
:enable-pagination="true"
:custom-actions="['edit', 'delete']"
/>
你美其名曰"通用组件",但别人拿去一用就发现:
- 某个页面只要展示,不要操作按钮,配置了也没法删;
- 有个页面需要自定义排序逻辑,你这边死写死;
- 另一个页面用 element-plus 的样式,这边你自绘一套 UI;
- 报错时控制台输出一大堆 warning,根本不知道哪来的。
最后大家的做法就是 ------ 不用你这套"通用组件",自己抄一份改改。
3. 数据向下流、事件向上传:你真的理解 props 和 emit 吗?
Vue 的单向数据流原则说得很清楚:
父组件通过 props 向下传数据,子组件通过 emit 通知父组件。
但现实是:
- props 传了 7 层,页面逻辑根本看不懂数据哪来的;
- 子组件 emit 了两个 event,父组件又传回了回调函数;
- 有时候干脆直接用
inject/provide
、ref
、eventBus
偷偷打通通信。
举个例子:
html
<!-- 祖父组件 -->
<template>
<PageWrapper>
<ChildComponent :formData="form" @submit="handleSubmit" />
</PageWrapper>
</template>
<!-- 子组件 -->
<template>
<Form :model="formData" />
<button @click="$emit('submit', formData)">提交</button>
</template>
看上去还好?但当 ChildComponent
再包一层 FormWrapper
、再嵌套 InputList
,你就发现:
formData
根本不知道是哪个组件控制的submit
被多层包装、debounce、防抖、节流、劫持- 你改一个按钮逻辑,要翻 4 个文件
4. 技术债爆炸的罪魁祸首:不敢删、不敢动
组件目录看似整齐,但大部分组件都有如下特征:
- 有 10 个 props,3 个事件,但没人知道谁在用;
- 注释写着"用于 A 页面",实际上 B、C、D 页面也在引用;
- 一个小改动能引发"蝴蝶效应",整个系统发疯。
于是你只能选择 ------ 拷贝再新建一个组件,给它加个 V2
后缀,然后老的你也不敢删。
项目后期的结构大概就是:
css
components/
├── Input.vue
├── InputV2.vue
├── InputWithTooltip.vue
├── InputWithValidation.vue
├── InputWithValidationV2.vue
└── ...
"为了让别人能维护我的代码,我决定不动它。"
5. 组件设计的核心,其实是抽象能力
我用三年才悟到一个道理:
Vue 组件设计的难点,不是语法、也不是封装,而是你有没有抽象问题的能力。
举个例子:
你需要设计一个"搜索区域"组件,包含输入框 + 日期范围 + 搜索按钮。
新手写法:
html
<SearchHeader
:keyword="keyword"
:startDate="start"
:endDate="end"
@search="handleSearch"
/>
页面需求一改,换成了下拉框 + 单选框怎么办?又封一个组件?
更好的设计是 ------ 提供slots 插槽 + 作用域插槽:
html
<!-- SearchHeader.vue -->
<template>
<div class="search-header">
<slot name="form" />
<button @click="$emit('search')">搜索</button>
</div>
</template>
<!-- 使用 -->
<SearchHeader @search="search">
<template #form>
<el-input v-model="keyword" placeholder="请输入关键词" />
<el-date-picker v-model="range" type="daterange" />
</template>
</SearchHeader>
把结构交给组件,把行为交给页面。组件不掌控一切,而是协作。
6. 那么组件怎么设计才对?
我总结出 3 条简单但有效的建议:
✅ 1. 明确组件职责:UI?交互?逻辑?
- UI 组件只关心展示,比如按钮、标签、卡片;
- 交互组件只封装用户操作,比如输入框、选择器;
- 逻辑组件封装业务规则,比如筛选区、分页器。
别让一个组件又画 UI 又写逻辑还请求接口。
✅ 2. 精简 props 和 emit,只暴露"必需"的接口
- 一个组件 props 超过 6 个,要小心;
- 如果事件名不具备业务语义(比如
click
),考虑抽象; - 不要用
ref
操作子组件的内部逻辑,那是反模式。
✅ 3. 使用 slots 替代"高度定制的 props 方案"
如果你发现你组件 props 变成这样:
html
<SuperButton
:label="'提交'"
:icon="'plus'"
:iconPosition="'left'"
:styleType="'primary'"
:loading="true"
/>
那它该用 slot 了:
html
<SuperButton>
<template #icon><PlusIcon /></template>
提交
</SuperButton>
🙂
三年前我以为组件化是 Vue 最简单的部分,三年后我才意识到,它是最深、最难、最容易出坑的部分。
如果你也踩过以下这些坑:
- 组件复用越写越复杂,别人都不敢用;
- props 和事件像迷宫一样,维护成本极高;
- UI 和逻辑耦合,改一点动全身;
- 项目后期组件膨胀、技术债堆积如山;
别再让组件成为项目的"技术债"。你们也有遇到吗?