现代 Vue 3 页面组件文件安排与通信实践

一、引言:从"能跑"到"好维护"的 Vue 3 工程

在实际项目中,很多团队使用 Vue 3 搭建单页或多页应用,但经常遇到这些痛点:

  • 项目一大,components/ 目录就变成"垃圾场",组件命名混乱。
  • 页面级组件、业务组件、基础组件混在一起,难以复用、难以定位。
  • 组件通信方式五花八门:propsemitprovide/injectPinia、事件总线、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 / defineExpose
  • TeleportSuspensedefineAsyncComponent
  • 更鼓励逻辑抽离到 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

核心思想:

  1. 按功能域(feature)划分 ,而不是全部放在 views/components/

  2. 区分不同层级的组件:

    • base/:完全 UI 级别、小粒度、与业务无关。
    • components/common/:业务无关 or 弱业务复合组件。
    • features/xxx/components/:强业务组件,只服务某个领域。
    • pages/ + features/xxx/pages/:路由页面,做拼装和承上启下。
  3. 逻辑抽离:页面只做布局和调用 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

推荐顺序:

  1. 层级不深、数据有限时:继续使用 propsemit,哪怕要多传一层;
  2. 层级很深、需要共享布局、主题、上下文配置 等"环境类"信息时:使用 provide/inject
  3. 如果是"业务数据状态",一般更推荐 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 优点分析

  1. 结构清晰,易于扩展

    • features/ 划分业务域,每个模块自成一体;
    • 页面、组件、hooks、api 各司其职,职责边界清晰。
  2. 通信规则简单可控

    • 有明确优先级:props + emit > 父级中转 > provide/inject(环境) > Pinia(跨模块)
    • 避免了"想用啥用啥"的混乱局面。
  3. 复用性和可测试性提高

    • 通用组件集中在 components/
    • 业务逻辑放在 hooks/features/xxx/hooks/,可以单独测试;
    • 页面变薄后,修改页面结构不会轻易影响逻辑。
  4. 有利于多人协作

    • 模块边界明确,不同团队成员可以负责不同 feature 文件夹;
    • 冲突减少,合并成本降低。
  5. 适应未来演进

    • 方便提炼出内部组件库 / 业务库;
    • 当业务增长时,可以把某个 feature 拆成独立子项目,迁移成本低。

4.2 潜在缺点与挑战

  1. 初始设计成本较高

    • 相比简单项目,一开始要考虑模块划分、文件布局;
    • 需要制定团队规范,并进行培训。
  2. 小型项目可能显得"过度工程化"

    • 对于几页的小应用,复杂结构反而增加了心智负担;
    • 可以酌情简化,比如不引入 features/ 层级,而是简单地 views/ + components/ + hooks/
  3. 不当拆分会导致"碎片化过度"

    • 组件拆得太细、hook 切得太散,导致阅读成本上升;
    • 需要掌握合适的粒度:一个 hook 至少完成一块有意义的业务逻辑。
  4. 需要纪律性和代码评审配合

    • 若团队成员不遵守约定,随意新建目录、组件,长期还是会变乱;
    • 需要在 Code Review 中把关命名和位置。

4.3 实际落地中的具体建议

  1. 从规则最混乱的地方开始重构

    • 优先清理 components/ 目录,把业务强耦合组件迁移到对应 features/xxx/components/
    • 把接口调用逻辑从组件中抽离到 api/hooks/
  2. 制定简洁的命名规范

    • 页面统一以 XXXPage.vue 结尾;
    • 列表页面统一 UserListPage.vue / OrderListPage.vue
    • 基础组件统一前缀 BaseBaseButton.vueBaseTable.vue
    • 业务组件使用功能领域 + 组件类型:UserTable.vueOrderSearchForm.vue
  3. 约定强制使用的通信方式

    • 明确写入团队文档:禁止使用全局事件总线做常规通信;
    • 限制使用 defineExpose:只在确有必要的场景(如表单实例方法)使用。
  4. 引入 ESLint + Prettier + Stylelint + 配套插件

    • 限制组件文件命名、导入顺序;
    • 约束 <script setup> 内部的写法风格;
    • 搭配 VSCode 插件,实现自动格式化。
  5. 利用单元测试和 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 项目的可维护性和扩展性都会有明显提升。


六、参考资料与延伸阅读

以下是一些值得深入阅读的官方文档和社区文章:

相关推荐
只会cv的前端攻城狮2 小时前
兼容性地狱-Uniapp钉钉小程序环境隔离踩坑实录
前端·uni-app
赵_叶紫2 小时前
Node.js 知识点梳理与实战代码
前端
IT_陈寒2 小时前
JavaScript这5个隐藏技巧,90%的开发者都不知道!
前端·人工智能·后端
明月_清风3 小时前
小程序云函数:从入门到全栈的“降维打击”指南
前端·微信小程序·小程序·云开发
wuhen_n3 小时前
告别 Options API:为什么 Composition API 是逻辑复用的未来?
前端·javascript·vue.js
明月_清风3 小时前
前端异常捕获:从“页面崩了”到“精准定位”的实战架构
前端·javascript·监控
wuhen_n3 小时前
高效的数据解构:用 toRefs 和 toRef 保持响应性
前端·javascript·vue.js
小兵张健14 小时前
价值1000的 AI 工作流:Codex 通用前端协作模式
前端·aigc·ai编程
sunny_14 小时前
面试踩大坑!同一段 Node.js 代码,CJS 和 ESM 的执行顺序居然是反的?!99% 的人都答错了
前端·面试·node.js