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,与你一起写规范、写优质代码,我们下篇干货见~

相关推荐
摸鱼的春哥2 小时前
Agent教程21:知识图谱🕸,让AI🤖学会联想
前端·javascript·后端
泯泷2 小时前
阶段二:为什么先设计指令集,编译器和运行时才能稳定对齐?
前端·javascript·架构
Dxy12393102162 小时前
HTML常用布局详解:从基础到进阶的网页结构指南
前端·html
ywf12153 小时前
前端的dist包放到后端springboot项目下一起打包
前端·spring boot·后端
恋猫de小郭4 小时前
2026,Android Compose 终于支持 Hot Reload 了,但是收费
android·前端·flutter
hpoenixf10 小时前
2026 年前端面试问什么
前端·面试
还是大剑师兰特10 小时前
Vue3 中的 defineExpose 完全指南
前端·javascript·vue.js
泯泷10 小时前
阶段一:从 0 看懂 JSVMP 架构,先在脑子里搭出一台最小 JSVM
前端·javascript·架构
mengchanmian11 小时前
前端node常用配置
前端