Vue3 组件拆分实战规范:页面 / 业务 / 基础组件边界清晰化,高内聚低耦合落地指南|Vue 组件与模板规范篇

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

📑 文章目录


同学们好,我是 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)

定义:围绕某一业务域,内部包含领域逻辑和业务状态。

职责

  • 实现特定业务功能(如订单卡片、用户表单)
  • 可能调用接口或处理业务逻辑
  • 内部可以再拆基础组件

特点

  • 名字和业务强相关(如 OrderCardUserProfileForm
  • 可复用,但通常只在一个或几个业务场景中使用
  • 会在内部用基础组件

目录示例

复制代码
src/components/business/   # 业务组件
  OrderCard.vue
  UserProfileForm.vue
  ProductSearchBar.vue

[⬆ 返回目录](#⬆ 返回目录)

2.3 基础组件(Base / Presentational Components)

定义:纯 UI 展示,不关心业务含义,尽量通用、可复用。

职责

  • 接收 props,负责展示
  • 通过 emit 把用户操作向外抛出
  • 不直接调用接口,不写业务逻辑

特点

  • 名字偏通用(如 ButtonInputTable
  • 可在多个页面、多个业务中复用
  • 易测、易维护,只关心 UI 和基础交互

目录示例

复制代码
src/components/base/       # 基础组件
  BaseButton.vue
  BaseInput.vue
  BaseTable.vue

[⬆ 返回目录](#⬆ 返回目录)


三、三者关系与数据流

关系可以概括为:

复制代码
页面组件(Page)
    ↓ 组合、编排
业务组件(Business)
    ↓ 使用
基础组件(Base)

数据流原则

  • 数据主要从父流向子(props down)
  • 事件从子传回父(events up)
  • 避免跨层、跨组件直接改数据,避免深层 provide/inject 滥用

[⬆ 返回目录](#⬆ 返回目录)


四、实战示例:用户列表页拆分

假设需求:一个用户列表页,包含搜索、列表展示、分页。

4.1 结构设计

  • 页面组件UserList.vue,负责布局、数据获取、分页逻辑
  • 业务组件UserSearchBarUserTableUserPagination
  • 基础组件BaseInputBaseButtonBaseTable

[⬆ 返回目录](#⬆ 返回目录)

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,只负责「搜索」这个业务概念
  • 通过 searchreset 把结果暴露给父组件
  • 不调用接口,由父组件(页面)决定怎么请求数据

④ 业务组件: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

可按项目规模调整,例如大项目可再增加 layoutcommon 等目录。

[⬆ 返回目录](#⬆ 返回目录)


八、简单自检清单

拆组件时,可以自问:

  1. 这个组件属于页面 / 业务 / 基础中的哪一层?
  2. 它是否只做一类事(高内聚)?
  3. 它和父、兄弟组件是否主要通过 props/emit 通信(低耦合)?
  4. 基础组件是否没有业务逻辑和接口调用?
  5. 页面组件是否只负责布局和数据编排?
  6. 业务组件是否只在一个或少数几个业务场景中使用?

[⬆ 返回目录](#⬆ 返回目录)


九、小结

  • 页面组件:对应路由,负责布局、数据获取和业务组件编排。
  • 业务组件:承载业务含义和状态,可调用接口,内部可用基础组件。
  • 基础组件:纯 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,与你一起写规范、写优质代码,我们下篇干货见~

相关推荐
灵感__idea3 小时前
Hello 算法:贪心的世界
前端·javascript·算法
GreenTea5 小时前
一文搞懂Harness Engineering与Meta-Harness
前端·人工智能·后端
killerbasd6 小时前
牧苏苏传 我不装了 4/7
前端·javascript·vue.js
吴声子夜歌6 小时前
ES6——二进制数组详解
前端·ecmascript·es6
码事漫谈7 小时前
手把手带你部署本地模型,让你Token自由(小白专属)
前端·后端
ZC跨境爬虫7 小时前
【爬虫实战对比】Requests vs Scrapy 笔趣阁小说爬虫,从单线程到高效并发的全方位升级
前端·爬虫·scrapy·html
爱上好庆祝7 小时前
svg图片
前端·css·学习·html·css3
橘子编程7 小时前
JavaScript与TypeScript终极指南
javascript·ubuntu·typescript
王夏奇7 小时前
python中的__all__ 具体用法
java·前端·python
叫我一声阿雷吧8 小时前
JS 入门通关手册(45):浏览器渲染原理与重绘重排(性能优化核心,面试必考
javascript·前端面试·前端性能优化·浏览器渲染·浏览器渲染原理,重排重绘·reflow·repaint