CHARLIE SAYS

查理如是说
DATE 2026-08-24
THEME
SERIES / ANGULAR / P-196 · Angular 高级教程

Angular 22+ 教程 31:编码风格指南与命名约定

风格之争最容易演变成口水战,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全部挂 Modulestandalone 默认,绝大多数场景无 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 后缀TaskStoreAuthApi
组件选择器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:strictDomEventTypesstrictAttributeTypes 等默认开启,模板里的类型错误与 TS 同等待遇。不要为了绕过报错去改配置——那些报错几乎总是真 bug。

一页速查

约定
文件名kebab-case,feature.type.ts,一个文件一件事
目录按 feature 组织,core/ shared/ 全局唯一
类名PascalCase + 类型后缀
组件选择器app- 前缀 kebab-case
指令选择器app 前缀 camelCase 属性
状态默认 signal,派生用 computed
注入统一 inject(),服务用 @Service()
模板@if/@for/@lettrack 必写
输入输出input() / output() / model(),字段加 readonly
TS严格模式全开,不绕过诊断

风格规范的落地靠工具而非自觉:eslint 成员排序与命名检查、prettier 统一格式、lint-staged 卡住不合规范的提交——这些在下一篇展开。

系列导航

← 算法 031:算法思想:分治算法 目录 算法 032:算法思想:回溯算法 →
← 返回文章列表