前言
在 Vue 应用开发中,组件就像是乐高积木,组件设计可以决定这些积木的形状和接口。好的设计可以让积木自由组合,构建出各种复杂的应用;而一个坏的设计则让积木之间互不兼容,最终导致代码难以维护、难以复用、难以测试。
尤其是随着项目规模的增长,组件设计的重要性愈发凸显。本文将深入探讨高内聚低耦合的核心概念,通过大量实战案例,帮助我们掌握 Vue 组件设计的精髓。
为什么组件设计如此重要?
现实痛点
开篇之前,我们先来看一个设计不良的组件会带来哪些问题:
<!-- ❌ 反例:一个上千行的 "上帝组件" -->
<template>
<div>
<!-- 用户信息区域 -->
<div class="user-section">
<img :src="user.avatar">
<h2>{{ user.name }}</h2>
<!-- 几百行用户相关代码 -->
</div>
<!-- 好友列表区域 -->
<div class="friends-section">
<!-- 又是几百行好友列表代码 -->
</div>
<!-- 动态列表区域 -->
<div class="activities-section">
<!-- 还有几百行动态列表代码 -->
</div>
</div>
</template>
<script>
export default {
props: ['user'], // 什么类型?不知道
data() {
return {
user: {},
friends: [],
activities: [],
loading: false,
error: null,
// ... 还有诸多数据字段
}
},
methods: {
// 所有方法全部混在一起
fetchUser() { /* ... */ },
fetchFriends() { /* ... */ },
fetchActivities() { /* ... */ },
followUser() { /* ... */ },
unfollowUser() { /* ... */ },
likeActivity() { /* ... */ },
// ... 其他方法
}
}
</script>
这个组件存在的问题:
- 牵一发而动全身:修改用户信息的样式,可能会意外影响好友列表
- 难以复用:想在另一个页面显示好友列表?那只能复制粘贴上百行代码
- 难以理解:新接手的人需要花一天时间才能理清逻辑
- 难以测试:如何单独测试好友列表的功能?
好的组件设计带来的收益
<!-- ✅ 好的设计:拆分为独立组件 -->
<template>
<div class="user-profile-page">
<UserInfoCard :user="user" />
<FriendList :friends="friends" @follow="handleFollow" />
<ActivityFeed :activities="activities" @like="handleLike" />
</div>
</template>
<script setup>
// 容器组件:只负责数据获取和组合
const { user, friends, activities } = await fetchUserData(props.userId)
function handleFollow(userId) { /* ... */ }
function handleLike(activityId) { /* ... */ }
</script>
这个组件带来的好处:
- 可维护性:每个组件独立修改,互不影响
- 可复用性:这个组件可以在任何地方使用
- 可测试性:可以为每个组件编写独立的单元测试
- 可读性:代码即文档,一目了然
高内聚低耦合:组件设计的黄金法则
什么是高内聚?
高内聚是指组件内部的元素(数据、方法、模板等)紧密相关,共同完成一个明确的职责:
<!-- ✅ 高内聚的计数器组件:所有逻辑都服务于"计数"这个单一职责 -->
<template>
<div class="counter">
<button @click="decrement" :disabled="count <= min">-</button>
<span class="count">{{ count }}</span>
<button @click="increment" :disabled="count >= max">+</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps<{
min?: number
max?: number
initial?: number
}>()
// 所有数据和方法都围绕 count 展开
const count = ref(props.initial ?? 0)
function increment() {
if (count.value < (props.max ?? Infinity)) {
count.value++
}
}
function decrement() {
if (count.value > (props.min ?? -Infinity)) {
count.value--
}
}
</script>
<style scoped>
/* 样式也只服务于这个组件 */
.counter {
display: flex;
align-items: center;
gap: 8px;
}
</style>
高内聚的特征
- 组件名称准确地描述了它的功能
- 组件的所有代码都是为了实现这个功能
- 移除任何一个部分都会影响核心功能
什么是低耦合?
低耦合是指组件之间的依赖关系简单、明确,修改一个组件不需要修改另一个组件:
<!-- 父组件 -->
<template>
<div>
<UserCard
:user="user"
@follow="handleFollow"
@unfollow="handleUnfollow"
/>
</div>
</template>
<!-- 子组件:不知道父组件的任何信息 -->
<template>
<div class="user-card">
<img :src="user.avatar" :alt="user.name">
<h3>{{ user.name }}</h3>
<button
v-if="!isFollowing"
@click="$emit('follow', user.id)"
>
关注
</button>
<button
v-else
@click="$emit('unfollow', user.id)"
>
取消关注
</button>
</div>
</template>
<script setup>
defineProps<{
user: { id: number; name: string; avatar: string }
isFollowing?: boolean
}>()
defineEmits<{
follow: [userId: number]
unfollow: [userId: number]
}>()
</script>
低耦合的特征
- 组件只通过
Props接收数据,通过Events发送消息 - 组件内部不依赖全局状态(除非必要)
- 修改组件内部实现,不需要修改使用它的地方
内聚与耦合的关系
高内聚和低耦合是相辅相成的:
- 高内聚是低耦合的基础:只有组件内部职责清晰,才能设计出清晰的接口
- 低耦合让高内聚更有价值:如果组件之间耦合度高,即使每个组件内聚再好,系统也难以维护
组件划分的边界艺术
如何判断一个组件是否应该拆分?
当我们在犹豫是否要拆分一个组件时,可以问问自己这几个问题:
- 独立复用:这个部分能否在其他地方使用?
- 独立逻辑:这个部分是否有独立的业务逻辑?
- 频繁变化:这个部分是否会频繁修改?
- 代码规模:代码是否过长,如是否超过 300 行?
- 过度拆分:是否为了拆分而拆分,导致组件冗余?
原子设计方法论
原子设计方法论是由 Brad Frost 提出的一种用于构建设计系统的方法论。它借鉴了化学中的基本概念,认为所有的用户界面(UI)都可以由一系列基本的、不可再分的元素(原子)组合而成。其核心思想是分层构建,就像搭积木一样,从最小的单元开始,逐步组合成越来越复杂的结构,这个过程分为五个层次:
原子(Atoms)→ 分子(Molecules)→ 组织(Organisms)→ 模板(Templates)→ 页面(Pages)
原子
原子 是构成用户界面的最基本、最小的元素,无法再进一步细分。其本身不具备独立的功能性,但它们定义了所有设计元素的基础样式和属性。比如一个 <label> 标签、一个 <input> 输入框、一个 <button> 按钮、颜色调色板、字体、动画等:
<template>
<button>原子按钮</button>
</template>
分子
分子 由多个原子组合在一起形成的相对简单的 UI 组件,具有简单、明确的功能,遵循“单一职责原则”,即:只做一件事,且把这件事做得很好。比如一个“搜索框”分子可以由一个 <label> 原子(“搜索”文字)、一个 <input> 原子(输入框)和一个 <button> 原子(“搜索”按钮)组合而成。这三个原子结合在一起,就形成了一个能执行搜索功能的最小单元:
<template>
<div class="search-bar">
<label>搜索:<label>
<input v-model="searchText" />
<button @click="search">搜索</button>
</div>
</template>
组织
组织 由分子、原子以及其他组织组合而成的相对复杂的 UI 结构。它们构成了页面中一个独立的区域,作为页面中功能完善的模块,但本身还不是一个完整的页面。比如“用户列表”,由多个“用户卡片”分子构成:
<template>
<div class="user-list">
<UserCard v-for="user in users" :key="user.id" :user="user" />
</div>
</template>
模板
模板 将多个组织、分子和原子组合在一起,形成页面的 骨架和布局结构。其关注的是内容在页面上的 排布方式,展示了各组件的相对位置和功能。如一个“管理布局”模板,定义了头部组织、正文内容区域和底部组织分别放在什么位置:
<template>
<div class="layout">
<header />
<main>
<SearchBar @search="handleSearch" />
<UserList :users="filteredUsers" />
</main>
<footer />
</div>
</template>
注:模板是 抽象 的,它没有填充真实的内容,只有占位符。只是定义了 布局结构。
页面
页面 是模板的具体实例。它将真实的内容(文本、图片等)填充到模板中,并精确地调整整个界面的样式和逻辑,最终呈现给用户的样子。
原子设计方法论与 Vue3 的结合
Vue3 的原子:Vue3 中的基础元素组件
在 Vue3 中,原子通常对应那些只封装了最基础 HTML 元素和样式的组件。它们通常只通过 props 接收数据,并通过 $emit 或 v-model 向外发送事件:
<!-- 1. 原子:BaseInput.vue -->
<template>
<div class="base-input">
<input
:id="id"
:type="type"
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
v-bind="$attrs"
/>
</div>
</template>
<script setup lang="ts">
defineProps({
id: String,
type: { type: String, default: 'text' },
modelValue: [String, Number]
})
defineEmits(['update:modelValue'])
</script>
Vue3 的分子:Vue3 中的功能组件
<!-- 分子:SearchForm.vue -->
<template>
<form class="search-form" @submit.prevent="handleSubmit">
<BaseInput
v-model="searchText"
label="搜索"
placeholder="请输入关键词..."
/>
<BaseButton type="submit">搜索</BaseButton>
</form>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import BaseInput from './BaseInput.vue'
import BaseButton from './BaseButton.vue'
const searchText = ref('')
const emit = defineEmits(['search'])
const handleSubmit = () => {
emit('search', searchText.value)
}
</script>
Vue3 的组织:Vue3 中的区块组件
<!-- 组织:HeaderOrganism.vue -->
<template>
<header class="site-header">
<div class="logo">
<img src="/logo.png" alt="Logo" />
<span>My App</span>
</div>
<nav class="nav-menu">
<a v-for="item in navItems" :key="item.link" :href="item.link">{{ item.text }}</a>
</nav>
<SearchForm @search="handleGlobalSearch" />
</header>
</template>
<script setup lang="ts">
import SearchForm from './SearchForm.vue' // 导入分子
const navItems = [ /* ... */ ]
const handleGlobalSearch = (query) => { /* 处理全局搜索 */ }
</script>
Vue3 中的模板:Vue3 中的布局或页面组件(此时无数据)
模板在 Vue 中通常对应一个布局组件或一个无具体数据的页面级组件。它负责定义页面的骨架结构,引入各种组织组件,并将它们摆放在正确的位置。此时,组件接收的 props 或 slot 插槽内容都是抽象的占位符:
<!-- 模板:ArticlePageTemplate.vue -->
<template>
<div class="article-page">
<HeaderOrganism />
<main class="content-wrapper">
<aside class="sidebar">
<!-- 这里是一个插槽,用于放置侧边栏内容,具体内容由页面填充 -->
<slot name="sidebar" />
</aside>
<article class="main-content">
<!-- 这里是主要内容插槽 -->
<slot />
</article>
</main>
<FooterOrganism />
</div>
</template>
<script setup lang="ts">
import HeaderOrganism from './HeaderOrganism.vue'
import FooterOrganism from './FooterOrganism.vue'
</script>
Vue3 中的页面:Vue2 中的完整页面组件(有数据)
<!-- 页面:ArticlePage.vue -->
<template>
<ArticlePageTemplate>
<!-- 向模板的 sidebar 插槽填充真实内容 -->
<template #sidebar>
<AuthorCard :author="article.author" />
<RelatedArticles :articles="article.related" />
</template>
<!-- 向默认插槽填充文章正文 -->
<ArticleContent :article="article" />
</ArticlePageTemplate>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import ArticlePageTemplate from './ArticlePageTemplate.vue'
import AuthorCard from './AuthorCard.vue'
import RelatedArticles from './RelatedArticles.vue'
import ArticleContent from './ArticleContent.vue'
const article = ref({})
onMounted(async () => {
article.value = await fetchArticleData()
})
</script>
Props 设计:定义组件的公开 API
Props 设计的黄金法则
法则一:尽可能少,尽可能明确
只接收必要的数据,不要接收和组件不相关的数据:
defineProps<{
user: User
isEditable?: boolean
}>()
法则二:提供合理的默认值
interface Props {
placeholder?: string
disabled?: boolean
maxLength?: number
}
const props = withDefaults(defineProps<Props>(), {
placeholder: '请输入',
disabled: false,
maxLength: 100
})
法则三:使用 TypeScript 定义类型
interface User {
id: number
name: string
avatar: string
role: 'admin' | 'user' | 'guest'
}
defineProps<{
user: User
permissions: string[]
}>()
法则四:避免传递不必要的 props
<ChildComponent :user="user" />
Props 的 4 种类型及使用场景
1. 数据型 Props:单纯的数据展示
<UserCard
:user="user"
:posts="userPosts"
/>
2. 配置型 Props:控制组件行为
<DataTable
:show-header="true"
:allow-sort="true"
:page-size="20"
:theme="'dark'"
/>
3. 回调型 Props:事件处理
<FormComponent
@submit="handleSubmit"
@cancel="handleCancel"
/>
4. 节点型 Props:自定义渲染
<ModalComponent>
<template #header>
<h2>自定义标题</h2>
</template>
<template #footer>
<button>确认</button>
</template>
</ModalComponent>
Props 命名的最佳实践
1. 使用完整单词
defineProps<{
userName: string // 不是 uname
userAvatar: string // 不是 uavatar(除非是标准术语)
}>()
2. 布尔值用 is/has/should 开头
defineProps<{
isActive: boolean // 状态
hasPermission: boolean // 拥有
shouldShow: boolean // 应该
}>()
3. 回调函数用 on 开头
defineProps<{
onSubmit: () => void
onClose: () => void
}>()
4. 数组等用复数
defineProps<{
users: User[]
}>()
事件通信:让组件之间优雅地对话
组件通信的 5 种方式及选择策略
1. Props + Events:父子组件直接通信(最常用)
<!-- 父组件 -->
<ChildComponent
:data="parentData"
@update="handleUpdate"
/>
<!-- 子组件 -->
<script setup>
defineProps<{ data: string }>()
const emit = defineEmits<{
update: [value: string]
}>()
</script>
2. v-model:双向绑定的场景(表单类)
<InputComponent v-model="searchText" />
3. Slots:父组件控制渲染内容(布局类)
<CardComponent>
<template #header>标题</template>
内容
<template #footer>底部</template>
</CardComponent>
4. Provide/Inject:跨多层组件传递(主题、用户信息)
// 祖先组件
provide('theme', 'dark')
// 后代组件
const theme = inject('theme')
5. Pinia:全局状态(用户信息、购物车)
const userStore = useUserStore()
事件设计的 3 个原则
原则一:只通知,不下命令
子组件只需要告诉父组件发生了什么,至于事件发生后该做什么,要怎么做,由父组件决定,子组件不作任何处理:
const emit = defineEmits<{
'item-selected': [item: Item]
'form-submitted': [data: FormData]
}>()
原则二:事件粒度适中
一个操作对应一个事件,不要把所有操作放在一个事件中(太粗),也不要把不需要处理的操作放在事件中(太细):
// ✅ 好:一个操作一个事件
const emit = defineEmits<{
'save-success': []
'save-error': [error: Error]
}>()
// ❌ 差:太细或太粗
const emit = defineEmits<{
'button-mousedown': [] // 太细,外部不需要知道
'button-mouseup': [] // 太细
'data-operation': [ // 太粗,不知道发生了什么
type: 'create' | 'update' | 'delete',
data: any
]
}>()
原则三:保持一致性
统一的命名风格,使用冒号 : 分隔命名空间:
const emit = defineEmits<{
'user:created': [user: User]
'user:updated': [user: User]
'user:deleted': [userId: string]
}>()
插槽设计:让组件拥有无限可能
插槽的 3 种形式及适用场景
1. 默认插槽:简单的内容占位
<!-- Card.vue -->
<template>
<div class="card">
<div class="card-content">
<slot>
<!-- 提供默认内容 -->
<p>暂无内容</p>
</slot>
</div>
</div>
</template>
<!-- 使用 -->
<Card>
<p>这是卡片内容</p>
</Card>
2. 具名插槽:多个位置的定制
<!-- Modal.vue -->
<template>
<div class="modal">
<header>
<slot name="header">默认标题</slot>
</header>
<main>
<slot name="content">默认内容</slot>
</main>
<footer>
<slot name="footer">
<button @click="close">关闭</button>
</slot>
</footer>
</div>
</template>
<!-- 使用 -->
<Modal>
<template #header>
<h2>自定义标题</h2>
</template>
<template #content>
<p>自定义内容</p>
</template>
<template #footer>
<button @click="confirm">确认</button>
<button @click="cancel">取消</button>
</template>
</Modal>
3. 作用域插槽:让父组件访问子组件数据
<!-- DataTable.vue -->
<template>
<div class="data-table">
<table>
<tbody>
<tr v-for="(item, index) in data" :key="index">
<td v-for="col in columns" :key="col.key">
<slot
:name="`column-${col.key}`"
:value="item[col.key]"
:row="item"
:index="index"
>
{{ item[col.key] }}
</slot>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<!-- 使用 -->
<DataTable :data="users" :columns="columns">
<template #column-status="{ value, row }">
<Badge :type="value === 'active' ? 'success' : 'default'">
{{ value }}
</Badge>
</template>
</DataTable>
插槽设计的 3 个最佳实践
1. 提供合理的默认内容
<template>
<div class="empty-state">
<slot name="icon">
<EmptyIcon />
</slot>
<slot name="message">
<p>暂无数据</p>
</slot>
<slot name="action">
<button @click="$emit('refresh')">刷新</button>
</slot>
</div>
</template>
2. 保持作用域数据的精简
<template>
<!-- ✅ 好:只暴露必要的数据 -->
<slot
:item="item"
:index="index"
:is-first="index === 0"
:is-last="index === items.length - 1"
/>
<!-- ❌ 差:暴露整个组件实例 -->
<slot :this="this" :$el="$el" :$props="$props" />
</template>
3. 使用 TypeScript 定义插槽类型
<script setup lang="ts">
interface User {
id: number
name: string
email: string
}
defineSlots<{
// 默认插槽不接受 props
default(props: {}): any
// 具名插槽
header(props: {}): any
// 作用域插槽
'user-item'(props: {
user: User
index: number
isSelected: boolean
}): any
// 可选插槽
footer?(props: {}): any
}>()
</script>
组件设计的 SOLID 原则(Vue 视角)
| SOLID 原则 | Vue 中的体现 | 实践建议 |
|---|---|---|
| 单一职责 | 一个组件只做一件事 | 组件代码不超过 300 行,功能单一明确 |
| 开闭原则 | 对扩展开放,对修改关闭 | 多用插槽,少改内部逻辑;通过 Props 配置行为 |
| 里氏替换 | 子组件可替换父组件 | 保持 Props 接口一致,遵循相同的契约 |
| 接口隔离 | Props 尽可能少 | 避免传递整个对象,只传必要字段;用多个小 Props 替代一个大对象 |
| 依赖倒置 | 依赖抽象,不依赖实现 | 用事件通信,不直接调用父组件方法;用 provide/inject 解耦 |
组件设计的 10 个坏味道(Anti-Patterns)
- 上帝组件:超过 500 行的组件
- Props 泛滥:超过 10 个 props
- 多层级 Props 透传:props 穿过 3 层以上
- 组件内直接修改 props:违反了单向数据流
- 模板内复杂逻辑:模板中有三元运算符嵌套
- CSS 全局污染:没有使用 scoped 或 CSS Modules
- 依赖父组件结构:组件假设父组件一定有某个 DOM 结构
- 过度抽象:为了复用而拆分,反而更难用
- 隐式通信:通过修改 store 来通知兄弟组件
- 没有 TypeScript:组件 API 全靠文档记忆
组件设计的检查清单
设计前思考
- 这个组件的职责是否单一?
- 是否真的需要拆分成独立组件?
- 这个组件会在哪些地方被使用?
设计时检查
- Props 命名是否清晰易懂?
- 是否提供了合理的默认值?
- 是否使用了 TypeScript 定义类型?
- 事件命名是否表达了发生了什么?
- 插槽是否有合理的默认内容?
- 样式是否 scoped?
设计后验证
- 组件能否独立运行?(不依赖外部数据)
- 修改组件内部,会影响外部吗?(低耦合验证)
- 其他开发者能看懂这个组件吗?(可读性验证)
- 能否为这个组件写单元测试?(可测试性验证)
- 组件文档是否清晰?(可用性验证)
结语
好的组件设计不是一蹴而就的,而是在每一次重构中不断完善的过程。当我们开始思考"这个组件是否应该拆分"、"这个 Props 命名是否合理"的时候,我们就已经走在了正确的道路上了。
对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!