【Vue3+组件设计】中后台业务场景:从三层组件划分到实战代码落地,彻底搞懂页面/业务/基础组件边界与高内聚低耦合,避开组件拆分过度/耦合混乱等高频坑!

📑 文章目录
- 开篇
- 一、为什么要把组件分三层?
- [1.1 三种常见问题](#1.1 三种常见问题)
- 二、三层组件的定义与边界
- [2.1 页面组件(Page Components)](#2.1 页面组件(Page Components))
- [2.2 业务组件(Business / Feature Components)](#2.2 业务组件(Business / Feature Components))
- [2.3 基础组件(Base / Presentational Components)](#2.3 基础组件(Base / Presentational Components))
- 三、三者关系与数据流
- 四、实战示例:用户列表页拆分
- [4.1 结构设计](#4.1 结构设计)
- [4.2 完整示例](#4.2 完整示例)
- 五、高内聚、低耦合怎么落地?
- [5.1 高内聚](#5.1 高内聚)
- [5.2 低耦合](#5.2 低耦合)
- [5.3 对比示例](#5.3 对比示例)
- 六、常见踩坑点
- [6.1 把业务逻辑塞进基础组件](#6.1 把业务逻辑塞进基础组件)
- [6.2 页面组件里写大量 UI 细节](#6.2 页面组件里写大量 UI 细节)
- [6.3 过度拆分](#6.3 过度拆分)
- [6.4 在子组件里直接改 props](#6.4 在子组件里直接改 props)
- 七、推荐目录结构
- 八、简单自检清单
- 九、小结
- [🔍 系列模块导航](#🔍 系列模块导航)
同学们好,我是 Eugene(尤金),一名多年中后台前端开发工程师。
(Eugene 发音 /juːˈdʒiːn/,大家怎么顺口怎么叫就好)
很多前端开发者都会遇到一个瓶颈:
代码能跑,但不够规范;功能能实现,但维护起来特别痛苦;一个人写没问题,一到团队协作就各种混乱、踩坑、返工。
想写出干净、优雅、可维护 的专业代码,靠的不是天赋,而是体系化的规范 + 真实实战经验。
这一系列《前端规范实战》,我会用大白话 + 真实业务场景,不讲玄学、不堆理论,只分享能直接落地的规范、标准与避坑指南。
帮你从「会写代码」真正升级为「会写优质、可维护、团队级别的代码」。
开篇
很多同学能写出能跑的 Vue 代码,但在「该拆哪些组件」「拆到什么粒度」「怎么拆才不乱」这些问题上拿不准。本文不讲复杂底层,而是围绕日常开发中的组件拆分,把边界说清楚,并给出可直接参考的示例和规范。
适用人群:
- 会用 JS/Vue,但组件拆分概念还比较模糊
- 刚开始学 Vue,希望一开始就养成好习惯
- 工作几年的前端,想系统梳理并修正自己的习惯
目标:搞清页面 / 业务 / 基础 三层组件的边界,以及高内聚、低耦合在组件设计中的具体落地方式。
[⬆ 返回目录](#⬆ 返回目录)
一、为什么要把组件分三层?
1.1 三种常见问题
问题一:一个页面几千行
html
<!-- 所有逻辑、样式、接口都塞在一个文件里 -->
<template>
<div class="page">
<!-- 几百行模板 -->
</div>
</template>
<script setup>
// 几百行业务逻辑
</script>
<style scoped>
/* 几百行样式 */
</style>
结果:维护难、复用难、测试难、协作难。
问题二:拆了和没拆差不多
- 只按「视觉块」拆分,没有按「职责」划分
- 组件之间互相依赖、互相改数据
- 看起来很多组件,但耦合严重
问题三:拆得过细
- 只有一两个 props 的小组件到处飞
- 组件树过深,反而增加心智负担
所以我们需要一个清晰的分层 + 边界思路,避免以上三种情况。
[⬆ 返回目录](#⬆ 返回目录)
二、三层组件的定义与边界
可以简单理解为:页面组件 ≈ 路由页;业务组件 ≈ 带领域逻辑的 UI 块;基础组件 ≈ 纯展示、可复用的 UI 零件。
2.1 页面组件(Page Components)
定义:对应路由,承载当前页面的整体布局和骨架,是入口。
职责:
- 负责页面整体结构(头部、侧栏、主内容区等)
- 协调多个业务组件
- 调用接口、拿数据,分发给子组件
特点:
- 通常与路由一一对应
- 不关心具体业务 UI 细节,只关心「用哪些业务块」
- 数据流清晰:Page → 业务组件 → 基础组件
目录示例:
src/views/ # 页面组件
UserList.vue # 用户列表页
OrderDetail.vue # 订单详情页
[⬆ 返回目录](#⬆ 返回目录)
2.2 业务组件(Business / Feature Components)
定义:围绕某一业务域,内部包含领域逻辑和业务状态。
职责:
- 实现特定业务功能(如订单卡片、用户表单)
- 可能调用接口或处理业务逻辑
- 内部可以再拆基础组件
特点:
- 名字和业务强相关(如
OrderCard、UserProfileForm) - 可复用,但通常只在一个或几个业务场景中使用
- 会在内部用基础组件
目录示例:
src/components/business/ # 业务组件
OrderCard.vue
UserProfileForm.vue
ProductSearchBar.vue
[⬆ 返回目录](#⬆ 返回目录)
2.3 基础组件(Base / Presentational Components)
定义:纯 UI 展示,不关心业务含义,尽量通用、可复用。
职责:
- 接收 props,负责展示
- 通过 emit 把用户操作向外抛出
- 不直接调用接口,不写业务逻辑
特点:
- 名字偏通用(如
Button、Input、Table) - 可在多个页面、多个业务中复用
- 易测、易维护,只关心 UI 和基础交互
目录示例:
src/components/base/ # 基础组件
BaseButton.vue
BaseInput.vue
BaseTable.vue
[⬆ 返回目录](#⬆ 返回目录)
三、三者关系与数据流
关系可以概括为:
页面组件(Page)
↓ 组合、编排
业务组件(Business)
↓ 使用
基础组件(Base)
数据流原则:
- 数据主要从父流向子(props down)
- 事件从子传回父(events up)
- 避免跨层、跨组件直接改数据,避免深层 provide/inject 滥用
[⬆ 返回目录](#⬆ 返回目录)
四、实战示例:用户列表页拆分
假设需求:一个用户列表页,包含搜索、列表展示、分页。
4.1 结构设计
- 页面组件 :
UserList.vue,负责布局、数据获取、分页逻辑 - 业务组件 :
UserSearchBar、UserTable、UserPagination - 基础组件 :
BaseInput、BaseButton、BaseTable
[⬆ 返回目录](#⬆ 返回目录)
4.2 完整示例
① 基础组件:BaseInput
html
<!-- src/components/base/BaseInput.vue -->
<template>
<div class="base-input">
<label v-if="label" class="base-input__label">{{ label }}</label>
<input
:type="type"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
class="base-input__input"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
</div>
</template>
<script setup lang="ts">
defineProps<{
modelValue: string
label?: string
type?: string
placeholder?: string
disabled?: boolean
}>()
defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
</script>
<style scoped>
.base-input { margin-bottom: 12px; }
.base-input__label { display: block; margin-bottom: 4px; font-size: 14px; }
.base-input__input { width: 100%; padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; }
</style>
要点:只管输入框的展示和双向绑定,不关心「搜索用户」这种业务。
② 基础组件:BaseButton
html
<!-- src/components/base/BaseButton.vue -->
<template>
<button
:type="nativeType"
:disabled="disabled"
:class="['base-button', `base-button--${variant}`]"
@click="$emit('click')"
>
<slot />
</button>
</template>
<script setup lang="ts">
defineProps<{
variant?: 'primary' | 'secondary' | 'danger'
disabled?: boolean
nativeType?: 'button' | 'submit' | 'reset'
}>()
defineEmits<{
(e: 'click'): void
}>()
</script>
<style scoped>
.base-button { padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer; }
.base-button--primary { background: #409eff; color: white; }
.base-button--secondary { background: #eee; color: #333; }
.base-button--danger { background: #f56c6c; color: white; }
.base-button:disabled { opacity: 0.6; cursor: not-allowed; }
</style>
要点:只负责按钮样式和点击事件,不写业务逻辑。
③ 业务组件:UserSearchBar
html
<!-- src/components/business/UserSearchBar.vue -->
<template>
<div class="user-search-bar">
<BaseInput
v-model="keyword"
placeholder="请输入用户名或手机号"
@keyup.enter="handleSearch"
/>
<BaseButton variant="primary" @click="handleSearch">搜索</BaseButton>
<BaseButton variant="secondary" @click="handleReset">重置</BaseButton>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseButton from '@/components/base/BaseButton.vue'
const keyword = ref('')
const emit = defineEmits<{
(e: 'search', keyword: string): void
(e: 'reset'): void
}>()
function handleSearch() {
emit('search', keyword.value)
}
function handleReset() {
keyword.value = ''
emit('reset')
}
</script>
<style scoped>
.user-search-bar { display: flex; gap: 12px; margin-bottom: 16px; }
.user-search-bar .base-input { flex: 1; margin-bottom: 0; }
</style>
要点:
- 内部维护
keyword,只负责「搜索」这个业务概念 - 通过
search、reset把结果暴露给父组件 - 不调用接口,由父组件(页面)决定怎么请求数据
④ 业务组件:UserTable
html
<!-- src/components/business/UserTable.vue -->
<template>
<div class="user-table">
<table class="user-table__table">
<thead>
<tr>
<th>ID</th>
<th>用户名</th>
<th>手机号</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<td>{{ user.id }}</td>
<td>{{ user.username }}</td>
<td>{{ user.phone }}</td>
<td>
<BaseButton variant="primary" size="small" @click="emit('edit', user)">
编辑
</BaseButton>
</td>
</tr>
</tbody>
</table>
<p v-if="!users?.length" class="user-table__empty">暂无数据</p>
</div>
</template>
<script setup lang="ts">
import BaseButton from '@/components/base/BaseButton.vue'
defineProps<{
users: Array<{ id: number; username: string; phone: string }>
}>()
const emit = defineEmits<{
(e: 'edit', user: { id: number; username: string; phone: string }): void
}>()
</script>
<style scoped>
.user-table__table { width: 100%; border-collapse: collapse; }
.user-table__table th, .user-table__table td { padding: 12px; border: 1px solid #eee; }
.user-table__empty { padding: 24px; text-align: center; color: #999; }
</style>
要点:
- 只负责「用户列表」的展示和编辑点击
- 数据由父组件传入,不主动拉接口
⑤ 业务组件:UserPagination
html
<!-- src/components/business/UserPagination.vue -->
<template>
<div class="user-pagination">
<BaseButton
:disabled="currentPage <= 1"
variant="secondary"
@click="emit('change', currentPage - 1)"
>
上一页
</BaseButton>
<span class="user-pagination__info">
第 {{ currentPage }} / {{ totalPages }} 页
</span>
<BaseButton
:disabled="currentPage >= totalPages"
variant="secondary"
@click="emit('change', currentPage + 1)"
>
下一页
</BaseButton>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
const props = defineProps<{
currentPage: number
totalPages: number
}>()
const emit = defineEmits<{
(e: 'change', page: number): void
}>()
</script>
<style scoped>
.user-pagination { display: flex; align-items: center; gap: 16px; margin-top: 16px; }
.user-pagination__info { font-size: 14px; color: #666; }
</style>
要点:
- 只关心「页码变化」,通过
change把新页码抛给父组件 - 不直接改 URL 或调接口
⑥ 页面组件:UserList(组装一切)
html
<!-- src/views/UserList.vue -->
<template>
<div class="user-list-page">
<h1>用户列表</h1>
<UserSearchBar
@search="handleSearch"
@reset="handleReset"
/>
<UserTable
:users="users"
@edit="handleEdit"
/>
<UserPagination
:current-page="pagination.currentPage"
:total-pages="pagination.totalPages"
@change="handlePageChange"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import UserSearchBar from '@/components/business/UserSearchBar.vue'
import UserTable from '@/components/business/UserTable.vue'
import UserPagination from '@/components/business/UserPagination.vue'
// 模拟接口
async function fetchUserList(params: { keyword?: string; page?: number }) {
// 实际项目中替换为真实的 API 调用
await new Promise(r => setTimeout(r, 300))
return {
list: [
{ id: 1, username: '张三', phone: '13800138000' },
{ id: 2, username: '李四', phone: '13800138001' },
],
total: 100,
}
}
const users = ref<Array<{ id: number; username: string; phone: string }>>([])
const keyword = ref('')
const pagination = ref({ currentPage: 1, totalPages: 10 })
async function loadUsers() {
const res = await fetchUserList({
keyword: keyword.value || undefined,
page: pagination.value.currentPage,
})
users.value = res.list
pagination.value.totalPages = Math.ceil(res.total / 10)
}
function handleSearch(kw: string) {
keyword.value = kw
pagination.value.currentPage = 1
loadUsers()
}
function handleReset() {
keyword.value = ''
pagination.value.currentPage = 1
loadUsers()
}
function handlePageChange(page: number) {
pagination.value.currentPage = page
loadUsers()
}
function handleEdit(user: { id: number }) {
console.log('编辑用户', user.id)
// 实际项目中:跳转编辑页或打开弹窗
}
onMounted(loadUsers)
</script>
<style scoped>
.user-list-page { padding: 24px; }
.user-list-page h1 { margin-bottom: 24px; font-size: 24px; }
</style>
要点:
- 只负责「用户列表页」的编排:搜索、列表、分页、编辑入口
- 所有接口调用集中在这里
- 通过事件从子组件接收用户操作,再决定如何请求和更新数据
[⬆ 返回目录](#⬆ 返回目录)
五、高内聚、低耦合怎么落地?
5.1 高内聚
含义:一个组件只做一类事,相关逻辑都在一起。
做法:
- 基础组件:只关心展示 + 简单交互
- 业务组件:只关心该业务的展示和交互
- 页面组件:只关心当前页面的布局和数据流
[⬆ 返回目录](#⬆ 返回目录)
5.2 低耦合
含义:组件之间依赖少,修改一个尽量不影响其他。
做法:
- 用 props / emit 传递数据,而不是全局状态或深层 provide
- 业务组件通过事件暴露行为,而不是直接操作父级数据
- 避免父子互相直接改对方的数据
[⬆ 返回目录](#⬆ 返回目录)
5.3 对比示例
❌ 高耦合写法:
html
<!-- 子组件直接改父组件数据,父子强耦合 -->
<script setup>
const parent = getCurrentInstance()?.parent
function doSomething() {
parent.ctx.someData = 'xxx' // 直接改父级
}
</script>
✅ 低耦合写法:
html
<!-- 通过事件通知父组件,由父组件决定怎么处理 -->
<script setup>
const emit = defineEmits<{ (e: 'done', value: string): void }>()
function doSomething() {
emit('done', 'xxx')
}
</script>
[⬆ 返回目录](#⬆ 返回目录)
六、常见踩坑点
6.1 把业务逻辑塞进基础组件
html
<!-- ❌ 错误:基础组件调接口 -->
<template>
<button @click="fetchUser">加载用户</button>
</template>
<script setup>
async function fetchUser() {
const res = await api.getUser() // 基础组件不该知道业务 API
}
</script>
正确做法:基础组件只负责按钮样式和点击事件,由父组件决定「点击后做什么」。
[⬆ 返回目录](#⬆ 返回目录)
6.2 页面组件里写大量 UI 细节
html
<!-- ❌ 错误:页面组件写具体表格结构 -->
<template>
<div>
<table>
<tr v-for="user in users">
<td>{{ user.username }}</td>
<!-- 几十行表格逻辑 -->
</tr>
</table>
</div>
</template>
正确做法:把表格抽成业务组件(如 UserTable),页面只负责传 users 和处理事件。
[⬆ 返回目录](#⬆ 返回目录)
6.3 过度拆分
html
<!-- ❌ 过度:只有一行内容的组件 -->
<!-- UserIdCell.vue -->
<template><span>{{ id }}</span></template>
适合拆分的标准:复用 ≥ 2 次 ,或逻辑/模板确实独立且复杂,否则可以先不拆。
[⬆ 返回目录](#⬆ 返回目录)
6.4 在子组件里直接改 props
html
<!-- ❌ 错误 -->
<script setup>
const props = defineProps<{ count: number }>()
props.count = 10 // Vue 会警告,且不符合单向数据流
</script>
正确做法:用 emit 通知父组件修改,或使用 v-model 这类约定好的通信方式。
[⬆ 返回目录](#⬆ 返回目录)
七、推荐目录结构
src/
├── views/ # 页面组件(路由对应)
│ ├── UserList.vue
│ └── OrderDetail.vue
├── components/
│ ├── business/ # 业务组件
│ │ ├── UserSearchBar.vue
│ │ ├── UserTable.vue
│ │ └── OrderCard.vue
│ └── base/ # 基础组件
│ ├── BaseButton.vue
│ ├── BaseInput.vue
│ └── BaseTable.vue
可按项目规模调整,例如大项目可再增加 layout、common 等目录。
[⬆ 返回目录](#⬆ 返回目录)
八、简单自检清单
拆组件时,可以自问:
- 这个组件属于页面 / 业务 / 基础中的哪一层?
- 它是否只做一类事(高内聚)?
- 它和父、兄弟组件是否主要通过 props/emit 通信(低耦合)?
- 基础组件是否没有业务逻辑和接口调用?
- 页面组件是否只负责布局和数据编排?
- 业务组件是否只在一个或少数几个业务场景中使用?
[⬆ 返回目录](#⬆ 返回目录)
九、小结
- 页面组件:对应路由,负责布局、数据获取和业务组件编排。
- 业务组件:承载业务含义和状态,可调用接口,内部可用基础组件。
- 基础组件:纯 UI,通用、可复用,不关心业务。
实践原则:
- 数据自上而下传递(props)
- 事件自下而上传递(emit)
- 每层只做自己该做的事
- 能复用、易测试、易维护
先把这三层边界想清楚,再根据项目迭代逐步调整粒度,会比一开始就追求「完美拆分」更稳、更好落地。
[⬆ 返回目录](#⬆ 返回目录)
🔍 系列模块导航
📝 编码语法规范
一、《Vue3 组件拆分实战规范:页面 / 业务 / 基础组件边界清晰化,高内聚低耦合落地指南|Vue 组件与模板规范篇》
二、《Vue3 Props 传参实战规范:必传校验 + 默认值 + 类型标注,避开 undefined / 类型混用坑|Vue 组件与模板规范篇》
三、《Vue3 模板语法规范实战:v-if/v-for 不混用 + 表达式精简,避坑指南|Vue 组件与模板规范篇》
四、《Vue3 样式实战:scoped + 深度选择器 + BEM 规范,解决冲突与穿透失效|Vue 组件与模板规范篇》
五、《Vue3 组合式函数(Hooks)封装规范实战:命名 / 输入输出 / 复用边界 + 避坑|Vue 组件与模板规范篇》
六、《Vue3 + Element Plus 中后台弹窗规范:开闭、传参、回调,告别弹窗地狱|Vue 组件与模板规范篇》
七、《Vue3 组件解耦实战:Props/Emit/ 事件总线用法 + 避坑指南|Vue 组件与模板规范》
👉 跟着系列慢慢学,把技术功底扎扎实实地打牢~
📚 系列总览
「前端规范实战系列 」正在持续更新中,后续会整理一篇《前端规范实战系列全系列目录导航》,包含每篇文章简介 + 直达链接,方便大家按顺序、体系化学习。
更新中,敬请期待~
[⬆ 返回目录](#⬆ 返回目录)
技术成长,从来不是比谁写得快,而是比谁写得稳、规范、可维护。
哪怕每次只吃透一条规范,长期下来,差距会非常明显。
后续我会持续更新前端规范、工程化、可维护代码相关实战干货,帮你告别面条代码、维护噩梦,在开发与面试中更有底气。
觉得有用欢迎 点赞 + 收藏 + 关注,不错过每一篇实战内容。
我是 Eugene,与你一起写规范、写优质代码,我们下篇干货见~