💥 开篇引言:你的 Vue3 项目是不是正在变成另一个 jQuery 时代的噩梦?
很多团队兴冲冲地升级到了 Vue3,用上了<script setup>和 TypeScript,以为这就算踏入了现代化前端开发的门槛。
然而,半年后,当你打开项目根目录,面对的是这样的景象:
components文件夹里躺着 50+ 个.vue文件,命名从UserList.vue到UserListNew.vue再到UserListFinal.vue,没人敢删;一个新来的同事想改登录页的一个按钮样式,却不得不先读懂
src/api/下 3000 行的request.ts和src/utils/里十几个毫不相干的工具函数;产品经理提了一个“在订单详情里加一个物流追踪地图”的需求,你发现地图组件和订单组件互相 import,形成了一个死循环依赖……
这不是 Vue3 的锅,这是架构的锅。
如果你只是把 Vue3 当成“更好的 Vue2”来用,那你的项目终将不可避免地滑向“面条代码”的深渊。在大型项目中,框架只是工具,架构才是灵魂。
今天,我们就来聊聊如何用领域驱动设计(DDD)思想和垂直切片架构,彻底重构你的 Vue3 项目目录,让组件和逻辑实现真正的物理级解耦。
一、 为什么传统的“技术角色”分层在大型项目中会失效?
我们先来看看大多数 Vue 项目的标准目录结构(也就是 Vue CLI 或 Vite 脚手架默认推荐的结构):
src/ ├── api/ # 所有接口请求 ├── assets/ # 静态资源 ├── components/ # 全局公共组件 ├── router/ # 路由 ├── store/ # 状态管理 ├── utils/ # 工具函数 └── views/ # 页面级组件这种按“技术角色”划分的方式,在小项目中非常直观。 但问题在于:它没有反映业务的边界。
想象一下,你的项目有“用户中心”、“商品交易”、“营销活动”三大业务板块。当你要修改“用户中心的收货地址”时,你需要同时改动:
views/UserCenter/Address.vueapi/userApi.ts(里面可能还混杂着商品和用户的所有接口)store/modules/user.tscomponents/AddressForm.vue(万一这个组件也被交易模块用了呢?)
这就是典型的“横切耦合”。 一个业务功能的变更,需要跨越 N 个技术目录去修改代码,开发效率极低,且极易引发回归 Bug。
二、 破局之道:引入领域驱动设计(DDD)与垂直切片
在后端架构中,领域驱动设计(Domain-Driven Design, DDD) 已经被证明是解决复杂业务耦合的银弹。它的核心思想是:按照业务领域(Domain)而非技术层级来组织代码。
映射到前端,我们要做的就是把“横向分层”改为“垂直切片(Vertical Slices)”。
🎯 核心概念:什么是垂直切片?
一个业务功能所需的所有代码(UI、逻辑、数据、样式),应该尽可能地待在同一个地方。
就像一把刀切下去,这一片里包含了这个功能所需的全部要素,而不是把所有 UI 放一堆、所有逻辑放一堆。
三、 实战:重构你的 Vue3 项目目录
下面是一套经过多个大型 ToB 和 ToC 项目验证的 Vue3 + TypeScript 企业级目录结构:
src/ ├── app/ # 🌐 应用级配置(只初始化一次的东西) │ ├── main.ts # 入口文件 │ ├── App.vue # 根组件 │ ├── router/ # 路由定义(仅路由表,不含业务逻辑) │ ├── store/ # 全局状态(仅放真正全局的,如用户信息、主题配置) │ └── plugins/ # 第三方插件初始化(如 axios 实例、监控 SDK) │ ├── entities/ # 🧱 业务实体(跨领域共享的数据模型) │ ├── User/ │ │ ├── types.ts # User 的类型定义 │ │ ├── constants.ts # User 相关常量 │ │ └── utils.ts # User 相关的纯函数工具(如格式化用户名) │ └── Product/ │ ├── types.ts │ └── constants.ts │ ├── features/ # 🎯 业务特征模块(核心!按领域垂直切片) │ ├── auth/ # 认证模块 │ │ ├── components/ # 仅属于 auth 的组件(LoginForm, SocialAuth) │ │ ├── composables/ # auth 相关的组合式函数(useLogin, useRegister) │ │ ├── api.ts # auth 相关的接口 │ │ ├── store.ts # auth 的局部状态(如果用 Pinia 分模块) │ │ ├── types.ts # auth 特有的类型 │ │ └── index.ts # 统一导出(barrel file) │ │ │ ├── order/ # 订单模块 │ │ ├── components/ # OrderList, OrderDetail, OrderStatusBadge │ │ ├── composables/ # useOrderList, useOrderCancel │ │ ├── api.ts │ │ ├── store.ts │ │ └── index.ts │ │ │ └── dashboard/ # 仪表盘模块 │ └── ... │ ├── shared/ # 🔧 跨领域共享资源(被多个 features 引用) │ ├── components/ # 真正的通用基础组件(Button, Modal, Table) │ │ ├── BaseButton/ │ │ │ ├── BaseButton.vue │ │ │ └── index.ts │ │ └── index.ts # 统一导出,方便 auto-import │ ├── composables/ # 通用组合式函数(useDebounce, useLocalStorage) │ ├── utils/ # 通用工具(formatDate, deepClone) │ ├── hooks/ # 通用 hooks(如 useTheme, useBreakpoint) │ └── styles/ # 全局样式、变量、mixins │ └── widgets/ # 🧩 业务挂件(可组合的页面区块,介于组件和页面之间) ├── HeaderWidget/ ├── SidebarWidget/ └── UserProfileCard/四、 逐层拆解:每一层该放什么,不该放什么?
1️⃣app/—— 应用的骨架,不是业务的垃圾桶
只放:
应用启动配置(路由实例、Pinia 实例、全局错误处理)
全局插件注册
根组件
不放:
❌ 任何具体业务逻辑
❌ 具体 API 调用
❌ 具体业务组件
💡判断标准:如果把
features/和shared/全部删掉,只靠app/能不能跑起来一个空白页面?如果能,说明你的app/层是干净的。
2️⃣entities/—— 业务实体的契约层
这里存放的是跨领域共享的数据模型和类型定义,是整个项目的"词汇表"。
// src/entities/User/types.ts export interface User { id: string name: string email: string role: UserRole avatar?: string } export type UserRole = 'admin' | 'editor' | 'viewer' // src/entities/User/utils.ts import type { User } from './types' export const formatUserName = (user: User): string => { return user.name || user.email.split('@')[0] } export const isAdmin = (user: User): boolean => { return user.role === 'admin' }关键点:
entities/层绝对不能引用features/或shared/中的任何东西(单向依赖原则)它是最底层的基础,像地基一样稳定
如果 A 模块和 B 模块都需要用到同一个数据类型,它就应该住在
entities/里
3️⃣features/—— 你的业务逻辑心脏
这是整个架构中最重要的一层。每个 feature 就是一个垂直切片。
features/auth/ ├── components/ │ ├── LoginForm.vue # 登录表单(只在这个模块内用) │ └── SocialAuthBtn.vue # 社交登录按钮 ├── composables/ │ ├── useLogin.ts # 登录逻辑(可复用、可测试) │ └── useAuthGuard.ts # 鉴权守卫 ├── api.ts # 登录/注册/登出的接口 ├── store.ts # auth 的 Pinia store ├── types.ts # auth 特有的类型(如 LoginParams, TokenInfo) └── index.ts # 统一导出useLogin.ts示例:
// features/auth/composables/useLogin.ts import { ref } from 'vue' import { useRouter } from 'vue-router' import { login as loginApi } from '../api' import type { LoginParams } from '../types' export function useLogin() { const router = useRouter() const loading = ref(false) const error = ref<string | null>(null) const login = async (params: LoginParams) => { loading.value = true error.value = null try { const { token } = await loginApi(params) localStorage.setItem('token', token) router.push('/dashboard') } catch (e: any) { error.value = e.message || '登录失败' } finally { loading.value = false } } return { login, loading, error } }然后在组件中:
<!-- features/auth/components/LoginForm.vue --> <script setup lang="ts"> import { reactive } from 'vue' import { useLogin } from '../composables/useLogin' const { login, loading, error } = useLogin() const form = reactive({ username: '', password: '' }) const handleSubmit = () => { login(form) } </script> <template> <form @submit.prevent="handleSubmit"> <input v-model="form.username" placeholder="用户名" /> <input v-model="form.password" type="password" placeholder="密码" /> <button type="submit" :disabled="loading"> {{ loading ? '登录中...' : '登录' }} </button> <p v-if="error" class="error">{{ error }}</p> </form> </template>看看这个组件有多干净? 它只关心"渲染表单"和"调用useLogin",所有的业务逻辑、接口请求、路由跳转都藏在 composable 里。这就是极致解耦。
4️⃣shared/—— 真正通用的东西才配待在这里
严格准入原则:放进shared/的东西,必须满足——至少被 3 个不同的 feature 引用,否则就应该放在对应的 feature 内部。
shared/components/ ├── BaseButton/ # ✅ 被 10+ 个 feature 用 → 放这里 ├── BaseModal/ # ✅ 同上 └── OrderTable/ # ❌ 只有 order 模块用 → 应该在 features/order/components/ shared/composables/ ├── useDebounce.ts # ✅ 通用防抖逻辑 ├── useEventListener.ts # ✅ 通用事件监听 └── useOrderPolling.ts # ❌ 订单专用 → 应该在 features/order/composables/🚨常见误区:把
shared/当成"不知道放哪就扔这"的垃圾桶。这会让你以为自己在做复用,实际上是在制造隐式耦合。
5️⃣widgets/—— 页面级的乐高积木
Widgets 是由多个组件组合而成、具有独立业务含义的页面区块。它比组件大,比页面小。
<!-- widgets/UserProfileCard/UserProfileCard.vue --> <script setup lang="ts"> import { useUserStore } from '@/stores/user' import BaseAvatar from '@/shared/components/BaseAvatar/BaseAvatar.vue' import BaseButton from '@/shared/components/BaseButton/BaseButton.vue' const userStore = useUserStore() </script> <template> <div class="user-profile-card"> <BaseAvatar :src="userStore.avatar" :alt="userStore.name" /> <div class="info"> <h3>{{ userStore.name }}</h3> <p>{{ userStore.bio }}</p> </div> <BaseButton @click="userStore.editProfile">编辑资料</BaseButton> </div> </template>这个 Widget 可以在 Dashboard 页、设置页、个人主页等多个地方复用,但它内部已经组装好了完整的 UI 和交互逻辑。
五、 依赖流向:一张图看懂各层关系
┌─────────────────────────────────────────────┐ │ app/ │ │ (路由、全局配置、入口) │ └──────────────────┬──────────────────────────┘ │ 引用 ▼ ┌─────────────────────────────────────────────┐ │ features/ │ │ (业务模块,垂直切片,核心逻辑) │ │ │ │ auth ◄──────► order ◄──────► dashboard │ │ │ │ │ │ │ └──────────────┼──────────────┘ │ │ │ 引用 │ └───────────────────┼─────────────────────────┘ ▼ ┌───────────────────┐ │ entities/ │◄──────┐ │ (数据模型、类型) │ │ └─────────┬─────────┘ │ │ 被引用 │ ▼ │ ┌───────────────────┐ │ │ shared/ │───────┘ │ (通用组件、工具) │ 引用 entities └───────────────────┘铁律:依赖只能从上往下,绝不反向。
层级 | 可以引用 | 不可以引用 |
|---|---|---|
| features, shared, entities | 无限制 |
| shared, entities | app, 其他 features(尽量避免) |
| 什么都不引用(纯类型+纯函数) | 所有其他层 |
| entities(类型引用) | features, app |
💡如果 feature A 必须要引用 feature B 的代码怎么办?
答案:提取到
entities/或shared/。比如订单模块需要用户信息,不应该直接 import 用户模块的组件,而是 import
entities/User/types.ts里定义的User类型,或者通过 Pinia store 来获取用户数据(store 放在app/store/或shared/store/)。
六、 配合 Vite 的路径别名,让导入更优雅
在vite.config.ts中配置:
import { defineConfig } from 'vite' import path from 'path' export default defineConfig({ resolve: { alias: { '@': path.resolve(__dirname, 'src'), '@app': path.resolve(__dirname, 'src/app'), '@features': path.resolve(__dirname, 'src/features'), '@entities': path.resolve(__dirname, 'src/entities'), '@shared': path.resolve(__dirname, 'src/shared'), '@widgets': path.resolve(__dirname, 'src/widgets'), } } })然后你就可以这样写:
// ❌ 之前:相对路径地狱 import { useLogin } from '../../../features/auth/composables/useLogin' // ✅ 现在:清晰明了 import { useLogin } from '@features/auth/composables/useLogin' import type { User } from '@entities/User/types' import BaseButton from '@shared/components/BaseButton'再配合tsconfig.json的paths配置,你还能获得完美的 TypeScript 类型跳转和自动补全支持。
七、 避坑指南:迁移过程中的 3 个致命误区
❌ 误区 1:一次性全量重构
别这么做。 在一个 50+ 页面的老项目中,试图"用一个周末把所有代码搬到新目录"是不现实的,也是极其危险的。
✅正确做法:增量迁移,新功能先行。
新建功能直接按新架构来
每次修改老功能时,顺手把它迁移到新结构
用 2-3 个月的时间平滑过渡
❌ 误区 2:过度拆分
有些团队学了 DDD 之后,把每个组件拆成index.vue+styles.ts+logic.ts+types.ts,一个简单的按钮组件搞出 5 个文件。
✅正确做法:按复杂度决定拆分粒度。
简单组件:一个
.vue文件搞定中等组件:
.vue+composables.ts复杂模块:完整目录结构
记住:架构是为了服务开发效率,不是为了炫技。
❌ 误区 3:忽视 Barrel File(统一导出)
没有index.ts的统一导出,你的 import 语句会变成这样:
import { LoginForm } from '@features/auth/components/LoginForm/LoginForm.vue'✅加上index.ts:
// features/auth/components/index.ts export { default as LoginForm } from './LoginForm/LoginForm.vue' export { default as SocialAuthBtn } from './SocialAuthBtn/SocialAuthBtn.vue'然后:
import { LoginForm, SocialAuthBtn } from '@features/auth/components'清爽多了,而且以后组件内部目录调整,只要index.ts的导出不便,所有消费方都不受影响。这就是"接口隔离"的魅力。
八、 总结:好的架构是长出来的,不是设计出来的
这篇文章我们从"为什么传统分层会失效"出发,引入了 DDD 和垂直切片的思想,给出了一套可直接落地的 Vue3 企业级目录结构,并拆解了每一层的职责边界和依赖规则。
回顾一下核心要点:
按业务领域垂直切片,而非按技术角色横向分层
features/ 是心脏,entities/ 是地基,shared/ 是工具箱
依赖只能从上往下,绝不反向
增量迁移,拒绝一刀切重构
架构服务于效率,不为炫技
但这只是第一步。目录结构只是物理层面的解耦,真正的灵魂解耦在于——如何把业务逻辑从组件中连根拔起。
在下一篇文章中,我们将深入探讨组合式函数(Composables)的艺术,教你如何写出高内聚、低耦合、可测试的业务逻辑,让组件变成纯粹的"视图壳"。