Angular 是少数把”编译器”当作核心竞争力的前端框架:模板在构建期被解析、类型检查并编译成指令码,错误在 ng build 阶段就暴露,而不是运行时白屏。理解编译器的工作方式,你就能解释那些”为什么模板里这行报错""为什么这个管道没进 bundle”的日常问题。本文按演进脉络漫谈 Angular Compiler,落点在 v22 的现状。
编译器在框架中的位置
flowchart LR
A["组件模板<br/>HTML + 绑定"] --> B["模板解析<br/>生成 AST"]
B --> C["类型检查块 TCB<br/>交给 TypeScript"]
C --> D["生成组件定义代码<br/>(静态字段)"]
D --> E["TS 编译 + esbuild 打包"]
E --> F["tree-shaking<br/>按需保留指令/管道"]
F --> G["产物<br/>dist/"]
日常说的”AOT”(Ahead-of-Time)指的就是这条流水线全部发生在构建期。浏览器拿到的已经是”编译好的组件定义”,不需要现场理解模板字符串。
从 JIT 到 AOT
| 维度 | JIT(Angular 2~8 时代默认) | AOT(v9 Ivy 起默认) |
|---|---|---|
| 编译时机 | 浏览器运行时 | 构建期 |
| 运行时编译器 | 必须随包下发 | 不需要 |
| 模板错误发现 | 运行时抛异常 | 构建期报错 |
| 包体积 | 更大 | 更小 |
| 启动速度 | 需先编译再渲染 | 直接渲染 |
JIT 时代的老代码用 platformBrowserDynamic().bootstrapModule() 引导,编译器在浏览器里现场干活;AOT 时代模板错误变成这样:
error TS2322: Type 'string' is not assignable to type 'number'.
出现在 ng build 的输出里,并精确指向模板文件与列号。v22 里 JIT 仅存于极特殊场景(运行时动态构造模板),新项目默认彻底 AOT。
strictTemplates 默认:把模板纳入类型系统
strictTemplates 让模板绑定按组件类的真实类型检查,v22 起默认开启(历史上要先开 strictTemplates 或更古老的 fullTemplateTypeCheck)。体验差异:
import { Component, input } from '@angular/core';
@Component({
selector: 'app-progress',
template: '<div [value]="score()"></div>',
})
export class ProgressComponent {
score = input.required<number>(); // 模板里传字符串试试?
}
<!-- 使用方:app-progress -->
<app-progress [score]="'ninety'" /> <!-- 构建期直接报错:string 不能赋给 number -->
这件事的工程价值随着代码规模增长:改名自动同步到模板、传参类型不符当场报错、IDE 对模板内表达式的补全与重构都能用。过去”模板是类型检查盲区”的抱怨,在 v22 已不成立。
顺便一提与 TS 语义对齐的细节:模板中的可选链 a?.b 现在与 TypeScript 的 ?. 语义一致(null/undefined 短路),不再有历史实现的边角差异。
编译期新检查:NG8023 与 NG1054
v22 的编译器继续收紧检查网,两个新错误码值得记住:
NG8023——多个组件匹配同一标签。当两个组件的选择器撞车且同时出现在 imports 里:
@Component({ selector: 'app-card', /* 订单卡片 */ })
export class OrderCardComponent {}
@Component({ selector: 'app-card', /* 会员卡片 */ })
export class MemberCardComponent {}
@Component({
imports: [OrderCardComponent, MemberCardComponent],
template: '<app-card />', // NG8023:app-card 同时匹配多个组件,无法确定用哪个
})
export class PageComponent {}
过去这类冲突要到运行时才能发现(或者悄悄用错),现在构建期直接点名。
NG1054——重名的输入/输出。同一个指令/组件上,输入与输出不允许重名:
import { Component, input, output } from '@angular/core';
@Component({ selector: 'app-toggle' })
export class ToggleComponent {
open = input<boolean>(false); // 输入:open
open = output<void>(); // NG1054:与输入重名
}
重名会导致 [(open)] 双向绑定的展开([open] + (openChange) 之外的同名情况)产生歧义,编译器干脆禁止。命名惯例上,输出建议用事件式名字(opened、openChange)。
预编译产物与 tree-shaking
AOT 的另一重红利在体积。组件被编译为类上的静态定义字段(内部形如 ɵcmp),其中模板已变成”创建节点/绑定更新”的指令函数。配合打包器可以做到:
- 模板里没用到的指令、管道、管道参数形态,不进 bundle(对旧编译器时代”NgModule 一注册全量进包”的根治)。
- 未被引用的组件、装饰器元数据被 dead-code elimination 移除。
- 生命周期钩子、输入输出信息全部是静态数据,无需运行时反射。
这也是 standalone 组件与 AOT 配合后包体积普遍下降的原因:依赖关系从”模块级”细化到”组件 import 级”。
增量编译与开发体验
开发态的编译器走的是”增量”路线:
- dev server(Vite)按需转换:只有被请求的模块才编译。
- 文件变化后,只重新生成受影响组件的定义代码,模板没变的组件直接复用缓存。
- 类型检查与代码生成解耦,改一行模板不必等全量类型检查完成才能看到界面刷新。
这解释了 v17 以来”改完代码毫秒级热更新”的来源:编译器从”批处理工具”进化成了”常驻的增量服务”。
构建链演进:esbuild、Vite 与实验中的 Rolldown
| 时期 | 构建链 |
|---|---|
| v16 及以前 | Webpack(配置复杂、冷启动慢) |
| v17+ | 开发态 Vite dev server,生产构建转向 esbuild 系 builder |
| v22 | @angular/build 默认 esbuild;Rolldown 作为实验性后端推进中 |
Rolldown 是 Vite 团队打造的 Rust 打包器,目标是统一开发与生产的打包语义。Angular 对它的支持在 v22 处于实验阶段、默认关闭,尝鲜前请以官方文档的最新说明为准。对应用开发者的建议不变:不要在业务里耦合具体构建器——ng build 的抽象已经把这些差异挡在身后,从 Webpack 到 esbuild 的整个迁移过程,业务代码一行没改。
展望:@boundary 模板错误边界
编译器管住”编译期”,运行期的模板错误(管道抛异常、渲染函数里的空引用)目前会让整个应用崩掉。v22.1/v23 规划中的 @boundary 块即为解决此问题:子树内的渲染错误被限制在边界内,降级为占位 UI,不再拖垮整页。它与 React 的 Error Boundary 思路相通,是模板体系下一步值得关注的演进。
小结
- AOT 是 v9 起的默认:模板构建期编译成静态定义,错误前移、体积更小。
- strictTemplates 默认让模板全面纳入 TS 类型系统,
?.与 TS 语义对齐。 - NG8023 拦截选择器冲突,NG1054 拦截重名输入/输出——新错误码体现”检查持续收紧”的方向。
- tree-shaking 的粒度来自预编译:用不到的指令与管道不进产物。
- 构建链已从 Webpack 走到 Vite/esbuild,Rolldown 实验推进中;业务代码与构建器保持解耦。
@boundary错误边界是模板体系下一步的重点,先混个眼熟。