次元图库
36.81M · 2026-03-09
在 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 发送消息高内聚和低耦合是相辅相成的:
当我们在犹豫是否要拆分一个组件时,可以问问自己这几个问题:
原子设计方法论是由 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 中,原子通常对应那些只封装了最基础 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>
<!-- 分子: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>
<!-- 组织: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>
模板在 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>
<!-- 页面: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>
只接收必要的数据,不要接收和组件不相关的数据:
defineProps<{
user: User
isEditable?: boolean
}>()
interface Props {
placeholder?: string
disabled?: boolean
maxLength?: number
}
const props = withDefaults(defineProps<Props>(), {
placeholder: '请输入',
disabled: false,
maxLength: 100
})
interface User {
id: number
name: string
avatar: string
role: 'admin' | 'user' | 'guest'
}
defineProps<{
user: User
permissions: string[]
}>()
<ChildComponent :user="user" />
<UserCard
:user="user"
:posts="userPosts"
/>
<DataTable
:show-header="true"
:allow-sort="true"
:page-size="20"
:theme="'dark'"
/>
<FormComponent
@submit="handleSubmit"
@cancel="handleCancel"
/>
<ModalComponent>
<template #header>
<h2>自定义标题</h2>
</template>
<template #footer>
<button>确认</button>
</template>
</ModalComponent>
defineProps<{
userName: string // 不是 uname
userAvatar: string // 不是 uavatar(除非是标准术语)
}>()
defineProps<{
isActive: boolean // 状态
hasPermission: boolean // 拥有
shouldShow: boolean // 应该
}>()
defineProps<{
onSubmit: () => void
onClose: () => void
}>()
defineProps<{
users: User[]
}>()
<!-- 父组件 -->
<ChildComponent
:data="parentData"
@update="handleUpdate"
/>
<!-- 子组件 -->
<script setup>
defineProps<{ data: string }>()
const emit = defineEmits<{
update: [value: string]
}>()
</script>
<InputComponent v-model="searchText" />
<CardComponent>
<template #header>标题</template>
内容
<template #footer>底部</template>
</CardComponent>
// 祖先组件
provide('theme', 'dark')
// 后代组件
const theme = inject('theme')
const userStore = useUserStore()
子组件只需要告诉父组件发生了什么,至于事件发生后该做什么,要怎么做,由父组件决定,子组件不作任何处理:
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]
}>()
<!-- Card.vue -->
<template>
<div class="card">
<div class="card-content">
<slot>
<!-- 提供默认内容 -->
<p>暂无内容</p>
</slot>
</div>
</div>
</template>
<!-- 使用 -->
<Card>
<p>这是卡片内容</p>
</Card>
<!-- 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>
<!-- 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>
<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>
<template>
<!-- 好:只暴露必要的数据 -->
<slot
:item="item"
:index="index"
:is-first="index === 0"
:is-last="index === items.length - 1"
/>
<!-- 差:暴露整个组件实例 -->
<slot :this="this" :$el="$el" :$props="$props" />
</template>
<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 中的体现 | 实践建议 |
|---|---|---|
| 单一职责 | 一个组件只做一件事 | 组件代码不超过 300 行,功能单一明确 |
| 开闭原则 | 对扩展开放,对修改关闭 | 多用插槽,少改内部逻辑;通过 Props 配置行为 |
| 里氏替换 | 子组件可替换父组件 | 保持 Props 接口一致,遵循相同的契约 |
| 接口隔离 | Props 尽可能少 | 避免传递整个对象,只传必要字段;用多个小 Props 替代一个大对象 |
| 依赖倒置 | 依赖抽象,不依赖实现 | 用事件通信,不直接调用父组件方法;用 provide/inject 解耦 |
好的组件设计不是一蹴而就的,而是在每一次重构中不断完善的过程。当我们开始思考"这个组件是否应该拆分"、"这个 Props 命名是否合理"的时候,我们就已经走在了正确的道路上了。
对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!