Angular 17+ 高级教程 – Component 组件 の ng-template

前言

上一篇 Dynamic Component 我们有提到,作为 MVVM 框架的 Angular 需要有方法替代掉 2 个 DOM Manipulation:

  1. document.createElement 

  2. template.content.clone

Dynamic Component 便是替代 document.createElement 的方案。

而这篇我们要讲的 ng-template 则是 template.content.clone 的替代方案。

 

ng-template の 简单使用

我们从浅入深,一个一个特性介绍,最后才逛源码,看看原理机制。

提醒:为了更容易体会到细节,我把 ngZone 关了,所有组件都设置成 ChangeDetectionStrategy.OnPush。

App Template

AppComponent

效果

整体流程相当简单,所有用到的技能,我们在前几篇文章都学过了

  1. query template(Query Elements 文章里学过了)

  2. use template create view (和 createComponent 大同小异,Dynamic Component 文章里学过了)

  3. 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 了。

小总结:

  1. same key

    let-title="title" 

    const title = templateContext['title']

  2. different key

    let-whatever="title" 

    const whatever = templateContext['title']

  3. child key

    let-childTitle="child.title"

    const childTitle = templateContext['child']['title']

  4. 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 呢?

可以!

效果

小心坑

上面的例子是这样的

  1. ng-template 被 declare 在 App Template
  2. ng-template 被插入到 App Template(因为 ng-container 在 App Template 里)
  3. @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 都一起更新了。

结论

  1. C2 作为 ng-template insert 的地方,只要它 refreshView,那么 ng-template 也一定会 refreshView。

  2. 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 后会产生

  1. TNode(type = Element)
  2. RNode (<app-say-hi>)
  3. TView
  4. LView

那换成 <ng-template> 它会产生

  1. TNode (type = Container)
  2. RNode (<!--container-->)
  3. TView
  4. LContainer(注:这里不是 LView 哦)

DOM

App LView

App TView.data

SayHi 和 ng-template 的差异

  1. SayHi TView 存放在 SayHi Definition.tView

    而 ng-template TView 存放在 ng-template TNode.tView。

    合理,因为 ng-template 没有 Definition。

  2. SayHi 有 LView,ng-template 没有。

    合理,因为 ng-template 的 LView 要等到 TemplateRef.createEmbeddedView 时才会创建。

  3. ng-template RNode 是 Comment 节点

    合理,因为 ng-template 不是原生 tag。就如同 <ng-container /> 那样,也是 Comment 节点。

  4. SayHi TNode 类型是 Element (1 号),ng-template TNode 类型是 Container (4号)。

    注:<ng-container /> 的 TNode 类型是 ElementContainer (8号)

  5. 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 函数的源码在 template.ts

templateFirstCreatePass 函数和 elementStartFirstCreatePass (以前研究过的) 函数大同小异

回到 ɵɵ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 息息相关的

几个点需要留意:

  1. 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。

  2. Declaration Component View 一定是组件 LView,上面 template1 和 template2 的 Declaration Component View 都是 App LView。

    另外一点,组件的 Declaration Component View 一定指向自己。

  3. 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]。

总结:

  1. LContainer[8] 装的是 insert 进来的 ViewRef,比如组件 hostView 或者 ng-template embeddedView

  2. LContainer[10-n] 装的是 insert 进来的 LView

  3. 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

createAndRenderEmbeddedLView 函数的源码在 view_manipulation.ts

ViewContainerRef.insert 源码我们上一篇逛过了,这里补充关于 ng-template 的部分就好。

insertView 函数的源码在 node_manipulation.ts

trackMovedView 函数

 

逛源码 の 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]。

addComponentLogic 函数

instantiateAllDirectives 函数

Dynamic Component 的流程是在 createRootComponentView 函数

createRootComponent 函数

 

逛源码 の 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 查找规则的细节

我们在 NodeInjectorDynamic Component 文章中讲解过 NodeInjector 的查找规则。

虽然都对,但是不够细,这里我们补上几个细节。

  1. 在没有 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. 动态创建的组件会有 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 资料也可以看出。

  3.  回到 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 函数

Embedded View Injector 查找路线是蛮特别的,我们用一个例子配上源码一步一步看
例子:

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 还特别写了注释

可见框架的实现与维护是很有难度的。

 

逛源码 の 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。

templateFirstCreatePass 函数源码在 template.ts

TQuery template 方法的源码在 query.ts

回到 templateFirstCreatePass 函数

TQueries.embeddedTView 方法源码在 query.ts

继续

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

ɵɵqueryRefresh 函数源码在 query.ts (这个源码我们在 Query Elements 文章中有研究过)

collectQueryResults 函数

 App LQueries

ng-template LQueries

总结

  1. App TQuery 和 ng-template TQuery 是有关联的

  2. 当 ng-template 被创建,ng-template TQuery 就会 matched

  3. 当 App refreshView 它除了 query App TQueries 也会 query 有关联的 ng-template TQueries

    所以也会 query 到 ng-template 的内容。

  4. 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 函数

markTransplantedViewsForRefresh 函数

回到 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 17+ 高级教程 – Signals

下一篇 Angular 17+ 高级教程 – Component 组件 の Structural Directive (结构型指令) & Syntax Reference (微语法)

想查看目录,请移步 Angular 17+ 高级教程 – 目录

  

 

posted @ 2024-01-03 21:33  兴杰  阅读(355)  评论(0编辑  收藏  举报