模块划分与分层结构
本章节从「零面向对象经验的前端同学」视角出发,讲清楚:
- 为什么要划分
core / shared / features这些层级? - 这些目录分别放什么?互相之间能不能相互引用?
- 如果你习惯了 Vue3
src/views + src/components + src/api的写法,对应关系是什么?
1. 整体分层一眼看懂
先看一个简化版的架构图(基于 cross-platform-app 实际项目):
可以简单理解为:
- Core:城市的「基础设施」——只有一份,自来水、电力、道路。
- Shared:多个小区之间共享的「公共配套」——公园、学校、医院。
- Features:每个小区的「具体楼栋」——真正住人的地方,各自有自己的物业、房号。
规则只有一条:楼栋可以使用公园和自来水,但反过来不行。也就是:
features可以依赖shared/core,但core绝不能依赖某个业务模块。
1.1 与传统 Vue3 项目对比
| 概念 | 本架构中的位置 | 典型 Vue3 写法中的位置 |
|---|---|---|
| 路由配置 / 全局服务 | app/core | src/router、src/store、src/api 等 |
| 通用 UI 组件 | app/shared/components | src/components 或 src/components/common |
| 页面级组件 | app/features/*/*.ts | src/views |
| 业务模块内的 service | app/features/*/services | src/api 或某些 src/modules/*/service.ts |
| 抽象基类 | app/shared/abstracts | Vue 里通常用「mixin/composable」替代 |
不同之处在于:我们把“长成架构”的那部分逻辑显式抽出来,放进 core 与 shared,而不是全部堆在 src 根目录下。
2. 目录结构拆解(结合实际代码)
下面这段来自 cross-platform-app 的 README,你在工程里能看到一模一样的目录:
src/
├── app/
│ ├── core/
│ ├── features/
│ ├── shared/
│ ├── app.config.ts
│ ├── app.routes.ts
│ └── app.ts / app.html / app.scss
└── ...
2.1 app/core —— 应用「大脑与中枢」
核心原则:只初始化一次,全局唯一,不直接被业务模块导入 Module。
典型内容(节选):
animations/route-animations.ts:全局路由动画。constants/app.event.ts:全局事件枚举,比如SHOW_GLOBAL_LOADING。constants/app.url.ts:所有页面路由与 API 地址集中管理。db/*:数据库 schema、查询构建器。services/*:扫码、数据库、迁移、语言切换等单例服务。
与 Java 世界里的
core/infrastructure模块非常类似:不直接写业务,但所有业务都要用到它。
2.2 app/features —— 真正的业务模块
每个子目录就是一个完整的业务域,例如:
features/home:首页模块(菜单搜索、小组件总览)features/menu:菜单模块features/users:用户模块
每个模块内部有自己的:
components/:该模块只在本模块使用的组件services/:该模块的业务服务与数据访问types/:只在本模块使用的类型定义constants/:与本模块相关的配置、枚举、文案 key 等常量
约定:「先放 features,自用;真有第二个模块要复用,再提升到 shared」。这样可以避免一开始就把一切都塞进 shared,导致共享层变成「垃圾场」。
2.2.1 各目录里应该写什么代码?
-
组件(
components/)- 只负责「长什么样」和「对外暴露什么事件」,不直接写业务流程。
- 尽量让组件的输入/输出用
@Input/@Output或事件回调表达,而不是在组件内部偷偷调用一堆 Service。 - 示例:
features/users/components/user-dialog.ts只关心表单字段和校验,保存按钮点下去后,调用父页面传进来的onSave(user)。
-
服务(
services/)- 把和这个业务模块相关的 数据获取、写入、本地缓存、状态组装 都放在这里。
- Service 不关心具体 UI,只提供「方法 + 类型」:
- 如
UserService.list()、UserService.save(user)。
- 如
- 页面组件调用 Service,而不是每个组件各自去调 HttpClient/Database。
-
类型(
types/)- 统一描述该模块内部使用的数据结构(请求参数、响应结果、UI 状态等)。
- 好处:当后端字段调整时,只需要改一处 type,就能通过 TypeScript 把所有受影响位置标出来。
-
常量(
constants/)- 放和模块强相关的枚举、配置、文案 key:例如按钮 data-id、表头配置、筛选条件 key。
- 统一命名,避免在代码里散落大量 magic string。
2.2.2 利用「单一职责」避免代码混乱
可以对比两种常见写法:
反例:什么都写在组件里
// users.ts(反例)
export class UsersPage {
users: User[] = [];
async ngOnInit() {
const res = await this.http.get('/api/users'); // 直接拼 URL
this.users = res.data;
localStorage.setItem('users', JSON.stringify(this.users)); // 直接操作存储
}
}
问题:
- 组件既管 UI,又管数据获取、URL、存储,后续很难复用或测试。
推荐:页面只调 Service,Service 再调框架
// features/users/services/user.service.ts
@Injectable({ providedIn: 'root' })
export class UserService extends AbstractAppService {
async list(): Promise<ResultVO<User[]>> {
return await this.request<User[]>(AppUrl.Users.List);
}
}
// features/users/users.ts
export class UsersPage extends AbstractAppPage {
users: User[] = [];
constructor(private readonly userService: UserService) {
super();
}
async ngOnInit() {
const res = await this.userService.list();
this.users = res.data;
}
}
现在:
- 页面只负责「什么时候加载、怎么展示」,不关心 URL / Header / 加解密。
- 业务 Service 的逻辑都集中在
features/users/services,避免业务散落到组件里。
简单记法:组件只管“长相”和“交互”,Service 只管“数据和规则”,types/constants 只管“约定”。 按这个思路组织 features 目录,模块内部结构会自然清晰很多。
2.2.3 页面内各文件的职责划分(HTML / SCSS / 页面类 / Service)
每个功能模块下的页面通常由四个文件组成:{module}.html、{module}.scss、{module}.ts 以及该模块下的 services/*.ts。各自只写一类东西,避免混在一起导致难以维护。
| 文件 | 职责 | 应该写的代码 | 不应出现的代码 |
|---|---|---|---|
{module}.html | 页面模板 | 结构、绑定、指令、事件绑定(如 (click)="onClick($event)")、data-id 等纯展示与交互结构 | 业务判断、复杂表达式、直接调用 Service |
{module}.scss | 样式 | 该页面的布局、颜色、间距、响应式等所有样式 | 业务逻辑、TypeScript |
{module}.ts(页面类) | 视图与交互层 | 双向绑定变量、DOM 相关操作、事件处理函数(如 onClick、onChange、registerHandler)、调用 Service 并接收结果后更新绑定变量;不包含任何业务规则与数据处理 | 业务逻辑、网络请求、复杂计算、直接操作 HttpClient/数据库 |
services/*.ts | 业务与数据层 | 所有业务逻辑、所有网络请求、数据组装、调用 Worker Service 并汇总结果,最后把「处理结果」返回给页面类 | DOM 操作、模板相关代码 |
一句话归纳:
- HTML:只写「长什么样、点哪里、绑什么」。
- SCSS:只写「怎么好看」。
- 页面类(
{module}.ts):只写「有什么数据要展示、点了以后调哪个 Service、拿到结果后更新哪几个变量」;不写「怎么算、怎么请求、怎么拼数据」。 - Service:只写「怎么算、怎么请求、怎么拼数据、怎么调 Worker」;不写「怎么展示、怎么绑 DOM」。
这样,页面类只需要知道:我有需求 → 交给 Service → Service 处理完告诉我结果是什么;至于请求发到哪、是否用 Worker 并行、内部怎么拆分,全部封装在 Service 内部。
2.2.4 Service 与 Worker Service 的协作(复杂业务拆分)
当某个 Service 方法内部逻辑很重(例如:大量数据过滤、多源数据合并、复杂统计)时,应把可拆分的子逻辑交给 Worker Service(或 Web Worker)去并行处理,主 Service 只负责:
- 拆分子任务并调用 Worker Service;
- 等待各 Worker 返回结果;
- 对结果进行汇总;
- 将最终结果返回给页面类。
网络请求一律放在 Service 中:页面类不直接使用 HttpClient,而是调用诸如 UserService.list()、MenuService.syncFromRemote() 等方法,由 Service 内部发起请求、处理 ResultVO、错误与重试,再向页面返回「业务层结果」。
流程可以概括为:
页面类只关心「调哪个方法、拿到什么结果、更新哪些变量」;所有业务细节与并行策略都留在 Service(及 Worker Service)内部,这样既避免页面类膨胀,也便于单测与复用。
2.3 app/shared —— 跨模块的「公共配套」
职责非常明确:能被多个模块复用,但不承载具体业务逻辑。
典型内容:
abstracts/abstract.app.service.ts:所有业务 Service 的抽象父类。abstracts/abstract.app.page.ts:所有页面组件的抽象父类。components/app-layout:统一的布局框架。components/data-table、components/status-badge等通用 UI 组件。types/*:像menu.shared.types.ts这种已经被多个模块使用的类型。
如果你是 Vue3 开发者,可以类比为:
shared/abstracts≈ 一组「强约束的 composables + 基类」,而不是随意的useXxx。shared/components≈ 全局注册的通用组件库,但仍然在项目里可见源码。
3. 面向对象视角下如何理解「模块」
对没有 OO 背景的前端,可以用一个简单类比:
- Module = 一个有清晰边界的「对象集合」,对外只暴露有限的入口(页面路由、Service 方法),内部可以自由重构。
以 Users 模块为例:
从外部来看,你只需要知道:
- 想展示用户管理页面 → 跳到
/users - 想进行用户 CRUD → 调用
UserService暴露的几个方法
内部这些「小对象」怎么协作,可以在不影响外部调用方的前提下自由演进。
4. 与传统 Vue3 项目目录的对比示例
假设你之前的 Vue3 项目大致是这样:
src/
├── api/
│ ├── user.ts
│ └── menu.ts
├── components/
│ ├── DataTable.vue
│ └── UserDialog.vue
├── views/
│ ├── Home.vue
│ ├── Menu.vue
│ └── Users.vue
└── store/
└── index.ts
迁移到当前架构的大致映射关系:
| Vue3 目录 | 新架构中的位置 |
|---|---|
views/Users.vue | features/users/users.ts(页面组件) |
api/user.ts | features/users/services/user.service.ts |
通用表格 DataTable.vue | shared/components/data-table/data-table.ts |
| Vuex store | 部分映射到 core/services + 各自模块 services/ |
好处:
- 每个业务模块在文件系统里「一眼可见」,新同事打开
features/users就能看到:页面、服务、类型都在这里。 - 通用能力(表格、布局、事件总线、数据库)集中在
shared/core,不用每个项目重造轮子。
5. 小结:你需要记住的三条规则
- Core 只初始化一次:放全局服务、枚举、DB、动画,业务模块不要直接往里塞东西。
- 先 features,后 shared:先让类型/组件在模块内长大,只有真的跨模块复用时才提升到 shared。
- 强约束的抽象基类:页面统一继承
AbstractAppPage,Service 统一继承AbstractAppService,这样模块间的上层结构就自然统一了。
理解了这一层之后,再去看「状态管理」与「HTTP 与 API 访问层」两篇文档,会更容易对整体架构有一张完整心智图。