Angular 22 源码级深度解析-第 06 章 模板指令全解:ɵɵelementStart、ɵɵproperty、ɵɵadvance 与绑定指令的实现
系列 :《Angular 22 源码级深度解析》 代码基准 :
angular/angularv22.2.3(packages/core@22.2.3) 核心源码路径:
packages/core/src/render3/instructions/(全目录,41 个文件,9154 行)packages/core/src/render3/instructions/shared.ts(864 行,本章主战场)packages/core/src/render3/instructions/element.ts、template.ts、text.ts、property.ts、listener.ts、styling.ts(1052 行)、interpolation.ts、attribute.ts、advance.ts、two_way.ts、write_to_directive_input.tspackages/core/src/render3/view/listeners.ts(273 行)packages/core/src/render3/node_manipulation.ts(applyStyling)packages/core/src/render3/bindings.ts本章目标:回答"Angular 不用 VDOM 却知道该更新哪个 DOM 节点"到底是怎么做到的。
- 掌握所有
ɵɵ指令共有的四条契约;- 能手写一个模板的编译产物(创建块 + 更新块);
- 理解绑定索引(
bindingIndex)的本质与危险性;- 理解"指令输入优先于 DOM 属性"的分派逻辑;
- 理解事件监听如何与变更检测联动;
- 理解样式绑定的优先级链(v22 的重要实现细节)。
6.0 引子:把编译产物摊开看
第 02 章我们看过了编译流水线的结构,本章要落到产物的每一个函数上。
先给一个完整例子。源模板:
html
<div class="box" [title]="t" (click)="onClick()">
Hello {{ name }}!
@if (show) { <b [style.color]="c">yes</b> }
</div>
Angular 编译器(AOT)产出的模板函数大致是:
typescript
// 伪代码,结构精确、命名与真实产物一致
function App_Template(rf: RenderFlags, ctx: App) {
if (rf & 1) { // ── 创建块(RenderFlags.Create)
ɵɵelementStart(0, 'div', 0); // <div class="box">
ɵɵlistener('click', function() { // (click)
return ctx.onClick();
});
ɵɵtext(1, ' Hello '); // 静态文本
ɵɵtext(2); // 插值文本节点(值在更新块写)
ɵɵtext(3, '!');
ɵɵtemplate(4, App_b_4_Template, 1, 2, 'b', 1); // @if 的嵌入视图锚点
ɵɵelementEnd();
}
if (rf & 2) { // ── 更新块(RenderFlags.Update)
ɵɵadvance(2); // 游标移到节点 2
ɵɵtextInterpolate1(' Hello ', ctx.name, '!');
ɵɵadvance(2); // 游标移到节点 4(模板锚点)
ɵɵconditional(4, ctx.show ? 1 : -1); // @if 的条件分派
}
}
function App_b_4_Template(rf: RenderFlags, ctx: any) {
if (rf & 1) {
ɵɵelementStart(0, 'b', 1);
ɵɵelementEnd();
}
if (rf & 2) {
ɵɵadvance(0);
ɵɵstyleProp('color', ctx.c);
}
}
这份产物里有几个反直觉的地方,正是本章要解释的:
ɵɵadvance(2)里的2是"前进 2 步"而不是"到第 2 个节点" ------ 这是增量游标,不是绝对索引。- 创建块和更新块里,指令的调用顺序必须严格一致 ------ 否则绑定索引会错位。
(click)的事件监听器在创建块注册,但onClick里读的ctx是更新时才用的 ------ 因为闭包捕获了ctx。ɵɵtext(2)没有值参数 ------ 因为它是插值节点,值在更新块写。ɵɵconditional而不是ɵɵif------ v22 的@if编译成了通用的条件指令(第 07 章详解)。
6.1 指令的四条共同契约
所有 ɵɵ 开头的函数(@codeGenApi 标记的)都遵守同样的四条规则。理解这四条,就理解了整个指令体系的骨架。
契约一:只通过全局状态通信,不通过参数传递上下文
指令的签名里没有 lView 参数 。它们通过 getLView() / getTView() / getSelectedIndex() 从模块级全局状态读取:
typescript
export function ɵɵproperty<T>(propName: string, value: T, sanitizer?: SanitizerFn | null) {
const lView = getLView(); // ← 全局状态
const bindingIndex = nextBindingIndex(); // ← 全局状态
if (bindingUpdated(lView, bindingIndex, value)) {
const tView = getTView();
const tNode = getSelectedTNode(); // ← 全局状态(游标)
setPropertyAndInputs(tNode, lView, propName, value, lView[RENDERER], sanitizer);
// ...
}
return ɵɵproperty;
}
为什么? 因为 enterView / leaveView(第 01、05 章讲过)已经在 refreshView 里设置好了全局状态。如果每个指令都要传 lView,模板函数会长成:
typescript
ɵɵproperty(lView, tView, index, 'title', ctx.t, renderer, sanitizer);
参数数量翻倍,代码体积暴增,而且 V8 需要为不同参数个数生成不同的调用点。
代价:指令严重依赖调用顺序(契约二)。
契约二:调用顺序即语义
由于 nextBindingIndex() 每次自增、getSelectedIndex() 由 advance 维护,指令序列的顺序本身就是数据结构的一部分。
这带来一个硬性约束:创建块和更新块必须走同样的"游标路径"。
具体来说,编译器保证:
- 创建块里每个
elementStart/text/template分配一个递增的 index(0,1,2,...); - 更新块里
advance(delta)累积出同样的 index; - 每个绑定指令消耗的 bindingIndex 数量在创建时确定(
vars的由来,第 02 章讲过)。
如果顺序错了会怎样? 绑定值写到错误的 LView 槽位 → 上一个绑定的旧值被比较 → DOM 永远不更新,或更新错误的值。这是编译器 bug 的表现,不是运行时 bug。
契约三:返回自身以支持链式调用
typescript
return ɵɵproperty; // 返回函数自身
于是可以:
typescript
ɵɵproperty('title', ctx.t)('id', ctx.id)('name', ctx.n);
为什么? 压缩体积。如果要写三行独立调用,每行都要重复 ɵɵproperty 这个(被压缩后可能是 a)标识符。链式调用只出现一次。
在大型模板里(几百个绑定),这能省下可观的代码体积。
注意 :不是所有指令都返回自身。ɵɵadvance、ɵɵtext、ɵɵelementEnd、ɵɵstyleMap、ɵɵclassMap 返回 void------因为它们不需要链式。
契约四:只在值变化时写 DOM
typescript
if (bindingUpdated(lView, bindingIndex, value)) {
// 只有这里才碰 DOM
}
这是第 05 章 §5.10 讲过的"第三层过滤",在每一个绑定指令里都有。
6.2 创建指令族:搭建 DOM 与 TView
创建块只在第一次 渲染时执行(tView.firstCreatePass 之后,LView 复用时不再执行)。
6.2.1 ɵɵelementStart / ɵɵelement / ɵɵelementEnd
typescript
// packages/core/src/render3/instructions/element.ts:83-120(节选)
export function ɵɵelementStart(
index: number,
name: string,
attrsIndex?: number | null,
localRefsIndex?: number,
): typeof ɵɵelementStart {
const lView = getLView();
ngDevMode && assertTNodeCreationIndex(lView, index);
const tView = lView[TVIEW];
const adjustedIndex = index + HEADER_OFFSET;
const tNode = tView.firstCreatePass
? directiveHostFirstCreatePass(
adjustedIndex, lView, TNodeType.Element, name,
findDirectiveDefMatches, getBindingsEnabled(),
attrsIndex, localRefsIndex,
)
: (tView.data[adjustedIndex] as TElementNode);
// If the node is a component host and we have a tracing service, we need to wrap the init logic.
if (isComponentHost(tNode)) {
const tracingService = lView[ENVIRONMENT].tracingService;
// ...
}
// ...
}
两个关键分支:
① tView.firstCreatePass 分支
true(首次) :调directiveHostFirstCreatePass,做三件事:- 创建
TNode(TNodeType.Element); - 匹配指令 (
findDirectiveDefMatches)------根据标签名和属性,查找哪些指令/组件应用到这个元素; - 把匹配结果存进
TView.data。
- 创建
false(后续) :直接从tView.data[adjustedIndex]读已经缓存好的TNode。
这就是"编译期能确定的事不在运行时重复做"的落实:指令匹配(选择器匹配)是昂贵的操作,只在第一次做,结果缓存进 TView,后续所有实例共享。
② index + HEADER_OFFSET
第 01 章讲过 HEADER_OFFSET = 28。模板里写的 0,1,2... 要加上 28 才是 LView 的真实下标。
6.2.2 elementLikeStartShared:所有元素类指令的公共部分
typescript
// packages/core/src/render3/instructions/shared.ts:611-653
export function elementLikeStartShared(
tNode: TElementNode | TElementContainerNode,
lView: LView,
index: number,
name: string,
locateOrCreateNativeNode: (
tView: TView, lView: LView, tNode: TNode, name: string, index: number,
) => RNode,
) {
const adjustedIndex = HEADER_OFFSET + index;
const tView = lView[TVIEW];
const native = locateOrCreateNativeNode(tView, lView, tNode, name, index);
lView[adjustedIndex] = native;
setCurrentTNode(tNode, true);
// It's important that this runs before we've instantiated the directives.
const isElement = tNode.type === TNodeType.Element;
if (isElement) {
setupStaticAttributes(lView[RENDERER], native as RElement, tNode);
// any immediate children of a component or template container must be pre-emptively
// monkey-patched with the component view data so that the element can be inspected
// later on using any element discovery utility methods (see `element_discovery.ts`)
if (getElementDepthCount() === 0 || isDirectiveHost(tNode)) {
attachPatchData(native, lView);
}
increaseElementDepthCount();
} else {
attachPatchData(native, lView);
}
if (wasLastNodeCreated() && (!isElement || !isDetachedByI18n(tNode))) {
// In the i18n case, the translation may have removed this element, so only add it if it is not
// detached. See `TNodeType.Placeholder` and `LFrame.inI18n` for more context.
appendChild(tView, lView, native, tNode);
}
return tNode;
}
四个值得注意的点:
① locateOrCreateNativeNode 回调:水合的统一入口
这个函数指针有两种实现:
- 普通模式 :直接
createElementNode - 水合模式 :
locateOrCreateElementNodeImpl,先在已有 DOM 里查找,找不到才创建
看 ɵɵtext 里的对应代码就很清楚:
typescript
// packages/core/src/render3/instructions/text.ts:60-90(节选)
let _locateOrCreateTextNode: typeof locateOrCreateTextNodeImpl = (
tView: TView, lView: LView, tNode: TNode, value: string,
) => {
lastNodeWasCreated(true);
return createTextNode(lView[RENDERER], value);
};
function locateOrCreateTextNodeImpl(
tView: TView, lView: LView, tNode: TNode, value: string,
): RText {
const isNodeCreationMode = !canHydrateNode(lView, tNode);
lastNodeWasCreated(isNodeCreationMode);
if (isNodeCreationMode) {
return createTextNode(lView[RENDERER], value);
}
// Hydration mode, looking up an existing element in DOM.
const hydrationInfo = lView[HYDRATION]!;
const textNative = locateNextRNode(hydrationInfo, tView, lView, tNode) as RText;
ngDevMode && validateMatchingNode(textNative, Node.TEXT_NODE, null, lView, tNode);
ngDevMode && markRNodeAsClaimedByHydration(textNative);
return textNative;
}
export function enableLocateOrCreateTextNodeImpl() {
_locateOrCreateTextNode = locateOrCreateTextNodeImpl;
}
这是一个非常干净的"特性开关"设计:
- 默认变量指向简单实现;
- 水合包加载时调
enableLocateOrCreateTextNodeImpl()替换成复杂实现; - 指令代码完全不变。
好处:不需要水合的应用,复杂实现被 tree-shake 掉。
② attachPatchData 的条件化
typescript
if (getElementDepthCount() === 0 || isDirectiveHost(tNode)) {
attachPatchData(native, lView);
}
attachPatchData 会给 DOM 元素挂 __ngContext__ 之类的元数据,用于 ng.getComponent() 等调试 API。
只在两种情况下挂:
- 深度为 0(根级元素)
- 或是指令宿主
这是纯性能优化:普通深层元素不需要被"发现",省掉一次对象属性写入。
③ appendChild 的条件
typescript
if (wasLastNodeCreated() && (!isElement || !isDetachedByI18n(tNode))) {
appendChild(tView, lView, native, tNode);
}
两个条件:
wasLastNodeCreated()------ 水合模式下如果复用了已有节点,不需要 append;!isDetachedByI18n(tNode)------ i18n 翻译可能移除了这个元素,此时它是"游离"的,不挂到树上。
④ increaseElementDepthCount()
维护元素的嵌套深度,配合上面的条件判断。
6.2.3 ɵɵelement:糖
typescript
// packages/core/src/render3/instructions/element.ts:216-225
export function ɵɵelement(
index: number,
name: string,
attrsIndex?: number | null,
localRefsIndex?: number,
): typeof ɵɵelement {
ɵɵelementStart(index, name, attrsIndex, localRefsIndex);
ɵɵelementEnd();
return ɵɵelement;
}
就是 elementStart + elementEnd。用于没有子元素的元素(自闭合或无子节点)。
6.2.4 ɵɵelementEnd:收尾与样式阴影处理
typescript
// packages/core/src/render3/instructions/element.ts:165-200(节选)
export function ɵɵelementEnd(): typeof ɵɵelementEnd {
const tView = getTView();
const initialTNode = getCurrentTNode()!;
ngDevMode && assertDefined(initialTNode, 'No parent node to close.');
const currentTNode = elementLikeEndShared(initialTNode);
ngDevMode && assertTNodeType(currentTNode, TNodeType.AnyRNode);
if (tView.firstCreatePass) {
directiveHostEndFirstCreatePass(tView, currentTNode);
}
if (isSkipHydrationRootTNode(currentTNode)) {
leaveSkipHydrationBlock();
}
decreaseElementDepthCount();
if (currentTNode.classesWithoutHost != null && hasClassInput(currentTNode)) {
setDirectiveInputsWhichShadowsStyling(
tView, currentTNode, getLView(), currentTNode.classesWithoutHost, true,
);
}
if (currentTNode.stylesWithoutHost != null && hasStyleInput(currentTNode)) {
setDirectiveInputsWhichShadowsStyling(
tView, currentTNode, getLView(), currentTNode.stylesWithoutHost, false,
);
}
// ...
}
classesWithoutHost / stylesWithoutHost 是一个有意思的边界:
如果元素上既有静态 class/style,又有指令的同名 @Input():
html
<div class="box" [class]="expr" my-dir> <!-- MyDir 有 @Input('class') -->
此时"class"这个属性名有两个去处:DOM 的 class,和指令的 input。setDirectiveInputsWhichShadowsStyling 负责把非 host 的静态样式作为输入传给指令。
elementLikeEndShared 的实现:
typescript
// packages/core/src/render3/instructions/shared.ts:656-667
export function elementLikeEndShared(tNode: TNode): TNode {
let currentTNode = tNode;
if (isCurrentTNodeParent()) {
setCurrentTNodeAsNotParent();
} else {
ngDevMode && assertHasParent(getCurrentTNode());
currentTNode = currentTNode.parent!;
setCurrentTNode(currentTNode, false);
}
return currentTNode;
}
维护"当前 TNode"这个游标,用于父子关系的建立(appendChild 决定挂到谁下面)。
6.2.5 ɵɵtext
typescript
// packages/core/src/render3/instructions/text.ts:37-57
export function ɵɵtext(index: number, value = ''): void {
const lView = getLView();
const tView = getTView();
const adjustedIndex = index + HEADER_OFFSET;
ngDevMode && assertTNodeCreationIndex(lView, index);
const tNode = tView.firstCreatePass
? getOrCreateTNode(tView, adjustedIndex, TNodeType.Text, value, null)
: (tView.data[adjustedIndex] as TElementNode);
const textNative = _locateOrCreateTextNode(tView, lView, tNode, value);
lView[adjustedIndex] = textNative;
if (wasLastNodeCreated()) {
appendChild(tView, lView, textNative, tNode);
}
// Text nodes are self closing.
setCurrentTNode(tNode, false);
}
注意最后一行:setCurrentTNode(tNode, false) ------ 参数是 false,表示"不是父节点"。文本节点不能当父节点,所以游标不进入它。
这就是为什么 ɵɵtext 后面不需要 ɵɵtextEnd。
6.2.6 ɵɵtemplate:嵌入视图的锚点
typescript
// packages/core/src/render3/instructions/template.ts:266-293
export function ɵɵtemplate(
index: number,
templateFn: ComponentTemplate<any> | null,
decls: number,
vars: number,
tagName?: string | null,
attrsIndex?: number | null,
localRefsIndex?: number | null,
localRefExtractor?: LocalRefExtractor,
): typeof ɵɵtemplate {
const lView = getLView();
const tView = getTView();
const attrs = getConstant<TAttributes>(tView.consts, attrsIndex);
declareDirectiveHostTemplate(
lView, tView, index, templateFn, decls, vars,
tagName, attrs, undefined, localRefsIndex, localRefExtractor,
);
return ɵɵtemplate;
}
参数含义:
decls/vars:嵌入视图自己的槽位数和变量数(第 02 章讲过它们的算法);templateFn:嵌入视图的模板函数;tagName:ng-template或ng-container对应的标签(用于 i18n 和 SSR)。
注意:这里不创建 DOM! 它只创建一个 LContainer(逻辑容器)+ 一个注释节点作为锚点。真正的 DOM 要等 ɵɵconditional / ɵɵrepeater 决定插入时才创建。
6.3 ɵɵadvance:隐式游标与一个隐藏的陷阱
typescript
// packages/core/src/render3/instructions/advance.ts:37-48
export function ɵɵadvance(delta: number = 1): void {
ngDevMode && assertGreaterThan(delta, 0, 'Can only advance forward');
selectIndexInternal(
getTView(),
getLView(),
getSelectedIndex() + delta,
!!ngDevMode && isInCheckNoChangesMode(),
);
}
delta 是增量 ,不是目标索引。ɵɵadvance(2) 表示"向前走 2 个槽位"。
6.3.1 selectIndexInternal:钩子执行的隐藏现场
typescript
// packages/core/src/render3/instructions/advance.ts:50-83
export function selectIndexInternal(
tView: TView,
lView: LView,
index: number,
checkNoChangesMode: boolean,
) {
ngDevMode && assertIndexInDeclRange(lView[TVIEW], index);
// Flush the initial hooks for elements in the view that have been added up to this point.
// PERF WARNING: do NOT extract this to a separate function without running benchmarks
if (!checkNoChangesMode) {
const hooksInitPhaseCompleted =
(lView[FLAGS] & LViewFlags.InitPhaseStateMask) === InitPhaseState.InitPhaseCompleted;
if (hooksInitPhaseCompleted) {
const preOrderCheckHooks = tView.preOrderCheckHooks;
if (preOrderCheckHooks !== null) {
executeCheckHooks(lView, preOrderCheckHooks, index);
}
} else {
const preOrderHooks = tView.preOrderHooks;
if (preOrderHooks !== null) {
executeInitAndCheckHooks(lView, preOrderHooks, InitPhaseState.OnInitHooksToBeRun, index);
}
}
}
// We must set the selected index *after* running the hooks, because hooks may have side-effects
// that cause other template functions to run, thus updating the selected index, which is global
// state. If we run `setSelectedIndex` *before* we run the hooks, in some cases the selected index
// will be altered by the time we leave the `ɵɵadvance` instruction.
setSelectedIndex(index);
}
这段代码揭示了一件容易被忽略的事:ɵɵadvance 不只是移动游标,它还会执行钩子。
具体地说:指令的 ngOnInit / ngDoCheck 是在 advance 时执行的,而不是在组件模板函数执行完之后统一执行。
executeCheckHooks(lView, preOrderCheckHooks, index) 的第三个参数 index 限制了"只执行索引小于 index 的钩子"。这保证了钩子的执行顺序与 DOM 顺序一致:
scss
模板: <child-a> <child-b> <child-c>
更新: advance(1) → childA 的 ngDoCheck
advance(1) → childB 的 ngDoCheck
advance(1) → childC 的 ngDoCheck
6.3.2 那段"为什么先跑钩子后设索引"的注释
typescript
// We must set the selected index *after* running the hooks, because hooks may have side-effects
// that cause other template functions to run, thus updating the selected index, which is global
// state. If we run `setSelectedIndex` *before* we run the hooks, in some cases the selected index
// will be altered by the time we leave the `ɵɵadvance` instruction.
这是一个真实的 bug 修复痕迹。
场景:指令的 ngDoCheck 里调用了 ViewContainerRef.createEmbeddedView(...).detectChanges()。这会执行另一个模板函数,进而调用 advance,改变全局的 selected index。
如果 setSelectedIndex(index) 在钩子之前执行,那么钩子跑完后,selected index 已经被污染成别的值,后续绑定指令就会写到错误的元素上。
先跑钩子、后设索引,保证 advance 结束时 selected index 一定是正确的。
这类"全局状态的重入污染"问题,是隐式上下文设计的典型代价。Angular 的解决办法是"注意顺序 + 写注释警告"。
6.3.3 PERF WARNING 注释
源码里多处出现:
typescript
// PERF WARNING: do NOT extract this to a separate function without running benchmarks
在 refreshView 里也出现过三次。
含义 :这段代码看起来可以重构成独立函数(更可读),但实测发现函数调用开销 + 参数传递在热路径上是有 measurable 影响的。所以宁可重复、内联,也不提取。
这提醒我们:框架源码里的"丑代码"往往是有意为之。不要以"可读性"为唯一标准去重构热路径。
6.4 绑定索引:Angular 最脆弱的机制
6.4.1 nextBindingIndex() 与槽位分配
typescript
// packages/core/src/render3/state.ts(相关函数)
export function nextBindingIndex(): number {
return incrementBindingIndex(1);
}
export function incrementBindingIndex(delta: number): number {
const current = bindingIndex;
bindingIndex += delta;
return current;
}
每次调用返回当前值并自增。这个 bindingIndex 是 LFrame(局部帧)里的字段,随 enterView/leaveView 保存恢复。
起点由 refreshView 步骤 4 设置:
typescript
setBindingIndex(tView.bindingStartIndex);
第 01 章讲过,tView.bindingStartIndex = HEADER_OFFSET + decls(节点槽位之后的第一个位置)。
6.4.2 一个绑定消耗几个槽位?
| 绑定类型 | 槽位数 | 说明 |
|---|---|---|
ɵɵproperty |
1 | 存值 |
ɵɵattribute |
1 | 存值 |
ɵɵtextInterpolate1 |
1 | 存 v0 |
ɵɵtextInterpolate2 |
2(用 bindingUpdated2) |
存 v0、v1 |
ɵɵtextInterpolateV |
N | 每个表达式一个 |
ɵɵstyleProp / ɵɵclassProp |
2 | 一个存值,一个存 TStylingRange |
ɵɵstyleMap / ɵɵclassMap |
2 | 同上 |
ɵɵproperty + sanitizer |
1 | ------ |
样式用 2 个槽位,源码里有明确注释:
typescript
// packages/core/src/render3/instructions/styling.ts:214-217
// Styling instructions use 2 slots per binding.
// 1. one for the value / TStylingKey
// 2. one for the intermittent-value / TStylingRange
const bindingIndex = incrementBindingIndex(2);
第二个槽位存 TStylingRange------用于 §6.9 的优先级链。
6.4.3 脆弱性:为什么手写指令很危险
如果你手写模板函数(比如做代码生成、或写测试工具),绑定索引错位会导致静默的错误:
typescript
// ❌ 错误:创建块写了 2 个绑定,更新块只写了 1 个
if (rf & 1) {
ɵɵelementStart(0, 'div');
ɵɵelementEnd();
}
if (rf & 2) {
ɵɵadvance(1);
ɵɵproperty('title', ctx.a);
// 忘记写 ɵɵproperty('id', ctx.b)
}
实际后果取决于 vars 的值:如果 vars 是 2,那么第二个槽位永远是初始值 NO_CHANGE,bindingUpdated 永远返回 false(因为没人写它),DOM 永远不更新。没有报错,只有静默失败。
这也是为什么 Angular 强烈建议用 AOT 编译而不是手写模板函数。
6.5 属性绑定族:ɵɵproperty 与其四个变体
Angular 有 5 个属性/特性绑定指令,它们分派逻辑不同:
| 指令 | 模板语法 | 分派逻辑 | 文件 |
|---|---|---|---|
ɵɵproperty |
[prop]="v" |
先试指令输入,再落 DOM 属性 | property.ts:38 |
ɵɵdomProperty |
[prop]="v"(确定是 DOM 属性时) |
只写 DOM 属性 | dom_property.ts:36 |
ɵɵattribute |
[attr.x]="v" |
只写 attribute | attribute.ts:26 |
ɵɵariaProperty |
[aria-x]="v" |
先试输入,再写 attribute | aria_property.ts:41 |
ɵɵsyntheticHostProperty |
[@foo] |
用子组件渲染器写 DOM 属性 | dom_property.ts:73 |
6.5.1 ɵɵproperty:三条路径
typescript
// packages/core/src/render3/instructions/property.ts:38-52
export function ɵɵproperty<T>(
propName: string,
value: T,
sanitizer?: SanitizerFn | null,
): typeof ɵɵproperty {
const lView = getLView();
const bindingIndex = nextBindingIndex();
if (bindingUpdated(lView, bindingIndex, value)) {
const tView = getTView();
const tNode = getSelectedTNode();
setPropertyAndInputs(tNode, lView, propName, value, lView[RENDERER], sanitizer);
ngDevMode && storePropertyBindingMetadata(tView.data, tNode, propName, bindingIndex);
}
return ɵɵproperty;
}
核心分派在 setPropertyAndInputs:
typescript
// packages/core/src/render3/instructions/shared.ts:251-273
export function setPropertyAndInputs<T>(
tNode: TNode,
lView: LView,
propName: string,
value: T,
renderer: Renderer,
sanitizer: SanitizerFn | null | undefined,
): void {
ngDevMode && assertNotSame(value, NO_CHANGE as any, 'Incoming value should never be NO_CHANGE.');
const tView = lView[TVIEW];
const hasSetInput = setAllInputsForProperty(tNode, tView, lView, propName, value);
if (hasSetInput) {
isComponentHost(tNode) && markDirtyIfOnPush(lView, tNode.index);
ngDevMode && setNgReflectProperties(lView, tView, tNode, propName, value);
return; // Stop propcessing if we've matched at least one input.
}
// If the property is going to a DOM node, we have to remap it.
if (tNode.type & TNodeType.AnyRNode) {
propName = mapPropName(propName);
}
setDomProperty(tNode, lView, propName, value, renderer, sanitizer);
}
三条路径(按顺序):
scss
setAllInputsForProperty(...) → 命中指令/组件输入?
│
├─ YES → markDirtyIfOnPush(如果是组件宿主)
│ setNgReflectProperties(dev)
│ return ← 注意:直接返回,不写 DOM!
│
└─ NO → tNode 是真实 DOM 节点?
├─ YES → mapPropName(propName) ← 属性名重映射
│ setDomProperty(...)
└─ NO → 容器节点:dev 模式下可能报错
关键洞察:"命中输入就不写 DOM"
return; // Stop propcessing if we've matched at least one input.
这解释了一个常见疑惑:<my-cmp [title]="x"> 时,title 不会写到宿主 DOM 元素上 。因为它匹配了 MyCmp 的 @Input() title。
而 <div [title]="x"> 时,没有指令匹配,所以走 setDomProperty 写到 DOM。
这个设计让 Angular 的"输入"和"DOM 属性"统一在了一个语法里 ,但也带来了歧义。Angular 的选择是:输入优先。
mapPropName:属性名重映射
typescript
if (tNode.type & TNodeType.AnyRNode) {
propName = mapPropName(propName);
}
模板里写 [for] 或 [class] 这类名字,在 DOM 上对应的是 htmlFor、className。mapPropName 做这个映射(映射表在 node_manipulation.ts 里)。
setDomProperty:净化 + 写入
typescript
// packages/core/src/render3/instructions/shared.ts:283-320(节选)
export function setDomProperty<T>(
tNode: TNode,
lView: LView,
propName: string,
value: T,
renderer: Renderer,
sanitizer: SanitizerFn | null | undefined,
) {
if (tNode.type & TNodeType.AnyRNode) {
const element = getNativeByTNode(tNode, lView) as RElement | RComment;
if (ngDevMode) {
if (lView[TVIEW].firstUpdatePass) {
validateAgainstEventProperties(propName);
}
if (!isPropertyValid(element, propName, tNode.value, lView[TVIEW].schemas)) {
handleUnknownPropertyError(propName, tNode.value, tNode.type, lView);
}
}
// It is assumed that the sanitizer is only added when the compiler determines that the
// property is risky, so sanitization can be done without further checks.
value = sanitizer != null ? (sanitizer(value, tNode.value || '', propName) as any) : value;
// The `src` property previously used a sanitizer which mapped `null`/`undefined` to `''`.
// Now that `img` and `video` `src` are no longer sanitized, we still need to map `null` and
// `undefined` to `''` to avoid stringifying them to `'null'` or `'undefined'` and causing
// broken network requests.
// TODO(v23): Remove this workaround once we can introduce a breaking change
value = unwrapImgVideoSrcValue(propName, element as RElement, value);
renderer.setProperty(element as RElement, propName, value);
} else if (tNode.type & TNodeType.AnyContainer) {
// If the node is a container and the property didn't
// match any of the inputs or schemas we should throw.
if (ngDevMode && !matchingSchemas(lView[TVIEW].schemas, tNode.value)) {
handleUnknownPropertyError(propName, tNode.value, tNode.type, lView);
}
}
}
四个要点:
-
dev 模式下的属性校验 :
isPropertyValid会检查这个属性是否真的存在于元素上。不存在就报NG0303(Can't bind to 'x' since it isn't a known property)。注意
if (lView[TVIEW].firstUpdatePass)------ 这个校验只在第一次更新时做,不是每次都做。又是一个性能优化。 -
净化器由编译器决定:注释说得很清楚------"It is assumed that the sanitizer is only added when the compiler determines that the property is risky"。
这意味着净化是编译期决策 :编译器分析
[innerHTML]、[src]这类危险绑定,在生成的代码里传入 sanitizer 参数。运行时不做判断,只执行。 -
unwrapImgVideoSrcValue的TODO(v23)------ 一个真实的兼容性 workaround。img/video的src之前走净化器(会把null映射成''),现在不净化了,但为了不产生"null"字符串导致的坏请求,仍需手动映射。团队标注了"v23 引入破坏性变更时移除"。 -
AnyContainer分支 :ng-container上写未匹配的属性会报错(除非有匹配的 schema)。
6.5.2 ɵɵattribute:attribute vs property
typescript
// packages/core/src/render3/instructions/attribute.ts:26-42
export function ɵɵattribute(
name: string,
value: any,
sanitizer?: SanitizerFn | null,
namespace?: string,
): typeof ɵɵattribute {
const lView = getLView();
const bindingIndex = nextBindingIndex();
if (bindingUpdated(lView, bindingIndex, value)) {
const tView = getTView();
const tNode = getSelectedTNode();
elementAttributeInternal(tNode, lView, name, value, sanitizer, namespace);
ngDevMode && storePropertyBindingMetadata(tView.data, tNode, 'attr.' + name, bindingIndex);
}
return ɵɵattribute;
}
attr. 前缀出现在 dev 元数据里,方便错误信息定位。
attribute vs property 的语义差:
- attribute:写在 HTML 标签上的(
<div title="x">),值永远是字符串,null/undefined时移除; - property:DOM 对象上的属性(
div.title = 'x'),可以是任意 JS 值。
Angular 区分它们是因为 SVG、ARIA 等场景必须用 attribute。
6.5.3 ɵɵariaProperty:无障碍的专用通道
typescript
// packages/core/src/render3/instructions/aria_property.ts:41-63(节选)
export function ɵɵariaProperty<T>(name: string, value: T): typeof ɵɵariaProperty {
const lView = getLView();
const bindingIndex = nextBindingIndex();
if (bindingUpdated(lView, bindingIndex, value)) {
const tView = getTView();
const tNode = getSelectedTNode();
const hasSetInput = setAllInputsForProperty(tNode, tView, lView, name, value);
if (hasSetInput) {
isComponentHost(tNode) && markDirtyIfOnPush(lView, tNode.index);
ngDevMode && setNgReflectProperties(lView, tView, tNode, name, value);
} else {
ngDevMode && assertTNodeType(tNode, TNodeType.Element);
const element = getNativeByTNode(tNode, lView) as RElement;
setElementAttribute(lView[RENDERER], element, null, tNode.value, name, value, null);
}
// ...
}
return ɵɵariaProperty;
}
为什么 ARIA 需要单独的指令? 因为 ARIA 属性(如 aria-label)必须写成 attribute ,写 property 无效(ARIA 反射是有条件的)。但同时又希望它能被指令的 @Input('aria-label') 捕获。
所以逻辑是:"先试输入,否则写 attribute"------和 ɵɵproperty 的区别只在最后一步(attribute 而非 property)。
6.6 writeToDirectiveInput:输入写入的四种情况
setAllInputsForProperty 最终会调 writeToDirectiveInput:
typescript
// packages/core/src/render3/instructions/write_to_directive_input.ts:18-72
export function writeToDirectiveInput<T>(
def: DirectiveDef<T>,
instance: T,
publicName: string,
value: unknown,
) {
const prevConsumer = setActiveConsumer(null);
try {
if (ngDevMode) {
if (!Object.hasOwn(def.inputs, publicName)) {
throw new Error(
`ASSERTION ERROR: Directive ${def.type.name} does not have an input with a public name of "${publicName}"`,
);
}
// Usually we resolve the directive instance using `LView[someIndex]` before writing to an
// input, however if the read happens to early, the `LView[someIndex]` might actually be a
// `NodeInjectorFactory`. Check for this specific case here since it can break in subtle ways.
if (instance instanceof NodeInjectorFactory) {
throw new Error(
`ASSERTION ERROR: Cannot write input to factory for type ${def.type.name}. Directive has not been created yet.`,
);
}
}
const [privateName, flags, transform] = def.inputs[publicName];
// If we know we are dealing with a signal input, we cache its reference
// in a tree-shakable way. The input signal node can then be used for
// value transform execution or actual value updates without introducing
// additional megamorphic accesses for accessing the instance field.
let inputSignalNode: InputSignalNode<unknown, unknown> | null = null;
if ((flags & InputFlags.SignalBased) !== 0) {
const field = (instance as any)[privateName] as InputSignalWithTransform<unknown, unknown>;
inputSignalNode = field[SIGNAL];
}
// If there is a signal node and a transform, run it before potentially
// delegating to features like `NgOnChanges`.
if (inputSignalNode !== null && inputSignalNode.transformFn !== undefined) {
value = inputSignalNode.transformFn(value);
} else if (transform !== null) {
// If there is a decorator input transform, run it.
value = transform.call(instance, value);
}
if (def.setInput !== null) {
def.setInput(instance, inputSignalNode, value, publicName, privateName);
} else {
applyValueToInputField(instance, inputSignalNode, privateName, value);
}
} finally {
setActiveConsumer(prevConsumer);
}
}
6.6.1 四条路径
scss
def.inputs[publicName] = [privateName, flags, transform]
│
├─ flags & SignalBased ?
│ └─ YES → 取出 field[SIGNAL](signal input 的 ReactiveNode)
│
├─ transform 从哪来?
│ ├─ signal input 且有 transformFn → inputSignalNode.transformFn(value)
│ └─ 装饰器 input 有 transform → transform.call(instance, value)
│
└─ 写入
├─ def.setInput !== null(有 ngOnChanges 等 feature)
│ → def.setInput(instance, node, value, publicName, privateName)
└─ 否则 → applyValueToInputField(instance, node, privateName, value)
6.6.2 setActiveConsumer(null) 的重要性
第一行和 finally 里的 setActiveConsumer(null) / 恢复。
为什么? 输入的 transform 函数是用户代码,它如果读了 signal,不应该被计入任何依赖图。输入写入是"外部推入"的行为,不是"响应式读取"。
这是一个安全网:保证用户代码不会意外污染框架的响应式追踪。
6.6.3 "避免巨态访问"的注释
typescript
// If we know we are dealing with a signal input, we cache its reference
// in a tree-shakable way. The input signal node can then be used for
// value transform execution or actual value updates without introducing
// additional megamorphic accesses for accessing the instance field.
回顾 PERF_NOTES.md:单态属性访问比巨态快 100 倍。
如果不缓存 inputSignalNode,每次写入都要 instance[privateName][SIGNAL]------两次属性查找,且 instance 可能是多种类型(巨态)。缓存后,只需一次已确定类型的访问。
6.6.4 dev 模式的两条断言
!Object.hasOwn(def.inputs, publicName)------ 防止编译器 bug(生成了不存在的输入)。instance instanceof NodeInjectorFactory------ 这是一个极其隐蔽的时序 bug 防护。
第二条值得展开:LView[someIndex] 在指令实例化之前,存放的是 NodeInjectorFactory(DI 的工厂占位对象)。如果输入写入发生得太早(指令还没创建),instance 会是工厂对象,写入会静默失败或产生诡异行为。这条断言把它变成明确的错误。
这是"把静默失败变成明确报错"的典范。
6.7 插值族:interpolation1 到 interpolation8 与 interpolationV
6.7.1 为什么要有 9 个函数?
typescript
// packages/core/src/render3/instructions/interpolation.ts:58-67
export function interpolation1(
lView: LView,
prefix: string,
v0: any,
suffix = '',
): string | NO_CHANGE {
const different = bindingUpdated(lView, nextBindingIndex(), v0);
return different ? prefix + renderStringify(v0) + suffix : NO_CHANGE;
}
对比可变参数版本:
typescript
// packages/core/src/render3/instructions/interpolation.ts:26-50
export function interpolationV(lView: LView, values: any[]): string | NO_CHANGE {
ngDevMode && assertLessThan(2, values.length, 'should have at least 3 values');
let isBindingUpdated = false;
let bindingIndex = getBindingIndex();
for (let i = 1; i < values.length; i += 2) {
isBindingUpdated = bindingUpdated(lView, bindingIndex++, values[i]) || isBindingUpdated;
}
setBindingIndex(bindingIndex);
if (!isBindingUpdated) {
return NO_CHANGE;
}
let content = values[0];
for (let i = 1; i < values.length; i += 2) {
content += renderStringify(values[i]) + (i + 1 !== values.length ? values[i + 1] : '');
}
return content;
}
源码注释直说了原因:
sql
* If there are 1 to 8 expressions `interpolation1()` to `interpolation8()` should be used instead.
* Those are faster because there is no need to create an array of expressions and iterate over it.
interpolationV 需要一个数组参数 ------ 每次调用都要分配一个数组(70 字节 + 元素)。而 interpolation1 用独立参数,零分配。
这是典型的"用代码体积换运行时性能":生成 9 个几乎一样的函数,让 99% 的场景(1-3 个插值)走零分配路径。
6.7.2 bindingUpdated2/3/4:多值批量比较
typescript
export function interpolation2(
lView: LView,
prefix: string,
v0: any,
i0: string,
v1: any,
suffix = '',
): string | NO_CHANGE {
const bindingIndex = getBindingIndex();
const different = bindingUpdated2(lView, bindingIndex, v0, v1);
incrementBindingIndex(2);
return different ? prefix + renderStringify(v0) + i0 + renderStringify(v1) + suffix : NO_CHANGE;
}
bindingUpdated2 一次比较两个值并写入两个槽位,比调两次 bindingUpdated 快(少一次索引计算)。
参数排列规律:prefix, v0, i0, v1, i1, v2, ..., suffix ------ 偶数位是静态文本,奇数位是表达式。
在 interpolationV 的文档里写明了:
markdown
* `values`:
* - has static text at even indexes,
* - has evaluated expressions at odd indexes.
6.7.3 NO_CHANGE 的传播
注意 interpolation1 返回 NO_CHANGE 而不是拼接字符串。调用方检查:
typescript
export function ɵɵtextInterpolate1(prefix: string, v0: any, suffix?: string) {
const lView = getLView();
const interpolated = interpolation1(lView, prefix, v0, suffix);
if (interpolated !== NO_CHANGE) {
textBindingInternal(lView, getSelectedIndex(), interpolated as string);
}
return ɵɵtextInterpolate1;
}
NO_CHANGE 一路传播到最外层,避免了:
- 字符串拼接(
+操作和renderStringify); - DOM 写入(
updateTextNode)。
6.7.4 textBindingInternal
typescript
// packages/core/src/render3/instructions/text_interpolation.ts:460-467
function textBindingInternal(lView: LView, index: number, value: string): void {
ngDevMode && assertString(value, 'Value should be a string');
ngDevMode && assertNotSame(value, NO_CHANGE as any, 'value should not be NO_CHANGE');
ngDevMode && assertIndexInRange(lView, index);
const element = getNativeByIndex(index, lView) as any as RText;
ngDevMode && assertDefined(element, 'native element should exist');
updateTextNode(lView[RENDERER], element, value);
}
最终通过 renderer.setValue(textNode, value) 写入。
注意它不用 bindingUpdated ------ 因为值比较已经在 interpolation* 里做过了。这里直接写。
6.8 事件绑定:ɵɵlistener 与变更检测的联动
6.8.1 ɵɵlistener 的分派
typescript
// packages/core/src/render3/instructions/listener.ts:31-47
export function ɵɵlistener(
eventName: string,
listenerFn: EventCallback,
eventTargetResolver?: GlobalTargetResolver,
): typeof ɵɵlistener {
const lView = getLView<{} | null>();
const tView = getTView();
const tNode = getCurrentTNode()!;
listenerInternal(
tView, lView, lView[RENDERER], tNode, eventName, listenerFn, eventTargetResolver,
);
return ɵɵlistener;
}
listenerInternal 是真正的核心:
typescript
// packages/core/src/render3/instructions/listener.ts:120-179(节选)
export function listenerInternal(
tView: TView,
lView: LView<{} | null>,
renderer: Renderer,
tNode: TNode,
eventName: string,
listenerFn: EventCallback,
eventTargetResolver?: GlobalTargetResolver,
): void {
ngDevMode && assertTNodeType(tNode, TNodeType.AnyRNode | TNodeType.AnyContainer);
let processOutputs = true;
let wrappedListener: WrappedEventCallback | null = null;
if (tNode.type & TNodeType.AnyRNode || eventTargetResolver) {
wrappedListener ??= wrapListener(tNode, lView, listenerFn);
const hasCoalescedDomEvent = listenToDomEvent(
tNode, tView, lView, eventTargetResolver, renderer, eventName, listenerFn, wrappedListener,
);
// Context: https://github.com/angular/angular/pull/30144
if (hasCoalescedDomEvent) {
processOutputs = false;
}
}
if (processOutputs) {
const outputConfig = tNode.outputs?.[eventName];
const hostDirectiveOutputConfig = tNode.hostDirectiveOutputs?.[eventName];
if (hostDirectiveOutputConfig && hostDirectiveOutputConfig.length) {
for (let i = 0; i < hostDirectiveOutputConfig.length; i += 2) {
const index = hostDirectiveOutputConfig[i] as number;
const lookupName = hostDirectiveOutputConfig[i + 1] as string;
wrappedListener ??= wrapListener(tNode, lView, listenerFn);
listenToOutput(tNode, lView, index, lookupName, eventName, wrappedListener);
}
}
if (outputConfig && outputConfig.length) {
for (const index of outputConfig) {
wrappedListener ??= wrapListener(tNode, lView, listenerFn);
listenToOutput(tNode, lView, index, eventName, eventName, wrappedListener);
}
}
}
}
关键:一个 (click) 绑定可能同时做两件事:
- 注册原生 DOM 事件监听(如果节点是真实 DOM 节点);
- 订阅指令/组件的
@Output()(如果名字匹配)。
html
<my-cmp (click)="onClick()"> <!-- MyCmp 有 @Output() click -->
这种情况下两个都会发生:DOM 的 click 监听 + Output 订阅。
hasCoalescedDomEvent 与 processOutputs = false
如果 DOM 事件被"合并"(coalesced,见下文),就不再订阅 Output------避免重复触发。这个逻辑追溯到 PR #30144。
6.8.2 wrapListener:事件与变更检测的桥梁
typescript
// packages/core/src/render3/view/listeners.ts:41-79
export function wrapListener(
tNode: TNode,
lView: LView,
listenerFn: EventCallback,
): WrappedEventCallback {
// Note: we are performing most of the work in the listener function itself
// to optimize listener registration.
return function wrapListenerIn_markDirtyAndPreventDefault(event: any) {
// When a native element is stored on this function (set by listenToDomEvent for
// non-global-target listeners), mark the (event, element) pair as handled so that
// jsaction does not replay an event already dispatched by the real DOM listener.
const nativeEl = (wrapListenerIn_markDirtyAndPreventDefault as any).__ngNativeEl__;
if (nativeEl !== undefined) {
markEventHandledForElement(event, nativeEl);
}
// In order to be backwards compatible with View Engine, events on component host nodes
// must also mark the component view itself dirty (i.e. the view that it owns).
const startView = isComponentHost(tNode) ? getComponentLViewByIndex(tNode.index, lView) : lView;
markViewDirty(startView, NotificationSource.Listener);
const context = lView[CONTEXT];
let result = executeListenerWithErrorHandling(lView, context, listenerFn, event);
// A just-invoked listener function might have coalesced listeners so we need to check for
// their presence and invoke as needed.
let nextListenerFn = (<any>wrapListenerIn_markDirtyAndPreventDefault).__ngNextListenerFn__;
while (nextListenerFn) {
// We should prevent default if any of the listeners explicitly return false
result = executeListenerWithErrorHandling(lView, context, nextListenerFn, event) && result;
nextListenerFn = (<any>nextListenerFn).__ngNextListenerFn__;
}
return result;
} as WrappedEventCallback;
}
这个包装器做了四件事:
① markEventHandledForElement(jsaction 兼容)
Angular 内部有事件委托机制(event_delegation_utils.ts),用于支持 jsaction(Google 的事件序列化方案,用于 SSR 场景的事件重放)。这里标记"这个 (event, element) 对已经处理过了",避免重放。
② markViewDirty(startView, NotificationSource.Listener) ★
这是事件驱动变更检测的物理实现。
typescript
const startView = isComponentHost(tNode) ? getComponentLViewByIndex(tNode.index, lView) : lView;
markViewDirty(startView, NotificationSource.Listener);
注意 startView 的选择:
- 如果事件在组件宿主元素 上 → 标脏组件自己的视图(而不是宿主所在的父视图);
- 否则 → 标脏当前 LView。
源码注释给出了理由:
In order to be backwards compatible with View Engine, events on component host nodes must also mark the component view itself dirty (i.e. the view that it owns).
为什么? 组件宿主上的事件(<my-cmp (click)="...">)语义上是"组件自己触发的",所以组件自己应该被检查。
这又是 §5.7 的 markViewDirty ------ 它会一路冒泡到根,并通知 changeDetectionScheduler。
所以在 zoneless 模式下,即使没有 signal,(click) 也会触发变更检测 ------ 因为 markViewDirty 调了 notify(NotificationSource.Listener)。
这解释了第 04 章的一个遗留问题 :为什么 zoneless 下 Listener 需要通知。
③ executeListenerWithErrorHandling
typescript
function executeListenerWithErrorHandling(
lView: LView,
context: unknown,
listenerFn: EventCallback,
e: any,
): boolean {
const prevConsumer = setActiveConsumer(null);
try {
profiler(ProfilerEvent.OutputStart, context as {} | null, listenerFn);
// Only explicitly returning false from a listener should preventDefault
return listenerFn(e) !== false;
} catch (error) {
handleUncaughtError(lView, error);
return false;
} finally {
profiler(ProfilerEvent.OutputEnd, context, listenerFn);
setActiveConsumer(prevConsumer);
}
}
三件事:
setActiveConsumer(null)------ 事件处理器里读 signal 不建立依赖。listenerFn(e) !== false------ 只有显式返回false才preventDefault。返回undefined/null/0都不会。- 错误捕获 ------
handleUncaughtError(lView, error),把错误送进 Angular 的错误处理链。
第 2 点是一个容易被误解的行为:
html
<!-- 两者都不会 preventDefault -->
<form (submit)="onSubmit()"> <!-- 返回 undefined -->
<a (click)="onClick()"> <!-- 返回 null -->
<!-- 只有这个会 -->
<form (submit)="!!onSubmit()"> <!-- ... -->
实际上,Angular 的模板语法里要 preventDefault,通常用 $event.preventDefault()。返回 false 是历史遗留。
④ 合并监听器的链表遍历
typescript
let nextListenerFn = (<any>wrapListenerIn_markDirtyAndPreventDefault).__ngNextListenerFn__;
while (nextListenerFn) {
result = executeListenerWithErrorHandling(lView, context, nextListenerFn, event) && result;
nextListenerFn = (<any>nextListenerFn).__ngNextListenerFn__;
}
事件合并(event coalescing) :同一个元素上的同一个事件,如果绑定了多个监听器,Angular 只注册一个 原生监听器,其余的挂在 __ngNextListenerFn__ 链表上。
收益:
- 原生监听器数量从 N 降到 1(省内存、省注册时间);
- 只调用一次
markViewDirty(而不是 N 次); - 只做一次
preventDefault判断。
注意 result = ... && result ------ 只要有一个返回 false,最终结果就是 false(会 preventDefault)。
6.8.3 ɵɵdomListener:只管 DOM 不管 Output
typescript
// packages/core/src/render3/instructions/listener.ts:99-118
export function ɵɵdomListener(
eventName: string,
listenerFn: EventCallback,
eventTargetResolver?: GlobalTargetResolver,
): typeof ɵɵdomListener {
const lView = getLView<{} | null>();
const tView = getTView();
const tNode = getCurrentTNode()!;
if (tNode.type & TNodeType.AnyRNode || eventTargetResolver) {
listenToDomEvent(
tNode, tView, lView, eventTargetResolver, lView[RENDERER],
eventName, listenerFn, wrapListener(tNode, lView, listenerFn),
);
}
return ɵɵdomListener;
}
编译器在确定名字不匹配任何 Output 时使用它,跳过 Output 查找。
6.8.4 ɵɵsyntheticHostListener:动画 trigger 的兼容层
typescript
// packages/core/src/render3/instructions/listener.ts:63-77
export function ɵɵsyntheticHostListener(
eventName: string,
listenerFn: EventCallback,
): typeof ɵɵsyntheticHostListener {
const tNode = getCurrentTNode()!;
const lView = getLView<{} | null>();
const tView = getTView();
const currentDef = getCurrentDirectiveDef(tView.data);
const renderer = loadComponentRenderer(currentDef, tNode, lView);
listenerInternal(tView, lView, renderer, tNode, eventName, listenerFn);
return ɵɵsyntheticHostListener;
}
源码注释解释了它的必要性:
Normally all host listeners are evaluated with the parent component's renderer, but, in the case of animation @triggers, they need to be evaluated with the sub component's renderer (because that's where the animation triggers are defined).
渲染器选择问题 :host listener 通常用父组件的渲染器 (因为是父组件在宿主元素上注册)。但动画 trigger 定义在子组件里,必须用子组件的渲染器。
loadComponentRenderer 就是取出子组件的渲染器。
这是"渲染器可能是任意实现"这个抽象带来的复杂度------动画渲染器需要特殊的事件处理。
6.9 样式与 class 绑定:优先级链
样式绑定是 instructions/styling.ts(1052 行,指令里最大的文件)。复杂度来自优先级。
6.9.1 四个指令
typescript
// packages/core/src/render3/instructions/styling.ts:83-90
export function ɵɵstyleProp(
prop: string,
value: string | number | SafeValue | undefined | null,
suffix?: string | null,
): typeof ɵɵstyleProp {
checkStylingProperty(prop, value, suffix, false);
return ɵɵstyleProp;
}
// :107-110
export function ɵɵclassProp(className: string, value: any): typeof ɵɵclassProp {
checkStylingProperty(className, value, null, true);
return ɵɵclassProp;
}
// :131-135
export function ɵɵstyleMap(
styles: {[styleName: string]: any} | string | undefined | null | NO_CHANGE,
): void {
checkStylingMap(styleKeyValueArraySet, styleStringParser, styles, false);
}
// :170-181
export function ɵɵclassMap(
classes:
| {[className: string]: boolean | undefined | null}
| string
| string[]
| Set<string>
| undefined
| null
| NO_CHANGE,
): void {
checkStylingMap(classKeyValueArraySet, classStringParser, classes, true);
}
注意 ɵɵstyleProp / ɵɵclassProp 返回自身(可链式),ɵɵstyleMap / ɵɵclassMap 返回 void。
6.9.2 checkStylingProperty
typescript
// packages/core/src/render3/instructions/styling.ts:206-237
export function checkStylingProperty(
prop: string,
value: any | NO_CHANGE,
suffix: string | undefined | null,
isClassBased: boolean,
): void {
const lView = getLView();
const tView = getTView();
// Styling instructions use 2 slots per binding.
// 1. one for the value / TStylingKey
// 2. one for the intermittent-value / TStylingRange
const bindingIndex = incrementBindingIndex(2);
if (tView.firstUpdatePass) {
stylingFirstUpdatePass(tView, prop, bindingIndex, isClassBased);
}
if (value !== NO_CHANGE && bindingUpdated(lView, bindingIndex, value)) {
if (ngDevMode && !isClassBased) {
warnInvalidStylePropValue(prop, value);
}
const tNode = tView.data[getSelectedIndex()] as TNode;
updateStyling(
tView, tNode, lView, lView[RENDERER], prop,
(lView[bindingIndex + 1] = normalizeSuffix(value, suffix)),
isClassBased,
bindingIndex,
);
}
}
三个要点:
- 2 个槽位(§6.4.2 已述)。
tView.firstUpdatePass时构建优先级元数据 (stylingFirstUpdatePass)。normalizeSuffix(value, suffix)------ 处理[style.width.px]="w"这种带单位的语法,把w变成w + 'px'。
6.9.3 优先级链:updateStyling
typescript
// packages/core/src/render3/instructions/styling.ts:881-914
function updateStyling(
tView: TView,
tNode: TNode,
lView: LView,
renderer: Renderer,
prop: string,
value: string | undefined | null | boolean,
isClassBased: boolean,
bindingIndex: number,
) {
if (!(tNode.type & TNodeType.AnyRNode)) {
// It is possible to have styling on non-elements (such as ng-container).
// This is rare, but it does happen. In such a case, just ignore the binding.
return;
}
const tData = tView.data;
const tRange = tData[bindingIndex + 1] as TStylingRange;
const higherPriorityValue = getTStylingRangeNextDuplicate(tRange)
? findStylingValue(tData, tNode, lView, prop, getTStylingRangeNext(tRange), isClassBased)
: undefined;
if (!isStylingValuePresent(higherPriorityValue)) {
// We don't have a next duplicate, or we did not find a duplicate value.
if (!isStylingValuePresent(value)) {
// We should delete current value or restore to lower priority value.
if (getTStylingRangePrevDuplicate(tRange)) {
// We have a possible prev duplicate, let's retrieve it.
value = findStylingValue(tData, null, lView, prop, bindingIndex, isClassBased);
}
}
const rNode = getNativeByIndex(getSelectedIndex(), lView) as RElement;
applyStyling(renderer, isClassBased, rNode, prop, value);
}
}
这是 v22 样式系统的核心算法,值得逐行读。
问题场景
html
<div [style.color]="a" [style]="{color: b}" [style.color]="c">
同一个 color 属性被绑定了三次。谁赢?
Angular 的规则 :模板中最后出现的绑定优先级最高(与 CSS 的直觉一致)。
数据结构:TStylingRange
每个样式绑定在 tData[bindingIndex + 1] 存一个 TStylingRange,它编码了两个指针:
getTStylingRangePrevDuplicate(tRange)------ 指向更低优先级的同名绑定getTStylingRangeNextDuplicate(tRange)------ 指向更高优先级的同名绑定
这是一个双向链表 ,把所有绑定同一个 prop 的绑定串起来,按模板顺序排列。
算法
scss
updateStyling(prop, value, bindingIndex):
1. 如果我没有"更高优先级的重复绑定":
→ 我的值就是最终值,写 DOM
2. 否则(存在更高优先级的):
→ 什么都不做!让更高优先级的那个去写
3. 当我的值是"空"(undefined/null)时:
→ 不能简单删除,要回退到"更低优先级"的绑定值
→ findStylingValue(往 prev 方向找)
为什么要"什么都不做"?
考虑三个绑定 a(低) b(中) c(高) 绑定到 color:
- 更新时按顺序执行:先
a,再b,再c a执行时发现有更高优先级 → 跳过b执行时发现有更高优先级 → 跳过c执行时没有更高优先级 → 写 DOM ✅
结果:只有一次 DOM 写入,且值是正确的(最高优先级的)。
这个设计避免了"三次写入,最后一次生效"的浪费。
回退逻辑
typescript
if (!isStylingValuePresent(value)) {
// We should delete current value or restore to lower priority value.
if (getTStylingRangePrevDuplicate(tRange)) {
value = findStylingValue(tData, null, lView, prop, bindingIndex, isClassBased);
}
}
当最高优先级的绑定变成 undefined 时,不能简单 removeStyle------要回退到次高优先级的值。
举例:
html
<div [style.color]="'red'" [style.color]="maybeUndefined">
maybeUndefined是'blue'→ 写bluemaybeUndefined变undefined→ 回退写red(不是删除!)- 两个都
undefined→ 删除
6.9.4 applyStyling:最终的 DOM 写入
typescript
// packages/core/src/render3/node_manipulation.ts:1136-1170
export function applyStyling(
renderer: Renderer,
isClassBased: boolean,
rNode: RElement,
prop: string,
value: any,
) {
if (isClassBased) {
// We actually want JS true/false here because any truthy value should add the class
if (!value) {
renderer.removeClass(rNode, prop);
} else {
renderer.addClass(rNode, prop);
}
} else {
let flags = prop.indexOf('-') === -1 ? undefined : (RendererStyleFlags2.DashCase as number);
if (value == null /** || value === undefined */) {
renderer.removeStyle(rNode, prop, flags);
} else {
// A value is important if it ends with `!important`. The style
// parser strips any semicolons at the end of the value.
const isImportant = typeof value === 'string' ? value.endsWith('!important') : false;
if (isImportant) {
// !important has to be stripped from the value for it to be valid.
value = value.slice(0, -10);
flags! |= RendererStyleFlags2.Important;
}
renderer.setStyle(rNode, prop, value, flags);
}
}
}
细节:
-
class 用
!value而不是value === false------ 注释说明:"any truthy value should add the class"。所以[class.foo]="'no'"(非空字符串)也会加 class。 -
DashCaseflag ------ 属性名含-(如background-color)时需要特殊 flag,因为 DOM 的style.setProperty需要区分 camelCase 和 dash-case。 -
!important的处理 ------ 剥离!important(10 个字符),转成RendererStyleFlags2.Important。这样[style.color]="'red !important'"能正确工作。 -
value == null用==而非===------ 同时覆盖null和undefined。注释里还留着/** || value === undefined */,是被==简化掉的冗余判断的痕迹。
6.9.5 v22 的一个变化:不再有独立的 stylingApply 指令
在早期版本(v9--v13 左右),样式指令需要在更新块的末尾调用 ɵɵstylingApply() 才真正写入 DOM。
v22 的 styling.ts 里已经没有这个导出了 ------updateStyling 里直接调 applyStyling。
源码注释里还残留着旧说法:
markdown
* Note that the styling instruction will not be applied until `stylingApply` is called.
(出现在 ɵɵstyleMap 和 ɵɵclassMap 的文档注释里,第 120 和 160 行)
这是一个"注释比代码活得久"的例子 ------重构移除了 stylingApply 指令,但两处文档注释没同步更新。
如果你在 v22 源码里看到这种不一致,优先相信代码。
6.10 双向绑定:ɵɵtwoWayProperty 与 signal 的深度集成
Angular 22 的 [(x)] 语法和 signal 有深度集成。
6.10.1 ɵɵtwoWayProperty
typescript
// packages/core/src/render3/instructions/two_way.ts:32-52
export function ɵɵtwoWayProperty<T>(
propName: string,
value: T | WritableSignal<T>,
sanitizer?: SanitizerFn | null,
): typeof ɵɵtwoWayProperty {
// TODO(crisbeto): perf impact of re-evaluating this on each change detection?
if (isWritableSignal(value)) {
value = value();
}
const lView = getLView();
const bindingIndex = nextBindingIndex();
if (bindingUpdated(lView, bindingIndex, value)) {
const tView = getTView();
const tNode = getSelectedTNode();
setPropertyAndInputs(tNode, lView, propName, value, lView[RENDERER], sanitizer);
ngDevMode && storePropertyBindingMetadata(tView.data, tNode, propName, bindingIndex);
}
return ɵɵtwoWayProperty;
}
关键:value 可以是 WritableSignal<T>。
typescript
if (isWritableSignal(value)) {
value = value(); // ← 在这里读 signal
}
所以 <my-cmp [(value)]="mySignal"> 不需要写 mySignal() ------ 编译器传的是 signal 本身,指令内部读。
这也意味着这次读会被当前 consumer 追踪 (因为 ɵɵtwoWayProperty 在模板函数里执行,此时 activeConsumer 是模板 consumer)。signal 变化 → 模板刷新 ✅
源码里的 TODO(crisbeto) 承认了每轮 CD 都做 isWritableSignal 检查的开销疑问。
6.10.2 ɵɵtwoWayBindingSet
typescript
// packages/core/src/render3/instructions/two_way.ts:60-65
export function ɵɵtwoWayBindingSet<T>(target: unknown, value: T): boolean {
const canWrite = isWritableSignal(target);
canWrite && target.set(value);
return canWrite;
}
编译器为 [(x)] 生成的更新回调里会调它:
typescript
ɵɵtwoWayListener('valueChange', function($event) {
ɵɵtwoWayBindingSet(ctx.mySignal, $event) || (ctx.myProp = $event);
return $event;
});
一行代码里的 || 短路处理了两种情况:
- 目标是 signal →
set(),返回true,短路,不执行后半句; - 目标是普通字段 → 返回
false,执行ctx.myProp = $event。
这是一个非常紧凑的双协议适配。
6.10.3 ɵɵtwoWayListener
typescript
// packages/core/src/render3/instructions/two_way.ts:74-83
export function ɵɵtwoWayListener(
eventName: string,
listenerFn: EventCallback,
): typeof ɵɵtwoWayListener {
const lView = getLView<{} | null>();
const tView = getTView();
const tNode = getCurrentTNode()!;
listenerInternal(tView, lView, lView[RENDERER], tNode, eventName, listenerFn);
return ɵɵtwoWayListener;
}
就是 ɵɵlistener 的别名(走同一个 listenerInternal)。分开命名是为了让编译产物更可读/可调试。
6.11 净化(Sanitization):编译期决策,运行时执行
6.11.1 净化器从哪来?
回看 ɵɵproperty 的签名:
typescript
export function ɵɵproperty<T>(
propName: string,
value: T,
sanitizer?: SanitizerFn | null, // ← 第三个参数
)
这个参数由编译器填入。编译器分析绑定目标:
| 绑定 | 编译产物 | 净化器 |
|---|---|---|
[innerHTML]="x" |
ɵɵproperty('innerHTML', x, ɵɵsanitizeHtml) |
HTML |
[src]="x"(img/video 之外) |
ɵɵproperty('src', x, ɵɵsanitizeUrl) |
URL |
[style.background]="x" |
ɵɵstyleProp('background', x) |
Style(内部处理) |
[title]="x" |
ɵɵproperty('title', x) |
无 |
6.11.2 运行时不做判断
typescript
// It is assumed that the sanitizer is only added when the compiler determines that the
// property is risky, so sanitization can be done without further checks.
value = sanitizer != null ? (sanitizer(value, tNode.value || '', propName) as any) : value;
注释说得很直白 :编译器已经判断过了,运行时只检查 sanitizer != null。
这是"把决策前移"的典型收益:运行时省掉了一个"这个属性危险吗?"的查表/判断。
6.11.3 unwrapImgVideoSrcValue:一个真实的妥协
typescript
// The `src` property previously used a sanitizer which mapped `null`/`undefined` to `''`.
// Now that `img` and `video` `src` are no longer sanitized, we still need to map `null` and
// `undefined` to `''` to avoid stringifying them to `'null'` or `'undefined'` and causing
// broken network requests.
// TODO(v23): Remove this workaround once we can introduce a breaking change
value = unwrapImgVideoSrcValue(propName, element as RElement, value);
背景 :早年 [src] 一律走 URL 净化器,净化器会把 null/undefined 转成 ''。后来团队发现 img/video 的 src 做 URL 净化反而有兼容问题(data URI、blob URL 等),于是移除了净化。
但移除后发现:<img [src]="null"> 会被写成字符串 "null",浏览器会尝试请求 http://host/null。
所以加了这个 workaround:手动把 null/undefined 映射为 ''。
团队标注了 TODO(v23) ------ 等能引入破坏性变更时移除。
这个故事的价值:它展示了一个"安全加固 → 发现副作用 → 局部回退 → 加兼容层"的完整周期,是理解大型框架演进的绝佳样本。
6.12 完整示例:手工反编译一个模板
现在把所有指令串起来。给定:
typescript
@Component({
selector: 'demo',
template: `
<div class="a" [title]="t" [style.color]="c" (click)="go()">
<span>Hi {{ who }}</span>
<my-cmp [data]="d" (ready)="onReady($event)"></my-cmp>
</div>
`,
})
export class Demo { t = ''; c = ''; who = ''; d = {}; go() {} onReady(e: any) {} }
编译产物(结构精确):
typescript
const _c0 = ['class', 'a']; // consts[0]
function Demo_Template(rf: RenderFlags, ctx: Demo) {
// ══════════ 创建块 ══════════
if (rf & 1) {
ɵɵelementStart(0, 'div', 0); // <div class="a">
ɵɵlistener('click', function() { // (click)
return ctx.go();
});
ɵɵelementStart(1, 'span'); // <span>
ɵɵtext(2); // {{ who }} 的占位节点
ɵɵelementEnd(); // </span>
ɵɵelement(3, 'my-cmp', 1); // <my-cmp ...>(无子元素,用 element)
ɵɵelementEnd(); // </div>
}
// ══════════ 更新块 ══════════
if (rf & 2) {
ɵɵadvance(1); // → 索引 1(跳过 0 之后 1 步...实际从 HEADER_OFFSET 起)
ɵɵproperty('title', ctx.t); // [title]
ɵɵstyleProp('color', ctx.c); // [style.color] ← 消耗 2 槽位
ɵɵadvance(1); // → 索引 2
ɵɵtextInterpolate1('Hi ', ctx.who); // Hi {{ who }}
ɵɵadvance(1); // → 索引 3
ɵɵproperty('data', ctx.d); // [data] → 命中 MyCmp 的 @Input
}
}
// 事件监听 (ready) 会在创建块里以 ɵɵlistener 出现(此处省略)
绑定槽位分配(vars 计算):
| 指令 | 槽位数 | 累计 |
|---|---|---|
ɵɵproperty('title') |
1 | 1 |
ɵɵstyleProp('color') |
2 | 3 |
ɵɵtextInterpolate1 |
1 | 4 |
ɵɵproperty('data') |
1 | 5 |
所以 vars = 5(假设没有额外的 styleMap 等)。
这正是第 02 章 countVariables 算法的实际体现。
6.12.1 执行时序(更新块)
scss
refreshView 步骤 4:setBindingIndex(tView.bindingStartIndex)
executeTemplate(RenderFlags.Update)
ɵɵadvance(1)
→ selectIndexInternal:执行索引 < HEADER_OFFSET+1 的 pre-order 钩子
→ setSelectedIndex(HEADER_OFFSET + 1) // 选中 <div>
ɵɵproperty('title', ctx.t)
→ bindingIndex = nextBindingIndex() // 槽位 N+0
→ bindingUpdated?
YES → setPropertyAndInputs
→ setAllInputsForProperty('title') // 无指令输入匹配
→ setDomProperty → mapPropName('title') → 'title'
→ renderer.setProperty(div, 'title', ctx.t)
ɵɵstyleProp('color', ctx.c)
→ bindingIndex = incrementBindingIndex(2) // 槽位 N+1, N+2
→ bindingUpdated?
YES → updateStyling → 优先级链检查 → applyStyling
→ renderer.setStyle(div, 'color', ctx.c)
ɵɵadvance(1)
→ setSelectedIndex(HEADER_OFFSET + 2) // 选中 {{ who }} 文本节点
ɵɵtextInterpolate1('Hi ', ctx.who)
→ interpolation1 → bindingUpdated(槽位 N+3)
YES → 'Hi ' + renderStringify(ctx.who)
→ textBindingInternal → updateTextNode
ɵɵadvance(1)
→ setSelectedIndex(HEADER_OFFSET + 3) // 选中 <my-cmp>
ɵɵproperty('data', ctx.d)
→ bindingUpdated(槽位 N+4)?
YES → setPropertyAndInputs
→ setAllInputsForProperty('data')
→ 命中 MyCmp 的 @Input('data')
→ writeToDirectiveInput → def.setInput / applyValueToInputField
→ markDirtyIfOnPush(lView, myCmpIndex) // MyCmp 是 OnPush 时标脏
→ return(不写 DOM)
注意最后一步 markDirtyIfOnPush ------ 输入变化时,如果目标组件是 OnPush,会被标脏。这是 OnPush 组件能被父组件输入变化触发刷新的原因。
6.13 实战:你可以动手做的五个实验
实验 1:看真实的编译产物
bash
# 在一个 Angular 22 项目里
ng build --configuration development
# 产物在 dist/<project>/
# 或者直接看 esbuild 输出
npx ngc -p tsconfig.app.json
或者更快的方式------在浏览器 devtools 里:
javascript
// 拿到组件定义
const el = document.querySelector('demo');
const cmp = ng.getComponent(el);
const def = cmp.constructor.ɵcmp;
console.log(def.template.toString()); // ← 打印模板函数源码
你会看到真实的 ɵɵ 指令序列,包含本文讲的所有内容。
实验 2:观察绑定索引
在模板函数的源码里数一数 ɵɵproperty 和 ɵɵstyleProp 的数量,验证:
scss
vars = (property 数量 × 1) + (styleProp 数量 × 2) + (textInterpolate1 × 1) + ...
然后对比 def.vars 的值,应该一致。
实验 3:事件监听的合并
html
<button (click)="a()" (click)="b()" (click)="c()">x</button>
在 devtools 里检查这个 button 的 click 监听器数量,应该是 1(不是 3)。
再看 getEventListeners(button)(Chrome devtools API),确认只有一个 Angular 的包装函数。
实验 4:样式优先级链
html
<div [style.color]="c1" [style.color]="c2" [style.color]="c3">x</div>
让 c1/c2/c3 取不同值,观察最终生效的是 c3(模板中最后出现)。
然后把 c3 设为 undefined,观察回退到 c2。
实验 5:验证"输入优先于 DOM 属性"
typescript
@Component({
selector: 'my-cmp',
template: `...`,
})
export class MyCmp {
@Input() title = ''; // 注意:名字叫 title
}
html
<my-cmp [title]="'abc'"></my-cmp>
检查 DOM:my-cmp 元素上没有 title 属性。值进了 @Input。
然后去掉 @Input() title,重新检查:DOM 上出现 title="abc"。
6.14 设计原则提炼
从指令体系里可以提炼出六条原则:
原则一:全局状态换取参数简洁
指令不传 lView,从全局读。代价是顺序敏感性,收益是代码体积和调用开销。
原则二:编译期决策,运行时执行
- 指令匹配结果缓存在 TView(只做一次);
- 净化器由编译器决定;
firstUpdatePass时做校验,之后跳过;- 绑定槽位数在编译期算好(
vars)。
这是 Angular "编译器不是可选优化,而是架构基础"的最强体现。
原则三:零分配优先
interpolation1..8避免数组分配;hostBindingOpCodes用扁平数组;- 链式调用返回自身;
- 样式优先链避免重复 DOM 写入。
原则四:输入优先于 DOM
setPropertyAndInputs 的三条路径。统一语法,明确优先级。
原则五:兼容性让步要标注
syntheticHostListener/syntheticHostProperty(ViewEngine 兼容);unwrapImgVideoSrcValue(TODO(v23));wrapListener里"组件宿主事件标脏组件自己"(ViewEngine 兼容)。
每一处都写了注释说明为什么。 这是可维护性的关键。
原则六:热路径不重构
// PERF WARNING: do NOT extract this to a separate function without running benchmarks
出现在 refreshView、selectIndexInternal 等多处。
6.15 与其他框架的指令层对比
| Angular | Vue 3 | React | Svelte | |
|---|---|---|---|---|
| 更新代码来源 | 编译期生成的指令序列 | 编译期生成的 render 函数(含 patchFlag) |
运行时 createElement + diff |
编译期生成精确的 set_data |
| DOM 引用获取 | LView[index](数组索引) |
n1 局部变量 |
Fiber / ref | 编译期捕获的局部变量 |
| 脏值比较 | Object.is + NO_CHANGE |
patchFlag 静态标记 + 运行时比较 |
diff(始终比较) | 编译期生成 if (changed) |
| 事件处理 | 包装器 + 合并链表 | 编译期缓存 handler | 运行时 onClick prop |
编译期 listen() |
| 指令/属性分派 | setPropertyAndInputs 三路 |
shouldSetAsProp 启发式 |
全走 props | 编译期确定 |
| 样式优先级 | TStylingRange 双向链 |
对象合并 | 对象合并 | 编译期展开 |
Angular 的独特性:
- 绑定索引是隐式游标 ------ 其他框架多用局部变量或闭包。Angular 的方案代码最紧凑,但最脆弱。
- 样式优先级链 ------ 只有 Angular 处理了"同名样式多次绑定"的优先级与回退。Vue/React 是简单合并。
- 输入/DOM 属性统一语法 ------ Vue 也有类似(
$attrsvsprops),但 Angular 的"命中输入就不写 DOM"是明确的短路规则。
6.16 本章核心结论
- 四条契约(§6.1):全局状态通信、顺序即语义、返回自身、只在变化时写 DOM。
ɵɵelementStart的两态 (§6.2.1):firstCreatePass时做指令匹配并缓存,后续直接读 TView。ɵɵadvance不只是移动游标(§6.3.1):它执行 pre-order 钩子,且必须先跑钩子后设索引(重入污染防护)。- 绑定索引的脆弱性(§6.4.3):错位会导致静默失败,所以必须用 AOT。
setPropertyAndInputs三路分派(§6.5.1):输入命中 → 短路返回,不写 DOM。writeToDirectiveInput的四条路径(§6.6):signal input / 装饰器 input / transform / setInput。- 插值族的 9 个函数(§6.7.1):用代码体积换零分配。
- 事件包装器做四件事 (§6.8.2):jsaction 标记、
markViewDirty、错误处理 +!== false、合并监听器链表。 - 样式优先级链 (§6.9.3):
TStylingRange双向链,只有最高优先级绑定写 DOM,降级时回退。 - 双向绑定与 signal 集成 (§6.10):
ɵɵtwoWayProperty内部value(),ɵɵtwoWayBindingSet用||短路适配两种目标。 - 净化是编译期决策 (§6.11):运行时只检查
sanitizer != null。
附录 A:指令速查表
| 指令 | 用途 | 槽位 | 返回 |
|---|---|---|---|
ɵɵelementStart(i, name, attrs?, refs?) |
开始元素 | 0 | self |
ɵɵelementEnd() |
结束元素 | 0 | self |
ɵɵelement(i, name, attrs?, refs?) |
无子元素元素 | 0 | self |
ɵɵtext(i, value?) |
文本节点 | 0 | void |
ɵɵelementContainerStart(i) |
ng-container |
0 | self |
ɵɵtemplate(i, fn, decls, vars, ...) |
嵌入视图锚点 | 0 | self |
ɵɵprojection(i, selectorIndex?, attrs?) |
<ng-content> |
0 | void |
ɵɵadvance(delta?) |
移动游标 + 跑钩子 | 0 | void |
ɵɵproperty(name, v, san?) |
属性/输入绑定 | 1 | self |
ɵɵdomProperty(name, v, san?) |
仅 DOM 属性 | 1 | self |
ɵɵattribute(name, v, san?, ns?) |
attribute 绑定 | 1 | self |
ɵɵariaProperty(name, v) |
ARIA 绑定 | 1 | self |
ɵɵsyntheticHostProperty(name, v, san?) |
动画 host 绑定 | 1 | self |
ɵɵtextInterpolate(v0) |
纯插值文本 | 1 | self |
ɵɵtextInterpolate1..8(...) |
N 个插值 | N | self |
ɵɵtextInterpolateV(values) |
可变插值 | N | self |
ɵɵstyleProp(prop, v, suffix?) |
单样式 | 2 | self |
ɵɵclassProp(cls, v) |
单 class | 2 | self |
ɵɵstyleMap(styles) |
样式对象 | 2 | void |
ɵɵclassMap(classes) |
class 对象 | 2 | void |
ɵɵlistener(name, fn, resolver?) |
事件/输出 | 0 | self |
ɵɵdomListener(name, fn, resolver?) |
仅 DOM 事件 | 0 | self |
ɵɵtwoWayProperty(name, v, san?) |
双向属性 | 1 | self |
ɵɵtwoWayListener(name, fn) |
双向事件 | 0 | self |
ɵɵtwoWayBindingSet(target, v) |
双向写回 | 0 | boolean |
ɵɵconditional(i, value) |
@if 分派 |
1 | void |
ɵɵrepeater(...) |
@for 分派 |
见第 07 章 | void |
附录 B:源码位置速查
| 主题 | 文件:行 |
|---|---|
ɵɵelementStart |
instructions/element.ts:83 |
ɵɵelementEnd |
instructions/element.ts:165 |
ɵɵelement |
instructions/element.ts:216 |
ɵɵtext |
instructions/text.ts:37 |
| 水合版文本创建 | instructions/text.ts:60-92 |
ɵɵtemplate |
instructions/template.ts:266 |
ɵɵadvance |
instructions/advance.ts:37 |
selectIndexInternal |
instructions/advance.ts:50 |
ɵɵproperty |
instructions/property.ts:38 |
ɵɵattribute |
instructions/attribute.ts:26 |
ɵɵdomProperty / syntheticHostProperty |
instructions/dom_property.ts:36 / :73 |
ɵɵariaProperty |
instructions/aria_property.ts:41 |
setPropertyAndInputs |
instructions/shared.ts:251 |
setDomProperty |
instructions/shared.ts:283 |
elementLikeStartShared |
instructions/shared.ts:611 |
elementLikeEndShared |
instructions/shared.ts:656 |
executeTemplate |
instructions/shared.ts:87 |
createDirectivesInstances |
instructions/shared.ts:122 |
writeToDirectiveInput |
instructions/write_to_directive_input.ts:18 |
interpolationV |
instructions/interpolation.ts:26 |
interpolation1 / interpolation2 |
instructions/interpolation.ts:58 / :75 |
textBindingInternal |
instructions/text_interpolation.ts:460 |
ɵɵlistener |
instructions/listener.ts:31 |
ɵɵdomListener |
instructions/listener.ts:99 |
ɵɵsyntheticHostListener |
instructions/listener.ts:63 |
listenerInternal |
instructions/listener.ts:120 |
wrapListener |
view/listeners.ts:41 |
executeListenerWithErrorHandling |
view/listeners.ts:81 |
ɵɵstyleProp / ɵɵclassProp |
instructions/styling.ts:83 / :107 |
ɵɵstyleMap / ɵɵclassMap |
instructions/styling.ts:131 / :170 |
checkStylingProperty |
instructions/styling.ts:206 |
updateStyling |
instructions/styling.ts:881 |
applyStyling |
node_manipulation.ts:1136 |
ɵɵtwoWayProperty |
instructions/two_way.ts:32 |
ɵɵtwoWayBindingSet |
instructions/two_way.ts:60 |
ɵɵtwoWayListener |
instructions/two_way.ts:74 |
思考题
-
为什么
ɵɵadvance的delta是增量而不是绝对索引?如果改成绝对索引,编译产物会有什么变化? -
selectIndexInternal里为什么必须先执行钩子、后设置 selected index?举一个会出错的具体场景。 -
<my-cmp [title]="x">时title不会写到 DOM。如果想同时 写到@Input和 DOM,该怎么做?(提示:用[attr.title]) -
interpolationV需要数组参数,interpolation1不需要。为什么编译器不为 9 个以上插值生成interpolation9、interpolation10? -
样式绑定消耗 2 个槽位,第二个存
TStylingRange。如果同一个color绑定了 3 次,TStylingRange的链表结构是什么样的?写入时哪个绑定实际执行applyStyling? -
当最高优先级的样式绑定变成
undefined时,为什么不能直接removeStyle?updateStyling怎么处理? -
wrapListener里setActiveConsumer(null)(在executeListenerWithErrorHandling里)的作用是什么?如果去掉会怎样? -
事件监听器返回
false会preventDefault,那返回0、''、null呢?为什么这么设计? -
ɵɵtwoWayBindingSet(ctx.sig, $event) || (ctx.prop = $event)这行代码为什么能同时支持 signal 和普通字段? -
writeToDirectiveInput开头的setActiveConsumer(null)防止了什么问题? -
如果
firstCreatePass时不做指令匹配缓存,而是每次实例都重新匹配,会带来什么性能问题?为什么这个缓存能放在 TView 而不是 LView?
6.17 创建流程的另一半:renderView 与 renderComponent
第 05 章讲 refreshView(更新模式),本节补上它的孪生兄弟 renderView(创建模式)。二者结构几乎对称,但有几处关键差异。
typescript
// packages/core/src/render3/instructions/render.ts:91-152(节选)
export function renderView<T>(tView: TView, lView: LView<T>, context: T): void {
ngDevMode && assertEqual(isCreationMode(lView), true, 'Should be run in creation mode');
ngDevMode && assertNotReactive(renderView.name);
enterView(lView);
try {
const viewQuery = tView.viewQuery;
if (viewQuery !== null) {
executeViewQueryFn<T>(RenderFlags.Create, viewQuery, context);
}
// Execute a template associated with this view, if it exists. A template function might not be
// defined for the root component views.
const templateFn = tView.template;
if (templateFn !== null) {
executeTemplate<T>(tView, lView, templateFn, RenderFlags.Create, context);
}
// This needs to be set before children are processed to support recursive components.
// This must be set to false immediately after the first creation run because in an
// ngFor loop, all the views will be created together before update mode runs and turns
// off firstCreatePass. If we don't set it here, instances will perform directive
// matching, etc again and again.
if (tView.firstCreatePass) {
tView.firstCreatePass = false;
}
// Mark all queries active in this view as dirty. This is necessary for signal-based queries to
// have a clear marking point where we can read query results atomically (for a given view).
lView[QUERIES]?.finishViewCreation(tView);
// We resolve content queries specifically marked as `static` in creation mode. Dynamic
// content queries are resolved during change detection (i.e. update mode), after embedded
// views are refreshed (see block above).
if (tView.staticContentQueries) {
refreshContentQueries(tView, lView);
}
// We must materialize query results before child components are processed
// in case a child component has projected a container. The LContainer needs
// to exist so the embedded views are properly attached by the container.
if (tView.staticViewQueries) {
executeViewQueryFn<T>(RenderFlags.Update, tView.viewQuery!, context);
}
// Render child component views.
const components = tView.components;
if (components !== null) {
renderChildComponents(lView, components);
}
} catch (error) {
// If we didn't manage to get past the first template pass due to
// an error, mark the view as corrupted so we can try to recover.
if (tView.firstCreatePass) {
tView.incompleteFirstPass = true;
tView.firstCreatePass = false;
}
throw error;
} finally {
lView[FLAGS] &= ~LViewFlags.CreationMode;
leaveView();
}
}
6.17.1 renderView vs refreshView:结构对照
| 步骤 | renderView(Create) |
refreshView(Update) |
|---|---|---|
| 前置断言 | isCreationMode(lView) === true |
isCreationMode(lView) === false |
| signal 上下文 | assertNotReactive(断言当前无活跃 consumer) |
consumerBeforeComputation(建立 consumer) |
| 模板执行 | RenderFlags.Create |
RenderFlags.Update |
| 查询 | 静态查询(staticContentQueries / staticViewQueries) | 动态查询(contentQueries / viewQuery) |
| 子组件 | renderChildComponents → renderComponent |
detectChangesInChildComponents → detectChangesInView |
| 收尾 | FLAGS &= ~CreationMode |
`FLAGS &= ~(Dirty |
最值得注意的差异:assertNotReactive。
typescript
ngDevMode && assertNotReactive(renderView.name);
创建模式下不允许有活跃的 signal consumer。
为什么? 因为创建模式的代码只执行一次 。如果此时读了一个 signal 并建立依赖,这个依赖关系会永远留在图里------但创建代码再也不会重跑了。这是一条会永久泄漏的死边。
所以 Angular 在开发模式下直接断言失败,提醒你"别在创建阶段读 signal"。
实战含义 :不要在构造函数、ngOnInit 的创建块路径、或任何一次性初始化代码里读 signal(除非用 untracked)。
6.17.2 firstCreatePass 的关闭时机
typescript
if (tView.firstCreatePass) {
tView.firstCreatePass = false;
}
注释解释了一个微妙场景:
This must be set to false immediately after the first creation run because in an ngFor loop, all the views will be created together before update mode runs and turns off firstCreatePass. If we don't set it here, instances will perform directive matching, etc again and again.
问题 :@for 循环里,10 个嵌入视图会在创建阶段全部创建完 ,然后才进入更新阶段。如果 firstCreatePass 不在每次创建后立刻关闭,第 2~10 个视图都会重复做指令匹配。
修复:在模板函数执行完(但子组件渲染前)就关闭。
为什么必须在"子组件渲染前"? 注释第一行说:"This needs to be set before children are processed to support recursive components."
递归组件(A 的模板里有 A)依赖这个顺序:父的 firstCreatePass 关了,子(同类型,共享 TView)就不会再走匹配逻辑。
6.17.3 错误恢复:incompleteFirstPass
typescript
} catch (error) {
// If we didn't manage to get past the first template pass due to
// an error, mark the view as corrupted so we can try to recover.
if (tView.firstCreatePass) {
tView.incompleteFirstPass = true;
tView.firstCreatePass = false;
}
throw error;
}
这是一个"损坏标记"机制。
如果首次创建中途抛异常,TView 处于"半构建"状态------部分 TNode 有数据,部分没有。如果之后再用这个 TView 创建实例,会读到不一致的数据,产生更加难以理解的错误。
所以标记 incompleteFirstPass = true,让框架有机会:
- 报一个清晰的错误("这个组件类型的首次渲染失败了")
- 或者在下次尝试时重跑 firstCreatePass
这又是一处"异常路径自愈" (对照第 05 章 §5.11.1 的 markAncestorsForTraversal 补救)。
6.17.4 syncViewWithBlueprint:一个并发式的边界
typescript
// packages/core/src/render3/instructions/render.ts:80-84
export function syncViewWithBlueprint(tView: TView, lView: LView) {
for (let i = lView.length; i < tView.blueprint.length; i++) {
lView.push(tView.blueprint[i]);
}
}
调用点在 renderComponent:
typescript
// packages/core/src/render3/instructions/render.ts:32-35
const componentView = getComponentLViewByIndex(componentHostIdx, hostLView);
const componentTView = componentView[TVIEW];
syncViewWithBlueprint(componentTView, componentView);
源码注释描述了一个非常隐蔽的 bug 场景 (render.ts:57-79):
vbnet
* App template:
* ```html
* <comp></comp>
* <comp></comp>
* ```
*
* 1. App template begins processing.
* 2. First <comp> is matched as a component and its LView is created.
* 3. Second <comp> is matched as a component and its LView is created.
* 4. App template completes processing, so it's time to check child templates.
* 5. First <comp> template is checked. It has a directive, so its def is pushed to blueprint.
* 6. Second <comp> template is checked. Its blueprint has been updated by the first
* <comp> template, but its LView was created before this update, so it is out of sync.
问题本质 :blueprint 在首次创建过程中会动态增长(比如发现某个节点匹配了指令,需要新增槽位)。而同一类型的多个实例,LView 长度在创建时固定,可能落后于 blueprint 的最新长度。
修复:在渲染每个组件视图前,把 LView 补齐到 blueprint 的长度。
注意注释里的补充:
Note that embedded views inside ngFor loops will never be out of sync because these views are processed as soon as they are created.
嵌入视图是"创建即处理",所以不会遇到这个时序问题。只有"先批量创建、后批量处理"的组件视图才有。
这是一个"共享可变状态 + 延迟处理"导致的经典竞态,值得作为设计警示:当你让多个实例共享一份可变元数据时,要考虑"元数据在处理过程中还在变化"这种情形。
6.18 投影指令:ɵɵprojectionDef 与 ɵɵprojection
内容投影(content projection)是 Angular 的核心特性之一。它的实现用了两个指令。
6.18.1 ɵɵprojectionDef:把子节点分配到"桶"里
typescript
// packages/core/src/render3/instructions/projection.ts:94-128(节选)
export function ɵɵprojectionDef(projectionSlots?: (string | (string | number)[][])[]): void {
const componentNode = getLView()[DECLARATION_COMPONENT_VIEW][T_HOST] as TElementNode;
if (!componentNode.projection) {
// If no explicit projection slots are defined, fall back to a single
// projection slot with the wildcard selector.
const numProjectionSlots = projectionSlots ? projectionSlots.length : 1;
const projectionHeads: (TNode | null)[] = (componentNode.projection = newArray(
numProjectionSlots,
null! as TNode,
));
const tails: (TNode | null)[] = projectionHeads.slice();
let componentChild: TNode | null = componentNode.child;
while (componentChild !== null) {
// Do not project let declarations so they don't occupy a slot.
if (componentChild.type !== TNodeType.LetDeclaration) {
const slotIndex = projectionSlots
? matchingProjectionSlotIndex(componentChild, projectionSlots as ProjectionSlots)
: 0;
if (slotIndex !== null) {
if (tails[slotIndex]) {
tails[slotIndex]!.projectionNext = componentChild;
} else {
projectionHeads[slotIndex] = componentChild;
}
tails[slotIndex] = componentChild;
}
}
componentChild = componentChild.next;
}
}
}
算法 :遍历组件宿主的所有子节点,按选择器把它归入某个"槽"(bucket)。每个槽是一个单向链表 (用 projectionNext 串起来)。
ini
componentNode.projection = [head_0, head_1, ..., head_n]
head_0 ──projectionNext──> tNode ──> tNode ──> null (槽 0:匹配 <ng-content select=".a">)
head_1 ──projectionNext──> tNode ──> null (槽 1:匹配 <ng-content select=".b">)
head_2 ──projectionNext──> tNode ──> ... ──> null (槽 2:通配符)
为什么用链表而不是数组?
- 避免数组分配(
newArray(n, null)只分配了 n 个头的数组,节点本身用projectionNext串); - 保持
TNode的字段布局稳定(第 03 章讲过的 hidden class 考量); - 投影顺序天然由
child/next遍历保证。
6.18.2 matchingProjectionSlotIndex:选择器匹配
typescript
// packages/core/src/render3/instructions/projection.ts:40-70
export function matchingProjectionSlotIndex(
tNode: TNode,
projectionSlots: ProjectionSlots,
): number | null {
let wildcardNgContentIndex = null;
const ngProjectAsAttrVal = getProjectAsAttrValue(tNode);
for (let i = 0; i < projectionSlots.length; i++) {
const slotValue = projectionSlots[i];
// The last wildcard projection slot should match all nodes which aren't matching
// any selector. This is necessary to be backwards compatible with view engine.
if (slotValue === '*') {
wildcardNgContentIndex = i;
continue;
}
// If we ran into an `ngProjectAs` attribute, we should match its parsed selector
// to the list of selectors, otherwise we fall back to matching against the node.
if (
ngProjectAsAttrVal === null
? isNodeMatchingSelectorList(tNode, slotValue, /* isProjectionMode */ true)
: isSelectorInSelectorList(ngProjectAsAttrVal, slotValue)
) {
return i; // first matching selector "captures" a given node
}
}
return wildcardNgContentIndex;
}
三个规则:
- 第一个匹配的槽"捕获"该节点 (
return i,注释明说 "first matching selector captures a given node")。不是"所有匹配的槽都收到"------一个节点只投影一次。 - 通配符
*不立即返回,而是记下来,最后作为兜底。 ngProjectAs优先 :如果节点有ngProjectAs="x",用x去匹配,而不是节点本身。
6.18.3 双格式选择器的设计取舍
源码注释(投影指令的文档块)有一段罕见地明确列出了被否决的替代方案:
less
* This function requires CSS selectors to be provided in 2 forms: parsed (by a compiler) and text,
* un-parsed form.
*
* The parsed form is needed for efficient matching of a node against a given CSS selector.
* The un-parsed, textual form is needed for support of the ngProjectAs attribute.
*
* Having a CSS selector in 2 different formats is not ideal, but alternatives have even more
* drawbacks:
* - having only a textual form would require runtime parsing of CSS selectors;
* - we can't have only a parsed as we can't re-construct textual form from it (as entered by a
* template author).
这是架构决策文档的范本:承认当前方案不完美,列出两个替代方案及其致命缺陷,说明为什么当前方案是"最不坏"的。
- 只存文本 → 运行时要解析 CSS 选择器(慢,且要打包解析器)
- 只存解析结果 →
ngProjectAs需要文本形式做isSelectorInSelectorList匹配,无法从解析结果重建原文
所以:两个都存。
6.18.4 ɵɵprojection:插入 + fallback 内容
typescript
// packages/core/src/render3/instructions/projection.ts:151-211(节选)
export function ɵɵprojection(
nodeIndex: number,
selectorIndex: number = 0,
attrs?: TAttributes | null,
fallbackTemplateFn?: ComponentTemplate<unknown>,
fallbackDecls?: number,
fallbackVars?: number,
): void {
const lView = getLView();
const tView = getTView();
const fallbackIndex = fallbackTemplateFn ? nodeIndex + 1 : null;
// Fallback content needs to be declared no matter whether the slot is empty since different
// instances of the component may or may not insert it. Also it needs to be declare *before*
// the projection node in order to work correctly with hydration.
if (fallbackIndex !== null) {
declareNoDirectiveHostTemplate(
lView, tView, fallbackIndex, fallbackTemplateFn!, fallbackDecls!, fallbackVars!, null, attrs,
);
}
const tProjectionNode = getOrCreateTNode(
tView, HEADER_OFFSET + nodeIndex, TNodeType.Projection, null, attrs || null,
);
// We can't use viewData[HOST_NODE] because projection nodes can be nested in embedded views.
if (tProjectionNode.projection === null) {
tProjectionNode.projection = selectorIndex;
}
// `<ng-content>` has no content. Even if there's fallback
// content, the fallback is shown next to it.
setCurrentTNodeAsNotParent();
const hydrationInfo = lView[HYDRATION];
const isNodeCreationMode = !hydrationInfo || isInSkipHydrationBlock();
const componentHostNode = lView[DECLARATION_COMPONENT_VIEW][T_HOST] as TElementNode;
const isEmpty = componentHostNode.projection![tProjectionNode.projection] === null;
if (isEmpty && fallbackIndex !== null) {
insertFallbackContent(lView, tView, fallbackIndex);
} else if (isNodeCreationMode && !isDetachedByI18n(tProjectionNode)) {
// re-distribution of projectable nodes is stored on a component's view level
applyProjection(tView, lView, tProjectionNode);
}
}
Angular 18+ 的 <ng-content> fallback 内容 (fallbackTemplateFn 参数)在这里落地:
html
<ng-content select=".a">
没有内容时显示这个 <!-- fallback -->
</ng-content>
三个关键细节:
-
fallback 模板无条件声明,不管槽是否为空。注释说:"different instances of the component may or may not insert it" ------ 同一个组件的不同实例,有的有内容、有的没有。所以模板必须声明(占好槽位),插入与否运行时决定。
-
fallback 必须声明在投影节点之前 (
fallbackIndex = nodeIndex + 1)。注释说是"in order to work correctly with hydration" ------ 水合的序列化顺序依赖这个。 -
setCurrentTNodeAsNotParent()------<ng-content>不能有子节点。fallback 是"显示在它旁边"(注释:"the fallback is shown next to it"),不是"作为它的子节点"。
6.19 渲染器抽象:指令如何做到平台无关
所有指令最终都通过 Renderer 接口写 DOM。这个接口定义在 packages/core/src/render3/interfaces/renderer.ts:34-80:
typescript
export interface Renderer {
destroy(): void;
createComment(value: string): RComment;
createElement(name: string, namespace?: string | null): RElement;
createText(value: string): RText;
/**
* This property is allowed to be null / undefined,
* in which case the view engine won't call it.
* This is used as a performance optimization for production mode.
*/
destroyNode?: ((node: RNode) => void) | null;
appendChild(parent: RElement, newChild: RNode): void;
insertBefore(parent: RNode, newChild: RNode, refChild: RNode | null, isMove?: boolean): void;
// TODO(thePunderWoman): remove the requireSynchronousElementRemoval flag once the animations package has been deleted after v23.
removeChild(
parent: RElement | null,
oldChild: RNode,
isHostElement?: boolean,
requireSynchronousElementRemoval?: boolean,
): void;
selectRootElement(selectorOrNode: string | any, preserveContent?: boolean): RElement;
parentNode(node: RNode): RElement | null;
nextSibling(node: RNode): RNode | null;
setAttribute(
el: RElement,
name: string,
value: string | TrustedHTML | TrustedScript | TrustedScriptURL,
namespace?: string | null,
): void;
removeAttribute(el: RElement, name: string, namespace?: string | null): void;
addClass(el: RElement, name: string): void;
removeClass(el: RElement, name: string): void;
setStyle(el: RElement, style: string, value: any, flags?: RendererStyleFlags2): void;
removeStyle(el: RElement, style: string, flags?: RendererStyleFlags2): void;
setProperty(el: RElement, name: string, value: any): void;
setValue(node: RText | RComment, value: string): void;
// TODO(misko): Deprecate in favor of addEventListener/removeEventListener
listen(
target: GlobalTargetName | RNode,
eventName: string,
callback: (event: any) => boolean | void,
options?: ListenerOptions,
): () => void;
}
6.19.1 接口设计的三个细节
① destroyNode? 的可选性
typescript
/**
* This property is allowed to be null / undefined,
* in which case the view engine won't call it.
* This is used as a performance optimization for production mode.
*/
destroyNode?: ((node: RNode) => void) | null;
生产模式下不需要销毁钩子(不需要清理调试数据),所以可以为 null。调用方必须判空。
这是"接口的可选方法"的一个实用模式:用可选属性 + 判空,而不是强制实现一个空方法。
② TrustedHTML / TrustedScript / TrustedScriptURL
setAttribute 的 value 类型包含了 Trusted Types API 的类型。这是 Chrome 的一项安全特性,Angular 22 已原生支持。
含义 :如果你的应用启用了 require-trusted-types-for 'script' CSP 指令,Angular 的 attribute 写入仍能工作(前提是值是 Trusted Type)。
③ TODO 注释透露的演进方向
typescript
// TODO(thePunderWoman): remove the requireSynchronousElementRemoval flag once the animations package has been deleted after v23.
动画包将在 v23 之后被删除 。requireSynchronousElementRemoval 这个参数是为动画服务的(动画需要同步移除元素以便做离场动画),动画包删了它就可以去掉。
typescript
// TODO(misko): Deprecate in favor of addEventListener/removeEventListener
listen 方法未来会被 addEventListener / removeEventListener 取代。当前的 listen 返回一个 unlisten 函数,语义比较别扭。
这些 TODO 是了解 Angular 演进方向的宝贵线索。
6.19.2 RendererFactory 与 begin/end
typescript
export interface RendererFactory {
createRenderer(hostElement: RElement | null, rendererType: RendererType2 | null): Renderer;
begin?(): void;
end?(): void;
}
begin() / end() 就是第 05 章 §5.5.1 讲的批处理钩子。它们也是可选的 (?)------默认 DOM 渲染器不实现,动画渲染器实现。
6.19.3 渲染器替换的实际用途
| 场景 | 渲染器实现 |
|---|---|
| 浏览器 DOM | EmulatedEncapsulationDomRenderer2 / ShadowDomRenderer 等(dom_renderer.ts) |
| 服务端渲染 | DomRenderer(@angular/platform-server) |
| 动画 | AnimationRenderer(@angular/animations) |
| 测试 | MockRenderer |
| 自定义(NativeScript、终端等) | 用户实现 Renderer 接口 |
指令层完全不关心用的是哪个渲染器 ------ 这就是抽象的价值。代价是每次 DOM 操作都是一次接口调用(无法内联)。
Angular 的选择是:默认渲染器的方法在 V8 里会因为单态调用点而被内联(所有元素都用同一个渲染器实例),所以开销可接受。
6.20 指令与 signal 追踪的协作全景
把第 03 章(signal 图)、第 05 章(变更检测)和本章(指令)串起来,看一次 signal 读取的完整链路:
scss
模板: {{ userName() }}
┌─ 编译产物 ─────────────────────────────────────────┐
│ if (rf & 2) { │
│ ɵɵadvance(1); │
│ ɵɵtextInterpolate1('', ctx.userName()); │
│ } │
└─────────────────────────────────────────────────────┘
│
┌─ 运行时 ────────────────────────────────────────────┐
│ refreshView 步骤 2 │
│ currentConsumer = getOrBorrowReactiveLViewConsumer│
│ consumerBeforeComputation(currentConsumer) │
│ → setActiveConsumer(currentConsumer) │
│ → 保存 prevConsumer │
│ │
│ 步骤 4:executeTemplate(Update) │
│ → ɵɵadvance(1) (游标 + pre-order 钩子) │
│ → ɵɵtextInterpolate1('', ctx.userName()) │
│ → ctx.userName() ← 读 signal! │
│ → signal 内部:producerAccessed(node) │
│ → 当前 activeConsumer = 模板 consumer │
│ → 建立 ReactiveLink(双向链表) │
│ → consumerOnSignalRead → 提交 consumer │
│ → interpolation1 → bindingUpdated │
│ → textBindingInternal → renderer.setValue │
│ │
│ 步骤 19:consumerAfterComputation │
│ → 清理失效的依赖边 │
│ → setActiveConsumer(prevConsumer) │
└─────────────────────────────────────────────────────┘
│
┌─ 后续:signal 被写入 ───────────────────────────────┐
│ userName.set('Bob') │
│ → producerIncrementEpoch() │
│ → producerNotifyConsumers() │
│ → 模板 consumer 的 consumerMarkedDirty │
│ → markAncestorsForTraversal(lView) │
│ → consumer.dirty = true │
│ → changeDetectionScheduler.notify(...) │
│ → 调度 tick │
│ → detectChangesInView 第 4 路命中 │
│ → refreshView → 回到上面 │
└─────────────────────────────────────────────────────┘
6.20.1 指令在其中扮演的角色
指令是 signal 读取的实际发生地。
consumerBeforeComputation 只是"打开录音机",真正"录进去什么"取决于模板执行期间哪些指令读了 signal。
ɵɵtextInterpolate1('', ctx.userName())------ 读了ɵɵproperty('title', ctx.t)(t是普通字段)------ 没读ɵɵstyleProp('color', ctx.c())------ 读了
所以:一个组件的依赖集合 = 它模板里所有读 signal 的指令的并集。
6.20.2 一个重要的推论:嵌入视图的依赖归属
第 05 章 §5.8.2 讲过嵌入视图共享组件视图的 consumer。从指令角度再确认一次:
@for 里的每个 item 的模板是一个独立嵌入视图,但它没有自己的 consumer。所以:
less
@for (item of items(); track item.id) {
<div>{{ item.name }}</div> ← 这里的 item.name 读
}
如果 item.name 是 signal,读操作被外层组件视图的 consumer 捕获。任何一个 item 的 name 变化 → 整个组件刷新 → 所有 item 重新渲染。
这是当前 Angular 精度上限的物理原因 (viewShouldHaveReactiveConsumer 的注释明确说了未来会改)。
优化手段:把每个 item 拆成独立子组件。
html
@for (item of items(); track item.id) {
<item-row [data]="item" /> ← ItemRow 有自己的 consumer
}
这样 item.name 的读发生在 ItemRow 自己的 consumer 里,只有它刷新。
6.21 实战补充:三个诊断工具
工具 1:追踪某个元素上的所有绑定
typescript
// 在浏览器控制台(dev 模式)
function dumpBindings(selector: string) {
const el = document.querySelector(selector)!;
const ctx = (el as any).__ngContext__;
const lView = ctx[0];
const tView = lView[1]; // TVIEW 索引
// 遍历 TView.data 找 INTERPOLATION_DELIMITER 格式的元数据
// (storePropertyBindingMetadata 写入的)
for (let i = tView.bindingStartIndex; i < lView.length; i++) {
const meta = tView.data[i];
if (typeof meta === 'string' && meta.includes('\u0000')) {
console.log(`槽位 ${i}: ${meta.split('\u0000')[0]} = `, lView[i]);
}
}
}
这个工具读的是 storePropertyBindingMetadata 写入的数据(§6.5.1),格式为 propertyName\0prefix\0part1\0...\0suffix。
工具 2:统计一轮 CD 的 DOM 写入次数
typescript
// 猴子补丁渲染器
const rendererFactory = /* 从 injector 拿 */;
const origSetProperty = renderer.setProperty;
let count = 0;
renderer.setProperty = function(...args) {
count++;
return origSetProperty.apply(this, args);
};
// 触发变更后查看
setTimeout(() => console.log('DOM property 写入次数:', count), 100);
这个数字是你的绑定优化基线。 如果它远超预期,检查:
- 是否有每次返回新对象的绑定(绕过
Object.is) - 是否有
CheckAlways组件被过度刷新
工具 3:可视化指令执行序列
typescript
// 打印一个组件的模板函数源码并格式化
function printTemplate(ComponentType: any) {
const def = ComponentType.ɵcmp;
console.log('decls:', def.decls, 'vars:', def.vars);
console.log(def.template.toString());
}
配合 §6.12 的手工反编译方法,你可以验证编译器是否按你预期生成了指令。
6.22 补充:本章与前后章节的知识衔接
bash
第 02 章(编译)
编译器分析模板 → 决定 decls/vars → 生成指令序列
↓
第 06 章(本章)
指令序列执行 → 建 TView/LView → 写绑定 → 调 Renderer
↓
第 05 章(变更检测)
refreshView 决定"哪些视图执行指令序列"
↓
第 03 章(signal 图)
指令执行时读 signal → 建立依赖边 → 反向决定下次刷新哪些视图
↓
第 04 章(调度)
依赖边被触发 → notify → 调度 tick → 回到第 05 章
这是一个闭环。 每一章都是这个环上的一段,单独看都不完整。
6.23 最终要点补充
assertNotReactive(§6.17.1):创建阶段禁止读 signal,防止产生永久死边。firstCreatePass的关闭时机 (§6.17.2):必须在模板执行后、子组件渲染前,兼顾@for批量创建和递归组件。syncViewWithBlueprint(§6.17.4):共享 blueprint 与延迟处理导致的长度竞态。- 投影是"分配 + 链表" (§6.18.1):
projectionDef把节点分进桶(链表),projection按需插入。 - 双格式选择器的取舍(§6.18.3):源码注释明确列出两个被否决方案。
- fallback 内容无条件声明(§6.18.4):因为同类型不同实例可能有不同的空/非空状态。
- 渲染器抽象(§6.19):7 个必需方法 + 2 个可选批处理钩子;TODO 透露动画包 v23 后删除。
- 指令是 signal 读取的发生地(§6.20.1):组件的依赖集合 = 模板里所有读 signal 的指令的并集。
- 嵌入视图依赖归属导致的精度上限(§6.20.2):拆子组件是当前的优化手段。
6.24 全章贯通:一条模板指令流的完整执行时间线
本章讲了很多条具体指令,这一节把它们放回同一条执行线上,看一个模板从"编译产物"到"屏幕上的像素"到底经历了什么。
编译期:模板变成一串函数调用。 编译器输出的不是什么中间结构,而是两条函数:一条 creation 模式的指令序列,一条 update 模式的指令序列。这就是 6.0 讲的两阶段模板函数。所有 ɵɵ 开头的调用都是指令 :ɵɵelementStart 建元素、ɵɵtext 建文本、ɵɵproperty 写属性、ɵɵlistener 挂事件、ɵɵadvance 移动游标。
创建期:一次建结构。 视图首次创建时只跑 creation 那一条序列。它做的事是:为每个节点建立 TNode(静态蓝图,存在 TView 里),创建真实 DOM,把 DOM 引用写进 LView(实例数据)。此时绑定指令不执行任何写入 ------它们只在 TView.data 里占好位置。这里有一个贯穿全书的分工:TView 是"这一类视图的公共结构",LView 是"这一个视图实例的私有数据"。
更新期:只打补丁。 之后每次变更检测只跑 update 序列。ɵɵadvance(n) 把内部游标移到第 n 个绑定槽(6.3),接着的 ɵɵproperty 用绑定索引从 LView 里取出上一次的值,与新值比较;只有不相等时才真正写 DOM(6.4、6.5)。
这里藏着本章最脆弱的机制:绑定索引。 索引是编译器按模板顺序静态分配的,运行时只认数字、不认名字。这意味着:模板里任何一个绑定的增删,都会让后面所有绑定的索引整体位移。它换来的是极致的运行时开销(一次数组下标访问),代价是编译期与运行期之间一份极其脆弱的隐式契约------也正因如此,Angular 才必须用严格的版本匹配保证编译器和运行时来自同一个版本。
事件的特殊之处。 ɵɵlistener(6.8)不只是 addEventListener,它同时承担"这个事件要引起变更检测"的登记职责。在 zoneless 下这一点尤其重要:事件处理器执行完后,必须有明确的机制告知调度器"可能有东西变了" ,否则屏幕不会更新。这也是为什么你在 @angular/core 之外手写 addEventListener 时,界面常常不刷新。
净化是编译期决策。 6.11 的关键结论是:一个插值需不需要净化(防 XSS),在编译期就根据绑定的目标属性决定了,运行时只是执行这个既定结论。这是"编译期能做的绝不留到运行时"这条哲学的又一处体现。
与 signal 的协作。 6.20 讲过:模板里的读取发生在响应式上下文中,signal 被读取时自动建立依赖;signal 变化时不是"重新跑一遍模板函数",而是标记对应视图变脏,再由调度器只刷新这些视图。指令流本身没有变,变的是"谁来触发 update 序列"。
一句话总结: 模板指令流的本质是"一次建结构,之后只打补丁 "。创建阶段建立 TNode/LView 这套骨架并创建 DOM;更新阶段靠游标 + 绑定索引做差分写入。Angular 为此付出的代价是一份编译期与运行期之间的脆弱契约(绑定索引),换来的是运行时极低的每帧开销。理解了这一句,你再回头看 ɵɵadvance 为什么存在、绑定索引为什么危险、为什么要分 creation/update 两阶段,都会变得顺理成章。
6.25 常见坑清单
- 绑定索引位移。 编译期按模板顺序分配索引,运行时只认数字。模板里增删一个绑定,后面所有绑定的索引都会位移------这也是 Angular 严格要求编译器与运行时同版本的原因之一。
ɵɵadvance的游标是隐式的。 手写指令流时漏一次advance,后续的绑定就会写到错误的节点上,而且不报错。- 纯管道(pure pipe)的"纯"只针对引用。
| slice之类对数组内容变化不敏感,传入新数组才会重算;DatePipe对同一 Date 对象修改也不重算。 - 以为
bypassSecurityTrust*是"推荐做法"。 它是逃生舱;净化的决策在编译期就按目标属性定好了,手动绕过等于放弃这道防线。 - 双向绑定
[(x)]与 signal 混用时写法不对。 signal 版本是[(x)]="sig"直接绑定 signal 本身,不是[(x)]="sig()"。 - 在模板里调用方法做计算。 每次变更检测都会重新执行(不像
computed有缓存),是性能问题最常见的来源之一。
附录 D:指令开发的三条黄金法则(如果你要写自定义编译器/工具)
-
永远不要手撕模板函数 ------ 绑定索引错位是静默失败,没有报错。用 AOT。
-
如果必须生成指令序列,保证:
- 创建块的 index 递增连续;
- 更新块
advance的累积和 = 创建块的 index; - 绑定指令的数量与种类决定
vars(样式算 2)。
-
热路径不要重构 ------ 看到
PERF WARNING注释就别动。
下一章预告 :第 07 章《控制流指令:
@if/@for/@switch/@defer与ɵɵconditional、ɵɵrepeater》。本章我们看到了ɵɵconditional(4, ...)这个指令,但没展开。下一章将钻进instructions/control_flow.ts(642 行)和instructions/control.ts(419 行),拆解 Angular 22 新控制流语法的运行时:视图的创建/复用/销毁、trackBy 的 diff 算法(LCS 最长公共子序列)、@defer的触发器状态机与增量水合的交互。这是"编译期语法糖如何落到运行时"的又一站。