Angular 18+ 高级教程 – Component 组件 の ng-template
前言
上一篇 Dynamic Component 我们有提到,作为 MVVM 框架的 Angular 需要有方法替代掉 2 个 DOM Manipulation:
-
document.createElement
-
template.content.clone
Dynamic Component 便是替代 document.createElement 的方案。
而这篇我们要讲的 ng-template 则是 template.content.clone 的替代方案。
ng-template の 简单使用
我们从浅入深,一个一个特性介绍,最后才逛源码,看看原理机制。
提醒:为了更容易体会到细节,我把 ngZone 关了,所有组件都设置成 ChangeDetectionStrategy.OnPush。
App Template
AppComponent
效果
整体流程相当简单,所有用到的技能,我们在前几篇文章都学过了
-
query template(Query Elements 文章里学过了)
-
use template create view (和 createComponent 大同小异,Dynamic Component 文章里学过了)
-
append view (ViewContainerRef 在 Dynamic Component 文章里学过了)
Template Context
上面例子缺少了一个重要的角色 -- Template Context (a.k.a ViewModel)。
我们拿一个组件来观摩
Template binding with Template Context = DOM
对于组件来说,所谓的 Template Context 就是组件实例。
那 ng-template 也要有 Template Context 才可以搞 Template Binding Syntax。
首先我们需要定义 Template Context 的类型结构,我们不需要用 class,用 interface 就可以了,因为我们只需要类型。
interface TemplateContext {
title: string;
description: string;
}
接着把 ng-template 改成这样
<ng-template #template let-title="title" let-description="description"> <h1>{{ title }}</h1> <p>{{ description }}</p> </ng-template>
let-title 表示定义一个 internal variable,它的值来自于 templateContext['title']。
它是一个 mapping 来的
那为什么需要声明 internal variable 呢?为什么不像组件那样直接可以引用 templateContext?
这个原因我们下一 part 会讲解。
接着在创建 EmbeddedView 时传入 Template Context。
效果
Template Context Type Guard
参考:
Stack Overflow – ng-template - typed variable
Docs – Making in-template type requirements more specific with template guards
对类型敏感的朋友可能已经发现到了,let-variable 的类型始终是 any
这是因为 Angular 不够聪明,我们可以通过一些 workaround 让它显示正确类型。
首先创建一个 TemplateContextTypeGuard 指令
import { Directive, Input } from '@angular/core'; @Directive({ selector: 'ng-template[templateContextType]', standalone: true, }) export class TemplateContextTypeGuardDirective<T> { @Input('templateContextType') type!: T; static ngTemplateContextGuard<T>( _dir: TemplateContextTypeGuardDirective<T>, ctx: unknown ): ctx is T { return true; } }
我们不需要懂原理,照着用就可以了。上面最关键的是 static ngTemplateContextGuard,Angular 就是靠它来搞类型的。
接着 AppComponent
属性用 declare 就可以了,因为它不用于 runtime。
接着 App Template
这样类型就出来了。
TemplateContextTypeGuardDirective 是泛型,所以是通用的,我们只需要每一次繁琐的 apply 指令传入不同的 Templete Context Type 就可以了。
$implicit 的用法
没有人喜欢写 mapping,尤其是 let-whatever="whatever" 这种,又长,又没有意义。
于是,Angular 搞了一个潜规则,叫 $implicit,implicit 就是 "隐含" 的意思。
把 Template Context 类型改成这样
$implicit 是 Angular 认得的一个特殊属性,我们把之前的 title 和 description 移进去。
接着 App Template
没有声明 mapping path 的 let-whatever 会被 Angular 识别为 let-whatever="$implicit"。
这样就不需要写一堆的 mapping 了。
小总结:
-
same key
let-title="title"
const title = templateContext['title']
-
different key
let-whatever="title"
const whatever = templateContext['title']
-
child key
let-childTitle="child.title"
const childTitle = templateContext['child']['title']
-
implicit
let-whatever
let-whatever="$implicit"
const whatever = templateContext['$implicit']
Declaration Template Context
上图,ng-template 没有定义 let-value,那 {{ value }} 会拿到什么值呢?
答案是 appComponent.value
ng-template 不只能访问本身的 Template Context,它还可以访问到它的 Declaration Template Context (而且是所有组件层的 Declaration Template Context)。
Declaration 指的是 ng-template 被声明的地方。
比如上图,这个 ng-template 是在 App Template 被声明的,所以它的 Declaration 指的就是 App Template。
App Template Context 指的是 App 组件实例,所以 {{ value }} 会先找本身的 Template Context 找,找不到就去 App 组件实例找。
再一个嵌套的例子
变量查找会一层一层往上,依据它们被 Declaration 声明的地方。
这也是为什么,每个 ng-template 都需要 let-whatever,没有 let- 就等于这个 ng-template 没有自身的 Template Context,那 binding syntax 就会直接往上找了。
ng-template as a function
把 ng-template 看成是 function 也有助于我们的理解。
Child Template 被 compile 后长这样
从这里可以看出它如何从每一层的 Template Context 抽出 value 赋值 let-variable 和最终的 binding to DOM。
ng-template 用于 Dynamic Component 的 Content Projection
在上一篇 Dyanmic Component 中,我们有一个 Content Projection 的例子,里面使用了原生的 template DOM Manipulation。
这里补上一个 ng-template 的版本。
SayHi Template
App Template
需求很简单,动态创建 SayHi 组件,把 ng-template 里的 3 个 paragraph 传进去 ng-content。
关键就是 rootNodes 属性会得到 Node Array。
效果
当 ng-template 遇上 Query Elements
上一篇我们说过 Dynamic Component 是无法通过 @ViewChildren query 出来的。
那 ng-template 呢?
可以!
效果
小心坑
上面的例子是这样的
- ng-template 被 declare 在 App Template
- ng-template 被插入到 App Template(因为 ng-container 在 App Template 里)
- @ViewChildren 也在 AppComponent
全部在一起,query 没有问题。
我们换一个场景
当我们把 ng-template 和 ng-container (插入的位置) 分别放到不同的组件时,结果只有 ng-template declare 的 C1 组件可以 query 到 ng-template 里的内容。
这里就涉及到 Query ng-template 的机制了,我们下面逛源码的时候才解答,这里我们只要记住,没有人可以从 ng-container 里 query 出任何东西。
Dynamic Component 和 ng-template 都不行。ng-template 可以被 query 是在 declare 它的地方,而不是在它插入的地方。
当 ng-template 遇上 Change Detection
同样一个跨组件的例子
C1 声明 ng-template,C2 insert
这个 ng-template 内有用到本身的 Template Context (C2 传入) 也有用到 Declaration Template Context (来自 C1)
C1 update value 并且 detectChanges,ng-template 内容没有更新,因为 C2 没有跟着 detectChanges。
我们知道 detectChanges 是往下的,sibling 的 C2 没有跟着 detectChanges 是正常的。
C2 update value 并且 detectChanges,有效果,正常。
当 C2 detectChanges 时,ng-template 内容连 C1 的 value 都一起更新了。
结论
-
C2 作为 ng-template insert 的地方,只要它 refreshView,那么 ng-template 也一定会 refreshView。
-
C1 作为 ng-template declare 的地方,即便它 refreshView,ng-template 也未必会 refreshView。
上面的结论有一个前提,那就是我们关闭了 ngZone 并且指定了组件做 detectChanges。
如果我们开启 ngZone 让它跑 tick,那么在 C1 refreshView 之后,C2 作为 ng-template insert 的地方也会被 refreshView。
这是因为 Angular 做了特别的处理,下面逛源码时会讲解。
逛源码 の declare ng-template
要深入理解 ng-template 最好的方式自然是逛源码咯😱...或者至少看一看它在 TView、LView、TNode 里的长相。
我们一样由浅入深。
View Information
App Template
单看 View (视图),组件和 ng-template 其实很像,毕竟组件也有 Template,所以我们可以借助原先对组件的认知来理解 ng-template。
假设上图是一个 <app-say-hi>
那这里 renderView 后会产生
- TNode(type = Element)
- RNode (<app-say-hi>)
- TView
- LView
那换成 <ng-template> 它会产生
- TNode (type = Container)
- RNode (<!--container-->)
- TView
- LContainer(注:这里不是 LView 哦)
DOM
App LView
App TView.data
SayHi 和 ng-template 的差异
-
SayHi TView 存放在 SayHi Definition.tView
而 ng-template TView 存放在 ng-template TNode.tView。
合理,因为 ng-template 没有 Definition。
-
SayHi 有 LView,ng-template 没有。
合理,因为 ng-template 的 LView 要等到 TemplateRef.createEmbeddedView 时才会创建。
-
ng-template RNode 是 Comment 节点
合理,因为 ng-template 不是原生 tag。就如同 <ng-container /> 那样,也是 Comment 节点。
-
SayHi TNode 类型是 Element (1 号),ng-template TNode 类型是 Container (4号)。
注:<ng-container /> 的 TNode 类型是 ElementContainer (8号)
-
ng-template 生成了一个 LContainer
Dynamic Component 文章中,我们知道 @ViewChildren + read: ViewContainerRef 不管 query 任何 RNode 都会生成 LContainer。
它用于 ViewContainerRef.insert 插入 Dynamic Component。
这里 ng-template 也会生成 LContainer,不过用途和 ViewContainerRef 不同,下一 part 会讲。
但有一点要 highlight,当 ng-template + @ViewChildren + read: ViewContainerRef 同时出现时,
它两会公用同一个 LContainer 哦。
相关源码
app.component.js
ng-template 的 template 方法
回到 ɵɵtemplate 函数
逛源码 の createEmbeddedView
继续由浅入深,上一 part declare ng-template,这一 part 我们 create and insert。
View Information
App Template
<ng-template #template> <h1>Hello</h1> </ng-template> <button (click)="append()" >append</button> <ng-container #container />
AppComponent
export class AppComponent { @ViewChild('template') templateRef!: TemplateRef<null>; @ViewChild('container', { read: ViewContainerRef }) viewContainerRef!: ViewContainerRef; cdr = inject(ChangeDetectorRef); append() { const embeddedView = this.templateRef.createEmbeddedView(null); console.log((embeddedView as any)._lView); // this.viewContainerRef.insert(embeddedView); // 先不插入,看 Embedded LView 先 } }
ng-template LView (a.k.a Embedded LView)
有 7 个是和 ng-template 息息相关的
几个点需要留意:
-
Declaration View 指的是这个 ng-template 被声明的地方,上面例子中 ng-template 是在 App Template 里被声明的,所以它的 Declaration View 是 App LView。
如果是嵌套 ng-template
那 ng-template2 的 Declaration View 则是 ng-template1 的 LView。
在 createLView 函数里,LView 创建时 Parent 和 Declaration View 就是当前的 parentLView。
注:Parent 会在插入 <ng-container /> 后变成 LContainer。
-
Declaration Component View 一定是组件 LView,上面 template1 和 template2 的 Declaration Component View 都是 App LView。
另外一点,组件的 Declaration Component View 一定指向自己。
-
Embedded View Injector 下面会教,这里我们略过。
接着插入到 <ng-container />
this.viewContainerRef.insert(embeddedView);
<ng-container /> LContainer
<ng-container /> LContainer 没什么特别的,上一篇 Dyanmic Component 我们研究过了,
唯一的区别是组件是 hostView,ng-template 是 embeddedView,不过看抽象它俩都是 ViewRef。
ng-template LContainer
如果 ng-template LView 不是插入到它自己的 LContainer,那这个 LView 被称为 Moved View,
这个 ng-template LView 自己的 LContainer 会保留这个 LView as a reference 在 LContainer[9]。
总结:
-
LContainer[8] 装的是 insert 进来的 ViewRef,比如组件 hostView 或者 ng-template embeddedView
-
LContainer[10-n] 装的是 insert 进来的 LView
-
LContainer[9] 装的是 Moved Views,这个比较绕,一步一步看。
首先这个 [9] 只出现在 ng-template LContainer,如果是 <ng-container /> LContainer 那 [9] 一定是 null。
那怎样才算 Moved View?
如果一个 ng-template LView 被插入到它自己的 ng-template LContainer,那这不算是 Moved VIew。
这个 LContainer[8] 和 [10] 会记入这个插入的 LView,但是 [9] 是空的。
如果一个 ng-template LView 被插入到不是自己的 LContainer (不管是 <ng-container /> LContainer 还是其它 ng-template 的 LContainer),这就算是 Moved View。
这个 <ng-container /> LContainer[8] 和 [10] 会记入这个插入的 LView,同时这个 ng-template 自己的 LContainer[9] 也会记入这个 LView。
相关源码
TemplateRef.createEmbeddedView 方法源码在 template_ref.ts
ViewContainerRef.insert 源码我们上一篇逛过了,这里补充关于 ng-template 的部分就好。
insertView 函数的源码在 node_manipulation.ts
逛源码 の Template Context
App Template
<ng-template #template let-vm let-whatever="name"> <h1>{{ appValue }}</h1> <h1>{{ vm.value }}</h1> <h1>{{ whatever }}</h1> </ng-template>
app.component.js
let 和 Template Context 之间的 mapping 是靠 compile 完成的。
每一个 LView 都有属于自己的 Context,它存放在 LView[8]。
上面 ctx 便是 ng-template LView[8 Context] 也就是 TemplateRef.createEmbeddedView(templateContext) 传进去的 Template Context。
而 ɵɵnextContext 返回的则是 ng-template LView[14 Declaration LView][8 Context],这个 Declaration LView 有可能是组件 LView 也有可能是 ng-template LView,不管哪个都会有 context 的。
我们看看 LView[8 Context] 的源码部分
创建 LView 时,context 会被传进去。
创建 ng-template 时,context 会一路传进去
TemplateRef.createEmbeddedView --> createEmbeddedViewImpl --> createAndRenderEmbeddedLView
组件分 2 个步骤,先创建 LView,此时 context 是 null,然后实例化组件,再把组件实例赋值到 LView[8 Context]。
Dynamic Component 的流程是在 createRootComponentView 函数
逛源码 の Embedded View Injector
App Template
问:把 ng-template 插入 <ng-container /> 后,SayHi 组件可以 inject 到 App 组件吗?
答:可以
App Template & C1 组件 Template
问:把 App Template 里的 ng-template 插入到 C1 Template 里的 <ng-container /> 后,SayHi 组件可以 inject 到 C1 组件吗?
答:不可以!
为什么?
NodeInjector 查找规则的细节
我们在 NodeInjector 和 Dynamic Component 文章中讲解过 NodeInjector 的查找规则。
虽然都对,但是不够细,这里我们补上几个细节。
-
在没有 Dynamic Component 和 ng-template 的场景下,LView 的 parent LView 和 Declaration View 一定是相同的。
在这个前提下,我们可以说 "parent Injector 在 parent LView 里",也可以说 "parent injector 在 Declaration View 里"。两句都是正确的。
但是,如果脱离了这个前提,那只有后一句 "parent injector 在 Declaration View 里" 是肯定正确的,前一句未必是正确的。
因为在 getParentInjectorView 函数中,查找用的是 parentLView[DECLARATION_VIEW] 而不是 parentLView[PARENT]。
-
动态创建的组件会有 2 个 LView,一个是组件 Host LView,一个是组件 LView。
在还没有插入 <ng-container /> 前,Host LView 的 parent LView 和 Declaration LView 都是 null。
在插入 <ng-container /> 后,Host LView 的 parent LView 变成了 <ng-container /> LContainer,但 Declaration LView 依然是 null。
也因为这样,组件 NodeInjector 无法往 LContainer 路线去查找,因为它的路线是 Declaration LView 而不是 parent LView,
我们从 Host LView 的 NodeInjector 资料也可以看出。
所以动态组件 inject 查找的路线主要是依靠我们传进去的 elementInjector。
-
回到 ng-template 的提问
SayHi 组件的 Declaration View 是 App LView,虽然 C1 里的 <ng-container /> LContainer 是它的 parent LView,但 NodeInjector 找的是 Declaration View,不是 parent LView。
所以 SayHi 组件只能 inject 到 App 组件不能 inject 到 C1 组件。
Embedded View Injector
在动态创建组件时,我们可以传入 environmentInjector 和 elementInjector。在创建 ng-template 时我们也可以传入 embeddedViewInjector。
embeddedViewInjector 的作用和 elementInjector 是一样的,为了扩展 NodeInjector。
比如上面的例子
SayHi 本来是 inject 不到 C1 组件。
但在 createEmbeddedView 时传入 C1 NodeInjector,这样 SayHi 就可以 inject 到 C1 了。
相关源码在 TemplateRef.createEmbeddedView 过程中的 createAndRenderEmbeddedLView 函数
createLView 倒数第二个参数传入 embeddedViewInjector
在 SayHi 组件 inject(C1Component) 过程中的 getOrCreateInjectable 函数
ng-template 在创建时传入了一个 embededViewInjector,然后在 C3 组件 inject Provider,我们看看它的查找路线图。
lookupTokenUsingEmbeddedInjector 函数的源码在 di.ts
上面第一轮的 white TNode 是 C3,第二轮是 C2,第三轮是 C1,我们直接进入第三轮 C1,因为 C2 和 C3 流程是一样的。
小感悟
当许多场景叠加在一起时,复杂度就上升了。原本 NodeInjector 就蛮多规则的了,再加上 ng-template,再加上 Content Projection,再加上嵌套 ng-template。
上面找 Embedded View Injector 完全没有使用 NodeInjector Bloom 的概念,它就只是单纯一层一层往上找,为此 Angular Team 还特别写了注释
可见框架的实现与维护是很有难度的。
ViewContainerRef.createEmbeddedView
我们在 Dynamic Component 文章中介绍了所有 ViewContainerRef 的功能,唯独遗漏了 createEmbeddedView 方法,因为这个和 ng-template 相关,所以放到本篇才介绍。
我们知道,使用 ViewContainerRef.createComponent 来动态创建组件,它会把 ViewContainerRef.parentInjector 用作 elementInjector。
由此推测,使用 ViewContainerRef.createEmbeddedView 来创建 EmbededView,它应该也会把 ViewContainerRef.parentInjector 用作 embeddedViewInjector 吧?
毕竟 Angular 总是追求统一,追求一致性的嘛👍
但是!你这样想就错了,因为你忘了 Angular 还喜欢挖坑😅
createEmbeddedView 方法的源码在 view_container_ref.ts
它没有使用 ViewContainerRef.parentInjector 作为 embededViewInjector。
所以,如果我们看回这个例子
使用 ViewContainerRef.createEmebedView 的话,SayHi 组件是 inject 不到 C1 组件的。
逛源码 の Query Elements
我们在 Query Elements 文章中翻过 Query 的源码,大致的流程是
bootstrapApplication --> renderView --> 创建 TQuery 和 LQuery --> 组件 template 方法 --> 创建 TNode 同时 matching with TQueries -->
refreshView --> refreshQuery --> 拿 matched index 去 LQuery 拿最终的 value 放到 QueryList。
上面有一个重点,matching 在 renderView 结束时就完成了,也就是说它没有 cover 到 Dynamic Component 和 ng-template 的场景。
上一篇 Dynamic Component 我们也说了,动态创建的组件是没有办法被 query 到的。
虽然 ng-template 也是动态生成的,但它却可以被 query 到。
只是它的机制和规则有一点点特别,下面就让我们通过源码来了解它。
renderView
App Template
AppComponent
依据上面讲的流程,在创建 ng-template TNode 时会顺便做 matching。
TQuery template 方法的源码在 query.ts
回到 templateFirstCreatePass 函数
继续
TQuery.embeddedTView 方法的源码
回到 TQueries.embeddedTView
回到 templateFirstCreatePass 函数
以上就是在 bootstrapApplication --> renderView 后会发生的事情。
回到上面的例子,我们看看大家的 TView 里都装了些什么。
App 'h1' TQuery
ng-template TQuery (它在 App TView.data[25].tView.queries[0])
App 'template' TQuery
ng-template TQuery (它在 App TView.data[25].tView.queries[1])
createEmbeddedView
接着我们继续看 TemplateRef.createEmbeddedView 之后的源码
createEmbeddedView --> 创建 ng-template LView --> renderView --> ng-template 的 template 方法 --> 创建 TNode 顺便 matching ng-template TQuery
ng-template LQueries
App 在 refreshView 的时候会执行 App viewQuery 方法,注意,只有 App 有 viewQuery 方法,ng-template 是没有 viewQuery 方法的。
refreshView
接着 insert ng-template LView to <ng-container /> LContainer
App Template
AppComponent
App LQueries
ng-template LQueries
总结
-
App TQuery 和 ng-template TQuery 是有关联的
-
当 ng-template 被创建,ng-template TQuery 就会 matched
-
当 App refreshView 它除了 query App TQueries 也会 query 有关联的 ng-template TQueries
所以也会 query 到 ng-template 的内容。
-
Query 是通过 ng-template LContainer[10-n] 和 [9 Moved Views] 去找到 ng-template LView 的,
所以不管 ng-template LView 被 insert 到哪一个 View Container 哪怕它在另外一个组件也能 Query 的到。
逛源码 の Change Detection
在 Change Detection 文章中,我们就研究过整个 Change Detection 源码和机制了,但是我们刻意避开了 ng-template 的部分。
好,这里就补上。我们直接进入重点 ApplicationRef.tick --> App ViewRef.detectChanges --> detectChangesInternal 函数
detectChangesInternal 函数源码在 change_detection.ts
回到 refreshView 函数
detectChangesInEmbeddedViews 函数
回到 detectChangesInternal 函数
总结
detectChanges 会有 2 轮遍历,第一轮来自 refreshView,第二轮来自 detectChangesInViewWhileDirty
refreshView 是 for 常规的更新,它有一套往下遍历的条件。
detectChangesInViewWhileDirty 是 for Signal 和 ng-template Moved Views。
refreshView 时,LContainer 里的 ng-template LView 会被 refreshView,另外 ng-template Moved Views 会被 markAncestorsForTraversal,
然后 detectChangesInViewWhileDirty 会 refreshView 这些 Moved Views。
所以,只要 ng-template LContainer 或者 insert ng-template 的 LContainer 其中一个 refreshView 就可以了。
目录
上一篇 Angular 18+ 高级教程 – Signals
下一篇 Angular 18+ 高级教程 – Component 组件 の Structural Directive (结构型指令) & Syntax Reference (微语法)
想查看目录,请移步 Angular 18+ 高级教程 – 目录
喜欢请点推荐👍,若发现教程内容以新版脱节请评论通知我。happy coding 😊💻