组件是 Vue 应用的基本构建单位。一个项目最终是好维护还是一团乱麻,很大程度上取决于组件划分是否合理。以下三个原则不是冰冷的教条,而是从无数真实项目血泪史中总结出来的实操经验。
单一职责:一个组件只做一件事
单一职责原则要求每个组件只承担一项明确的职责。当你想用一句话描述一个组件是“干什么的”却发现需要一大段话甚至“和/或”时,就说明它承担了过多的职责。
反面例子:
一个 UserProfileCard 组件,内部既负责发起 API 请求获取用户数据,又负责展示头像、昵称、简介,还内嵌了编辑表单的逻辑。表面上看,一个文件解决了所有事情,但带来的问题是:
- 组件代码动辄 500+ 行,修改展示样式时可能不小心改坏了请求逻辑。
- 这个组件无法在“只展示用户信息”的场景复用,因为它强制包含了编辑功能。
- 单元测试需要模拟网络请求、DOM 交互、表单验证,复杂性爆炸。
改进:
<!-- UserInfo.vue —— 只负责展示 -->
<template>
<div class="user-info">
<img :src="avatar" />
<h3>{{ name }}</h3>
<p>{{ bio }}</p>
</div>
</template>
<script setup>
defineProps(['avatar', 'name', 'bio'])
</script>
<!-- EditUserForm.vue —— 只负责编辑表单 -->
<template>
<form @submit.prevent="handleSubmit">
<input v-model="localName" />
<button>保存</button>
</form>
</template>
<script setup>
const props = defineProps(['name'])
const emit = defineEmits(['submit'])
const localName = ref(props.name)
function handleSubmit() {
emit('submit', localName.value)
}
</script>
<!-- UserPage.vue —— 容器组件,负责组装与数据获取 -->
<template>
<UserInfo v-if="user" v-bind="user" />
<EditUserForm :name="user.name" @submit="updateName" />
</template>
<script setup>
import { fetchUser, updateUser } from '@/api'
const user = ref(null)
onMounted(async () => { user.value = await fetchUser() })
function updateName(newName) { /* 调接口并更新 user */ }
</script>
这样拆分后,每个组件职责清晰,各自独立演进。展示组件甚至可以在完全不依赖 API 的环境下用 Storybook 单独调试。
粒度拆分:不大不小,合适最好
单一职责自然会引出拆分,但拆分到什么粒度合适?过粗会导致一个组件里面塞满几十个方法和模板区域,过细又会让项目充斥大量只有三行代码的“碎片组件”,增加理解项目结构的成本。
实用的判断标准:
- 模板长度:一个组件的模板部分如果超过 200 行,就应该考虑拆分成子组件。模板过长意味着可读性急剧下降,一眼扫过去很难找到“我想改的那个按钮在哪里”。
- 逻辑集中度:如果组件内出现了多个互不相关的数据群体——比如“用户头像信息”和“用户订单列表”同时出现在同一个组件内,它们应该被分开。
- 复用可能性:即使当前只用了一次,如果将来可能在其他页面被重用(比如一个评分组件、一个上传按钮),就应该抽出来。
- 维护边界:当一张页面由两个开发者并行开发时,按模块拆分成几个组件能避免频繁的代码冲突。
真实建议:如果读完一个组件的 <script setup> 部分让你觉得“这个逻辑也没多复杂”,那粒度通常是合适的。如果每次打开都要滚动好几屏,或者改个样式要在一堆类名和不相关的标签里扒拉半天,就说明该继续拆了。
可复用性:设计时多做一点,使用时少写很多
组件的可复用性不是主观感觉,而是通过 Props 配置化和插槽扩展这两个具体技术手段实现的。
Props 让组件参数化,而不是写死:
<!-- ❌ 硬编码,不可复用 -->
<template>
<button class="btn-primary" @click="handleDelete">删除</button>
</template>
<!-- ✅ 通过 props 暴露配置 -->
<template>
<button :class="`btn-${type}`" @click="$emit('click')">{{ label }}</button>
</template>
<script setup>
defineProps({ type: { type: String, default: 'default' }, label: String })
defineEmits(['click'])
</script>
现在同一个按钮组件可以渲染删除、保存、取消等不同场景,无需复制代码。
插槽让组件骨架可填充:
<!-- Card.vue —— 只提供结构,内容完全由父组件决定 -->
<template>
<div class="card">
<div class="card-header">
<slot name="header">默认标题</slot>
</div>
<div class="card-body">
<slot>默认内容</slot>
</div>
<div class="card-footer">
<slot name="footer"></slot>
</div>
</div>
</template>
父组件使用时可以灵活注入不同内容,Card 组件本身与业务逻辑完全解耦。
可复用性的真实收益:
- 减少重复代码,一个按钮样式统一需求只需改一处。
- 带来一致的交互体验:所有弹窗、所有日期选择器都遵循同一个经过测试的基础组件。
- 降低新人上手成本:熟悉一次通用组件,就能快速拼装出新页面。
单看一个简单的 BaseInput、BaseTable 觉得有点小题大做,但当项目有几十个表单、几十个表格时,你会发现这些“小题大做”就是项目不失控的防线。
小结: 单一职责让你能看懂自己的代码,粒度拆分让团队合作不打架,可复用性让你明天不用重复写今天的代码。三个原则不是束缚,而是长期维护项目中最朴素的自我保护。