在讨论一个事情的时候,我们通常需要先了解这个事情的背景,知道我们讨论这个事情的意义在哪里,然后在分析这个事情包含了什么内容,最后我们才能针对性的对这些内容提出自己的解决方案或者看法。
所以本文也是按照这个思路来分结构。
- 先知道我们设计组件的意义
- 然后知道我们设计组件需要涉及哪些内容
- 针对这些内容,我们如何拆解,并提出解决的办法
为什么要设计组件
先来看长期迭代中,需求数、业务代码、测试代码的曲线图。

三个阶段,到了第三个阶段之后,只是新增了一点点需求,但是业务代码和测试代码量的异常的提升,这不仅让开发工作量剧增,基本无法准确的评估工时,也间接的影响了需求的流转。
项目初期,做一个相同的需求可能只需要一天,长期迭代后,可能三天、四天都不止。这不是开发人员的能力不足,而是项目代码的维护成本太高,甚至可能牵一发而动全身了。
影响项目交付的原因有项目复杂度、项目文档、角色的沟通协同。
而只是从前端开发角度出发的话,但是如果我们在每次写需求的时候,对每一个组件都设计的足够的好,更具扩展性,维护性,以及可读性的话,会极大的减少项目复杂度。这就是我们需要设计组件的原因。
设计组件包括的内容
- 需求的确定
- 设计组件包含的内容
- 组件封装基本原则
要讨论标题这个问题,我们首先要了解这个问题包含哪一些内容。如下图:

图上面需要注意的是业务组件和基础组件的关系,它们虽然是并列的关系,但是大多数人都应该知道业务组件里面是由很多基础组件构成的。所以给它们都添加了一个备注,里面涉及两个概念:特性逻辑和共性逻辑。
顾名思义,共性逻辑或者交互代表的是多个业务组件,多个模块中一样的逻辑。而特性逻辑/交互是只存在在特定的组件当中,而这个组件又可能存在和其他组件一样的逻辑或交互,这样的组件就是业务组件。它是由特性+共性构成的。如下图:

当然业务组件之间也是可以相互嵌套的,也就是说他们业务组件的层级越高,那么它的特性的越多,需要定制化的代码就越多,可以通过下马这张图表示:

这样说,可能有点抽象,举一个例子, 有如下两个组件写法:
vue
<!-- 组件 1 -->
<y-input>
<template #icon>
<el-icon v-if="iconText === 'warn'" name='warn' />
<el-icon v-else name='success' />
</template>
</y-input>
vue
<!-- 组件 2 -->
<c-input :iconText="warn">
<!-- c-input.vue -->
<template>
<el-inpute />
<el-icon v-if="iconText === 'warn'" name='warn' />
<el-icon v-else name='success' />
</template>
可以预想到, 当我需要添加一个'danger'的图标的话,组件 1 只需要在父级组件添加对应的 if-else-if 就可以。但是组件 2 还需要在组件内的 Props 给 iconText
多定义一个'danger'来进行判断。组件 1 的扩展性明显是更加的好的。
所以,我们应该如何更好的把组件给设计好呢?
确定需求
在软件开发中,编写业务代码通常会增加系统的复杂性(熵增)。但通过创建可复用、易维护且灵活的组件,可以减少这种复杂性。
所以为了进一步降低系统复杂度,优化需求 至关重要。这需要开发者深入理解业务逻辑,并与产品经理紧密合作,确保需求既满足用户体验又遵循良好的编程规范。
例如,当遇到一个新功能请求时,如果其界面设计仅在细节上略有不同,并且这些差异对用户体验或实际问题解决无明显帮助,技术人员应建议尽量利用现有组件来实现,避免不必要的代码增加。
有一句话说得好:"最好的代码是没有必要写的代码。"
这也解释了,为什么我们前端也需要关注需求的背后,有什么背景,是什么人提升的,是为了解决什么问题而存在的。不需要多么多大的理由,就是为了我写代码的时候能够更加方便,为了减少返工的可能性。
组件封装基本原则
封装以组件的道依旧是软件工程中对于可维护性代码的三条原则:
- 单一职责
- 高内聚低耦合
- 开放封闭
我们平时使用的所有所谓的技巧都是围绕这三条原则展开的。
组件设计核心的问题是,缺乏抽象能力。具体体现为下面这 4 点:
- 组件滥用与目录混乱
初学者常把"组件化"理解为"UI重复就抽组件",导致组件数量爆炸、复用性差,维护困难。
- 抽象失控,通用组件没人敢用
为了复用而过度封装,结果组件变得复杂、难以定制,团队成员宁愿复制粘贴也不愿用"通用组件"。久而久之就变成了如下:
TextInput.vue
IconTextInput.vue
ValidateableInput.vue
LoadingInput.vue
FormInput.vue
一旦最初的 TextInput 的组件需要修改,你要做多少重复的工作,这个工期又如何评估呢?而且再有下 一个需求你又当如何?这么多相似的组件 ,每一个都可以凑合使用,每一个又无法共用。
- 数据流混乱,props/emit 滥用
数据和事件传递层级过深,props 传七层、事件回调嵌套,甚至用 inject/provide、eventBus 等方式"打通",导致逻辑难以追踪和维护。 - 技术债堆积,组件不敢删不敢动
组件参数多、用途混乱,改动容易引发连锁反应,开发者只能不断复制新组件,留下大量"V2"后缀的冗余代码。
例子 1: 二次封装最佳实践
以下例子来自于【远方 OS】的 B 站/抖音账户, 这里不方便贴地址,自行搜索,偶然发现,说话声音平稳,内容总有一些小技巧感觉很有意思,故推荐。
封装一个el-input组件面对的三个问题:
1. props.如何穿透过去
方法一: **$attrs**
vue
<template>
<div>el-input 二次封装</div>
<el-input v-bind="$attrs"/>
</tempalte>
这样有如下问题:
- ts 提示没有
- 多于了很多不想要的参数
方法二: props + Partial + InputProps
vue
<template>
<el-input v-bind="{ ...$attrs, ...props }"></el-input>
</template>
<script lang="ts" setup>
import { type InputProps }from 'element-plus'
const props = defineProps<Partial<InputProps>()
</script>
- Partial 为 TS 原生属性,用于将接口都改为非必填。
InputProps
为 element 默认导出的接口。{ ...$attrs, ...props }
为了弥补 props 没有事件的问题。
2. 插槽如何穿透过去
方法一:循环 template + slot
vue
<template>
<el-input v-bind="{ ...$attrs, ...props }">
<template v-for="(_, slot) in $slots" #name="porps">
<slot :name="slot" v-bind="props" />
</template>
</el-input>
</template>
这个是我不理解的,作者为了卖课非说麻烦,我不理解,这个哪里麻烦了?
方法二:component
vue
<template>
<component :is="h(ElInput, { ...$attrs, ...props }, $slots)" />
</template>
<script lang="ts" setup>
import { ElInput, type InputProps }from 'element-plus'
import { h } from 'vue'
const props = defineProps<Partial<InputProps>()
</script>
3. 组件方法如何暴露出去
方法一:传统方法 ref
vue
<template>
<component :is="h(ElInput, { ...$attrs, ...props, ref: 'inputRef' }, $slots)" />
</template>
<script lang="ts" setup>
import { ElInput, type InputProps }from 'element-plus'
import { h } from 'vue'
const props = defineProps<Partial<InputProps>()
const inputRef = ref<InstanceType<typeof ElInput> | null>(null)
// 仅暴露必要方法,避免全量暴露
defineExpose({
focus: () => inputRef.value?.focus(),
select: () => inputRef.value?.select()
})
</script>
这样写有一个问题,如果 component 有 v-if 属性的时候,且初始值为 false 的话,这个 InputRef 就拿不到了。
拿不到就拿不到,本来就不应该能用。这个有什么关系呢?
方法二:ref 使用函数返回
vue
<template>
<component
:is="h(ElInput, { ...$attrs, ...props, ref: getInputRef }, $slots)"
/>
</template>
<script lang="ts" setup>
import { ElInput, type InputProps }from 'element-plus'
import { h } from 'vue'
const props = defineProps<Partial<InputProps>()
const vm = getCurrentInstance()
const getInputRef = (inputInstance) => {
vm.exposed = inputInstance || {} // 如果组件没有渲染处理,inputInstance 为 Null
vm.exposeProxy = inputInstance || {}
}
</script>
vm.exposeProxy = inputInstance || {}
我们在外面直接使用其实是 exposeProxy,也就是 exposed 的这个代理。
例子 2:封装一个弹窗
以下例子来源 gas-design, 模仿 element-plus 中 el-dialog 的写法
- 如何全局注册一个弹窗的组件
- 如何让全局样式来影响的组件的内部样式
- 插槽的基本设计
原型如下:

交互如下:
- 点击标题可以拖拽整个 弹窗
- content 部分可以放入任何内容
- footer 部分有取消和确定两个按钮
vue
<template>
<Teleport to="body">
<Transition name="modal-fade">
<div
v-if="modelValue"
@click="handleOverlayClick"
>
<modal-trap :draggable="draggable">
<div :style="modalStyle">
<modal-content :content="content">
<template #header>
<slot v-if="!$slots.title" name="header" :close="handleClose" />
<slot v-else name="title" />
</template>
<template v-if="$slots.default" #default>
<slot />
</template>
<template #footer>
<div class="flex justify-end">
<button
v-if="showCancel"
@click="cancel"
>
取消
</button>
<button @click="confirm">
确定
</button>
</div>
</template>
</modal-content>
</div>
</modal-trap>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, provide } from "vue"
import ModalContent from "./ModalContent.vue"
import ModalTrap from "./ModalTrap.vue"
import { modalInjectionKey } from "./constants"
const props = defineProps({
modelValue: Boolean, // v-model绑定值
title: {
type: String,
default: "提示",
},
content: String,
width: {
type: [String, Number],
default: "500px",
},
height: {
type: [String, Number],
default: "auto",
},
showCancel: {
type: Boolean,
default: true,
},
closeOnClickOverlay: {
type: Boolean,
default: true,
},
draggable: {
type: Boolean,
default: true,
},
})
const headerRefs = ref(null)
provide(modalInjectionKey, {
headerRefs
})
const emit = defineEmits(["update:modelValue", "confirm", "cancel"])
const modalStyle = computed(() => {
return {
width: typeof props.width === "number" ? `${props.width}px` : props.width,
height:
typeof props.height === "number" ? `${props.height}px` : props.height,
}
})
function handleClose() {
emit("update:modelValue", false);
}
function confirm() {
emit("confirm")
handleClose()
}
function cancel() {
emit("cancel")
handleClose()
}
function handleOverlayClick(event) {
// 只有点击遮罩层而不是弹窗本身才关闭
if (props.closeOnClickOverlay && event.target.classList.contains("fixed")) {
handleClose()
}
}
// 按ESC键关闭弹窗
function handleKeyDown(event) {
if (event.key === "Escape" && props.modelValue) {
handleClose()
}
}
onMounted(() => {
window.addEventListener("keydown", handleKeyDown)
})
onUnmounted(() => {
window.removeEventListener("keydown", handleKeyDown)
})
</script>
modal-content
的相关逻辑
vue
<template>
<div class="p-4 overflow-auto flex-1">
<!-- 标题栏 -->
<div ref="headerRefs" class="modal-header flex justify-between items-center cursor-move">
<slot name="header">
<h3>{{ title }}</h3>
</slot>
<button class="cursor-pointer" @click="close">X</button>
</div>
<!-- 内容区 -->
<slot name="default" v-if="hasDefaultSlot"></slot>
<h3 v-else>{{ content }}</h3>
<!-- 按钮区 -->
<div class="text-left">
<slot name="footer" />
</div>
</div>
</template>
<script setup lang="ts">
import { defineProps, useSlots, inject } from "vue";
import { modalInjectionKey } from "./constants"
defineProps({
title: {
type: String,
default: "提示",
},
content: String,
})
const emit = defineEmits(["close"])
const { headerRefs } = inject(modalInjectionKey)!
const slots = useSlots()
const hasDefaultSlot = !!slots.default
const close = () => {
emit("close")
}
</script>
modal-trap
的相关逻辑:
vue
<template>
<div
:style="modalStyle"
v-bind="$attrs"
@mousedown="startDrag"
@mousemove="onDrag"
@mouseup="stopDrag"
@mouseleave="stopDrag"
>
<slot />
</div>
</template>
<script setup lang="jsx">
import { ref, defineProps, computed, inject } from "vue"
import { modalInjectionKey } from "./constants"
const props = defineProps({
draggable: {
type: Boolean,
default: true,
},
})
const { headerRefs } = inject(modalInjectionKey)
const isDragging = ref(false);
const dragOffset = ref({ x: 0, y: 0 });
const position = ref({ x: 0, y: 0 });
const modalStyle = computed(() => {
return {
transform: `translate(${position.value.x}px, ${position.value.y}px)`,
}
});
function startDrag(event) {
if (!props.draggable) return;
if (!headerRefs.value) return;
// 点击的是 headerRef 时才能拖动
if (event.target === headerRefs.value) {
isDragging.value = true;
dragOffset.value = {
x: event.clientX - position.value.x,
y: event.clientY - position.value.y,
};
}
}
function onDrag(event) {
if (isDragging.value) {
position.value = {
x: event.clientX - dragOffset.value.x,
y: event.clientY - dragOffset.value.y,
};
}
}
function stopDrag() {
isDragging.value = false;
}
</script>
在业务组件中使用如下:
xml
<Modal v-model={isModalVisible.value} title='测试通知2'>
<p>slot: 这是一条测试通知2</p>
</Modal>
总结其他封装组件小技巧
-
良好的注释有锦上添花的效果
-
业务组件应该提供最小变量原则,开发者只需要几个参数就可以实现效果。
-
对于业务组件的props不应该超过3个,UI组件的props多是因为它们面对的项目数不胜数。如果业务组件真的需要这么的props的话,那么可以考虑使用插槽来实现。
-
state 状态应该尽量的简洁而全面,已经有一个 state 是数组的了,那么你当前你需要他的长度的时候,就不需要再定义一个 state 来存储它的长度了,直接 state.length 就可以了。《重构》这本书中亦有提到。
-
封装组件封装到什么程度?
-
以业务为主,如果没有其他业务需要,那么对于一个复杂组件而言直接抛出组件都没有任务问题。
-
业务层面的组件和代码层面的组件是两个概念
-
代码层面的组件还是要根据受控组件和非受控组件来进行切割,高内聚低耦合
-
不要通过 props 参数来座位控制组件的显示隐藏。把显示隐藏的变量放到里面,然后 defineExpord(open()和 close())这两个方法除了可能更加的合适,符合单一指责原则。或者干脆不要这个参数,直接在父组件对这个进行控制。
-
组件设计建议
-
明确组件职责(UI、交互、逻辑分离)
-
精简 props 和事件,只暴露必要接口
-
用 slots 替代高度定制的 props
-
当 props 过多的时候就可以考虑是否改为使用 slot 了?但是这里有一个问题了,时间从哪里来呢?
技术之外:设计/封装组件面临其他困境
不是我的组件抽象能力不行,实在是产品太奇葩,公司不给时间
这句话其实是在说:
- 需求未冻结,技术可行性评审流于形式;
- 工期赶,人力资源分配不合理。
- 我有一颗想要把组件设计好的心也迫于这些原因而做不到,非战之罪!
是的,这些问题完全能够理解,一个经验老道的开发或许可以在赶工和代码质量之间做到很好的权衡,但是大多数人不是这样的,包括我,一直在路上。整个氛围都紧张的情况下,每当坐上工位都感觉工位有脏东西,会附身的,让你忍不住骂娘。
所以这样的情况确实是客观存在的,我们能做什么?有的,集美集帅们。我们来上班不是为了长结节的,如果现状无法改变的话,我们还可以调整自己的心态,一般来说,我们可有两种心态:
- 在工位生闷气,然后回家焦虑,骂天骂地,骂 TM 的。
- 如果我可以在短时间内很好的完成任务,这会成为我将来跳槽的基石。
核心逻辑 :当我们面对无法改变的客观事实的时候,将自我的成长和要面对的显示绑定。事情是要做的,但是我将被动接受负面情绪转换为自己主动去控制。这个在行为心理学当中,是将我们做事情的动力转化为对理想状态的向往。
那么我们如何能够很好的完成任务呢?把代码写清楚。
我们先来看下面这张图,复用机会和功能数,以及颗粒度的关系,总结来说颗粒度越高复用机会越低,功能数越高复用机会越高,所以如何平衡功能数和颗粒度就成了我们做好这件事情的关键。

现实很理想,ui突发奇想,产品突发奇想,领导突发奇想,客户突发奇想... 所谓的"通用组件"像... 好多ui和产品水得没边,做个草图/设计图就是硬生生一版,然后实际开发可能还没他们改动得快理想很丰满,现实有点难蚌。
上图的功能数和颗粒度似乎有一个交叉点,但是这个点没有一个明确的标准的,意味着它的标准可以很灵活。
所以我在开发一个组件的时候,有一条底线,那就是而是如何把代码给写清楚,让其他人一开就懂。这个是我们在团队协作中应该有的态度。
什么时候需要对组件进行封装
有这么一种说法相同的代码代码大于 2 次考虑封装,大于 3 次就必须封装?
我不同意这样的观点,组件的封装是一个设计思维而不是一个补救措施。前期一个良好的组件封装随着经验的提升,是不会占据更多的时间的。
在软件开发中,理想的实现路径应该是:
设计时遵守三大原则 -> 开发时模块化 -> 重复的时候快速抽离适配
实在无法办到的时候,在遇到重复的代码的时候,最好的办法一定是先复制粘贴原本的代码,然后按照设计三大原则以及模块化来重新写的,等到下一次有需求涉及到这部分的时候,才进行组件的替换。这样做的好处有哪一些呢?
- 降低风险,不管你重写了和非本次需求的任何代码,对于开发都会导致自己的任务时长超出预期,对于测试也是会增加新的工作量。
- 合理人人力资源的使用。
- 在之后遇到这个模块的时候再进行适配,也更加的好统计工时。
这里有一个小技巧,为了之后不被忘记,在需要替换的组件部分添加一个 TODO,或者使用文档来作为一个技术债。
在我们业务开发当中,写代码在我们工作的占比不是百分之百,它很重要,但是更重要的是团队的协作,更重要的是业务价值。
最后,缺少一个统筹全局,将不同声音统一成一个声音的人。开发缺少时间,要形成可复用的公共组件,不是说普通的业务开发顺手写一下就好了,公共组件是需要设计,需要重构,需要精炼的。是需要专人长期的不间断的投入时间和精力的。