Angular 22 源码级深度解析-第 02 章 编译链路总览:从模板字符串到 ɵɵ 指令
系列:Angular 22 源码级深度解析 版本基线:
angular@22.2.3(本仓库)
本章导读
第 1 章我们站在"运行时"这边,看 ɵcmp 和模板函数是怎么被消费的。这一章站到对面去——编译器——回答那个更根本的问题:你写在 .html 里的一行模板,到底是怎么变成一串 ɵɵelementStart 调用的?
这一章要打通一条完整的链路:
<span>Hello {{name}}!</span> ← 你写的模板字符串
│
▼ HTML 词法/语法分析 (ml_parser)
HTML AST
│
▼ R3 转换 + 绑定解析 (r3_template_transform / binding_parser)
R3 AST(Angular 语义的 AST)
│
▼ 指令/管道匹配与类型检查 (t2_binder / typecheck)
R3 AST + 依赖信息
│
▼ ingest(吸入 IR)
IR(中间表示:op 列表)
│
▼ transform(74 个变换阶段,实际跑 57 个)
优化后的 IR
│
▼ emit(生成代码)
function Cmp_Template(rf, ctx) {
if (rf & 1) { ɵɵelementStart(0, 'span'); ɵɵtext(1); ɵɵelementEnd(); }
if (rf & 2) { ɵɵadvance(); ɵɵtextInterpolate1('Hello ', ctx.name, '!'); }
}
除了这条主线,本章还要解决三个绕不开的工程问题:
- 为什么 Angular 的库(npm 包)不是发布编译好的代码,而是发布"半成品"? → 这引出 partial compilation(部分编译)与 linker(链接器);
- JIT 和 AOT 到底差在哪? → 引出
R3JitReflector与运行时编译; decls: 2, vars: 2这两个数字究竟是谁算出来的? → 引出allocateSlots和countVariables两个阶段(第 1 章只说了"编译器算的",本章给出算法)。
读完这一章,你再看任何一个 Angular 组件的编译产物,都能在脑子里把它还原成模板。
2.1 三种编译形态:JIT、AOT 与 Partial Compilation
Angular 的编译不是"一种方式",而是三种,分别服务于不同场景。理解它们的差别,是理解 Angular 整个构建体系的前提。
2.1.1 JIT(Just-In-Time,即时编译)
做什么:在浏览器里、运行时,拿到组件类和模板字符串,现场编译出 ɵcmp。
什么时候用:开发/调试、在线 Playground、动态加载未编译组件的场景。
代价:
- 要把编译器
@angular/compiler打包进应用(几百 KB); - 启动时要花时间编译;
- 因为模板是运行时才知道的,很多编译期检查(类型检查、未知属性告警)做不了或做得很晚。
JIT 的实现入口很小,packages/compiler/src/render3/r3_jit.ts 全文只有 33 行,核心是一个"符号解析器":
export class R3JitReflector implements ExternalReferenceResolver {
constructor(private context: {[key: string]: unknown}) {}
resolveExternalReference(ref: o.ExternalReference): unknown {
// 只处理 @angular/core 的引用
if (ref.moduleName !== '@angular/core') {
throw new Error(
`Cannot resolve external reference to ${ref.moduleName}, only references to @angular/core are supported.`,
);
}
if (!Object.hasOwn(this.context, ref.name!)) {
throw new Error(`No value provided for @angular/core symbol '${ref.name!}'.`);
}
return this.context[ref.name!];
}
}
这个类暴露了 JIT 的本质:编译器生成的代码里会有"外部引用"(比如 ɵɵdefineComponent 这个符号),在 AOT 下这些引用会被编译成真实的 import 语句;但在 JIT 下没有 import 可用,于是用一个 context 对象(通常就是 @angular/core 模块的导出集合)来按需查表解析这些符号。R3JitReflector 就是这个查表器。
第 1 章 §1.9.1 里 bootstrapApplication 在 JIT 模式下要 await resolveJitResources() 先拉取 templateUrl——因为 JIT 下模板是运行时才 fetch 的,编译必须在拿到模板文本之后才能开始。
2.1.2 AOT(Ahead-Of-Time,预先编译)
做什么:在构建阶段(跑 ng build 时)就把模板编译成 ɵcmp 和模板函数,产物是纯 JS,不含编译器。
什么时候用:所有生产构建(Angular CLI 默认)。
好处:
- 不用打包编译器,体积小;
- 启动即渲染,首屏快;
- 编译期就能做完整类型检查和模板错误检查——你在 IDE 里看到的"模板里
user.nmae不存在"这类报错,就是 AOT 期间的类型检查给的。
这也是 Angular 默认走 AOT 的原因。本系列后面所有关于编译的讨论,默认都是 AOT。
2.1.3 Partial Compilation(部分编译)+ Linker:给"库"准备的第三条路
前两条路解决了"应用怎么编译"。但**库(npm 包)**遇到一个死结。
假设你是组件库作者,用 Angular 写了个 @my-lib/button。你在发布时有两个选择:
| 选择 | 后果 |
|---|---|
发布源码(带 @Component 装饰器、模板字符串) | 每个使用者都要把 @angular/compiler 装进来自己编译;编译慢、体积大、版本耦合 |
发布完全编译的产物(含 ɵɵelementStart 等指令调用) | 灾难:产物被绑死在编译它的那个 Angular 版本的内部实现上。用户用 Angular 22.2 编译、22.3 运行时,只要 22.3 改了某个 ɵɵ 指令的签名,库就崩了 |
第二个选项正是 View Engine(Angular 9 之前的老引擎)时代的惨痛教训——库必须和使用它的应用用同一个 Angular 版本编译,否则各种诡异崩溃。这被称为"库与应用的版本锁死"。
Angular 的解法是 partial compilation(部分编译)+ linker(链接器):
- 库作者发布时,编译器把组件编译成一个**"声明式元数据"的半成品**——它保留了模板字符串、选择器、输入输出等语义信息,但不含任何
ɵɵ指令调用; - 应用构建时,一个叫 linker 的工具(通常是 Babel 插件)扫到这些半成品声明,用当前应用所用的 Angular 版本把它们"链接(link)"成最终的完整定义。
linker 的官方 README(packages/compiler-cli/linker/README.md)把这件事说得很清楚:
The partial declaration format allows library packages to be published to npm without exposing the underlying Ivy instructions. (部分声明格式让库可以发布到 npm 而无需暴露底层的 Ivy 指令。)
The tooling here allows application build tools (e.g. CLI) to produce fully compiled components at the point when the application is bundled. These linked files can be cached outside
node_modulesso it does not suffer from problems of mutating packages innode_modules.
两个关键点:
- 库不再包含任何
ɵɵ指令 → 库的产物与 Angular 内部实现解耦,只要"部分声明格式"不变,库就能被未来版本的 linker 链接; - 链接发生在应用打包时,产物可以缓存在
node_modules之外 → 不用去改写node_modules里的文件(改写node_modules是构建工具的大忌:缓存失效、可移植性差)。
这套机制彻底解决了"库与应用版本锁死"。Angular 库只需发布一次,能被后续任何 Angular 版本的应用消费——只要那个版本的 linker 认识这个库用的声明版本。
为了做到这一点,部分声明里带了两个版本字段(packages/compiler/src/render3/partial/api.ts):
/**
* The minimum version of the compiler that can process this partial declaration.
*/
minVersion: string;
/**
* ... will be able to detect which version a library is using and interpret its metadata accordingly.
*/
version: string;
version:这个声明是用哪个编译器版本生成的;minVersion:处理它所需的最低编译器版本。
linker 用 semver 做版本范围匹配,选择对应版本的链接器(后面 §2.10 会看到 PartialLinkerSelector 的实现)。
2.1.4 三种形态对照表
| 维度 | JIT | AOT | Partial + Linker |
|---|---|---|---|
| 编译时机 | 浏览器运行时 | 应用构建时 | 库构建时(部分)+ 应用构建时(链接) |
产物含 ɵɵ 指令 | 是(运行时生成) | 是 | 否(库产物),链接后才有 |
| 需要打包编译器 | 需要 | 不需要 | 不需要(只需 linker,构建期) |
| 适用 | 开发调试、动态场景 | 生产应用 | npm 库 |
| 版本耦合 | 强 | 强 | 弱(靠声明格式 + semver 解耦) |
理解这张表,你就理解了 Angular 构建体系的"分人群策略":应用走 AOT 求快,库走 partial 求兼容,调试走 JIT 求方便。
2.2 编译器全景:packages/compiler 的模块地图
编译器的源码在 packages/compiler/src/。它不是"一个编译器",而是一条分工明确的流水线。先看地图:
packages/compiler/src/
├── ml_parser/ # ① HTML 层:词法分析 + 语法分析
│ ├── lexer.ts # 词法:把字符流切成 token
│ ├── parser.ts # 语法:把 token 组装成 HTML AST
│ ├── ast.ts # HTML AST 节点定义
│ ├── html_tags.ts # HTML 标签/属性的元数据(哪些是自闭合、哪些是 void 元素)
│ ├── tags.ts # Angular 特有的标签/属性定义(如 ng-container、ng-content)
│ ├── html_whitespaces.ts # 空白处理
│ ├── entities.ts # HTML 实体(& 等)
│ └── xml_parser.ts # XML 模式(SVG/MathML 用)
│
├── expression_parser/ # ② 表达式层:解析 {{ }} 和 (click)="..." 里的 JS 表达式
│ ├── lexer.ts
│ ├── parser.ts
│ ├── ast.ts # 表达式 AST(ASTWithSource、Binary、Conditional...)
│ └── serializer.ts
│
├── template_parser/ # ③ 模板层:绑定解析与预扫描
│ ├── binding_parser.ts # 把属性解析成"属性绑定/事件绑定/指令输入"
│ └── template_preparser.ts # 预扫描(先快速判断有没有 ngIf 等结构指令)
│
├── render3/ # ④ Ivy 层:R3 AST 与代码生成
│ ├── r3_ast.ts # R3 AST 节点(Element、BoundText、BoundAttribute、DeferredBlock...)
│ ├── r3_template_transform.ts # HTML AST → R3 AST
│ ├── r3_identifiers.ts # 运行时符号表(ɵɵelementStart 等的名字集中定义)
│ ├── r3_factory.ts # ɵfac 工厂生成
│ ├── r3_module_compiler.ts / r3_pipe_compiler.ts / r3_injector_compiler.ts
│ ├── r3_jit.ts # JIT 反射器(§2.1.1)
│ ├── partial/ # ★ 部分编译:生成 ɵɵngDeclareComponent 等声明
│ └── view/ # ★ 完整编译的主战场
│ ├── template.ts # parseTemplate():解析模板的入口
│ ├── compiler.ts # compileComponentFromMetadata():编译组件
│ ├── t2_binder.ts # 指令/管道匹配(模板类型检查的基础)
│ ├── t2_api.ts # Target 抽象
│ ├── query_generation.ts
│ └── i18n/
│
├── template/pipeline/ # ★⑤ IR 流水线(Angular 17+ 的新编译器架构)
│ ├── ir/ # IR 节点与 trait 定义
│ └── src/
│ ├── ingest.ts # 吸入:R3 AST → IR
│ ├── compilation.ts # CompilationJob / CompilationUnit 抽象
│ ├── emit.ts # 发射:IR → 输出 AST(含 phases 顺序表)
│ ├── conversion.ts
│ ├── instruction.ts
│ └── phases/ # ★ 74 个变换阶段
│
├── output/ # ⑥ 输出层
│ ├── output_ast.ts # 输出 AST(o.Expression / o.Statement ...)
│ ├── output_jit.ts # JIT 下执行输出 AST
│ ├── abstract_emitter.ts # 发射器抽象基类(缩进 / 分号策略)
│ └── abstract_js_emitter.ts # JS 发射器实现
│
└── constant_pool.ts # ★⑦ 常量池:把重复的字面量提升为共享常量
2.2.1 这条流水线的设计哲学:分三层 AST
注意一个关键结构:编译过程中 AST 换了三次形态。
| 层 | AST 类型 | 谁产生的 | 表达的是什么 |
|---|---|---|---|
| HTML 层 | HTML AST(ml_parser/ast.ts) | ml_parser | 纯粹的 HTML 结构,不懂 Angular 语义 |
| Angular 层 | R3 AST(render3/r3_ast.ts) | r3_template_transform | 带 Angular 语义:BoundText、BoundAttribute、IfBlock、DeferredBlock |
| 输出层 | 输出 AST(output/output_ast.ts) | template/pipeline 的 emit | TypeScript/JS 代码本身(函数调用、if 语句、变量声明) |
为什么要分这么多层?因为每一层要解决的问题不同:
- HTML 层只关心"标签闭合了吗、属性写法合法吗"——它甚至可以是 XML 模式(SVG/MathML 用
xml_parser.ts); - R3 层关心"这是个插值吗、这是
@if块吗、这个属性是 DOM 属性还是指令输入"——它需要 Angular 的领域知识; - 输出层关心"生成什么样式的代码最优"——它已经不关心 HTML 了,只关心 JS 表达式和语句。
中间还夹着一层 IR(中间表示),是 Angular 17 引入的新架构。IR 的作用是把"语义分析"和"代码生成"彻底分开:先 ingest 成 IR,然后几十个独立的变换阶段依次加工 IR,最后 emit 成输出 AST。这种"多趟(multi-pass)变换"的架构,让每个优化都是独立、可测试、可增删的小函数——这也是为什么 phases/ 目录下能有 74 个文件。
2.2.2 一个贯穿全局的角色:ConstantPool
constant_pool.ts 值得单独点名,因为它贯穿整条流水线。它的职责是:
A constant pool allows a code emitter to share constants in an output context. The constant pool also supports sharing access to ivy definitions references.
简单说:编译器在生成代码时会产生大量字面量(选择器字符串、属性名数组、样式……),如果每个组件都内联一份,产物会又大又重复。ConstantPool 把它们收集起来,提升成顶层的共享常量变量,多个地方引用同一个变量。
它有几个设计细节很有意思:
const CONSTANT_PREFIX = '_c'; // 生成的常量变量名前缀,你会在产物里看到 _c0、_c1、_c2...
/**
* Generally all primitive values are excluded from the `ConstantPool`, but there is an exclusion
* for strings that reach a certain length threshold. This constant defines the length threshold for
* strings.
*/
const POOL_INCLUSION_LENGTH_THRESHOLD_FOR_STRINGS = 50;
注意这个"50":短字符串不值得提升(_c0 = 'div' 反而比直接写 'div' 更占空间,还得付出一次变量查找),只有长度 ≥ 50 的字符串才进池。这是编译器里典型的"优化也要算账"——不是所有常量都该提升,得权衡。
还有那个 FixupExpression 类,它解决的是一个鸡生蛋问题:
/**
* A node that is a place-holder that allows the node to be replaced when the actual
* node is known.
*
* This allows the constant pool to change an expression from a direct reference to
* a constant to a shared constant.
*/
class FixupExpression extends o.Expression {
shared = false;
// ...
fixup(expression: o.Expression) {
this.resolved = expression;
this.shared = true;
}
}
编译器在生成代码时,一开始不知道某个值最终会不会被共享(可能先内联了,后来发现还有别处也用了同一个值)。于是先发一个 FixupExpression 占位符,等所有代码都生成完、池子收集齐了,再回头 fixup() 把它替换成指向共享常量的引用。"先占位、后回填"是编译器处理未知决策的经典手法。
你在编译产物里看到的那些 _c0、.consts 数组,背后就是这套机制。
2.3 第一步:模板解析 —— 从字符串到 HTML AST
编译的第一步是把模板字符串变成结构化的树。这一步由 ml_parser/("ml" = markup language)完成,它分两个经典阶段:词法分析(lex) 和 语法分析(parse)。
2.3.1 词法分析:字符流 → Token 流
ml_parser/lexer.ts 负责把字符流切成有意义的 token。对 <span class="x">Hello</span>,它会产出:
TAG_OPEN_START (<span)
ATTR_NAME (class)
ATTR_QUOTE (")
ATTR_VALUE_TEXT (x)
ATTR_QUOTE (")
TAG_OPEN_END (>)
TEXT (Hello)
TAG_CLOSE (</span>)
EOF
词法分析器是状态机驱动的,它要处理 HTML 的各种古怪之处:
<div>、<br/>、<input>(void 元素,没有闭合标签);- 属性值可以单引号、双引号、无引号;
- 注释
<!-- -->、CDATA、DOCTYPE; - HTML 实体
&{(entities.ts负责解码); - 插值
{{...}}要作为特殊 token 识别出来(不能当普通文本); - 扩展操作符(控制流
@if的@、})也要识别。
有个细节体现了 Angular 的严谨:ml_parser/html_tags.ts 和 tags.ts 维护了标签元数据表——哪些标签是自闭合的(img、br、input),哪些属性是特殊的。这些元数据决定了解析器"看到一个 <img> 时,要不要等它的闭合标签"。HTML 的宽容性(浏览器能容错各种错误写法)在这套表里被显式建模了。
另外 xml_parser.ts 的存在说明:SVG 和 MathML 是 XML 方言,解析规则比 HTML 严格(比如 <path /> 必须自闭合)。所以 Angular 在解析 SVG 内部的内容时会切到 XML 模式。这就是为什么 <svg> 内部的模板写法要和普通 HTML 略有不同,根源在解析器模式切换。
2.3.2 语法分析:Token 流 → HTML AST
ml_parser/parser.ts 消费 token 流,产出 HTML AST(ml_parser/ast.ts)。节点类型很朴素:
Element(元素,含name、attrs、children)Text(文本)Comment(注释)Attribute(属性)Expansion(插值{{...}},含ExpansionCase,用于 i18n 的 ICU 表达式)Block系列(控制流块,由@语法产生)
这一层完全不懂 Angular 语义。对它来说,*ngIf="cond"、[title]="x"、@if (cond) {} 都只是"带特殊名字的属性/块"而已。它只保证一件事:树的结构是对的(标签正确嵌套、闭合匹配)。
语法分析器还负责 html_whitespaces.ts 里的空白处理——默认情况下 Angular 会折叠模板里的多余空白(这就是为什么你可以在模板里随意缩进而不影响渲染)。有个 preserveWhitespaces 选项可以关掉这个行为(第 1 章 §1.18 提到它在 ɵcmp 里有个字段)。
2.3.3 输出:一棵纯 HTML 树
对 Hello {{name}}! 这段模板,ml_parser 的产出是:
Element(span)
└── Text("Hello ")
└── Expansion / Interpolation: {{ name }}
└── Text("!")
注意此时 {{name}} 里的 name 还只是一个字符串,没有被解析成表达式。表达式解析是下一层的事。这种"分层"是刻意的:HTML 解析器不该懂 JavaScript。
2.4 第二步:R3 AST —— 注入 Angular 语义
HTML AST 只是"结构正确",还缺少 Angular 的灵魂:哪个属性是绑定?哪个是事件?@if 块的条件表达式是什么?这一步由 render3/r3_template_transform.ts 完成,产出 R3 AST(render3/r3_ast.ts)。
2.4.1 R3 AST 的节点家族
r3_ast.ts 里定义的节点类型,就是 Angular 模板语义的完整清单:
文本与元素:
export class Text implements Node { ... } // 静态文本
export class BoundText implements Node { ... } // 含插值的文本:Hello {{name}}
export class TextAttribute implements Node { ... } // 静态属性:class="x"
export class Element implements Node { ... } // 元素
export class Template implements Node { ... } // 模板(<ng-template>)
绑定:
export class BoundAttribute implements Node { ... } // 属性绑定:[title]="x" 或 指令输入
export class BoundEvent implements Node { ... } // 事件绑定:(click)="do()"
控制流块(Angular 17+ 的新语法):
export class IfBlock extends BlockNode implements Node { ... } // @if
export class IfBlockBranch extends BlockNode implements Node { ... } // @else if / @else
export class ForLoopBlock extends BlockNode implements Node { ... } // @for
export class ForLoopBlockEmpty extends BlockNode implements Node { ... } // @empty
export class SwitchBlock extends BlockNode implements Node { ... } // @switch
export class SwitchBlockCase extends BlockNode implements Node { ... }// @case
export class DeferredBlock extends BlockNode implements Node { ... } // @defer
export class DeferredBlockPlaceholder ... // @placeholder
export class DeferredBlockLoading ... // @loading
export class DeferredBlockError ... // @error
看到这份清单,你会发现一件重要的事:@if / @for / @switch / @defer 在编译器的 AST 层面就是一等节点了。而在老语法(*ngIf、*ngFor)时代,它们只是"碰巧叫 ngIf 的指令",编译器要靠名字约定去识别。新语法把它们提升为语言级构造,带来的好处是:
- 编译器能针对性地优化(第 13 章讲
@for的 reconciliation 时会看到); - 能做精确的类型检查;
- 不需要注册指令就能用;
- 能生成更紧凑的代码。
这也解释了第 1 章 §1.9 提到的那行:部分编译时,如果模板里出现了任何块(block),就要把 minVersion 设为 17.0.0——因为只有 17.0.0 以上的 linker 才认识这些新语法。
2.4.2 表达式解析:expression_parser
R3 AST 里的绑定(BoundAttribute、BoundEvent、BoundText)都包含一个表达式 AST,由 expression_parser/ 解析。
{{ user.name + '!' }} 会被解析成:
Binary(+)
├── PropertyRead(user.name)
│ ├── PropertyRead(user)
│ └── name
└── LiteralPrimitive('!')
表达式解析器是一个完整的 JavaScript 子集解析器——它处理:
- 二元运算(
+ - * / && || ??)、一元运算(! -); - 三元条件
a ? b : c; - 属性访问
a.b、a?.b(安全导航)、a['b']; - 方法调用
fn(x); - 管道
x | uppercase; - 特殊的
$event(事件对象); - 键值对象/数组字面量。
它刻意不是完整的 JS 解析器。Angular 模板表达式是 JS 的一个受限子集:不支持赋值、不支持 new、不支持语句、不支持函数声明。这不是偷懒,是安全与性能的双重考量:
- 模板表达式不能产生副作用(否则变更检测会失控);
- 受限语法让类型检查(模板类型检查器)可以精确推断;
- 受限语法让代码生成更简单、更快。
expression_parser/lexer.ts 里还有一类特殊处理:$event、$any() 这些"魔法"标识符,以及管道符号 | 的优先级处理。
2.4.3 绑定解析:binding_parser.ts —— 三选一的判定
template_parser/binding_parser.ts 是整个编译链路里最"裁决性"的一环。它要回答一个问题:模板里的一个属性,到底是三种东西里的哪一种?
以 <div [title]="x" (click)="y" role="button"> 为例:
| 写法 | 判定结果 | 依据 |
|---|---|---|
[title]="x" | 属性绑定(Property Binding) | 方括号语法 |
(click)="y" | 事件绑定(Event Binding) | 圆括号语法 |
role="button" | 静态属性(TextAttribute) | 无特殊语法 |
[dir.title]="x" | 指令输入绑定 | 带指令前缀 |
[(ngModel)]="v" | 双向绑定 | 香蕉盒语法 [()] |
animate.enter="..." | 动画绑定 | 特殊前缀 |
这个判定不能只靠语法——还需要知道这个元素上匹配了哪些指令。因为 [title] 如果碰巧是某个指令的 @Input() title,它就应该被当作指令输入处理,而不是 DOM 属性。所以绑定解析需要和"指令匹配"配合,这正是下一节的主题。
template_parser/template_preparser.ts 则是一个轻量预扫描器:在做完整解析之前,先快速扫一遍看有没有结构性指令(如 ngIf、ngFor)或 <ng-template>,决定后续解析策略。这是编译器的常见优化——用一次廉价的扫描避免昂贵的完整解析。
2.5 第三步:指令匹配与类型检查
模板解析完了,编译器还不知道一个关键信息:<my-card> 这个元素上,到底匹配了哪些指令? 这决定了:
[title]是 DOM 属性还是某个指令的输入?- 这个元素需要多少个
LView槽位(EXPANDO 段,见第 1 章 §1.11)? - 该元素上的 provider 有哪些?
这一步由 render3/view/t2_binder.ts 完成。
2.5.1 R3TargetBinder:把模板和指令表对上
export function findMatchingDirectivesAndPipes(template: string, directiveSelectors: string[]) { ... }
export class R3TargetBinder<DirectiveT extends DirectiveMeta> implements TargetBinder<DirectiveT> { ... }
R3TargetBinder 的职责是:给定"这个组件可见的指令/管道集合"(来自它的 imports),遍历模板的元素,用选择器匹配算法(render3/../directive_matching.ts)找出每个元素上匹配的指令。
选择器匹配本身是个有意思的算法问题。Angular 的选择器支持:
my-card /* 元素选择器 */
[highlight] /* 属性选择器 */
.highlight /* class 选择器 */
my-card[highlight] /* 组合 */
:not(.disabled) /* 否定 */
a,b /* 列表 */
匹配器要把这些 CSS 风格的选择器和模板元素对上。它用的是先否定、后正向的策略:先排除掉所有 :not(...) 匹配失败的,再在剩下的里找最具体的。
2.5.2 匹配结果如何流入后续编译
匹配完成后,每个元素上会挂上"匹配的指令列表"。这些信息会:
- 决定绑定归属:
[title]若命中某个指令的输入,就生成指令输入绑定(走ɵɵproperty的指令分支);否则当 DOM 属性处理; - 决定是否需要指令匹配代码:如果一个组件完全没有指令依赖(standalone 且
imports为空),编译器可以切换到一种特殊模式——这一点极其重要,下一节详述; - 生成
dependencies数组:组件定义里的dependencies: [Foo, Bar]就是这么来的; - 支撑模板类型检查:IDE 里的模板报错,靠的就是"这个元素匹配了哪个指令 → 那个指令有哪些输入 → 你传的类型对不对"这条链。
2.5.3 顺带说清:模板类型检查是怎么做到的
Angular 的模板类型检查(你在 IDE 里看到的"Property 'nmae' does not exist")不是编译器前端做的,而是一个巧妙的**"用 TypeScript 检查 TS 代码"的技巧**:
编译器把你的模板反向生成成一段 TypeScript 代码(Type Check Block,TCB),比如把 {{user.nmae}} 生成为:
function tcb(ctx: MyComponent) {
// 编译器生成的检查代码
_t1 = ctx.user.nmae; // TypeScript 会在这里报错:Property 'nmae' does not exist
}
然后交给 TypeScript 编译器去检查这段生成的代码。TS 报错了,Angular 就把错误位置映射回模板的对应行列。用现成的类型系统检查 DSL,而不是自己实现一个类型检查器——这是个非常聪明的工程决策,代价小、准确度高、还能随 TS 升级自动受益。
这段逻辑在 compiler/src/template/typecheck/ 和 compiler-cli/src/ngtsc/typecheck/ 里。本系列不单开一章讲它,但你至少要知道:模板里的红线,本质是 TypeScript 在检查编译器生成的"影子代码"。
2.6 第四步:IR 流水线 —— Angular 17+ 的编译器架构
从这里开始进入本章最硬核的部分。Angular 17 重写了编译器的代码生成部分,引入了 IR(Intermediate Representation,中间表示)+ 多趟变换的架构。源码在 packages/compiler/src/template/pipeline/。
2.6.1 为什么需要 IR
在 IR 之前,编译器是"一遍扫描直接生成代码"的:遍历 AST 的同时就吐出 ɵɵxxx 调用。这种做法的问题是优化无从下手:
- 你想做"把相邻的
advance合并",但等你发现可以合并时,前一条指令已经生成了; - 你想做"常量提升",但生成指令时还不知道后面会不会有相同常量;
- 你想做"重排指令顺序以复用变量",但代码顺序已经定了。
IR 架构的解法是:把"生成"推迟到最后,中间用几十趟独立的变换来优化。
R3 AST
│ ingest(吸入)
▼
IR(一组 op 的列表,可被任意重排、合并、改写)
│ transform(57 趟变换,每趟只做一件事)
▼
优化后的 IR
│ emit(发射)
▼
输出 AST → TypeScript 代码
每一趟变换都是一个纯函数(接收 job,就地修改),独立可测试。phases/ 目录下有 74 个文件——这就是这套架构的"优化清单"。
2.6.2 三种编译任务:CompilationJobKind
// template/pipeline/src/compilation.ts
export enum CompilationJobKind {
Tmpl, // 模板编译(组件的模板)
Host, // 宿主绑定编译(@HostBinding、host: {} 里的绑定)
Both, // 特殊值:表示"两种任务都适用"的阶段
}
一个组件的编译实际上包含两个编译任务:
- Tmpl:组件模板本身的编译 → 生成
ɵcmp.template函数; - Host:组件宿主元素上的绑定(
@HostBinding('class.x')或host: {'[class.x]': 'expr'})→ 生成ɵcmp.hostBindings函数。
两者共用同一套 IR 设施,但阶段清单不同:transform(job, kind) 会只跑标注为该 kind(或 Both)的阶段。
2.6.3 两种编译模式:TemplateCompilationMode
这是一个能显著提升 standalone 组件性能的优化,值得细看:
export enum TemplateCompilationMode {
/** Supports the full instruction set, including directives. */
Full,
/** Uses a narrower instruction set that doesn't support directives and allows optimizations. */
DomOnly,
}
判定逻辑在 compileComponentFromMetadata 里:
const compilationMode =
meta.isStandalone && !meta.hasDirectiveDependencies
? TemplateCompilationMode.DomOnly
: TemplateCompilationMode.Full;
如果一个组件是 standalone 且没有任何指令依赖(没有 imports 指令、模板里不依赖任何指令),就用 DomOnly 模式编译。 这种模式下:
- 生成的指令集更窄(不需要匹配指令、不需要 EXPANDO 段的指令实例化逻辑);
- 可以做更多优化(因为没有"可能有指令会来改这个元素"的顾虑)。
这带来的实战启示很直接:standalone 组件如果不用指令,模板编译产物会更小更快。而绝大多数"纯展示型"组件(只有 HTML + 插值)恰好属于这一类。这是 Angular 推动 standalone 的又一份红利——不只是写法简化,连编译产物都更优。
2.6.4 主流程源码:compileComponentFromMetadata
现在看完整编译组件的函数本体(render3/view/compiler.ts)。这个函数不长,却是整个编译链路的"总装车间":
export function compileComponentFromMetadata(
meta: R3ComponentMetadata<R3TemplateDependency>,
constantPool: ConstantPool,
bindingParser: BindingParser,
): R3CompiledExpression {
const definitionMap = baseDirectiveFields(meta, constantPool, bindingParser);
addFeatures(definitionMap, meta);
// e.g. `template: function MyComponent_Template(_ctx, _cm) {...}`
const templateTypeName = meta.name;
// ① @defer 依赖函数的处理(整组件级别)
let allDeferrableDepsFn: o.ReadVarExpr | null = null;
if (meta.defer.mode === DeferBlockDepsEmitMode.PerComponent && meta.defer.dependenciesFn !== null) {
const fnName = `${templateTypeName}_DeferFn`;
constantPool.statements.push(
new o.DeclareVarStmt(fnName, meta.defer.dependenciesFn, undefined, o.StmtModifier.Final),
);
allDeferrableDepsFn = o.variable(fnName);
}
// ② 决定编译模式(DomOnly vs Full,见 §2.6.3)
const compilationMode =
meta.isStandalone && !meta.hasDirectiveDependencies
? TemplateCompilationMode.DomOnly
: TemplateCompilationMode.Full;
// ③ First the template is ingested into IR:
const tpl = ingestComponent(
meta.name, meta.template.nodes, constantPool, compilationMode,
meta.relativeContextFilePath, meta.i18nUseExternalIds, meta.defer,
allDeferrableDepsFn, meta.relativeTemplatePath,
meta.enableTemplateSourceLocations || ENABLE_TEMPLATE_SOURCE_LOCATIONS,
meta.legacyOptionalChaining, meta.foreignImports,
);
// ④ Then the IR is transformed to prepare it for code generation.
transform(tpl, CompilationJobKind.Tmpl);
// ⑤ Finally we emit the template function:
const templateFn = emitTemplateFn(tpl, constantPool);
// ⑥ 把结果写进定义对象
if (tpl.contentSelectors !== null) {
definitionMap.set('ngContentSelectors', tpl.contentSelectors);
}
definitionMap.set('decls', o.literal(tpl.root.decls as number)); // ★ decls 在这里定下来
definitionMap.set('vars', o.literal(tpl.root.vars as number)); // ★ vars 在这里定下来
if (tpl.consts.length > 0) {
// 常量数组:如果有初始化器就包成箭头函数(惰性),否则直接字面量数组
if (tpl.constsInitializers.length > 0) {
definitionMap.set('consts', o.arrowFn([],
[...tpl.constsInitializers, new o.ReturnStatement(o.literalArr(tpl.consts))],
o.DYNAMIC_TYPE));
} else {
definitionMap.set('consts', o.literalArr(tpl.consts));
}
}
definitionMap.set('template', templateFn);
// ...
}
短短几十行,把三个阶段(ingest / transform / emit)串成一条清晰的主线。注意那句注释 // First the template is ingested into IR / // Then the IR is transformed... / // Finally we emit...——编译器作者把架构写在注释里了,读源码时这些注释是最好的路标。
另外注意 ⑥ 里 consts 的两种发射形态:如果常量里有"需要初始化器"的(比如要 new 一个对象),就把整个 consts 包进一个箭头函数(惰性求值,首次访问时才建);否则直接是字面量数组(更快)。这又是一次"编译期决策"——带初始化器的东西不能静态求值,那就延迟到运行时。
2.7 变换阶段详解:57 趟变换都在干什么
transform() 的实现极简(emit.ts):
export function transform(job: CompilationJob, kind: Kind): void {
for (const phase of phases) {
if (phase.kind === kind || phase.kind === Kind.Both) {
phase.fn(job as CompilationJob & ComponentCompilationJob & HostBindingCompilationJob);
}
}
}
就是一个 for 循环,按数组顺序执行每个阶段。真正的复杂度藏在 phases 数组的顺序和内容里。下面我把这 57 个(对 Tmpl 而言)阶段按"它们在干什么"分组讲一遍。这部分是本章最有价值的内容之一——它告诉你 Angular 编译器究竟做了哪些优化。
2.7.1 第一组:语义规范化(把特殊语法降到统一的 IR)
resolveForeignContent // 处理跨组件边界的内容
removeContentSelectors // 移除不需要的 content selector
optimizeRegularExpressions // 正则优化
emitNamespaceChanges // SVG/MathML 命名空间切换(ɵɵnamespaceSVG 等)
propagateI18nBlocks // i18n 块传播
wrapI18nIcus // i18n ICU 表达式包装
deduplicateTextBindings // ★ 去重:相同的文本绑定只留一份
specializeStyleBindings // ★ 样式绑定特化([style.color] → 专用指令)
specializeBindings // ★ 绑定特化(通用绑定 → 更专用的指令)
specializeControlProperties // 控制流属性特化
convertAnimations // 动画绑定转换
extractAttributes // 属性提取(静态属性 → consts 数组)
createI18nContexts // 创建 i18n 上下文
parseExtractedStyles // 解析提取出的样式
removeEmptyBindings // 移除空绑定
collapseSingletonInterpolations// ★ 单插值折叠:{{a}} → ɵɵtextInterpolate1
这一组的核心工作是把高层语法降级(lower)成低层、更专用的形式。最有代表性的是 collapseSingletonInterpolations——这正是第 1 章 §1.10.3 讲的:模板里 Hello {{name}}! 这种"只有一个插值"的文本,会被折叠成专用的 ɵɵtextInterpolate1,而不是通用的插值处理函数。
同理 specializeBindings 会把通用的属性绑定,根据类型(DOM 属性 / 指令输入 / class / style / 动画)替换成更专用的指令调用。"特化"是编译器优化的经典手法:通用的慢路径留给少数情况,常见的快路径专门生成。
2.7.2 第二组:控制流与结构
orderOps // 重排 op 顺序(为后续优化铺路)
generateConditionalExpressions // @if/@switch → 条件表达式
generateBoundaryConditions // 边界条件(@for 的边界等)
createPipes // 管道实例化(ɵɵpipe)
configureDeferInstructions // @defer 指令配置
insertIncrementalHydrationRuntime // ★ 增量水合运行时插入
createVariadicPipes // 可变参数管道
generateArrowFunctions // 需要惰性的地方生成箭头函数
generatePureLiteralStructures // ★ 纯字面量结构(直接内联,不做纯函数调用)
generateProjectionDefs // 内容投影定义(ɵɵprojectionDef)
generateLocalLetReferences // @let 局部引用
generateVariables // 变量生成
resolveBoundaries // 解析视图边界
saveAndRestoreView // ★ 保存/恢复视图上下文(嵌套视图切换时)
注意 orderOps 这个阶段排在很前面——它先把 op 排好序,后续阶段才能做"合并相邻的 advance"这类依赖顺序的优化。编译器里阶段顺序本身就是设计的一部分。
generatePureLiteralStructures 是又一个"算账"的优化:如果一个管道的参数全是字面量({{ 'x' | uppercase }}),那它永远不变,直接内联结果,连纯函数调用都省了。
2.7.3 第三组:名称与上下文解析
deleteAnyCasts // 删除 $any() 的类型转换(运行时不需要)
removeSafeNavigationMigration // 安全导航迁移处理
resolveDollarEvent // ★ $event 解析
generateTrackVariables // @for 的 track 变量
removeIllegalLetReferences // 非法 @let 引用检查
resolveNames // ★ 名称解析(局部变量、上下文引用 → 具体的读取表达式)
resolveDeferTargetNames // @defer 目标名称
transformTwoWayBindingSet // 双向绑定的 set 变换
optimizeTrackFns // @for track 函数优化
resolveContexts // ★ 上下文解析(ctx.x 还是 $event.x → 生成正确的读取路径)
resolveSanitizers // ★ 消毒器解析(哪些绑定需要 XSS 消毒)
liftLocalRefs // 本地引用提升
expandSafeReads // 安全读取展开(a?.b → a == null ? null : a.b)
stripNonrequiredParentheses // 去掉多余括号(产物更好看、解析更快)
resolveContexts 是理解模板函数的关键:模板里写 {{name}},但组件可能嵌套在 @for 里,此时 name 可能来自 ctx 也可能来自 for 循环的局部变量。这个阶段决定生成 ctx.name 还是 ɵɵreadContextLet(...) 之类。
resolveSanitizers 则是安全相关的:Angular 会自动识别哪些绑定可能引入 XSS(如 [innerHTML]),并插入消毒调用。这是 Angular "默认安全"的保障之一(第 12 章会展开)。
2.7.4 第四组:i18n 专用
convertI18nText // i18n 文本转换
convertI18nBindings // i18n 绑定转换
removeUnusedI18nAttributesOps // 移除未使用的 i18n 属性
assignI18nSlotDependencies // i18n 槽位依赖
applyI18nExpressions // 应用 i18n 表达式
resolveI18nElementPlaceholders // i18n 元素占位符
resolveI18nExpressionPlaceholders // i18n 表达式占位符
extractI18nMessages // ★ 提取 i18n 消息(供翻译)
collectI18nConsts // i18n 常量收集
resolveI18nAttrSanitizers // i18n 属性消毒
i18n 占了 9 个阶段,可见其复杂度。第 19 章会专门讲。
2.7.5 第五组:★ 槽位与变量计数(decls / vars 的来源)
这一组是第 1 章留下的悬念的答案。
// template/pipeline/src/phases/slot_allocation.ts
export function allocateSlots(job: ComponentCompilationJob): void {
// Process all views in the component and assign slot indexes.
for (const unit of job.units) {
// Slot indices start at 0 for each view (and are not unique between views).
let slotCount = 0;
for (const op of unit.create) {
// Only consider declarations which consume data slots.
if (!ir.hasConsumesSlotTrait(op)) {
continue;
}
// Assign slots to this declaration starting at the current `slotCount`.
op.handle.slot = slotCount;
// Each declaration may use more than 1 slot, so increment `slotCount` to reserve the number
// of slots required.
slotCount += op.numSlotsUsed;
}
// Record the total number of slots used on the view itself...
}
}
这就是 decls 的算法:
- 遍历视图的 create 阶段的所有 op;
- 只考虑"消耗槽位"的 op(即会创建 DOM 节点/管道/本地引用的那些,实现了
ConsumesSlotOpTrait); - 给它们从 0 开始顺序分配槽位号(
op.handle.slot = slotCount); - 每个 op 可能占多个槽位(
numSlotsUsed),累加; - 最终
slotCount就是这个视图的decls。
"每个视图的槽位号都从 0 开始,且视图之间不唯一"——这句注释很重要:第 1 章 §1.11.2 说 LView 的索引是"模板内的逻辑索引",正是因为每个视图(根视图 + 每个嵌入式视图)都独立从 0 编号。这也是为什么 ɵɵelementStart(0, ...) 里的 0 在 @if 块内外都可能出现在不同的视图里。
// template/pipeline/src/phases/var_counting.ts
export function countVariables(job: CompilationJob): void {
// First, count the vars used in each view, and update the view-level counter.
for (const unit of job.units) {
let varCount = 0;
// Count variables on top-level ops first. Don't explore nested expressions just yet.
for (const op of unit.ops()) {
if (ir.hasConsumesVarsTrait(op)) {
varCount += varsUsedByOp(op);
}
}
// Count variables on expressions inside ops. We do this later because some of these expressions
// might be conditional (e.g. `pipeBinding` inside of a ternary), and we don't want to interfere
// with indices for top-level binding slots (e.g. `property`).
// ...
}
}
这是 vars 的算法,注意它的两趟计数(注释解释得很清楚):
- 先算顶层 op 的变量:确保顶层绑定的槽位号连续、可预测;
- 再算表达式内部的变量:因为有些表达式是条件性的(比如三元表达式里的管道绑定),如果混在一起算,会打乱顶层绑定的索引分配。
"先确定主干、再填充细节"——这种顺序保证了最常用、最热的绑定拿到最紧凑的槽位。
2.7.6 第六组:★ 生成 ɵɵadvance
// phases/generate_advance.ts(排在 countVariables 之后)
generateAdvance
第 1 章 §1.10.3 说 ɵɵadvance() 是"游标前进"指令。它就是在这一趟生成的:编译器遍历 update 阶段的 op,发现"当前 op 操作的节点,和上一个 op 不是同一个"时,就在两者之间插入一条 ɵɵadvance()。
注意它的执行顺序必须在 countVariables 之后——因为要先知道每个 op 对应哪个槽位,才能判断"是否需要 advance"。阶段顺序的这种依赖关系,在 74 个阶段里到处都是,也是这套架构最难维护的地方(改一个阶段的顺序可能悄悄破坏另一个阶段的假设)。
2.7.7 第七组:收尾与发射准备
nameFunctionsAndVariables // 给函数和变量命名(Cmp_Template、_r0 等)
resolveDeferDepsFns // @defer 依赖函数解析
mergeNextContextExpressions // ★ 合并连续的 nextContext(ɵɵnextContext(2) 而不是两次 1)
generateNgContainerOps // ng-container 处理(不需要真实 DOM)
collapseEmptyInstructions // 折叠空指令
attachSourceLocations // ★ 附加源码位置(供 sourcemap / 调试)
disableBindings // 禁用绑定(@defer 等场景)
extractPureFunctions // ★ 纯函数提取(管道 → ɵɵpureFunctionX)
reify // ★ 实体化:把 IR op 变成真正的指令调用表达式
chain // ★ 链式优化:ɵɵproperty 链式调用
三个值得展开:
mergeNextContextExpressions:模板里 {{a.b.c}} 如果 a 在外层视图,可能需要两次 ɵɵnextContext(1)。这个阶段把它们合并成一次 ɵɵnextContext(2)。少一次函数调用 = 更快。
reify(实体化):这是 IR → 指令调用的关键一步。在此之前,IR 里的 op 还是"抽象操作"("我要绑定 title 属性");reify 把它们变成具体的 ɵɵproperty('title', ctx.x) 调用表达式。名字 "reify"(使抽象事物具体化)起得很贴切。
chain(链式):这是产物里最显眼的优化之一。看这段产物:
ɵɵproperty("title", ctx.a)("id", ctx.b)("class", ctx.c);
注意是链式调用(一个函数调用后面直接跟括号),而不是三次独立的 ɵɵproperty(...) 语句。chain 阶段把连续的同类指令调用改成链式,好处是:
- 产物更小(少写几次
ɵɵproperty); - V8 对这种"单态调用点"的优化更友好。
这就是为什么 Angular 的编译产物看起来那么"压缩"——它是被 57 趟优化反复打磨过的。
2.7.8 阶段顺序的"潜规则"
把这 57 趟按顺序排完,你会发现一条清晰的脉络:
语义规范化 → 控制流结构化 → 名称/上下文解析 → i18n → 槽位分配 → advance 生成 → 命名 → 收尾优化 → 实体化 → 链式
先做语义转换,再做布局决策(槽位/变量),最后做代码形态优化。 这个顺序不是随意的:
- 槽位必须在所有节点确定之后才能分配(所以
allocateSlots排在中后段); advance必须在槽位之后(依赖槽位号判断);reify/chain必须在最后(它们是"落地成代码"的动作,之后就没法再优化 IR 了)。
理解这条脉络,你就能预判"如果我要加一个新优化,应该插在哪一趟"。这也是读编译器源码时最有价值的收获之一——不是记住 74 个文件名,而是理解它们的编排逻辑。
2.8 发射阶段:两阶段函数是怎么被"拼"出来的
IR 优化完了,最后一步是把它变成真正的 TypeScript/JS 代码。这一步在 template/pipeline/src/emit.ts。
2.8.1 emitTemplateFn:一个组件 = 一个根函数 + N 个子视图函数
export function emitTemplateFn(tpl: ComponentCompilationJob, pool: ConstantPool): o.FunctionExpr {
const rootFn = emitView(tpl.root); // 根视图函数
emitChildViews(tpl.root, pool); // 递归发射子视图
return rootFn;
}
function emitChildViews(parent: ViewCompilationUnit, pool: ConstantPool): void {
for (const unit of parent.job.units) {
if (unit.parent !== parent.xref) continue;
// Child views are emitted depth-first.
emitChildViews(unit, pool);
const viewFn = emitView(unit);
pool.statements.push(viewFn.toDeclStmt(viewFn.name!)); // 子视图函数放进常量池的 statements
}
}
这解释了编译产物里一个常见现象:如果你在模板里用了 @if,产物里除了 Cmp_Template 主函数,还会多出 Cmp_Conditional_0_Template 之类的额外函数。那些就是嵌入式视图(embedded view)的函数,在这里被作为独立的语句放进 ConstantPool.statements,最终输出到模块顶层。
顺序是深度优先:先递归发射子视图的子视图,再发射自己。这保证了函数声明的顺序符合"被引用前已定义"的要求(至少在大多数情况下)。
2.8.2 emitView:把 create/update 两个列表装进 if 块
function emitView(view: ViewCompilationUnit): o.FunctionExpr {
if (view.fnName === null) {
throw new Error(`AssertionError: view ${view.xref} is unnamed`);
}
// 收集 create 阶段的语句
const createStatements: o.Statement[] = [];
for (const op of view.create) {
if (op.kind !== ir.OpKind.Statement) {
throw new Error(
`AssertionError: expected all create ops to have been compiled, but got ${ir.OpKind[op.kind]}`,
);
}
createStatements.push(op.statement);
}
// 收集 update 阶段的语句
const updateStatements: o.Statement[] = [];
for (const op of view.update) {
if (op.kind !== ir.OpKind.Statement) { /* 同上断言 */ }
updateStatements.push(op.statement);
}
// ★ 生成两个 if 块
const createCond = maybeGenerateRfBlock(1, createStatements);
const updateCond = maybeGenerateRfBlock(2, updateStatements);
return o.fn(
[new o.FnParam(RENDER_FLAGS, o.NUMBER_TYPE), new o.FnParam(CONTEXT_NAME, o.DYNAMIC_TYPE)],
[...createCond, ...updateCond],
o.DYNAMIC_TYPE,
undefined,
view.fnName,
);
}
这段代码把第 1 章 §1.10.3 的"两阶段模型"落实成了真实的函数结构。三个细节:
① 断言"所有 op 都已被编译"。 if (op.kind !== ir.OpKind.Statement) throw ...——经过 reify 阶段后,每个 op 都应该已经变成 Statement 形态(真正可发射的代码)。如果还有 op 停留在抽象形态,说明某个阶段漏了工作,直接抛错而不是生成错误代码。这是编译器健壮性的体现:宁可崩溃,也不静默产出错误代码。
② 两个 maybeGenerateRfBlock(1, ...) 和 (2, ...)。看它的实现:
function maybeGenerateRfBlock(flag: number, statements: o.Statement[]): o.Statement[] {
if (statements.length === 0) {
return []; // ★ 空的就不生成 if 块
}
return [
o.ifStmt(
new o.BinaryOperatorExpr(
o.BinaryOperator.BitwiseAnd,
o.variable(RENDER_FLAGS),
o.literal(flag),
),
statements,
),
];
}
这就生成了 if (rf & 1) {...} 和 if (rf & 2) {...}。注意那个 statements.length === 0 的短路:如果某一段是空的(比如组件没有任何绑定,update 段为空),就完全不生成对应的 if 块。产物里就只剩 if (rf & 1) {...}。这又是一次"编译期决策省运行时开销"——不需要的分支一个字节都不产出。
③ 函数签名 (rf, ctx)。第 1 章看到的模板函数签名就是在这里定的:RENDER_FLAGS(数字类型)和 CONTEXT_NAME(动态类型)。
2.8.3 从 IR 到产物的完整对照
把这一节和前面串起来,一个 @if + 插值的模板:
<div>Hello {{name}}!</div>
@if (show()) {
<p>Extra</p>
}
大致会经历:
| 阶段 | 形态 |
|---|---|
| R3 AST | Element(div) 含 BoundText;IfBlock 含 Element(p) |
| ingest 后 IR | create: [ElementStartOp, TextOp, ElementEndOp, TemplateOp(条件)];update: [InterpolateOp, ConditionalOp] |
| allocateSlots 后 | 根视图 decls=2(div + text);条件视图 decls=1(p) |
| countVariables 后 | 根视图 vars=1(插值) |
| generateAdvance 后 | update 段插入 ɵɵadvance(1) |
| naming 后 | 主函数 Cmp_Template,子视图 Cmp_Conditional_0_Template |
| reify + chain 后 | 具体指令调用,链式合并 |
| emit 后 | function Cmp_Template(rf, ctx) { if (rf & 1) {...} if (rf & 2) {...} } + 顶层 Cmp_Conditional_0_Template 函数 |
这就是从模板到产物的完整旅程。
2.9 常量池深入:产物里那些 _c0 是怎么来的
第 1 章的读者可能已经在产物里见过这种代码:
const _c0 = ["title"];
const _c1 = (a) => ({title: a});
// ...
ɵɵdefineComponent({
consts: [_c0],
template: function Cmp_Template(rf, ctx) { ... }
});
这些 _c0、_c1 就是 ConstantPool 的产物。本节讲它的工作机制。
2.9.1 池子的三个数据结构
export class ConstantPool {
statements: o.Statement[] = []; // 最终输出的顶层语句(常量声明)
private literals = new Map<string, FixupExpression>(); // 字面量 → 占位表达式
private sharedConstants = new Map<string, o.Expression>(); // 已共享的常量
// ...
}
工作流程:
- 编译器某处需要一个常量(比如属性名数组
['title']); - 调用
constantPool.getConstLiteral(expr); - 池子给这个表达式生成一个 key(用
KEY_CONTEXT遍历表达式的"原始形态"而非引用变量,保证相同结构得到相同 key); - 如果 key 已存在 → 返回已有的共享引用(去重);
- 如果不存在 → 创建
FixupExpression占位,登记进literals; - 最后
produceStatements()时,把所有收集到的常量变成顶层的const _cN = ...声明,并回填FixupExpression的引用。
2.9.2 为什么需要 FixupExpression(先占位后回填)
回顾 §2.2.2 提到的那个类。它解决的是一个真实的时序问题:
编译器生成代码是一遍流式的。当它写下第一个 ['title'] 时,它不知道:
- 后面会不会还有一模一样的
['title']?(如果只有一个,内联反而更好) - 这个常量最终会被放在池子的第几号?(
_c0还是_c7?)
所以它先发一个"占位符"(FixupExpression),把所有需要这个常量的地方都指向同一个占位对象。等全部生成完毕,池子知道"总共有 N 个常量、这个排第几、是否被多处共享",再统一 fixup() 把占位符替换成真正的 _cN 引用。
用一次延迟绑定,换取了全局最优决策。 这和链接器(linker)的"符号重定位"思想其实是同一类问题的两种解法。
2.9.3 那个"50"的阈值
const POOL_INCLUSION_LENGTH_THRESHOLD_FOR_STRINGS = 50;
只对长度 ≥ 50 的字符串做池化。原因很实在:
// 不池化(3 字节)
ɵɵelement(0, "div")
// 池化(更差)
const _c0 = "div"; // 声明本身就要十几个字节
ɵɵelement(0, _c0) // 还要一次变量查找
短字符串内联比引用更划算。 池化只在常量足够大、或复用次数足够多时才划算。这个阈值就是那条权衡线。你在产物里看到的 _cN 通常都是数组、大字符串、或函数——正是"值得池化"的那些。
2.9.4 实战:读懂产物里的 consts
组件的 consts 字段通常是这样:
consts: [[3, "title"], ["class", "foo"]]
这个数组是 allocateSlots 之后、collectElementConsts 阶段收集的元素静态属性。它们的索引和 ɵɵelementStart 的槽位号对应。当你调试"某个元素的属性没生效"时,看 consts 数组能快速确认编译器到底把它识别成了什么属性。
2.10 Partial Compilation 深入:ɵɵngDeclareComponent 的真面目
现在回到 §2.1.3 提出的第三条路。库发布时,组件被编译成什么样子?答案是:一个对 ɵɵngDeclareComponent(...) 的调用,参数是一个纯数据对象(元数据),不含任何 ɵɵ 指令。
2.10.1 生成器:compileDeclareComponentFromMetadata
packages/compiler/src/render3/partial/component.ts:
export function compileDeclareComponentFromMetadata(
meta: R3ComponentMetadata<R3TemplateDependencyMetadata>,
template: ParsedTemplate,
additionalTemplateInfo: DeclareComponentTemplateInfo,
): R3CompiledExpression {
const definitionMap = createComponentDefinitionMap(meta, template, additionalTemplateInfo);
const expression = o.importExpr(R3.declareComponent).callFn([definitionMap.toLiteralMap()]);
const type = createComponentType(meta);
return {expression, type, statements: []};
}
对比完整编译的 compileComponentFromMetadata(§2.6.4),差别一目了然:
| 完整编译 | 部分编译 | |
|---|---|---|
| 调用的函数 | ɵɵdefineComponent | ɵɵngDeclareComponent |
| 参数 | 含 template 函数(已生成指令) | 含 template 字符串 |
是否含 ɵɵ 指令 | 是 | 否 |
| 是否含 decls/vars | 是(编译期算好) | 否(留给 linker 算) |
部分编译的产出长这样(概念示意):
MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({
version: "22.2.3",
minVersion: "17.0.0",
type: MyComponent,
selector: "my-card",
inputs: {title: "title"},
template: `<div class="card">{{title}}</div>`, // ← 原始模板字符串!
styles: [".card { padding: 8px }"],
dependencies: [{kind: "component", type: Other, selector: "other-cmp"}],
// ...
});
模板是以字符串形式保留的。 这就是"部分"的含义——编译只做了一半(解析了装饰器元数据、提取了模板字符串、识别了依赖),把真正的代码生成推迟到 linker。
2.10.2 元数据里都有什么
createComponentDefinitionMap 逐个字段填充:
function createComponentDefinitionMap(meta, template, templateInfo) {
const definitionMap = createDirectiveDefinitionMap(meta); // 继承指令的公共字段
const blockVisitor = new BlockPresenceVisitor();
visitAll(blockVisitor, template.nodes);
definitionMap.set('template', getTemplateExpression(template, templateInfo));
if (templateInfo.isInline) {
definitionMap.set('isInline', o.literal(true));
}
// 用了块语法(@if/@for/@defer 等)→ 要求 linker ≥ 17.0.0
if (blockVisitor.hasBlocks) {
definitionMap.set('minVersion', o.literal('17.0.0'));
}
definitionMap.set('styles', toOptionalLiteralArray(meta.styles, o.literal));
definitionMap.set('dependencies', compileUsedDependenciesMetadata(meta));
definitionMap.set('viewProviders', meta.viewProviders);
definitionMap.set('animations', meta.animations);
if (meta.changeDetection !== null) {
definitionMap.set('changeDetection',
o.importExpr(R3.ChangeDetectionStrategy).prop(core.ChangeDetectionStrategy[meta.changeDetection]));
}
if (meta.encapsulation !== core.ViewEncapsulation.Emulated) {
definitionMap.set('encapsulation',
o.importExpr(R3.ViewEncapsulation).prop(core.ViewEncapsulation[meta.encapsulation]));
}
if (template.preserveWhitespaces === true) {
definitionMap.set('preserveWhitespaces', o.literal(true));
}
// ... @defer 依赖处理
return definitionMap;
}
几个值得注意的点:
① BlockPresenceVisitor 与 minVersion: '17.0.0'。 这是个精巧的版本协商机制。看这个 visitor:
class BlockPresenceVisitor extends RecursiveVisitor {
hasBlocks = false;
override visitDeferredBlock(): void { this.hasBlocks = true; }
override visitDeferredBlockPlaceholder(): void { this.hasBlocks = true; }
override visitDeferredBlockLoading(): void { this.hasBlocks = true; }
override visitDeferredBlockError(): void { this.hasBlocks = true; }
override visitIfBlock(): void { this.hasBlocks = true; }
override visitIfBlockBranch(): void { this.hasBlocks = true; }
override visitForLoopBlock(): void { this.hasBlocks = true; }
override visitForLoopBlockEmpty(): void { this.hasBlocks = true; }
override visitSwitchBlock(): void { this.hasBlocks = true; }
override visitSwitchBlockCase(): void { this.hasBlocks = true; }
override visitSwitchBlockCaseGroup(): void { this.hasBlocks = true; }
}
它遍历模板,只要发现了任何一种块语法,就设 minVersion = '17.0.0'。源码注释解释了这个设计的克制:
We don't do this for templates without blocks, in order to preserve backwards compatibility. (对不含块的模板我们不设这个,以保持向后兼容。)
也就是说:老库(用 *ngIf 写的)即使被新版 Angular 编译,也不会被强制要求 17.0.0+ 的 linker,从而能被老版本的应用消费。只有用了新语法的库才"升级要求"。这是版本兼容上的精细权衡——能力升级不该无谓地抬高门槛。
② 默认值字段被省略。 注意 if (meta.encapsulation !== core.ViewEncapsulation.Emulated)——只有当封装模式不是默认的 Emulated 时才写进元数据。同理 changeDetection 为 null(即默认)时也不写。
这样做的好处是元数据更小,而且未来改默认值不会破坏老库。如果 Angular 23 把默认封装从 Emulated 改成别的,老库因为没写这个字段,会自动采用新默认值——而这通常正是期望的行为(库作者写"默认"时,意思是"跟随框架推荐")。"省略即默认"让库的语义能随框架演进而演进,这是向前兼容的一个巧妙设计。
③ dependencies 的格式。 依赖被写成带 kind 的结构:
case R3TemplateDependencyKind.Directive:
dirMeta.set('kind', o.literal(decl.isComponent ? 'component' : 'directive'));
dirMeta.set('type', wrapType(decl.type));
dirMeta.set('selector', o.literal(decl.selector));
dirMeta.set('inputs', toOptionalLiteralArray(decl.inputs, o.literal));
dirMeta.set('outputs', toOptionalLiteralArray(decl.outputs, o.literal));
dirMeta.set('exportAs', toOptionalLiteralArray(decl.exportAs, o.literal));
return dirMeta.toLiteralMap();
case R3TemplateDependencyKind.Pipe:
pipeMeta.set('kind', o.literal('pipe'));
pipeMeta.set('type', wrapType(decl.type));
pipeMeta.set('name', o.literal(decl.name));
return pipeMeta.toLiteralMap();
linker 拿到这些 kind 就知道怎么重建依赖关系。注意 wrapType——根据 declarationListEmitMode,可能要包一层 forwardRef(处理循环引用)。
2.10.3 模板字符串的三种处理
getTemplateExpression 处理三种情况,注释写得极详细:
function getTemplateExpression(template, templateInfo): o.Expression {
// ① 内联且是字面量 → 直接用原表达式,保留 sourcemap
if (templateInfo.inlineTemplateLiteralExpression !== null) {
return templateInfo.inlineTemplateLiteralExpression;
}
// ② 内联但非字面量(通过静态求值得到)→ 建字面量,但无法提供 source span
if (templateInfo.isInline) {
return o.literal(templateInfo.content, null, null);
}
// ③ 外部文件 → 合成一个带 source span 的字面量,指向 .html 文件
const contents = templateInfo.content;
const file = new ParseSourceFile(contents, templateInfo.sourceUrl);
const start = new ParseLocation(file, 0, 0, 0);
const end = computeEndLocation(file, contents);
const span = new ParseSourceSpan(start, end);
return o.literal(contents, null, span);
}
为什么这么讲究? 因为 linker 之后还要重新解析这个模板字符串。如果模板报错(比如标签没闭合),错误信息要能指回原始的 .html 文件或 .ts 文件的正确行列。所以:
- 情况 ① 保留原始字面量表达式 → 直接映射到源文件的精确位置;
- 情况 ③ 外部文件 → 手工合成一个跨越整个文件的 source span(那个
computeEndLocation就是在算"这份内容最后一行第几列")。
编译器的错误处理质量,取决于它保存了多少源码位置信息。 这一段就是对这件事的执着。
2.11 Linker 深入:把半成品"链接"成成品
库发布的是 ɵɵngDeclareComponent({...}) 这种半成品。应用构建时,需要有个东西把它变成 ɵɵdefineComponent({...}) 成品。这个东西就是 linker。
2.11.1 整体架构
应用构建流程
│
├─ 打包器(webpack / esbuild / Vite)读源码
│
├─ Babel 插件(@angular/compiler-cli/linker/babel)拦截 AST
│ │
│ ├─ 发现调用 ɵɵngDeclareComponent(...)
│ │
│ ├─ 提取参数(对象字面量)
│ │
│ ├─ FileLinker.linkPartialDeclaration(...)
│ │ │
│ │ ├─ PartialLinkerSelector 按版本选链接器
│ │ │
│ │ └─ PartialComponentLinkerVersion1.linkPartialDeclaration(...)
│ │ │
│ │ ├─ 重新解析 template 字符串
│ │ ├─ 跑完整的 IR 流水线(ingest/transform/emit)
│ │ └─ 返回 ɵɵdefineComponent({...}) 的 AST
│ │
│ └─ 用新 AST 替换原调用
│
└─ 输出不含 ɵɵngDeclare* 的最终代码
关键点:linker 内部跑的是和 AOT 完全相同的编译器。 它拿到模板字符串后,走的是 parseTemplate → IR 流水线 → emitTemplateFn 这一整套。区别只在于:AOT 是从 .ts 源文件里的装饰器拿元数据,linker 是从一个 JS 对象字面量里读元数据。
这就是为什么"部分编译"能保证产物和本机 AOT 一致——它们共用同一个代码生成器。
2.11.2 FileLinker:单文件链接的主控
packages/compiler-cli/linker/src/file_linker/file_linker.ts:
export class FileLinker<TConstantScope, TStatement, TExpression, TType> {
private linkerSelector: PartialLinkerSelector<TExpression>;
private emitScopes = new Map<TConstantScope, EmitScope<TStatement, TExpression, TType>>();
constructor(
private linkerEnvironment: LinkerEnvironment<TStatement, TExpression, TType>,
sourceUrl: AbsoluteFsPath,
code: string,
) { /* ... */ }
/** 这个函数名是不是一个可链接的部分声明? */
isPartialDeclaration(calleeName: string): boolean {
return this.linkerSelector.supportsDeclaration(calleeName);
}
linkPartialDeclaration(
declarationFn: string,
args: TExpression[],
declarationScope: DeclarationScope<TConstantScope, TExpression>,
): TExpression {
// ① 校验:必须只有一个对象字面量参数
if (args.length !== 1) {
throw new Error(
`Invalid function call: It should have only a single object literal argument, but contained ${args.length}.`,
);
}
// ② 把参数解析成 AstObject(带类型的对象访问器)
const metaObj = AstObject.parse<R3PartialDeclaration, TExpression>(
args[0], this.linkerEnvironment.host,
);
// ③ 取出 ngImport(这个声明是从哪个模块导入的 ɵɵngDeclare*)
const ngImport = metaObj.getNode('ngImport');
const emitScope = this.getEmitScope(ngImport, declarationScope);
// ④ 读版本号
const minVersion = metaObj.getString('minVersion');
const version = metaObj.getString('version');
// ⑤ 按版本选链接器,执行链接
const linker = this.linkerSelector.getLinker(declarationFn, minVersion, version);
const definition = linker.linkPartialDeclaration(emitScope.constantPool, metaObj, version);
// ...
}
}
几个设计要点:
① AstObject 抽象。 linker 的难点在于它要操作别人家的 AST:Babel 的 AST 和 TypeScript 的 AST 完全不同。AstObject 提供了一层统一接口(getString、getNode、getArray...),让链接器代码不必关心底层是哪种 AST。这就是为什么 linker 能同时提供 Babel 插件(linker/babel/)和给其他工具集成的能力——靠 AST 抽象层隔离了转译器差异。
② emitScopes 与 ngImport。 每个文件里可能有多个部分声明,它们可能各自 import 自不同的模块(通常是 @angular/core)。ngImport 节点标识了这个声明的"导入来源",emitScopes 按导入来源分组管理——同一个来源的声明共享一个常量池和输出作用域,这样生成的常量(_c0)不会冲突、能跨声明复用。
③ 版本从这里进入决策。 minVersion 和 version 被取出来,交给下一步的选择器。
2.11.3 PartialLinkerSelector:用 semver 做版本协商
这是 linker 最精妙的部分。先看声明函数名清单:
export const ɵɵngDeclareDirective = 'ɵɵngDeclareDirective';
export const ɵɵngDeclareClassMetadata = 'ɵɵngDeclareClassMetadata';
export const ɵɵngDeclareComponent = 'ɵɵngDeclareComponent';
export const ɵɵngDeclareFactory = 'ɵɵngDeclareFactory';
export const ɵɵngDeclareInjectable = 'ɵɵngDeclareInjectable';
export const ɵɵngDeclareInjector = 'ɵɵngDeclareInjector';
export const ɵɵngDeclareNgModule = 'ɵɵngDeclareNgModule';
export const ɵɵngDeclarePipe = 'ɵɵngDeclarePipe';
export const ɵɵngDeclareClassMetadataAsync = 'ɵɵngDeclareClassMetadataAsync';
export const ɵɵngDeclareService = 'ɵɵngDeclareService';
十种声明函数,覆盖了组件、指令、管道、NgModule、可注入服务、工厂、注入器、类元数据。注意 ɵɵngDeclareService 是较新加的——standalone 时代的产物。
然后是按版本选择链接器的映射表:
export function createLinkerMap(...) {
const linkers = new Map<string, LinkerRange<TExpression>[]>();
const LATEST_VERSION_RANGE = getRange('<=', PLACEHOLDER_VERSION);
linkers.set(ɵɵngDeclareComponent, [
{range: LATEST_VERSION_RANGE, linker: new PartialComponentLinkerVersion1(...)},
]);
// ... 其他同理
return linkers;
}
关于这套机制的规则,源码注释给了完整说明:
Versions should be sorted in ascending order. The most recent partial-linker will be used as the fallback linker if none of the other version ranges match.
When there is a change to a declaration interface that requires a new partial-linker, the
minVersionof the partial-declaration should be updated, the new linker implementation should be added to the end of the collection, and the version of the previous linker should be updated.
翻译一下这套"演进协议":
- 每个声明函数对应一组
LinkerRange(semver 范围 + 链接器实例); - 范围按升序排列,第一个匹配的范围胜出;
- 如果声明格式变了(需要新的链接器),就:① 提高新产物的
minVersion;② 把新链接器加到列表末尾;③ 更新上一个链接器的版本上限; - 最新的链接器作为兜底——没有任何范围匹配时用它。
这就是 Angular 库能跨版本长期兼容的技术保障:声明格式的每次变更都伴随一个版本号,linker 用 semver 判断"我认识这个格式吗",并选择合适的解析器。
2.11.4 版本不匹配怎么办:unknownDeclarationVersionHandling
如果库的 minVersion 比当前 linker 支持的最高版本还新(比如库用 Angular 25 编译,应用用 Angular 22 的 linker),怎么办?
export interface LinkerOptions {
sourceMapping: boolean; // 是否用 sourcemap 定位外部模板,默认 true
linkerJitMode: boolean; // 是否为下游 JIT 编译器生成额外信息,默认 false
unknownDeclarationVersionHandling: 'ignore' | 'warn' | 'error';
}
export const DEFAULT_LINKER_OPTIONS: LinkerOptions = {
sourceMapping: true,
linkerJitMode: false,
unknownDeclarationVersionHandling: 'error', // ★ 默认:直接报错
};
三种策略(源码注释解释得很清楚):
error(默认):版本不匹配是致命错误,直接失败。默认选最严格的——与其让一个"可能链接错误"的库悄悄进入产物,不如早点失败让你知道"请升级 Angular"。这是正确的默认选择:沉默的错误比响亮的失败危险得多。warn:打警告,但还是用最新的链接器硬着头皮处理。适合"我知道版本略高但我确认格式没变"的场景。ignore:静默处理。最危险,只适合你完全掌控的环境。
另外两个选项:
sourceMapping: true:linker 会用 sourcemap 把外部模板的错误位置映射回原始的.ts/.html文件。这和第 2.10.3 讲的"保存源码位置"呼应——整条链路都在为了"报错指得准"而努力。linkerJitMode:JIT 模式下,NgModule 定义需要额外保留declarations/imports/exports等信息(AOT 下这些可以被消掉)。如果下游还要 JIT 编译,就得留着。
2.11.5 Babel 插件:linker 的落地形态
packages/compiler-cli/linker/babel/src/babel_plugin.ts:
export function defaultLinkerPlugin(api: ConfigAPI, options: Partial<LinkerOptions>): PluginObject {
api.assertVersion(8); // 要求 Babel 8
return createEs2015LinkerPlugin({
...options,
fileSystem: new NodeJSFileSystem(),
logger: new ConsoleLogger(LogLevel.info),
});
}
注释说明了它的定位:
This is the Babel plugin definition that is provided as a default export from the package, such that the plugin can be used using the module specifier of the package. This is the recommended way of integrating the Angular Linker into a build pipeline other than the Angular CLI.
也就是说:Angular CLI 内部有自己集成 linker 的方式,而如果你用的是别的构建工具(Vite、Rollup、webpack 等),官方推荐用这个 Babel 插件。这体现了 linker 的"可移植"设计——它不绑定任何构建工具,只依赖一个 AST 抽象。
es2015_linker_plugin.ts 里的实现负责:遍历 Babel AST,找到形如 ɵɵngDeclareXxx({...}) 的调用,交给 FileLinker 处理,再用返回的 AST 替换原节点。
2.11.6 为什么缓存到 node_modules 之外
回顾 README 那句话:
These linked files can be cached outside
node_modulesso it does not suffer from problems of mutating packages innode_modules.
早期(View Engine 时代)的做法是:直接改写 node_modules 里的库文件,把编译结果写回去。这个做法有一堆问题:
- 包管理器(npm/pnpm/yarn)会认为
node_modules被篡改,校验失败; - 缓存失效:每次
npm ci都要重跑; - 可移植性差:换台机器、
node_modules重建后状态不一致; - CI 里并行构建可能互相踩。
新版 linker 的做法是:在打包过程中内存里转换,需要持久化就缓存到 node_modules 之外的目录。node_modules 保持只读、纯净——这是现代构建工具的主流做法,也是 pnpm 那套严格依赖治理(第 1 章 §1.3)能成立的前提之一。
2.11.7 三种编译形态的最终闭环
把 §2.1 到 §2.11 串起来,Angular 的编译体系是这样自洽的:
┌──────────────────────────────────────┐
│ 共享的编译器核心 packages/compiler │
│ (ml_parser → R3 AST → IR → emit) │
└──────────────────────────────────────┘
▲ ▲ ▲
│ │ │
┌───────────────┘ │ └───────────────┐
│ │ │
┌─────────┐ ┌─────────────┐ ┌─────────────┐
│ AOT │ │ Partial │ │ JIT │
│ (ngtsc) │ │ (库发布) │ │ (运行时) │
└─────────┘ └─────────────┘ └─────────────┘
从装饰器读元数据 产出声明对象 运行时解析
直接生成 ɵcmp 含模板字符串 R3JitReflector
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ Linker │ │
│ │ (应用构建) │ │
│ └─────────────┘ │
│ │ │
└────────────┬───────────────┴──────────────┬──────────────┘
▼ ▼
ɵɵdefineComponent ɵɵdefineComponent
(完整定义 + 指令) (运行时现场生成)
│ │
└──────────► 运行时 render3 ◄──┘
一个编译器核心,三种入口,同一个产物契约(ɵɵdefineComponent)。 这就是 Angular 编译体系的完整图景。
2.12 ngtsc:AOT 的"总指挥"
前面讲的都是"编译器核心"(packages/compiler)。但真正把这套核心用在一次真实构建上的,是 ngtsc(packages/compiler-cli/src/ngtsc/)——Angular 的 TypeScript 编译器插件。理解它的工作方式,才能把编译链路和你的日常 ng build 对上。
2.12.1 ngtsc 的本质:TypeScript 编译器 + Angular 分析层
ngtsc 不是"另一个编译器",而是寄生在 TypeScript 编译器之上的一层。它利用 TS 的 CustomTransformers 机制:
ts.Program(TypeScript 的标准编译流程)
│
├─ 分析阶段:ngtsc 遍历所有类,找出带 @Component/@Directive/... 的
│ ├─ reflection:反射读取装饰器元数据
│ ├─ partial_evaluator:静态求值装饰器参数(把表达式算成值)
│ ├─ scope:确定每个组件的可见指令集(standalone imports / NgModule)
│ └─ typecheck:生成 Type Check Block 做模板类型检查
│
├─ 转换阶段:ngtsc 通过 CustomTransformer 改写 AST
│ └─ 把 @Component 类转换成带 ɵfac / ɵcmp 静态属性的类
│
└─ 输出:TS 编译器照常输出 JS + .d.ts
这样做的好处是复用 TS 的一切:模块解析、类型系统、增量编译、声明文件生成、sourcemap。Angular 只需要做"Angular 特有"的那部分工作。
2.12.2 ngtsc 的目录职责
packages/compiler-cli/src/ngtsc/ 下的每个目录对应一个关注点,看名字就能猜出职责:
| 目录 | 职责 |
|---|---|
reflection/ | ★ 反射:从 TS AST 里读出装饰器、类成员、导入导出 |
partial_evaluator/ | ★ 静态求值:把 @Component({template: A + B}) 这种表达式算成具体值 |
annotations/ | 各类装饰器(@Component/@Directive/@Injectable/...)的分析器 |
scope/ | ★ 作用域:确定每个组件可见的指令/管道集合 |
typecheck/ | ★ 模板类型检查(生成 TCB) |
incremental/ | ★ 增量编译:只重新分析改动的部分 |
transform/ | TS AST 转换(把分析结果写回代码) |
diagnostics/ | 诊断信息(错误/警告的格式化) |
imports/ | 导入管理(自动添加缺失的 import) |
cycles/ | 循环引用检测 |
sourcemaps/ | 源码映射 |
hmr/ | 热模块替换支持 |
indexer/ | 索引(供 IDE 语言服务用) |
xi18n/ | i18n 消息提取 |
perf/ | 性能计时 |
program.ts | 主控:协调整个编译流程 |
tsc_plugin.ts | TS 插件入口 |
2.12.3 partial_evaluator:一个被低估的关键组件
@Component 的参数不是字面量,而是任意 TypeScript 表达式:
const STYLES = ['.card {}'];
@Component({
selector: 'my-' + NAME, // 字符串拼接
template: require('./tpl.html'), // 函数调用(旧写法)
styles: [...STYLES, extraStyle], // 数组展开 + 变量引用
providers: [{provide: API, useValue: CONFIG.url}], // 对象嵌套
})
class MyCard {}
编译器需要知道这些参数的具体值才能工作。但 TS 编译器不会帮你"执行"这些表达式——它只给 AST。
partial_evaluator/ 就是干这个的:静态解释(static interpretation) TypeScript 表达式的一个子集。它能:
- 求字面量的值(
'a' + 'b'→'ab'); - 跟随变量引用(找到
STYLES的声明并取出它的值); - 展开数组/对象(
[...a, b]); - 解析导入(跨文件的变量引用);
- 对函数调用等"无法静态求值"的情况,优雅失败并给出清晰的诊断("这个装饰器参数必须是静态可求值的")。
这也是为什么 Angular 对装饰器参数有"必须是静态可求值"的要求——不是框架任性,是编译器必须能在不执行代码的情况下算出它的值。你见过 NG1010: Value at position X in the ... decorator is not statically analyzable 这类错误,就来自这里。
2.12.4 incremental/:为什么第二次构建那么快
ng build --watch 时,改一个文件后重新构建通常很快,靠的就是 incremental/。它的核心思想:
- 记住上一次的分析结果(哪些类是什么组件、模板解析结果、作用域信息);
- 只重新分析受影响的部分:改了
a.component.ts,只有它自己和依赖它的组件需要重新分析;改了一个被很多组件import的指令,影响面就大得多; - 结果按"依赖图"组织,精确计算"脏"的范围。
Angular 还有更激进的一层:编译结果按组件粒度缓存。你改了 A 组件的模板,只要 B 组件不依赖 A,B 的编译产物直接从缓存复用。这让大型项目的增量构建能从分钟级降到秒级。
2.12.5 typecheck/:模板错误的来源
§2.5.3 提到模板类型检查靠"生成 TS 代码让 TS 检查"。typecheck/ 就是干这个的:
- 为每个组件生成一个 Type Check Block(TCB)——一段虚拟的 TS 函数,把模板里的每个表达式"复现"一遍;
- 把 TCB 塞进一个虚拟的 TS 源文件(shim,见
shims/); - 让 TS 编译器像检查普通代码一样检查它;
- 收集 TS 报的错,映射回模板的行列;
- 以 Angular 的诊断格式呈现给你。
你在终端看到的这类报错:
src/app/card.component.html:3:12 - error TS2339: Property 'nmae' does not exist on type 'User'.
它的完整旅程是:模板第 3 行第 12 列 → 编译器生成 TCB → TS 报 TS2339 → Angular 映射回模板位置 → 展示。"错误指得准"这件事,背后是 typecheck/ + sourcemaps/ 两个模块在协作。
2.12.6 hmr/ 与 indexer/:两个"顺带"的能力
hmr/:热模块替换(Hot Module Replacement)。第 1 章 §1.2 提到r3_hmr_compiler.ts,它生成的元数据让 dev server 能在不刷新页面的情况下替换组件实现。编译器在这里专门为 HMR 生成额外信息——开发体验是靠编译器专门支持换来的。indexer/:为 IDE 语言服务(language-service/和 VSCode 插件)生成索引,让"跳转到模板里引用的组件定义"这类功能成为可能。
2.12.7 编译器的"可观测性":perf 与 diagnostics
perf/ 模块记录编译各阶段耗时,diagnostics/ 统一格式化错误。这两者合起来让你能:
ng build --verbose # 看到编译各阶段耗时
Angular 甚至有专门的编译性能剖析支持。一个成熟的编译器,必须能回答"编译为什么慢、慢在哪"。
2.13 实战:亲手观察编译链路
理论讲完,动手验证。这一节的所有操作都能在本仓库或你自己的 Angular 项目里执行。
2.13.1 实验一:看真实应用的编译产物
最直接的方法——编译一个组件看输出。在本仓库里,编译器的单元测试有现成的"编译结果快照"。搜索 packages/compiler-cli/test/ 下的编译测试,你会看到形如:
// 期望的编译产物
MyComponent.ɵcmp = i0.ɵɵdefineComponent({
type: MyComponent,
selectors: [["my-cmp"]],
decls: 2,
vars: 1,
consts: [[3, "title"]],
template: function MyComponent_Template(rf, ctx) {
if (rf & 1) {
i0.ɵɵelementStart(0, "div");
i0.ɵɵtext(1);
i0.ɵɵelementEnd();
}
if (rf & 2) {
i0.ɵɵadvance();
i0.ɵɵtextInterpolate1("Hello ", ctx.name, "!");
}
}
});
对照本章的知识,你能在这段产物里读出:
decls: 2→allocateSlots算出来:div 占槽 0,text 占槽 1;vars: 1→countVariables算出来:一个插值绑定;consts: [[3, "title"]]→ 常量池收集的元素静态属性(3可能是属性类型标记);if (rf & 1)/if (rf & 2)→maybeGenerateRfBlock生成的两个阶段;ɵɵadvance()→generateAdvance插入的游标前进;ɵɵtextInterpolate1→collapseSingletonInterpolations折叠出的专用指令。
验证方法:在你自己的 Angular 项目里跑 ng build --configuration development(保留可读的产物),然后去 dist/ 里找组件的 JS 文件,就能看到真实产物。生产构建会被压缩,但结构一致。
2.13.2 实验二:验证 DomOnly 优化
本章 §2.6.3 说 standalone 且无指令依赖的组件会用 DomOnly 模式编译。验证方法:
// A:纯展示组件,standalone,无 imports
@Component({
selector: 'app-plain',
template: `<div>{{msg}}</div>`,
})
class PlainComponent { msg = 'hi'; }
// B:用了指令的 standalone 组件
@Component({
selector: 'app-with-dir',
imports: [NgIf],
template: `<div *ngIf="ok">{{msg}}</div>`,
})
class WithDirComponent { msg = 'hi'; ok = true; }
分别编译两者,对比产物大小和内容。你会观察到:
- A 的产物里没有指令匹配相关的代码路径,指令集更精简;
- B 的产物里有
dependencies数组和指令实例化逻辑。
实战启示:把"纯展示"的叶子组件写成不依赖任何指令的 standalone 组件,不只是代码更清晰,编译产物也更小更快。这是 standalone + DomOnly 带来的隐性红利。
2.13.3 实验三:观察常量池
写一个含重复字面量的模板:
<div class="card"><span class="card">a</span></div>
<div class="card"><span class="card">b</span></div>
编译后观察:那些 'card' 和属性数组是否被提升成了 _cN。再写一个超长的静态文本(>50 字符),看它是否被池化;再写一个短文本(<50 字符),看它是否被内联。用这个实验亲手验证 §2.9.3 那个"50"的阈值。
2.13.4 实验四:查看 linker 的输入与输出
如果你有权限查看 node_modules 里的 Angular 官方库(如 @angular/material),打开它的 .mjs 文件,你会看到:
Button.ɵcmp = i0.ɵɵngDeclareComponent({
version: "22.x.x",
minVersion: "17.0.0",
type: Button,
selector: "button[mat-button]",
template: `...原始模板字符串...`,
// ...
});
这就是部分编译的产物——模板还在,没有 ɵɵ 指令。
然后在你应用的构建产物里搜同一个组件,你会看到它变成了:
Button.ɵcmp = i0.ɵɵdefineComponent({
type: Button,
decls: 3,
vars: 2,
template: function Button_Template(rf, ctx) { /* ɵɵ 指令 */ }
});
同一次链条的两端,亲眼看到了 linker 做了什么。 这个对比是理解 partial compilation 最直观的方式。
2.13.5 实验五:故意触发编译错误,读诊断信息
编译器的诊断质量值得亲身体会。试几个:
// ① 模板里访问不存在的属性
@Component({template: `{{user.nmae}}`})
class C { user = {name: 'a'}; }
// → TS2339: Property 'nmae' does not exist on type '{name: string}'
// ② 绑定到不存在的指令输入
@Component({template: `<my-cmp [notAnInput]="1"></my-cmp>`, imports: [MyCmp]})
class C {}
// → NG8002: Can't bind to 'notAnInput' since it isn't a known property of 'my-cmp'
// ③ 装饰器参数不可静态求值
const x = Math.random();
@Component({template: TPL}) // TPL 来自运行时
class C {}
// → 装饰器参数必须静态可求值
每一条错误都带着精确的行列位置(来自 §2.5.3 的 sourcemap 映射)和错误码(第 1 章 §1.12.3 讲的 NGxxxx 体系)。拿到错误码去 angular.dev/errors/NGxxxx 能查到完整解释和修复建议。
2.13.6 实验六:感受增量编译
在一个有几十个组件的项目里:
# 首次完整构建,记下耗时
time ng build
# 只改一个叶子组件的模板
echo '<!-- change -->' >> src/app/leaf.component.html
# 再次构建,对比耗时
time ng build
第二次应该明显更快(§2.12.4 的增量编译)。如果你改的是一个被大量组件 import 的核心指令,第二次会更慢——因为影响面更大。这个实验能让你直观理解"依赖图决定增量构建成本"这件事,对设计组件结构有直接指导意义:让高频修改的组件尽量位于依赖图的叶子位置。
2.13.7 实战小结:你现在能做的三件事
- 读懂产物:看到任何一段
ɵcmp产物,能反推出它对应的模板结构和编译阶段; - 定位问题:编译报错时,知道错误来自链路的哪一层(解析 / 绑定 / 类型检查 / 元数据求值),能更快地定位;
- 优化结构:知道 standalone + 无指令依赖 →
DomOnly;高频改动放叶子 → 增量构建快。
2.14 编译链路全景:一张表看完整个过程
把本章所有内容压缩成一张"从源码到产物"的全景表。建议收藏,读后续章节时反复对照。
| 阶段 | 输入 | 输出 | 核心模块 | 关键函数/概念 |
|---|---|---|---|---|
| ① 反射 | TypeScript 源文件 | 装饰器元数据 + 类信息 | ngtsc/reflection | 读 @Component({...}) |
| ② 静态求值 | 装饰器参数 AST | 具体值(字符串、数组、对象) | ngtsc/partial_evaluator | 不求值则报 NG1010 |
| ③ 模板解析 | 模板字符串 | HTML AST | ml_parser | lexer → parser |
| ④ 表达式解析 | {{...}} 里的文本 | 表达式 AST | expression_parser | 受限 JS 子集 |
| ⑤ R3 转换 | HTML AST | R3 AST | r3_template_transform | Text/BoundText/IfBlock... |
| ⑥ 绑定解析 | R3 AST 的属性 | 属性/事件/指令输入判定 | binding_parser | [x] / (x) / [(x)] |
| ⑦ 指令匹配 | R3 AST + 可见指令集 | 每元素的匹配指令 | view/t2_binder | CSS 选择器匹配 |
| ⑧ 类型检查 | R3 AST | 诊断信息(错误/警告) | ngtsc/typecheck | Type Check Block |
| ⑨ ingest | R3 AST | IR(op 列表) | pipeline/src/ingest | ingestComponent |
| ⑩ transform | IR | 优化后的 IR | pipeline/src/phases | 74 个阶段(Tmpl 跑 57 个) |
| ⑪ 槽位分配 | IR | 带槽位号的 IR | phases/slot_allocation | ★ 产出 decls |
| ⑫ 变量计数 | IR | 带变量数的 IR | phases/var_counting | ★ 产出 vars |
| ⑬ 生成 advance | IR | 含 ɵɵadvance 的 IR | phases/generate_advance | 游标前进 |
| ⑭ 命名 | IR | 各视图有名 | phases/naming | Cmp_Template |
| ⑮ 实体化 | IR | 指令调用表达式 | phases/reify | 抽象 op → ɵɵproperty(...) |
| ⑯ 链式优化 | 指令调用 | 链式调用 | phases/chain | ɵɵproperty(a)(b)(c) |
| ⑰ emit | IR | 输出 AST | pipeline/src/emit | emitView + maybeGenerateRfBlock |
| ⑱ 常量池 | 输出 AST | 顶层 _cN 常量 | constant_pool | FixupExpression 回填 |
| ⑲ 写回 TS | 输出 AST | 带 ɵfac/ɵcmp 的类 | ngtsc/transform | TS CustomTransformer |
| ⑳ 输出 | TS | JS + .d.ts + sourcemap | TypeScript 编译器 | —— |
如果是库:①②③ 之后不走 ⑨–⑳,而是走 partial 编译——产出 ɵɵngDeclareComponent({template: '<原始字符串>'})。等应用构建时,linker 从声明对象里读出元数据,从 ③ 重新开始跑完整链路。
2.14.1 两条路径的分岔点
装饰器元数据 + 模板
│
┌─────────────┴─────────────┐
│ │
应用(AOT) 库(Partial)
│ │
继续走 ③→⑳ 只走到 ①②③
产出 ɵɵdefineComponent 产出 ɵɵngDeclareComponent
│ (保留模板字符串)
│ │
│ 应用构建时
│ │
│ Linker 读回元数据
│ 从 ③ 继续走到 ⑳
│ │
└──────────┬────────────────┘
▼
ɵɵdefineComponent
(完全相同的产物形态)
分岔点在"是否立即做代码生成",但最终汇合到同一个产物契约。这就是 partial compilation 设计得漂亮的地方:两条路殊途同归,中间的差异被 linker 完全吸收。
2.19 全章贯通:一个模板字符串到两条函数的完整旅程
本章讲了编译器的九个阶段,这一节把它们收成一条单向的数据流,看一个模板字符串到底经历了什么。全程只讲"输入是什么、输出是什么、为什么必须有这一步"。
① 解析:字符串 → HTML AST(2.3)。 输入是模板文本,输出是一棵 HTML 节点树。这一步不理解 Angular 语义,*ngIf 和 @if 在它眼里只是属性或文本块。分开的意义是:HTML 解析可以被独立测试、独立复用(语言服务也用它)。
② 升维:HTML AST → R3 AST(2.4)。 这一步注入 Angular 语义:{{ }} 变成 Interpolation 节点,绑定变成 BoundAttribute,事件变成 BoundEvent,控制流块被识别成 IfBlock / SwitchBlock / DeferredBlock。这是 Angular 对模板的第一层理解,也是模板类型检查的输入。
③ 匹配与类型检查(2.5)。 编译器拿着 R3 AST 去问"这个元素上有哪些指令匹配",然后把绑定和指令输入对上,并检查类型。这一步是 strictTemplates 的实现位置,也是编译期能做的最有价值的一类检查——写错一个输入名在构建时就报错,而不是运行时静默失效。
④ 降级为 IR(2.6)。 从 Angular 17 起,编译器中间多了一层显式 IR。R3 AST 被降级成一串 op,每个 op 带若干 trait。这一层存在的理由是让后续变换可组合、可复用、可单独测试:57 趟变换(2.7)每一趟只做一件事,按顺序跑一遍。这跟现代编译器的 pass 流水线是同一个思路。
⑤ 发射(2.8、2.9)。 变换跑完后发射成 JS。这里的关键产出就是 1.10 见过的两条函数:creation 与 update。发射时还有个容易被忽略的机制——常量池:模板里所有字面量被抽成 _c0、_c1(2.9),这样重复的样式数组、净化器配置在整个应用里只存在一份,且能被压缩器更好地处理。
⑥ 当你发布的是库:partial compilation(2.10、2.11)。 应用可以 AOT 全编译,但库不行——库发布时不知道使用者的编译选项。所以库的编译产物是 ɵɵngDeclareComponent(...) 这种"半成品",把元数据原样保留;使用者的构建里由 linker 在 Babel 层面把它翻译成真正的 ɵcmp。这条设计的代价是 linker 必须理解所有元数据形状,收益是同一个 npm 包能被不同 Angular 版本消费。
⑦ 总指挥:ngtsc(2.12)。 它不是一个独立编译器,而是 TypeScript 的一个 plugin:挂在 TS 程序上,借用 TS 的类型系统与增量能力,同时驱动上面所有阶段。理解这一点就能理解为什么 Angular 的编译错误经常表现为 TS 错误——它们共用同一条诊断通道。
这条流读下来最值得记住的是:Angular 的模板不是"运行时解释"的,而是被翻译成了一串普通函数调用。你在 devtools 里看到的 ɵɵelementStart 不是框架在运行,是编译产物在原样执行。
2.15 常见坑清单
Q1:AOT 和 JIT 产出的 ɵcmp 一样吗?
结构一致(都是 ɵɵdefineComponent 的产物),但生成时机不同:AOT 在构建时由 ngtsc 生成并写进 JS;JIT 在浏览器运行时由 @angular/compiler 现场生成。另外 JIT 下模板里的错误只能到运行时才发现,而 AOT 在构建时就报出来。
Q2:为什么修改模板后有时构建很慢?
看 §2.12.4。增量编译按依赖图传播"脏":改一个被大量组件 import 的核心指令,会让所有依赖它的组件重新编译。优化方向:把高频修改的部分放在依赖图叶子侧;减少不必要的 imports 扩散。
Q3:minVersion: '17.0.0' 是什么意思?能不能手动改?
它是 linker 的版本协商信号,表示"处理这份声明需要 ≥17.0.0 的 linker"。它由编译器根据模板是否使用了块语法自动设置(§2.10.2 的 BlockPresenceVisitor)。不要手动改——改低了会让老 linker 尝试解析它不认识的语法,导致链接错误。
Q4:为什么我的库发布后,用户升级 Angular 不需要重新发布库?
这正是 partial compilation 的价值(§2.1.3)。库产物里没有 ɵɵ 指令,只有声明式元数据 + 模板字符串。用户用新版本 Angular 构建时,新版 linker 会重新解析这些元数据、用新版编译器生成代码。库与运行时实现解耦了。
那什么情况下库必须重新发布?当声明格式本身发生不兼容变更时——此时新库会带更高的 minVersion,老 linker 会因为 unknownDeclarationVersionHandling: 'error'(默认)直接报错提示升级。
Q5:ɵɵ 指令会不会在版本间变化?
会。它们是内部实现细节(第 1 章 §1.10.1 明确说"not meant for developer consumption")。你的应用代码不该直接依赖它们;但这也正是库采用 partial 格式的原因——库里没有 ɵɵ,所以不受影响。而应用每次构建都会用当前的编译器重新生成,自然与当前运行时匹配。
Q6:模板类型检查为什么有时"太严格"或"太宽松"?
它用的是 TypeScript 的类型系统(§2.5.3),所以严格程度取决于你的 tsconfig 里 strictTemplates(Angular 特有)和相关 TS 严格开关。开启 strictTemplates 后,模板里的类型检查会接近 TS 的严格程度——这是推荐做法,能在编译期抓住大量 bug。
Q7:decls/vars 我能手动指定吗?
不能,也不需要。它们是 allocateSlots 和 countVariables 根据模板结构自动算出来的(§2.7.5)。手动写只会写错——写小了数组越界,写大了浪费内存。
Q8:为什么有的组件产物里有多个函数(Cmp_Template、Cmp_Conditional_0_Template)?
每个嵌入式视图(@if 块内、@for 块内、<ng-template> 内)都是一个独立的视图,有自己独立的模板函数(§2.8.1)。它们被作为顶层语句放进常量池,由 ɵɵtemplate 指令在运行时引用。
Q9:编译器报错"装饰器参数不可静态求值"怎么办?
见 §2.12.3。partial_evaluator 必须能在不执行代码的情况下算出装饰器参数的值。常见触发:用了函数调用、用了运行时变量、跨了无法静态分析的模块边界。解法:把动态部分移到组件内部(用字段、用注入的服务),让装饰器参数保持静态。
Q10:IR 那 74 个阶段,我需要都读懂吗? 不需要。建议的策略是:
- 先理解 §2.7 的分组脉络(语义规范化 → 控制流 → 名称解析 → i18n → 槽位/变量 → 命名 → 收尾优化 → 实体化 → 链式);
- 重点读与你想研究的问题相关的那几个(比如想研究
@for,读track_fn_optimization;想研究水合,读insert_incremental_hydration_runtime); - 其余作为"知道存在"的清单,需要时再查。
2.16 本章核心结论
这一章我们从"模板字符串"走到了"ɵɵ 指令",把 Angular 编译器的整条链路拆了个透。回顾核心认知:
三种编译形态(§2.1):
- JIT:浏览器运行时编译,靠
R3JitReflector查表解析符号,用于调试和动态场景; - AOT:构建时编译(ngtsc),产物不含编译器,是生产默认;
- Partial + Linker:库发布半成品声明(保留模板字符串、不含
ɵɵ),应用构建时由 linker 链接成完整定义,彻底解决了库与应用版本锁死。
编译流水线(§2.2–§2.8):
- 三种 AST 分工:HTML AST(结构)→ R3 AST(Angular 语义)→ IR(可优化)→ 输出 AST(代码);
- IR + 多趟变换是 Angular 17+ 的核心架构:74 个阶段(Tmpl 跑 57 个),每个阶段独立、可测试;
- 阶段顺序有内在逻辑:语义规范化 → 结构 → 名称解析 → 槽位/变量分配 → 命名 → 优化 → 实体化 → 链式;
decls来自allocateSlots(按 create 阶段 op 顺序分配槽位),vars来自countVariables(先顶层后表达式内,两趟计数)——这两个数字终于有了算法级解释;- 两阶段函数由
maybeGenerateRfBlock(1/2)生成,空的那一段根本不产出(if块都不生成)。
工程细节中的智慧:
ConstantPool用FixupExpression做"先占位后回填",并给字符串设了 50 字符的池化阈值——优化也要算账;minVersion: '17.0.0'只在模板用到块语法时才设置——能力升级不该无谓抬高门槛;- 默认值字段被省略(
encapsulation非默认才写)——"省略即默认"让库能随框架演进; - linker 用 semver 做版本协商,默认
error策略——沉默的错误比响亮的失败危险; - 链接结果缓存到
node_modules之外——保持 node_modules 只读纯净。
ngtsc(§2.12):寄生在 TypeScript 之上,靠 reflection 读元数据、partial_evaluator 静态求值、scope 算可见指令、typecheck 生成 TCB 做类型检查、incremental 支撑增量构建。
带着这些认知,下一章我们回到运行时,进入本系列的重头戏——Signals 的图算法。你会发现一个有趣的呼应:编译器把"决策提前到编译期",而 signals 把"刷新精确化到运行期"——两者都在追求同一件事:只做必要的工作。
附录 A:本章关键源码文件速查表
| 你想知道 | 去看这个文件 |
|---|---|
| 三种编译形态的选择 | packages/compiler/src/render3/r3_jit.ts(JIT 反射器) |
| HTML 词法/语法分析 | packages/compiler/src/ml_parser/(lexer.ts / parser.ts / ast.ts) |
| 表达式解析 | packages/compiler/src/expression_parser/ |
| R3 AST 节点定义 | packages/compiler/src/render3/r3_ast.ts |
| HTML AST → R3 AST | packages/compiler/src/render3/r3_template_transform.ts |
| 绑定判定 | packages/compiler/src/template_parser/binding_parser.ts |
| 指令/管道匹配 | packages/compiler/src/render3/view/t2_binder.ts |
| 模板解析入口 | packages/compiler/src/render3/view/template.ts(parseTemplate) |
| 编译组件主流程 | packages/compiler/src/render3/view/compiler.ts(compileComponentFromMetadata) |
| IR 流水线主控 | packages/compiler/src/template/pipeline/src/emit.ts(transform / emitTemplateFn / emitView) |
| 74 个变换阶段 | packages/compiler/src/template/pipeline/src/phases/ |
decls 的算法 | .../phases/slot_allocation.ts(allocateSlots) |
vars 的算法 | .../phases/var_counting.ts(countVariables) |
ɵɵadvance 的生成 | .../phases/generate_advance.ts |
| IR 节点与 trait | packages/compiler/src/template/pipeline/ir/ |
| 编译模式(Full/DomOnly) | .../pipeline/src/compilation.ts |
| 常量池 | packages/compiler/src/constant_pool.ts |
| 部分编译(声明生成) | packages/compiler/src/render3/partial/(component.ts / api.ts) |
| Linker 主控 | packages/compiler-cli/linker/src/file_linker/file_linker.ts |
| Linker 版本协商 | .../file_linker/partial_linkers/partial_linker_selector.ts |
| Linker 选项 | .../file_linker/linker_options.ts |
| Babel 插件 | packages/compiler-cli/linker/babel/src/babel_plugin.ts |
| ngtsc 主控 | packages/compiler-cli/src/ngtsc/program.ts |
| 静态求值 | packages/compiler-cli/src/ngtsc/partial_evaluator/ |
| 模板类型检查 | packages/compiler-cli/src/ngtsc/typecheck/ |
附录 B:74 个变换阶段速查(按功能分组)
| 分组 | 阶段 |
|---|---|
| 语义规范化 | resolveForeignContent removeContentSelectors optimizeRegularExpressions emitNamespaceChanges deduplicateTextBindings specializeStyleBindings specializeBindings specializeControlProperties convertAnimations extractAttributes parseExtractedStyles removeEmptyBindings collapseSingletonInterpolations |
| 控制流与结构 | orderOps generateConditionalExpressions generateBoundaryConditions createPipes configureDeferInstructions insertIncrementalHydrationRuntime createVariadicPipes generateArrowFunctions generatePureLiteralStructures generateProjectionDefs generateLocalLetReferences generateVariables resolveBoundaries saveAndRestoreView |
| 名称与上下文 | deleteAnyCasts removeSafeNavigationMigration resolveDollarEvent generateTrackVariables removeIllegalLetReferences resolveNames resolveDeferTargetNames transformTwoWayBindingSet optimizeTrackFns resolveContexts resolveSanitizers liftLocalRefs expandSafeReads stripNonrequiredParentheses generateTemporaryVariables optimizeVariables optimizeStoreLet |
| i18n | propagateI18nBlocks wrapI18nIcus createI18nContexts convertI18nText convertI18nBindings removeUnusedI18nAttributesOps assignI18nSlotDependencies applyI18nExpressions resolveI18nElementPlaceholders resolveI18nExpressionPlaceholders extractI18nMessages collectI18nConsts resolveI18nAttrSanitizers |
| 槽位与变量 | allocateSlots(decls) countVariables(vars) generateAdvance |
| 收尾与发射 | nameFunctionsAndVariables resolveDeferDepsFns mergeNextContextExpressions generateNgContainerOps collapseEmptyInstructions attachSourceLocations disableBindings extractPureFunctions reify chain emptyElements nonbindable ngContainer hostStylePropertyParsing nextContextMerging namespace ordering temporaryVariables variableOptimization pipe_creation pipe_variadic pure_function_extraction pure_literal_structures local_refs const_collection chaining naming reify resolveBoundaries |
附录 C:编译产物字段对照表
看到一段 ɵɵdefineComponent({...}) 时,按这张表反推:
| 字段 | 来源 | 含义 |
|---|---|---|
type | 组件类 | 组件构造函数 |
selectors | @Component.selector | 选择器 |
decls | allocateSlots | 模板里的声明节点数(DECLS 段大小) |
vars | countVariables | 模板里的绑定数(VARS 段大小) |
consts | ConstantPool + collectElementConsts | 元素静态属性/常量数组 |
template | emitTemplateFn | 两阶段模板函数 |
dependencies | t2_binder 匹配结果 | 模板用到的指令/管道 |
ngContentSelectors | IR 的 contentSelectors | <ng-content> 的选择器 |
encapsulation | 非默认时才写 | 样式封装模式 |
changeDetection | 非默认时才写 | 变更检测策略 |
features | addFeatures | 各类特性(Providers、ExternalStyles、Control 等) |
hostBindings | Host 编译任务 | 宿主绑定函数 |
inputs / outputs | 装饰器元数据 | 输入输出映射 |
思考题
- AOT、JIT、Partial 三种编译形态各自的适用场景是什么?Partial 解决了什么历史难题?
- 为什么库不能发布"完全编译"的产物?如果发布了会发生什么?
- 编译过程中 AST 换了几次形态?每次转换解决什么问题?
decls: 2这个数字是怎么算出来的?请描述allocateSlots的算法。countVariables为什么要分两趟(先顶层、后表达式内)?不分会有什么问题?TemplateCompilationMode.DomOnly是什么?什么条件下会启用?它带来什么好处?ɵɵngDeclareComponent里的minVersion为什么只在用了块语法时才设为17.0.0?- linker 遇到"不认识的声明版本"时默认怎么处理?为什么默认策略是这样?
ConstantPool为什么需要FixupExpression?那个"50 字符阈值"背后的权衡是什么?- 模板里
{{user.nmae}}报错,这个错误是怎么产生的?经过了哪几个模块? - 如果你要往编译器里加一个新的优化,应该插在阶段列表的哪个位置?判断依据是什么?
- 为什么 standalone + 无指令依赖的组件编译产物更优?这对你的组件设计有什么启示?
2.17 深入:IR 的数据结构 —— op、trait 与 handle
§2.7 讲了"74 个阶段在做什么",但没说清它们操作的数据长什么样。这一节补上这个缺口。理解 IR 的数据模型,你才能真正读懂那些阶段的代码。
2.17.1 一切皆 op:用链表组织的双向序列
IR 的基本单位是 op(operation),定义在 template/pipeline/ir/src/ops/ 下,按所属阶段分四个文件:
ops/
├── create.ts # create 阶段的 op(建结构)
├── update.ts # update 阶段的 op(刷绑定)
├── host.ts # 宿主绑定的 op
└── shared.ts # 两阶段共用的 op
OpKind 枚举(ir/src/enums.ts)列出了全部 op 类型,挑几个认识一下:
export enum OpKind {
/** 特殊 op:表示 op 链表(双向)的头尾哨兵节点 */
ListEnd,
/** 包装了一个输出 AST 语句的 op——即"已经落地成代码"的 op */
Statement,
/** 声明并初始化一个 SemanticVariable */
Variable,
/** 开始渲染一个元素 */
ElementStart,
/** 渲染一个无子元素的元素 */
Element,
/** 渲染一个外部(foreign)组件 */
ForeignComponent,
/** 声明一个嵌入式视图 */
Template,
/** 结束 ElementStart 开始的元素 */
ElementEnd,
/** 开始一个 ng-container */
ContainerStart,
// ... 还有几十种
}
两个细节值得注意:
① ListEnd 是哨兵节点。 IR 的 op 不是存在数组里,而是组织成带头尾哨兵的双向链表。为什么不用数组?因为编译器要频繁地插入、删除、重排 op(想想 orderOps、chain、mergeNextContextExpressions 这些阶段)。链表上插入删除是 O(1),数组则要搬移元素。这个选择在"编译器要反复改写 IR"的场景下是对的。
② Statement 是一个特殊 op 类型。 回忆 §2.8.2 里 emitView 断言 op.kind !== ir.OpKind.Statement 就抛错——那时每个 op 都必须已经变成 Statement。也就是说,Statement 是 op 的"终态":抽象的 ElementStart op 经过 reify 之后,就变成了一个包着 ɵɵelementStart(...) 调用的 Statement op。IR 的演化过程,本质上是 op 从"抽象形态"变成"Statement 形态"的过程。
2.17.2 Trait:给 op 打的能力标签
不同 op 有不同能力:有的占槽位、有的消耗变量、有的产生变量。IR 用 trait(特质) 来标记这些能力,定义在 ir/src/traits.ts:
/** 表示这个 op 会消耗数据槽位 */
export interface ConsumesSlotOpTrait {
readonly [ConsumesSlot]: true;
/**
* Assigned data slot (the starting index, if more than one slot is needed) for this operation, or
* `null` if slots have not yet been assigned.
*/
handle: SlotHandle;
// ...
}
/** 表示这个 op 会消耗变量槽位 */
export interface ConsumesVarsTrait {
[ConsumesVarsTrait]: true;
}
/** 表示一个表达式需要知道"它之前用了多少个变量槽" */
export interface UsesVarOffsetTrait {
// ...
}
回忆 §2.7.5 的两个阶段:
// allocateSlots
if (!ir.hasConsumesSlotTrait(op)) continue; // 只处理带 ConsumesSlotOpTrait 的
op.handle.slot = slotCount;
slotCount += op.numSlotsUsed;
// countVariables
if (ir.hasConsumesVarsTrait(op)) { // 只处理带 ConsumesVarsTrait 的
varCount += varsUsedByOp(op);
}
这就是 trait 的价值:阶段不需要知道"具体有哪些 op 类型",只需要问"你有这个 trait 吗"。新增一种 op 时,只要给它打上正确的 trait,所有相关阶段就自动处理它了。这是典型的**开闭原则(对扩展开放、对修改封闭)**应用——在 74 个阶段的系统里,这个设计至关重要,否则加一个 op 要改十几个阶段。
2.17.3 Handle:延迟绑定的引用
// ir/src/handle.ts
export class SlotHandle {
slot: number | null = null;
}
SlotHandle 是个极简的类,只装一个可能为 null 的槽位号。它的存在形式很有意思:op 不直接持有 slot: number,而是持有一个 handle 对象。
为什么?因为槽位号在 ingest 阶段还不知道(要等 allocateSlots 跑完)。如果 op 直接持有一个数字,那在分配之前就得填个占位值(比如 -1),而且所有"引用这个 op 的槽位"的表达式都要在分配后逐个回填。
用 handle 的话:
// ingest 时:op 创建 handle,表达式持有同一个 handle 对象
op.handle = new SlotHandle(); // slot: null
someExpr.slot = op.handle; // 表达式引用同一个对象
// allocateSlots 时:只改一处
op.handle.slot = 42; // 所有引用者自动"看到" 42
用一层间接引用(handle),把"N 处回填"变成"一处赋值"。 这和 §2.9.2 的 FixupExpression 是同一个思路——用间接层解决"决策晚于使用"的时序问题。编译器里这类手法反复出现,认出它能大幅降低读码难度。
handle.slot === null 时的语义是"槽位还没分配",这也是为什么 trait 的注释里写 or null if slots have not yet been assigned——null 是有意义的合法状态,不是错误。
2.17.4 三种 IR 表达式与变量
ir/src/expression.ts 和 variable.ts 定义了 IR 层的表达式扩展。IR 的表达式不是输出 AST 的表达式,而是"带 Angular 语义的表达式":
SemanticVariable:语义变量(对应模板里的局部变量、@for的 item 变量等);- 各类 IR 特有的读取操作(读取上下文、读取局部变量、纯函数调用……)。
这些 IR 表达式最终会在 reify 阶段被转换成输出 AST 的表达式(如 ɵɵreadContextLet(...))。IR 表达式层是"语义"和"代码"之间的最后一道缓冲——它让 74 个阶段可以在"还不知道最终代码长什么样"的情况下自由地分析和变换。
2.17.5 数据模型小结
把这一节串起来,IR 的完整数据模型是:
CompilationJob ← 一次编译任务(Tmpl 或 Host)
├── units: ViewCompilationUnit[] ← 多个编译单元(根视图 + 每个嵌入式视图)
│ ├── create: op 链表(双向,带 ListEnd 哨兵) ← create 阶段的操作
│ ├── update: op 链表 ← update 阶段的操作
│ ├── decls / vars ← 由 allocateSlots / countVariables 填
│ └── xref / parent ← 视图间关系
└── pool: ConstantPool ← 共享常量池
op ← 基本单位
├── kind: OpKind ← 类型(ElementStart / Property / Statement ...)
├── traits ← 能力标签(ConsumesSlot / ConsumesVars ...)
├── handle: SlotHandle ← 延迟绑定的槽位引用
└── statement? ← reify 后填入的真实代码
链表组织 + trait 标记 + handle 间接——这三者的组合,让 74 个阶段能各自独立地增删改查 IR,而不互相纠缠。理解了这套数据模型,你再打开任何一个 phases/*.ts 文件,都能立刻看懂它在操作什么。
下一章预告:第 03 章《Signal 图算法:producer/consumer 依赖图、版本号与 glitch-free》。我们将钻进
packages/core/primitives/signals/src/graph.ts,看 Angular 22 的整个反应式系统是如何建立在一张"带版本号的依赖图"之上的——包括为什么它能做到"无中间态(glitch-free)"、为什么不需要手动声明依赖、以及内存如何回收。这是全系列技术含量最高的一章之一。
附录 D:编译期决策 vs 运行时决策对照表
第 1 章 §1.10.4 讲过 Ivy 的核心设计原则——"把 if 从运行时挪到编译期"。本章走完编译链路后,可以系统性地列出这条原则的落实清单。这张表是理解 Angular "为什么快"的总纲:
| 决策 | 谁决定 | 运行时是否还要判断 |
|---|---|---|
| 模板结构(有哪些节点) | 编译期(ingest + 各阶段) | 否,按槽位直接访问 |
decls / vars(数组大小) | 编译期(allocateSlots / countVariables) | 否,创建时按数分配 |
| 每个节点的槽位号 | 编译期 | 否,硬编码在指令调用里 |
| 绑定是 DOM 属性还是指令输入 | 编译期(binding_parser + t2_binder) | 否,直接生成对应指令 |
| 绑定是否需要 XSS 消毒 | 编译期(resolveSanitizers) | 否,需要的地方才插入消毒 |
插值是几个(用哪个 *InterpolateN) | 编译期(collapseSingletonInterpolations) | 否,专用指令直接生成 |
是否需要 ɵɵadvance | 编译期(generateAdvance) | 否,游标位置编译期排好 |
| 是否需要指令匹配代码 | 编译期(TemplateCompilationMode) | 否,无指令依赖就不生成 |
| 常量是否被多处共享 | 编译期(ConstantPool) | 否,已提升为 _cN |
| 管道参数是否全字面量(可内联) | 编译期(generatePureLiteralStructures) | 否,直接内联结果 |
连续 nextContext 是否可合并 | 编译期(mergeNextContextExpressions) | 否,已合并为一次调用 |
| 哪个指令匹配到这个元素 | 运行时(首次创建时匹配,结果缓存进 TView) | 是(编译期不知道具体实例) |
| 绑定的值是多少 | 运行时 | 是 |
| 视图是否需要刷新(脏标记) | 运行时(signals / markForCheck) | 是 |
| 条件块当前是否显示 | 运行时 | 是 |
观察这张表可以得出一个清晰的结论:凡是"静态可知"的决策,Angular 全在编译期做了;运行时只留那些真正依赖运行状态的判断(第 12–15 行)。
这就是为什么 Ivy 的运行时代码那么"直"——它拿到的是一份已经把所有能算的都算完的指令序列,只需要按顺序执行。对照虚拟 DOM 方案(运行时要 diff 整棵树、要判断节点类型、要处理各种动态情况),Angular 的快不是偶然,是编译期把工作做完了。
实战含义:这也解释了为什么 Angular 的性能优化常常是"改变模板写法"——因为模板写法直接决定了编译产物。比如用 @for 的 track(影响 track_fn_optimization)、避免模板里的函数调用(影响纯函数提取)、拆分组件(影响 DomOnly 和增量构建)。在 Angular 里做性能优化,本质是在给编译器喂更好的输入。
2.18 本章核心结论(续):阅读回顾与自评
合上讲义前,试着不翻文件回答这四个"一句话问题":
- 一句话说清三种编译形态:JIT / AOT / Partial+Linker 各自的时机、产物和适用对象是什么?
- 一句话说清流水线:模板字符串到
ɵɵ指令,中间经历了哪几次 AST 形态变化? - 一句话说清 decls/vars:它们分别由哪个阶段的什么算法算出?
- 一句话说清 partial compilation 的价值:为什么它能解决"库与应用版本锁死"?
如果有卡壳,回到对应小节(§2.1 / §2.2 / §2.7.5 / §2.1.3)再读一遍。这四条是本系列后续章节反复用到的承重结构。
2.18.1 一个值得品味的观察:编译器的三次架构演进
把本章的内容和 Angular 的历史放在一起看,有一条清晰的演进脉络:
| 时代 | 架构 | 核心思路 |
|---|---|---|
| View Engine(≤ v8) | 单趟、全量编译 | 编译产物与运行时强耦合,导致库/应用版本锁死 |
| Ivy 早期(v9–v16) | 单趟直接生成代码 | 产物可摇树,但优化空间受限 |
| IR 流水线(v17+) | ingest → 多趟 transform → emit | 优化可独立增删,74 个阶段各司其职 |
每一次演进都在解决上一代的痛点:版本耦合 → 用 partial 格式解耦;优化受限 → 用 IR 多趟变换解耦。
这背后是一以贯之的工程哲学——把"决策"从"不可逆的时刻"往后挪。库发布时不做代码生成(挪到应用构建时),IR 到 emit 前都是可变的(挪到所有分析做完之后),常量池用 FixupExpression 占位(挪到所有代码生成完之后),槽位号用 SlotHandle 间接引用(挪到布局确定之后)。"能晚点决定的,就别早决定"——这条原则贯穿了整个 Angular 编译器,也是读它源码时最值得带走的方法论。
版本与勘误说明
本章所有源码引用、文件路径、常量值、函数行为均以本仓库 angular@22.2.3 的实际代码为准,并在写作前逐一核对过对应源文件。需要特别提示两点:
- IR 阶段清单是易变区域。
phases/下的 74 个文件、以及emit.ts里phases数组的顺序和内容,会随版本增删调整(某个阶段可能被合并、拆分或重排)。本章给出的是 22.2.3 的快照,请以此版本源码为准。 ɵɵngDeclare*是版本协商的公开契约。与ɵɵ指令不同,部分声明格式(R3DeclareComponentMetadata等)是长期稳定的契约——它必须稳定,否则老库就无法被新 linker 链接。因此这方面的描述具有更长的时效性。
若发现与最新源码有出入之处,多半是内部实现(尤其是 IR 阶段)已重构。请以 goldens/public-api/ 界定的公开契约为准绳,内部实现细节允许随版本演进。
换言之:本章讲的是"编译器的构造原理",而不是"某个版本的阶段清单"。原理稳定,清单易变——把这个区别记住,你就能在任何版本的 Angular 源码里复用本章的方法论。
延伸阅读建议:如果你想在本章之后再深入编译器的某一块,推荐按这个顺序自修——先看 packages/compiler/src/render3/r3_ast.ts(建立 R3 AST 的完整心智模型),再读 template/pipeline/src/ingest.ts(看 AST 是怎么被"翻译"成 IR 的),最后挑 phases/ 里你最感兴趣的 3–5 个阶段精读。不要试图一次读完 74 个阶段——记住 §2.7.8 那条编排脉络,需要时按需查阅即可。编译器源码的正确读法从来不是通读,而是"带着一个问题,追一条线"。
举三个可以马上动手的"追线练习":
- 追
@for的 track 是怎么优化的 →phases/track_fn_optimization.ts+phases/track_variables.ts; - 追
@defer是怎么生成懒加载代码的 →phases/defer_configs.ts(导出configureDeferInstructions)+phases/resolve_defer_deps_fns.ts+render3/r3_deferred_blocks.ts; - 追增量水合是怎么插进模板的 →
phases/insertIncrementalHydrationRuntime.ts(这条线会一直通到第 23 章)。
每条线追通一次,你对编译器的理解就会从"知道有 74 个阶段"升级为"知道它们是如何协作解决一个具体问题的"。