风格之争最容易演变成口水战,Angular 的价值在于它有官方 style guide 兜底。v20 时官方对 2016 年以来的 style guide 做了一次整体重写,去掉了大量与 standalone、Signals 时代脱节的细则,收敛为几条可执行的原则。v22 的现代写法(signals、inject、新控制流)在其中被明确推荐。本文把官方约定与实践结合,给出一份可以直接落地的团队规范。
重写后的 style guide:变与不变
v20 重写的方向是”少规定、多原则”:
| 维度 | 旧 style guide(2016) | 新 style guide(v20 重写后) |
|---|---|---|
| 文件命名 | 严格规定 x.y.component.ts 多段式后缀 | 弱化硬性后缀,强调”名实相符 + 全库一致” |
| 结构组织 | 按类型分目录曾占主流 | 明确推荐按 feature 组织 |
| 代码风格 | class 字段、构造器注入 | prefer signals、inject over constructor |
| NgModule | 全部挂 Module | standalone 默认,绝大多数场景无 Module |
| 篇幅 | 大量细则 | 少量原则 + 示例 |
对存量团队,官方态度很务实:旧约定不是错误,一致性本身比具体选择更重要。下面给的建议因此都标注了”官方推荐”与”团队约定”两个层次。
文件命名
官方原则:文件名描述内容,全小写,单词用连字符(kebab-case)分隔,一个文件一件事(one file one thing)。
实践中存在两个流派:
# 流派 A:保留类型后缀(大多数存量团队的延续选择)
user-profile.component.ts # UserProfileComponent
user-profile.component.html
user-profile.component.scss
user-profile.spec.ts
task.service.ts # TaskService(v22 用 @Service() 声明)
highlight.directive.ts # HighlightDirective
duration.pipe.ts # DurationPipe
# 流派 B:极简后缀(新 style guide 弱化后缀后的选择)
user-profile.ts
task.ts
highlight.ts
推荐团队从一而终。如果没有历史包袱,流派 A 仍然值得选——类型后缀带来的检索收益(一眼筛出所有组件)大于输入成本;库开发(见第 29 篇)则建议保留后缀方便消费方按类型导入。
配套细则:
src/app/
core/ # 单例服务、根组件级配置
shared/ # 跨 feature 复用的哑组件、管道、指令
features/
tasks/
tasks.routes.ts # 该 feature 的子路由
task.service.ts
task-list/
task-detail/
task.types.ts # 接口与类型
- 一个组件一个目录(
task-list/),模板、样式与类同目录同名 *.types.ts集中放领域类型,避免到处 import 相对路径九层楼- 路由文件命名
xxx.routes.ts与 CLI 生成的懒加载约定对齐 - 不再需要
*.module.ts——standalone 时代它只在极特殊的动态场景出现(见第 28 篇)
类命名与选择器
| 元素 | 规则 | 示例 |
|---|---|---|
| 组件类 | PascalCase + Component 后缀 | TaskListComponent |
| 指令类 | PascalCase + Directive 后缀 | HighlightDirective |
| 管道类 | PascalCase + Pipe 后缀 | DurationPipe |
| 服务类 | PascalCase,v22 起不再强制 Service 后缀 | TaskStore、AuthApi |
| 组件选择器 | kebab-case + 前缀 | app-task-list |
| 指令选择器 | camelCase 属性 + 前缀 | [appHighlight] |
| 信号字段 | 名词或形容词,不加 Signal 后缀 | readonly tasks = signal([]) |
| 常量 | camelCase(TS 惯例)或 UPPER_SNAKE(团队约定) | const pageSize = 20 |
选择器前缀(默认 app)是防碰撞的命名空间,组件库(第 29 篇)应改用自己的库前缀。@Component 里两者写法不同不是历史包袱:组件是元素用 kebab,指令是属性用 camel,与 HTML 属性的惯例一致。
类成员排序
一个约定俗成且对 review 友好的成员顺序:
@Component({
selector: 'app-task-list',
templateUrl: './task-list.component.html',
})
export class TaskListComponent {
// 1. inputs / outputs / models(接口面,最先被消费方看到)
readonly tasks = input.required<Task[]>();
readonly selectTask = output<Task>();
// 2. 依赖注入(字段注入,统一 inject())
private readonly store = inject(TaskStore);
private readonly router = inject(Router);
// 3. 公共可写状态(signals)
readonly filter = signal<'all' | 'done'>('all');
// 4. 派生状态(computed / resources)
readonly visible = computed(() =>
this.filter() === 'done' ? this.tasks().filter((t) => t.done) : this.tasks(),
);
// 5. 生命周期钩子
ngOnInit(): void { /* ... */ }
// 6. 公共方法
onSelect(task: Task): void { /* ... */ }
// 7. 私有方法
private toQuery(): string { /* ... */ }
}
要点:readonly 默认加(信号和 input 都是),生命周期钩子放中间而不是最后,公共方法在上私有在下。这个顺序与”读者关心的程度”一致,eslint 的成员排序规则(第 32 篇)可以机械保证。
prefer signals:状态声明的默认选择
v22 中 signals 已是地基,新的状态字段默认用信号表达:
export class TaskListComponent {
// 好的写法
readonly filter = signal<Filter>('all');
readonly visible = computed(() => this.filter() === 'all'
? this.store.tasks()
: this.store.tasks().filter((t) => t.done));
// 坏的写法(Zoneless 时代摸鱼状态)
filterValue: Filter = 'all'; // 模板读它,更新后不会刷新
get visible() { return ...; } // getter 无法参与依赖追踪
}
什么时候不用信号:不进模板、不参与响应式流的中间变量(比如构造某个请求参数的局部 const)。判断标准只有一个——这个状态变化后,UI 需要自动知道吗?需要就上信号。
配合 OnPush(v22 已是默认策略),变更检测友好的写法几乎全部自动成立,但仍要守住两条:
@for必须写track(既是性能项,也是正确性项——DOM 复用依赖它)- 状态更新走不可变替换或
update(),避免”改了对象内部字段但引用不变”的经典事故
inject over constructor
构造器注入与字段注入功能等价,但 v22 的新 API 体系(@Service()、函数式拦截器、resource)全部对齐 inject 风格,统一它:
// v22 风格
@Service()
export class TaskStore {
private readonly http = inject(HttpClient);
readonly query = signal('');
}
// 旧风格(能跑,但与 @Service() 不兼容)
@Injectable({ providedIn: 'root' })
export class TaskStore {
constructor(private http: HttpClient) {}
}
@Service() 装饰器干脆不支持构造器参数注入(第 19 篇),与其维护两套肌肉记忆,不如全库统一 inject()。唯一注意点:inject() 必须在注入上下文(字段初始化器、构造函数)中调用,这也是字段注入顺序天然满足的。
模板风格:新控制流一以贯之
v22 的模板应该看不到 *ngIf / *ngFor / ngSwitch(迁移命令见第 33 篇):
<!-- 好的写法 -->
@if (task(); as task) {
<h2>{{ task.title }}</h2>
} @else {
<app-skeleton />
}
@for (item of visible(); track item.id) {
<app-task-row [task]="item" />
} @empty {
<p>暂无任务</p>
}
@let remaining = visible().filter((t) => !t.done).length;
<!-- 坏的写法
<ng-container *ngIf="task as t">...</ng-container>
<div *ngFor="let item of visible()">...</div>
-->
长模板(超过百行)拆外部文件 xxx.component.html;简短模板内联在 template 里可以减少文件数——这也是新 style guide 认可的取舍,标准是可读性而非行数。
TypeScript 严格模式
CLI 生成的 tsconfig.json 默认已开启严格模式("strict": true),这是底线不是加分项。团队应在此基础上补齐:
{
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true
}
}
Angular 特有的”风格警察”还有编译器的 extended diagnostics:strictDomEventTypes、strictAttributeTypes 等默认开启,模板里的类型错误与 TS 同等待遇。不要为了绕过报错去改配置——那些报错几乎总是真 bug。
一页速查
| 项 | 约定 |
|---|---|
| 文件名 | kebab-case,feature.type.ts,一个文件一件事 |
| 目录 | 按 feature 组织,core/ shared/ 全局唯一 |
| 类名 | PascalCase + 类型后缀 |
| 组件选择器 | app- 前缀 kebab-case |
| 指令选择器 | app 前缀 camelCase 属性 |
| 状态 | 默认 signal,派生用 computed |
| 注入 | 统一 inject(),服务用 @Service() |
| 模板 | @if/@for/@let,track 必写 |
| 输入输出 | input() / output() / model(),字段加 readonly |
| TS | 严格模式全开,不绕过诊断 |
风格规范的落地靠工具而非自觉:eslint 成员排序与命名检查、prettier 统一格式、lint-staged 卡住不合规范的提交——这些在下一篇展开。