资讯动态

Angular 工程化实践:代码规范、目录结构与团队协作指南

发布时间:2026/10/1 12:27:30 来源:尧图企业网站定制
1. 先聊聊这套规范是怎么来的Angular 这个框架能活到今天还在企业级项目里大量出现靠的不仅仅是依赖注入、RxJS 和模块化这些底子更关键的是它在工程化上的态度。Angular 从 2.0 开始几乎就是“带着组织纪律出生的”——强制模块、强制依赖树、强制变更检测策略连 CLI 都替你决定了大半目录结构。但正因为框架本身约束力强团队真正拉开差距的地方反而不在“能不能跑”而在“跑得顺不顺”。我见过太多 Angular 项目一开始脚手架拉起来挺干净三个月后 modules 目录里堆了四十几个文件shared 文件夹变成了杂物间一个组件里同时干着请求数据、拼装表单、控制路由、弹 Modal 四件事。代码能跑但改起来要命。这其实不是 Angular 的问题而是团队从第一天起就没有把“规范”当成工程来对待。这一篇“Angular 持续提升”系列的第五篇我就集中聊代码规范、项目结构和团队协作这三大块。这三件事在文档里永远各占一章但在实际操作里它们是咬死的目录结构决定了规范能不能落地规范决定了协作时 review 效率高不高而协作方式又会反过来逼着项目结构持续调整。所以这篇文章不打算给你一条“银弹命令”而是把我自己带团队沉淀下来的一套玩法拆开讲清楚包括每种选择背后的利弊以及踩过的坑。如果你是刚接手一个 Angular 项目的新手或者团队正好在 5 人以上开始出现“每个人风格都不一样”的苗头这篇文章可以拿来当一份起步参照。顺便说一句网上很多人在搜 Angular 1.4.6 的旧资料——那是 AngularJS 的时代跟现在 Angular 2 是完全两套思路本文聊的都是后者。如果你还在维护 AngularJS 老项目抱歉本文帮不上太多但工程化的思考方式依然可以参考。2. 目录结构从“按类型堆文件”转向“按业务边界切模块”2.1 先分清 feature、core 和 shared 三层的边界Angular 官方推荐的目录结构其实非常简洁但团队在实际执行的时候经常会跑偏。核心原因在于官方只说“按功能组织”却没给一套足够细的裁决标准。结果就是很多人把“按功能组织”理解成了“新建一个页面就新建一个文件夹”到头来还是等于按页面堆文件。我个人的划分标准是这样的简单粗暴但三年下来很稳core 目录核心模块放全应用只有一个实例的东西。比如单例服务AuthService、UserService、HttpInterceptor、应用级守卫、全局错误处理器、根级布局组件。这一层的特点是“整个应用有且只有一份”它只被 AppModule 导入一次。shared 目录共享模块放那些“没有业务语义”或“弱业务语义”的复用件。比如自定义 Button、Pipe、Directive、通用表格封装、通用弹窗。任何 feature 都可以引用它但它不能引用任何 feature 模块内部的业务逻辑。features 目录功能模块按业务域划分每个业务域占一个文件夹内部再分 components、services、models、store 等子目录。这才是真正的业务代码所在地。有一个比较容易迷惑的点shared 和 core 到底怎么区分我自己的裁决办法是问一个问题——如果删掉某个文件应用是会“报错崩溃”还是“只是少了个公共组件”如果是前者它基本该进 core如果是后者它大概率属于 shared。例如 Token 拦截器删了所有请求都会 401这必然进 core一个水印指令删了只是界面少了个效果不会影响功能它属于 shared。2.2 feature 模块内部应该长什么样我见过最舒服的 feature 目录结构长下面这样features/ order/ components/ order-list/ order-detail/ pages/ order-page/ services/ order-api.service.ts order-state.service.ts models/ order.model.ts order-state.model.ts order.module.ts order-routing.module.ts这里有两个容易被忽略的小细节。第一个是 components 和 pages 分开。pages 代表“可以被路由直接加载的顶层组件”components 是它内部拆出来的子组件。这样路由配置会非常干净而且强迫组件保持合理的层级。第二个是 services 和 models 是“私有的”如果某个 service 只有 order 模块内部使用不要把它塞到 core 或 shared 里就留在 order/services 下让 feature 模块自包含。这样做的最大好处是未来如果你要做微前端拆分或者是按需打包每个 feature 文件夹都可以像一个独立小应用一样往外抬。再补一条关于懒加载的细节。Angular 路由懒加载并不神奇原理就是 route 的 loadChildren 引用一个独立 NgModule 时才做代码分割。所以你想要懒加载顺利前提就是每个 feature 模块必须是真正独立的。什么算真正独立就是“它自己 import 了自己需要的所有依赖而不是依赖全局的东西”。所以我在团队里的规则是feature 模块可以引用 core 和 shared但 feature 之间绝对不能相互 import。一旦 A feature import 了 B feature 的一个 service你们就形成了“半懒加载”状态——局部打包时会把 B 的部分代码也带进来问题会非常隐蔽。2.3 和 FastAPI 或 SpringBoot 项目结构比一比很多团队在聊“项目结构”的时候会下意识拿后端框架的习惯套。比如 SpringBoot 的标准结构是 controller / service / mapper / entityFastAPI 喜欢 app / api / models / schemas / core这些都是“按技术层次分”的思路。Angular 如果也照搬成 components / services / pipes / directives 这种顶层划分就会出问题。为什么因为在前后端项目里业务复杂度的承载方式不一样。后端的 controller/service/mapper本质上是对一个请求链路的纵向切分每个请求进来层次清晰而前端的业务复杂度主要来自界面状态、交互流程和组件树的横向组合。按技术类型堆目录会导致一个业务功能比如“订单支付”的代码被拆散到 components、services、models 三个顶层文件夹里改造一个功能要同时打开三四个地方跨目录跳跃。按业务边界切分之后代码阅读时只需要在一个文件夹内完成。所以 Angular 团队几乎一致地推荐按 feature 切分这不是拍脑袋而是前端业务组织方式的必然选择。你如果把 Angular 和 FastAPI/SpringBoot 的结构放在一起看会发现它们各自都有一套“边界”概念SpringBoot 的边界是“技术层次”FastAPI 的边界是“资源路由”Angular 的边界是“页面功能域”。理解这个差异就不会再犯“把 Angular 当成 SpringMVC 来组织”的毛病。3. 代码规范把“玄学”变成可执行规则3.1 TypeScript 严格模式必须开到底很多 Angular 项目是从 old 版本升级上来的或者一开始为了赶进度把 strict 关掉了。我强烈建议新项目从第一天就开严格模式老项目也要在一个迭代周期内逐步打开。// tsconfig.json { compilerOptions: { strict: true, noImplicitOverride: true, noPropertyAccessFromIndexSignature: true, noFallthroughCasesInSwitch: true } }strict: true这一项已经包含了 strictNullChecks、strictFunctionTypes 等一整套约束。在 Angular 里最直接的受益是你不能再把可能为 undefined 的东西随手塞给一个必填参数。可能有人觉得“不严格要求也能跑而且开发更快”我的体会恰恰相反——在大型代码库中90% 的运行时错误都来自“我以为这个值一定有但它是 undefined”。严格模式等于在编译期帮你拦截这一大类 bug省下的是无数个 dark web 调试的深夜。特别想说一下noImplicitOverride这个选项它要求你显式使用override关键字来标记重写方法。这个约束在 Angular 里特别有用因为 Angular 组件的生命周期方法ngOnInit、ngOnDestroy经常会被误写成ngOninit或ngOnDestory一旦拼错Angular 根本不会调用它而且不报错。开了noImplicitOverride之后父类生命周期方法要求你显式写override从根本上杜绝了这种拼写错误的静默失败。3.2 组件设计容器组件和展示组件必须分工Angular 项目里常见的坏味道有两种一种是“上帝组件”一个组件里既管数据请求又管表单逻辑还负责十几个子组件的状态分发另一种是“什么都做的 service”Service 里塞满了组件才需要用的临时状态。解决这个问题最有效的方案是回归到最朴素的“容器组件和展示组件分离”思想上。容器组件smart component负责请求数据、订阅 store、组织业务逻辑、把数据通过 Input 传给子组件子组件的事件则通过 Output 抛回来。展示组件dumb component负责只接收 Input 数据渲染 UI把用户操作通过 Output 告诉外界。展示组件内部不要直接调用 Service也不要直接注入 store。举个典型的例子订单列表页我会这样拆// order-list.page.ts容器组件 Component({ selector: app-order-list-page, template: app-order-table [orders]orders$ | async [loading]loading$ | async (selectOrder)onSelectOrder($event) /app-order-table }) export class OrderListPageComponent { orders$ this.store.select(selectOrders); loading$ this.store.select(selectOrdersLoading); constructor(private store: Store) {} onSelectOrder(order: Order) { this.store.dispatch(loadOrderDetail({ id: order.id })); } }// order-table.component.ts展示组件 Component({ selector: app-order-table, changeDetection: ChangeDetectionStrategy.OnPush, template: table tr *ngForlet order of orders td{{ order.id }}/td td{{ order.total }}/td tdbutton (click)onSelect.emit(order)查看/button/td /tr /table }) export class OrderTableComponent { Input() orders: Order[] []; Input() loading false; Output() selectOrder new EventEmitterOrder(); }这样拆完之后组件的可测试性直接上一个台阶。展示组件只需要 mock 一份 orders 数组就能在 Storybook 里独立开发容器组件的大部分逻辑也可以通过 mock store 来做单元测试。更重要的是UI 设计师或者初级程序员去改样式的时候只需要面对展示组件完全不担心碰坏业务逻辑。3.3 RxJS 的几条铁律AsyncPipe、takeUntil、避免嵌套订阅Angular 和响应式编程深度绑定但 RxJS 是一把双刃剑。用得好状态流非常优雅用得差就是一场“订阅地狱”。我团队内部立了三条铁律违反任何一条都过不了 review第一模板里能用 AsyncPipe 的地方就绝不手动 subscribe。手动 subscribe 意味着你必须手动 unsubscribe而人总会忘。AsyncPipe 会自动管理订阅和销毁这是 Angular 官方给的安全带不用白不用。第二组件里所有手动订阅必须用takeUntil统一在ngOnDestroy里销毁。export class OrderDetailComponent implements OnInit, OnDestroy { private destroy$ new Subjectvoid(); ngOnInit() { this.orderService.getOrder(id) .pipe(takeUntil(this.destroy$)) .subscribe(order this.order order); } ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); } }注意这里有个常见误区有人在订阅里直接调用unsubscribe()这在简单场景可行但当你拆了十来个订阅之后代码就会变成一团麻。用takeUntil(this.destroy$)统一管理你不需要知道有多少订阅只要记住一个原则——组件销毁时必须调用destroy$.next()。但也要小心一点takeUntil要放在 pipe 链的最后如果放在前面会吃掉后面的操作符导致行为异常。第三禁止嵌套订阅。也就是在一个subscribe回调里再写另一个subscribe。这会让错误处理、取消订阅和可读性全面崩坏。正确的做法是用switchMap、mergeMap、exhaustMap这样的高阶操作符把流压平。比如先根据用户 ID 查询订单再根据订单查询明细最开始的版本很容易写成嵌套订阅但这样写风格能改造成this.userService.currentUser$ .pipe( switchMap(user this.orderService.getOrdersByUser(user.id)), switchMap(orders this.orderService.getOrderDetails(orders[0].id)) ) .subscribe(details this.details details);这个改造不仅代码行数减少了而且天然支持用户变化后自动重新拉取这是嵌套订阅完全做不到的。3.4 命名规范文件名和类名的约定要写进 READMEAngular 几乎每个文件都关联一个类所以命名混乱的情况特别常见。我的建议很简单文件名全部小写、单词之间用中划线kebab-case例如order-list.component.ts、auth.service.ts。类名使用大驼峰PascalCase且必须包含对应的类型后缀。例如组件类叫OrderListComponent服务类叫AuthService模型类叫OrderModel。变量名使用小驼峰camelCase布尔变量必须以is、has、can开头比如isLoading、hasError、canEdit。常量全部大写加下划线例如MAX_PAGE_SIZE。Observable 类型的变量名统一加$后缀比如orders$、loading$。很多人觉得这是形式主义但我实际体会是orders$和orders在团队沟通中差异巨大。当你说“把 orders$ 丢给 async 管道”的时候所有人都知道你传的是 Observable而当代码中出现orders$ | async时你不会搞混它和orders数组。这省掉的沟通成本远高于多打一个字符的成本。4. 团队协作让规范真正跑起来的那一步4.1 代码审查必须盯住这四个点规范写进文档只是第一步真正让规范“活”起来要靠 code review。但 review 不能变成大型吵架现场否则团队气氛会迅速恶化。我的做法是明确告诉所有人review 只需要对四个点负责其他风格问题交给 linter 和 formatter 自动处理第一个点是变更检测策略。任何新组件如果没写ChangeDetectionStrategy.OnPushreview 都可以直接打回。原因无他OnPush 是 Angular 性能的第一道防线它告诉 Angular“这个组件的输入不变就别再跑变更检测了”。尤其是列表型组件数据动辄几百条不用 OnPush 就是白白浪费性能。第二个点是订阅管理。看代码里有没有直接subscribe后不处理销毁的有没有 AsyncPipe 能解决但非要手动 subscribe 的。第三个点是依赖方向。feature 模块是否 import 了其他 feature 模块shared 模块里是否混入了业务逻辑core 模块是否被多个 feature 模块重复 import。第四个点是单向数据流。父组件的状态是否被直接传给了子组件然后又通过双向绑定反向修改父组件状态。Angular 支持双向绑定但一旦项目超过一定规模双向绑定就会变成状态追踪的灾难。我团队里的约定是子组件不允许直接修改 Input 对象内部的属性所有修改必须通过Output抛出去由父组件统一处理。如果遇到实在绕不开的场景优先考虑改用 Angular Forms 或者把共享状态提升到 Store。有人会问这么严的 review 会不会很累我的经验是把检查规则固化下来之后真正需要人工看的东西反而变少了。因为大部分机械检查可以被工具替代后面会说到人只需要集中看业务逻辑和结构关系效率反而更高。4.2 Git 分支和工作流一个简单的约定就够了很多团队花大量时间争论 Git Flow 还是 GitHub Flow其实哪种模型都能跑关键是团队的迭代节奏。目前我比较推荐的是带feat/、fix/、chore/前缀的轻量级分支策略配合主干合并加 MR 审查的模式main └── release/1.0.x ├── feat/login-page ├── feat/support-chat └── fix/order-sort-bug分支命名规则和提交信息规则要写清楚。提交信息我推荐用 Conventional Commits 的简单子集也就是常规前缀feat、fix、refactor、docs、style、test加上一句不超过 50 个字的描述。例如fix: 修复订单列表在移动端点击无效的问题。这样做的好处有三点第一未来任何一个人看 git log 都能快速了解变更意图第二它可以自动触发版本号和 changelog 生成第三它让事故回溯的成本大幅降低。我还有一个亲身教训分支的生命周期要短。一个分支如果超过两天还没合回主干它大概率已经和 main 产生了大量冲突而且业务逻辑也容易偏离。所以我在团队里的约定是——一个功能分支最多活两天如果没做完拆分功能或者用“后备队”WIP MR先同步到主干而不是让它腐烂在分支里。4.3 用 Git Hooks 和 CLI 工具把规范自动化人工审查的成本高、遗漏多所以凡是能被工具固定下来的规范我都不依赖人的自觉。我推荐的最小自动化组合是用 Prettier 统一代码格式保证所有人的缩进、引号、分号风格一致。讨论缩进用空格还是 Tab 没有意义直接用工具裁决。用 ESLint 配合angular-eslint检查代码质量。Angular 官方现在推荐 ESLint它捕获的问题包括“没有使用 AsyncPipe 却手动订阅”“组件模板缺少类型”等。用 Husky lint-staged 在提交前强制跑 lint 和 prettier只有格式规范、lint 通过的代码才能进入 Git。// package.json { scripts: { lint: ng lint, format: prettier --write \src/**/*.{ts,html,scss,json}\ }, lint-staged: { *.{ts,html,scss,json}: [ prettier --write, eslint --fix ] } }除了上面这些还有一个“隐藏功臣”是 Angular CLI 的ng generate系列命令。团队里所有新文件的创建我都要求走ng generate而不是手动新建文件。为什么因为 CLI 会替你生成符合 Angular 约定的文件夹骨架和规范命名同时也生成配套的 spec 测试文件。很多团队的文件命名不规范根本原因就是“人肉创建”环节没有控制住。用 CLI 生成等于把规范前置到了“创建那一刻”。4.4 AI 辅助代码检查大型代码库里的新常态最近很多人在讨论 AI 工具比如 Claude Code在大型代码库里的最佳实践。我自己的实际感受是这类工具在 Angular 项目里最有价值的场景恰恰是代码规范检查而非“自动写业务代码”。为什么呢因为大型项目里的规范往往散落在多个文件、多个历史 commit 里人脑很难在 review 时把全套规范都记在脑子里而 AI 可以把项目历史、lint 配置、甚至团队 README 里的约定都作为上下文然后帮你做一轮“规范扫描”。我目前的用法是每次在合并 MR 之前除了跑 lint还会把改动的 diff 交给 AI 工具让它按我们团队的编码规范检查一遍。它会发现诸如“这里订阅了却没有取消”“这个组件没加 OnPush”“这个 service 的路径放错了”这类问题。当然AI 的输出不能全信最终裁决权还是在人。但它确实能扮演一个“永不疲惫的初级 reviewer”帮我们把 80% 的机械性检查过滤掉把人的精力解放出来看真正需要判断力的逻辑问题。我建议大家可以试试这招成本极低收益却很直接。5. 实操中遇到的坑和排查记录5.1 OnPush 遇到异步更新的经典坑这是一位组员踩过的坑他把某个组件设成了ChangeDetectionStrategy.OnPush然后组件里有一段代码在按钮点击后修改了某个数组的数据直接this.items.push(newItem)结果界面没更新。排查了大半天最后发现是 OnPush 的机制导致的。解释一下OnPush 模式下Angular 只对“Input 引用变化”“模板事件触发”“AsyncPipe 发出新值”“手动触发 detectChanges”这四类情况做变更检测。你往数组里 push 新元素并不会改变数组的引用所以 Angular 完全不知道数据变了。解法很简单不要用push修改原数组而是创建新数组。比如用扩展运算符this.items [...this.items, newItem]或者用 immutable 的更新方式。这个坑很典型不懂的人会以为是 OnPush 有 bug其实这是设计特性它逼着你使用不可变数据模式。用过之后你会感谢这个设计因为不可变数据模式让变化追踪变得异常清晰。5.2 懒加载路由的“闪白屏”问题团队在接入懒加载后发现每次切路由会有很明显的白屏闪烁。排查过程很曲折最后发现是共享模块的样式没有抽离导致懒加载模块加载完成前页面缺少样式。这个问题的本质是懒加载模块的样式是在 JS chunk 加载完成后才注入到页面中的如果样式文件体积大用户会先看到无样式的裸 HTML然后突然“刷”地一下出来完整页面。解决方案有两条路一是把全局公共样式放进angular.json的styles数组里让它们在应用启动时就一路加载好而不是随着懒加载 chunk 走二是对特别大的组件样式考虑用预加载策略。我后来发现其实绝大多数项目把样式文件控制在合理体积内这个白屏问题都不会太明显。所以排查的顺序是先看自己是不是把全局样式错误地放到了某个懒加载组件的 styleUrl 里再看文件体积。5.3 依赖注入作用域providers 放错位置的“幽灵状态”Angular 依赖注入的作用域是一个特别容易踩的陷阱。我在项目里遇到过一次非常隐蔽的 bug某个模块的 Service 被放到了模块的providers里然后这个模块同时被两个路由懒加载引用导致实例被创建了两次。两个页面各自维护一份状态用户在 A 页面修改了数据切到 B 页面发现数据没变。最后查清原因是这个模块不是一次性被AppModule导入的而是被两个懒加载路由各自 import。Angular 对懒加载模块会在自己的注入器里单独创建 providers所以两个路由指向的实例就是“两份内存里各玩各的”。解决办法也很明确确认这个 Service 是否应该全局单例——如果是就把它放进core模块并由AppModule导入如果它只属于某个功能域且不需要跨页面共享那么放在 feature 模块里就没问题。搞清楚这两者的边界是设计依赖注入的第一步。我还想多提一个细节providedIn: root和providedIn: SomeModule之间有微妙的区别。前者会在应用根注入器创建单例后者会随着模块的注入器走。很多人图省事统一写providedIn: root但如果你遇到懒加载模块想隔离状态时就不得不改用providedIn: any或模块级配置。我建议团队里从第一天就约定全局单例状态用providedIn: root模块内临时状态不要用 root而是放在模块的 providers 或组件级 providers 里。6. 最后分享几个我折腾出来顺手的工具链小技巧代码规范这块其实还有一个隐性收益就是新人上手成本的大幅降低。我见过很多团队招了个新人光是“先看哪个目录、再看哪个文件、代码按什么风格写、提交信息怎么写”就要解释半小时。有了规范文档加上自动化的检查新人只需要照着 README 里的“Three steps to start”执行自然就能跑起来。我个人感受是项目规范做得好的团队新人融入期基本缩短一半以上。工具链方面除了前面说到的 Prettier、ESLint、Husky、lint-staged我还强烈推荐在 Angular 项目里加上 Compodoc 或 Storybook。Compodoc 能从代码注释里自动生成模块关系图Storybook 则让展示组件可以在浏览器里脱离业务接口独立调试。这两者有一个共同的好处它们会把“组件是否独立、复用性好不好”暴露在明面上。如果一个组件在 Storybook 里没法独立展示那多半是它悄悄依赖了某个业务 service这种问题代码审查很难发现工具却一眼就看穿。最后分享一个我一直坚定的观点Angular 项目里的规范本质上不是用来“限制人”的而是用来“降低沟通成本”的。在一个团队里如果每个人写代码都按自己的喜好来表面上是自由实际上是每个人都要花额外精力去理解别人的“方言”。规范不是万能药它不能替你解决业务复杂度也不能弥补糟糕的产品设计但它能让团队以最小的摩擦把复杂度推向前进。从一个 5 人团队到一个 50 人团队这种收益会不断放大。如果你现在正处在“项目开始混乱但还能改”的阶段尽早把结构和规范定下来是性价比最高的一笔投资。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑