一、引言:从"能跑"到"好维护"的 Vue 3 工程
在实际项目中,很多团队使用 Vue 3 搭建单页或多页应用,但经常遇到这些痛点:
- 项目一大,
components/目录就变成"垃圾场",组件命名混乱。 - 页面级组件、业务组件、基础组件混在一起,难以复用、难以定位。
- 组件通信方式五花八门:
props、emit、provide/inject、Pinia、事件总线、window.xxx......时间一长,谁也不敢动。 - 需求变动时,牵一发而动全身,改一处"炸"一片。
这些问题本质上不是"不会写 Vue",而是缺少一套现代化的页面组件文件组织方式 和清晰的组件通信策略。
本文将结合 Vue 3 + <script setup> + Vite 的主流技术栈,系统介绍一种可扩展、可维护的组件文件安排与通信实践方案,帮助你从"写得出来"走向"管得住、扩得大"。
适用场景包括但不限于:
- 中大型后台管理系统
- 多模块业务前台站点
- 组件库或内部 UI/业务库
- 新项目工程规范制定
二、问题与背景:Vue 项目走向"失控"的典型路径
1. 随手堆组件导致的混乱
常见目录结构(反面教材):
css
src/
components/
Header.vue
Footer.vue
LoginForm.vue
UserTable.vue
Dialog.vue
Chart.vue
...
views/
Home.vue
Login.vue
User.vue
...
很快会出现这些现象:
components/被塞满页面级组件、业务组件、基础组件。views/文件名越来越多,"这个页面对应哪个路由?"、"这个组件在哪用?"需要全局搜索。- 组件间逻辑强耦合:
UserTable.vue直接请求接口,还直接操作store,导致复用困难。
2. 组件通信方式不统一
常见使用方式:
- 父子组件使用
props + emit,但部分地方用ref + defineExpose直接调用子组件方法; - 平级组件通过全局事件总线或 Pinia 通信;
- 跨层级通信用
provide/inject,但部分地方通过"祖先组件传 props 到孙组件"硬顶; - 有些功能直接写在全局
window。
结果是:
- 想改一个字段,从接口到视图,要改十几个文件。
- 调试 bug 时很难追踪"这个数据从哪里来"。
3. 背景:Vue 3 带来的新机会
Vue 3 提供了很多支持工程化的能力:
setup+ 组合式 API<script setup>语法糖defineProps / defineEmits / defineExposeTeleport、Suspense、defineAsyncComponent- 更鼓励逻辑抽离到 composable(
useXXX) 而不是到处塞在组件内。
如果我们结合这些能力,配合合理的文件组织结构和通信规范,可以显著降低项目复杂度。
三、解决方案:现代 Vue 3 文件组织与组件通信整体方案
下面给出一个适用于中大型项目的"推荐结构",你可以根据团队情况裁剪。
3.1 整体目录结构与命名规范
推荐基础目录结构(可按业务需要调整):
csharp
src/
api/ # 接口封装(按业务域划分)
user.ts
auth.ts
...
assets/ # 静态资源
components/ # 通用可复用组件(跨业务、跨页面)
base/ # 基础 UI 组件(原子级)
BaseButton.vue
BaseInput.vue
BaseModal.vue
common/ # 业务无关的通用复合组件
PageLayout.vue
DataTable.vue
SearchForm.vue
features/ # 业务模块级(按功能域拆)
user/
components/ # 该功能域内部专用的组件
UserForm.vue
UserTable.vue
hooks/ # 与该功能域相关的 composables
useUserList.ts
useUserForm.ts
pages/
UserListPage.vue
UserDetailPage.vue
pages/ # 路由页面(只做路由入口和轻度装配)
HomePage.vue
LoginPage.vue
store/ # 全局状态管理(Pinia)
userStore.ts
appStore.ts
router/
index.ts
hooks/ # 跨业务的通用逻辑 hooks
useRequest.ts
usePagination.ts
utils/ # 工具函数
date.ts
format.ts
types/ # TS 类型定义
api.d.ts
user.d.ts
App.vue
main.ts
核心思想:
-
按功能域(feature)划分 ,而不是全部放在
views/或components/。 -
区分不同层级的组件:
base/:完全 UI 级别、小粒度、与业务无关。components/common/:业务无关 or 弱业务复合组件。features/xxx/components/:强业务组件,只服务某个领域。pages/+features/xxx/pages/:路由页面,做拼装和承上启下。
-
逻辑抽离:页面只做布局和调用
useXxx,业务逻辑放 hooks(composable)中。
示例:features/user/pages/UserListPage.vue
xml
<script setup lang="ts">
import PageLayout from '@/components/common/PageLayout.vue'
import UserSearchForm from '../components/UserSearchForm.vue'
import UserTable from '../components/UserTable.vue'
import { useUserList } from '../hooks/useUserList'
const {
searchParams,
userList,
loading,
pagination,
handleSearch,
handleReset,
handlePageChange,
} = useUserList()
</script>
<template>
<PageLayout title="用户列表">
<template #search>
<UserSearchForm
v-model="searchParams"
@search="handleSearch"
@reset="handleReset"
/>
</template>
<UserTable
:data="userList"
:loading="loading"
:pagination="pagination"
@page-change="handlePageChange"
/>
</PageLayout>
</template>
特点:
- 页面组件只负责:结构 + 调用 hooks + 数据分发到子组件 + 处理事件。
- 具体业务逻辑(接口请求、翻页规则、筛选规则等)封装在
useUserList内。
3.2 Vue 3 组件通信方式的分层使用策略
在现代 Vue 3 项目里,可以用的通信方式很多,但最重要的是划清边界和优先级。推荐如下优先级和职责划分:
1)父子组件通信:props + emit 为主
适用于:
- 父组件向子组件传数据、配置项;
- 子组件向父组件上报事件(用户交互、状态变化)。
示例:搜索表单组件
UserSearchForm.vue:
typescript
<script setup lang="ts">
interface SearchParams {
keyword: string
status: string | null
}
const props = defineProps<{
modelValue: SearchParams // v-model 约定
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: SearchParams): void
(e: 'search'): void
(e: 'reset'): void
}>()
const localForm = reactive<SearchParams>({ ...props.modelValue })
watch(
() => props.modelValue,
(val) => {
Object.assign(localForm, val)
},
{ deep: true }
)
const handleSearch = () => {
emit('update:modelValue', { ...localForm })
emit('search')
}
const handleReset = () => {
const resetVal: SearchParams = { keyword: '', status: null }
Object.assign(localForm, resetVal)
emit('update:modelValue', resetVal)
emit('reset')
}
</script>
<template>
<form @submit.prevent="handleSearch">
<input v-model="localForm.keyword" placeholder="关键字" />
<select v-model="localForm.status">
<option :value="null">全部</option>
<option value="enabled">启用</option>
<option value="disabled">禁用</option>
</select>
<button type="submit">搜索</button>
<button type="button" @click="handleReset">重置</button>
</form>
</template>
父组件使用:
ini
<UserSearchForm
v-model="searchParams"
@search="handleSearch"
@reset="handleReset"
/>
优点:
- 数据流清晰:单向数据流,自上而下传值,自下而上发事件。
- 易于重构、易于封装。
2)跨层级(祖孙)通信:优先 props 链 + 组合式 API,其次 provide/inject
推荐顺序:
- 层级不深、数据有限时:继续使用
props和emit,哪怕要多传一层; - 层级很深、需要共享布局、主题、上下文配置 等"环境类"信息时:使用
provide/inject; - 如果是"业务数据状态",一般更推荐 Pinia,而非
provide/inject。
典型使用场景:
- 表单上下文(例如
Form内部各个FormItem); - 布局组件向内部所有内容提供主题配置;
- 弹窗管理上下文。
示例:PageLayout 提供一个统一的"页面上下文",供子组件控制面包屑、标题等。
PageLayout.vue:
xml
<script setup lang="ts">
import { provide, ref } from 'vue'
const title = ref('')
const setTitle = (t: string) => {
title.value = t
}
const PAGE_CONTEXT_KEY = Symbol('PageContext')
provide(PAGE_CONTEXT_KEY, {
title,
setTitle,
})
</script>
<template>
<section class="page-layout">
<header class="page-header">
<h1>{{ title }}</h1>
<slot name="actions"></slot>
</header>
<main class="page-main">
<slot></slot>
</main>
</section>
</template>
子组件中:
typescript
import { inject } from 'vue'
const PAGE_CONTEXT_KEY = Symbol('PageContext')
const pageContext = inject<{ title: Ref<string>; setTitle: (t: string) => void }>(
PAGE_CONTEXT_KEY
)
if (pageContext) {
pageContext.setTitle('用户列表')
}
这种方式对"布局级上下文信息"非常适合。
3)跨页面、跨模块共享状态:Pinia(或 Vuex)
Vue 3 推荐使用 Pinia 作为状态管理库,它提供:
- 类型推导友好;
- 支持模块化;
- 支持组合式写法。
适合存放:
- 用户登录信息;
- 应用配置(主题、语言等);
- 多模块共用的数据缓存(如字典项、全局配置)。
示例:store/userStore.ts
javascript
import { defineStore } from 'pinia'
import { fetchUserInfo } from '@/api/user'
export const useUserStore = defineStore('user', () => {
const info = ref<UserInfo | null>(null)
const loading = ref(false)
const loadUserInfo = async () => {
if (loading.value) return
loading.value = true
try {
const res = await fetchUserInfo()
info.value = res.data
} finally {
loading.value = false
}
}
const logout = () => {
info.value = null
// ...清理 token 等
}
return {
info,
loading,
loadUserInfo,
logout,
}
})
任意组件中使用:
scss
const userStore = useUserStore()
await userStore.loadUserInfo()
注意:
- 不要把所有状态都塞进 Pinia,避免"迷你后端";
- 只放跨页面需要共享 或会被多个模块依赖的状态;
- 页面自己独有的状态,尽量放在页面的 hooks/composable 里。
4)兄弟组件通信:通过最近共同父组件中转 或 共享 composable/store
- 如果兄弟组件都在同一页面内,而且交互强相关:通过父组件中转
props + emit最简单。 - 如果兄弟组件距离较远(跨页面 / 路由):考虑共享 Pinia store 或功能级 hook(如
useModalManager)。
不推荐:
- 使用全局事件总线(
mitt等)作为常规通信方式; - 到处挂
window.eventBus。
3.3 用 useXxx composable 统一封装页面逻辑
我们已经在前文多次提到把业务逻辑封装在 hooks/composable 中。下面以 useUserList 为例。
features/user/hooks/useUserList.ts:
typescript
import { ref, reactive, watch } from 'vue'
import { fetchUserList } from '@/api/user'
import type { Pagination } from '@/types/common'
import { useRequest } from '@/hooks/useRequest'
interface SearchParams {
keyword: string
status: string | null
}
export function useUserList() {
const searchParams = reactive<SearchParams>({
keyword: '',
status: null,
})
const pagination = reactive<Pagination>({
page: 1,
pageSize: 20,
total: 0,
})
const userList = ref<UserItem[]>([])
const { loading, run: loadList } = useRequest(
async () => {
const res = await fetchUserList({
page: pagination.page,
pageSize: pagination.pageSize,
...searchParams,
})
userList.value = res.data.list
pagination.total = res.data.total
},
{
manual: true,
}
)
const handleSearch = () => {
pagination.page = 1
loadList()
}
const handleReset = () => {
searchParams.keyword = ''
searchParams.status = null
pagination.page = 1
loadList()
}
const handlePageChange = (page: number, pageSize?: number) => {
pagination.page = page
if (pageSize) pagination.pageSize = pageSize
loadList()
}
// 可选:监听搜索条件变化自动刷新
watch(
() => ({ ...searchParams, page: pagination.page, pageSize: pagination.pageSize }),
() => {
// 视需求决定是否自动刷新
}
)
return {
searchParams,
userList,
loading,
pagination,
handleSearch,
handleReset,
handlePageChange,
loadList,
}
}
好处:
- 页面组件变得非常"薄",主要是模板结构;
- 逻辑集中在
useUserList中,更易单测、调试、复用; - 将来想要把这段逻辑与其他页面共享,只需在另一个页面重复调用
useUserList。
3.4 组件文件内的组织规范
除了目录层级,也要约定单个组件文件内部的结构,避免风格各异。
推荐 Vue 3 + <script setup> 组件模板:
typescript
<script setup lang="ts">
// 1. import:先第三方,再项目内,路径由短到长
import { computed, ref } from 'vue'
import { useUserStore } from '@/store/userStore'
import BaseButton from '@/components/base/BaseButton.vue'
// 2. 类型定义(可以也放到 types 文件)
interface Props {
id: string
disabled?: boolean
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
})
// 3. emit 定义
const emit = defineEmits<{
(e: 'confirm', payload: { id: string }): void
}>()
// 4. 组合式逻辑:store / composable / 本地状态
const userStore = useUserStore()
const loading = ref(false)
const label = computed(() => (props.disabled ? '不可用' : '可用'))
// 5. methods / handlers
const handleClick = async () => {
if (props.disabled) return
loading.value = true
try {
await userStore.doSomething(props.id)
emit('confirm', { id: props.id })
} finally {
loading.value = false
}
}
</script>
<template>
<BaseButton :loading="loading" :disabled="disabled" @click="handleClick">
{{ label }}
</BaseButton>
</template>
<style scoped lang="scss">
/* 6. 样式:按 BEM 或团队约定书写 */
</style>
统一风格带来的好处:
- 新人快速上手;
- 读代码时"零认知负担";
- 更容易自动化检查、生成文档。
四、技术优缺点分析与实践建议
4.1 优点分析
-
结构清晰,易于扩展
- 按
features/划分业务域,每个模块自成一体; - 页面、组件、hooks、api 各司其职,职责边界清晰。
- 按
-
通信规则简单可控
- 有明确优先级:
props + emit>父级中转>provide/inject(环境)>Pinia(跨模块); - 避免了"想用啥用啥"的混乱局面。
- 有明确优先级:
-
复用性和可测试性提高
- 通用组件集中在
components/; - 业务逻辑放在
hooks/和features/xxx/hooks/,可以单独测试; - 页面变薄后,修改页面结构不会轻易影响逻辑。
- 通用组件集中在
-
有利于多人协作
- 模块边界明确,不同团队成员可以负责不同
feature文件夹; - 冲突减少,合并成本降低。
- 模块边界明确,不同团队成员可以负责不同
-
适应未来演进
- 方便提炼出内部组件库 / 业务库;
- 当业务增长时,可以把某个
feature拆成独立子项目,迁移成本低。
4.2 潜在缺点与挑战
-
初始设计成本较高
- 相比简单项目,一开始要考虑模块划分、文件布局;
- 需要制定团队规范,并进行培训。
-
小型项目可能显得"过度工程化"
- 对于几页的小应用,复杂结构反而增加了心智负担;
- 可以酌情简化,比如不引入
features/层级,而是简单地views/ + components/ + hooks/。
-
不当拆分会导致"碎片化过度"
- 组件拆得太细、hook 切得太散,导致阅读成本上升;
- 需要掌握合适的粒度:一个 hook 至少完成一块有意义的业务逻辑。
-
需要纪律性和代码评审配合
- 若团队成员不遵守约定,随意新建目录、组件,长期还是会变乱;
- 需要在 Code Review 中把关命名和位置。
4.3 实际落地中的具体建议
-
从规则最混乱的地方开始重构
- 优先清理
components/目录,把业务强耦合组件迁移到对应features/xxx/components/; - 把接口调用逻辑从组件中抽离到
api/和hooks/。
- 优先清理
-
制定简洁的命名规范
- 页面统一以
XXXPage.vue结尾; - 列表页面统一
UserListPage.vue/OrderListPage.vue; - 基础组件统一前缀
Base:BaseButton.vue、BaseTable.vue; - 业务组件使用功能领域 + 组件类型:
UserTable.vue、OrderSearchForm.vue。
- 页面统一以
-
约定强制使用的通信方式
- 明确写入团队文档:禁止使用全局事件总线做常规通信;
- 限制使用
defineExpose:只在确有必要的场景(如表单实例方法)使用。
-
引入 ESLint + Prettier + Stylelint + 配套插件
- 限制组件文件命名、导入顺序;
- 约束
<script setup>内部的写法风格; - 搭配 VSCode 插件,实现自动格式化。
-
利用单元测试和 Storybook/Playwright
- 针对
components/base/和一些components/common/建立 Storybook; - 对核心 hooks(如
useUserList)编写单测,防止频繁调整引发回归问题。
- 针对
五、结论:现代 Vue 3 组件工程化的价值与演进方向
本文围绕"现代 Vue 3 页面组件文件安排与通信实践"这个主题,系统地介绍了:
- 项目中常见的文件组织与通信混乱问题及成因;
- 一套基于 功能域划分(features)+ 分层组件结构 + 组合式 API hooks 的推荐目录结构;
- 针对父子、跨层级、跨模块、兄弟组件的通信优先级和适用场景;
- 利用
<script setup>和 composable 把页面"变薄"、把逻辑抽象的实践; - 该方案的优势、潜在问题以及团队落地的具体建议。
这套实践的核心价值在于:
- 让 Vue 3 项目的结构可以随着业务一起成长,而不是在版本迭代中越发难以维护;
- 降低团队成员之间的沟通和协作成本;
- 为后续的组件库沉淀、微前端拆分、SSR/CSR 切换等提供坚实基础。
未来方向上,你可以在此基础上进一步尝试:
- 把
components/base/升级为独立 UI 组件库(内部 npm 包); - 利用模块联邦 / 微前端体系,将某些
features/拆成独立部署单元; - 结合 TypeScript、Zod/Valibot 等,进一步加强类型安全和运行时校验。
只要你能坚持在新需求和重构中遵循"按领域拆分、逻辑抽离、通信有序"的原则,Vue 3 项目的可维护性和扩展性都会有明显提升。
六、参考资料与延伸阅读
以下是一些值得深入阅读的官方文档和社区文章:
-
Vue 官方文档(Vue 3)
- 组件基础与进阶:
vuejs.org/guide/compo... - 组合式 API:
vuejs.org/guide/extra... <script setup>:
vuejs.org/api/sfc-scr...- 组件通信(props/emit/provide/inject):
vuejs.org/guide/compo...
vuejs.org/guide/compo...
vuejs.org/guide/compo...
- 组件基础与进阶:
-
Pinia 官方文档:
pinia.vuejs.org/ -
Vue 官方风格指南(强烈推荐作为团队规范基础):
vuejs.org/style-guide... -
Vite 官方文档(Vue 3 工程脚手架首选):
vitejs.dev/