Angular 18+ 高级教程 – Component 组件 の Control Flow
前言
Control Flow 是 Angular v17 版本后推出的新模板语法,用来取代 NgIf、NgForOf、NgSwitch 这 3 个 Structure Directive。
Structure Directive 的好处是比较灵活,原理简单,但是即便用了微语法,它看上去还是相当繁琐,而且不够优雅。
Conrol Flow 的好处是它的语法够美,缺点是不必 Structure Directive 灵活,开发者无法做任何 customize,只能看 Angular 给什么用什么。
参考
@if @else if @else
这个是 NgIf 指令的写法
<ng-template #loadingTemplate> <p>loading...</p> </ng-template> <p *ngIf="person$ | async as person; else loadingTemplate">{{ person.name }}</p>
这个是 Control Flow 的写法
@if(person$ | async; as person) { <p>{{ person.name }}</p> } @else { <p>loading...</p> }
另外,pipe async 后还可以继续判断哦,比如
@if(number$() | async; as number === 5) { <p>{{ number }}</p> }
number$ 类型是 Signal<Observable<number>>。
另外,Control Flow 还支持 @else if,这个是 NgIf 指令不支持的。
@if (value >= 5) { <p>greater than or equal to five</p> } @else if (value >= 2) { <p>greater than or equal to two</p> } @else { <p>less than two</p> }
Control Flow @if 的原理
Contorl Flow 不依赖 NgIf 指令,甚至不依赖 ViewContainerRef,它使用的是比较底层的接口,
比如 createAndRenderEmbeddedLView 函数和 addLViewToLContainer 函数。这两个函数的源码我们在 Dynamic Component 文章中研究过,
它们也是 ViewContainerRef 内部使用的。
app.component.js
在 renderView 阶段,@if、@else if、@else 分别会创建三个 ng-template。
在 refreshView 阶段,会执行 ɵɵconditional 函数。第二个参数依据判断条件它会渲染第几个 ng-template。
ɵɵconditional 函数的源码在 control_flow.ts
当 @If 遇上 <ng-content />
当 @if 或者 *ngIf 遇上 <ng-content /> 会有一些化学反应。
the problem...
我们先看看日常会遇到的两个问题:
-
multiple @if + <ng-content />
App Template
<app-card> <h1>title</h1> </app-card>
Card Template
<button (click)="shown1.set(!shown1())">show 1</button> <button (click)="shown2.set(!shown2())">show 2</button> @if (shown1()) { <ng-content /> } <p>----------分割线----------</p> @if (shown2()) { <ng-content /> }
Card 组件
export class CardComponent { protected readonly shown1 = signal(false); protected readonly shown2 = signal(false); }
问:shown1 和 shown2 都能成功显示传入的 h1 title 吗?
效果
只有 shown2 成功显示 title,shown1 失败了。
WHY🤔?
-
project @if wrap multiple element to <ng-content select />
注:这道题还有 Github Issue 呢 -- Issue – Angular 17 New Control Flow | Allow more than one node at root @if, @else, @for, etc
App Template
<app-card> @if (true) { <h1>title</h1> } </app-card>
Card Template
<p>card works!</p> <ng-content select="h1" />
效果
没问题,现在我们加入一个 <p>,让它变成 multiple elements
App Template
加上 <p> 之后,h1 出现了一个 warning 说,h1 会 project 不进去,我们看看是不是真的。
效果
WHY🤔?
逛 <ng-content /> 源码 の overview simplest case
我们先来了解一下 <ng-content /> 底层到底是如何工作的,接着解释上述 2 道问题的原理,最后找一个方法来达到我们的目的,走起 🚀
一个简单正常的例子
App Template
<app-card> <h1>Title</h1> <p>Description</p> </app-card>
Card Template
<p>card works!</p> <ng-content />
compile
yarn run ngc -p tsconfig.json
app.component.js
这些源码以前都讲解过了,我们就直接看它生成出来的 LView 吧。
紧接着
大概是这么一个结构
const cardTNode = {}; const h1TNode = cardTNode.child; const pTNode = cardTNode.child.next; const nextTNode = cardTNode.child.next.next; // 如果还有的话
从 Card 开始,往 child,然后一直 next 到完。(记住这个结构和动线,它可以解答上述的第二道题)
card.component.js
ɵɵprojectionDef 源码在 projection.ts
搞来搞去就是做了下面这些
接着
结论:Card TNode.projection = [h1 TNode] 和 h1 TNode.projectionNext = p TNode。
接着看 ɵɵprojection 函数,它的源码在 projection.ts
主要就是创建了一个 ngContent TNode,继续看 applyProjection 函数,它的源码在 node_manipulation.ts
细节我们就不探讨了,总之最后就是 cut and paste DOM elements,透过 DOM Manipulation insertBefore 或者 appendChild。
结论:ɵɵprojectionDef 负责搞 TNode 关系链,ɵɵprojection 依据 TNode 关系链做具体 DOM 操作 (cut and paste DOM elements by appendChild or insertBefore)。
逛 <ng-content /> 源码 の quick view common case
上一 part 的例子实在太过简单了,我们来加入 select 元素。
Card Template
<p>card works!</p> <ng-content select="h1" /> <ng-content select="p" />
compile
yarn run ngc -p tsconfig.json
card.component.js
ɵɵprojectionDef 函数
上一 part 简单例子 slotIndex 始终是 0 不需要任何匹配,但这一次需要了,let's check it out 👀
回到 ɵɵprojectionDef
结论:Card TNode.projection = [h1 TNode, p TNode]。
对比简单例子的结论:Card TNode.projection = [h1 TNode] 和 h1 TNode.projectionNext = p TNode。
回到 card.component.js
进入 ɵɵprojection 函数,我们看最关键的几行就好了。
接着
nodeToProjectOrRNodes 就是要被 cut and paste 到指定 <ng-content /> 的 DOM。
逛 <ng-content /> 源码 の 总结
这个
compile 成
接着
生产出 Card TNode.projection = [h1 TNode, p TNode]
然后
compile 成
最后
cut and paste h1 到 <ng-content select="h1" />。
<ng-content select="p" /> 的做法和 h1 一模一样,我就不再赘述了。
知识点:
-
Compiler
有多少个 <ng-content />,_c0 就会有多少个 selectors,然后就会有多少个 ɵɵprojection 调用 (并且对应 selectorIndex),但是 ɵɵprojectionDef 只会有一个调用哦。
- ɵɵprojectionDef
它负责搞匹配,把 _c0 selector [[["h1"]], [["p"]]] 拿去和 Card TNode.child.next.next 做配对,
最后把结果记入在 Card TNode.projection array 里。
[[["h1"]], [["p"]]] 配对成了 [h1 TNode, p TNode]。
假如配对失败就是 null,假如配对超过一个,那会记入在 h1 TNode.projectionNext,串链子的概念。
-
ɵɵprojection
它负责 cut and paste DOM。
ɵɵprojection 调用时会传入 selector index,它用来对应 Card TNode.projection array。
假如之前没有配对到,那就是 null,无法 cut and paste。
假如只有一个 h1 TNode,那就 cut and paste 这个 h1。
假如 h1 TNode 有 projectionNext,那就继续 cut and paste 到 h1 element 的下面。
solve the problem 1
回看第一道题 -- multiple @if + <ng-content />
效果
show 1 失灵了,show 2 可以,WHY🤔?
我们拆解一下,看看为什么它的行为会是这样。
先做一个简化
App Template
<app-card> <h1>Title</h1> </app-card>
只 project 一个 h1 进去
Card Template
<ng-content /> <p>card works!</p> <ng-content />
有两个 <ng-content />,问:最终的显示会是怎样?
效果
第二个 <ng-content /> 显示了 h1,第一个则是空的。
其实挺合理的,<ng-content /> 是 cut and paste,不是 copy and paste,一定只能 paste 去其中一个 <ng-content />,所以我们写两个 <ng-content /> 是完全错误的。
相关的源码
结论:Card TNode.projection = [null, h1 TNode]
第一个 <ng-content /> 的 selectorIndex 是 0,对应 Card TNode.projection[0] 就是 null,所以没有 cut and paste。
第二个 <ng-content /> 的 selectorIndex 是 1, 对应 Card TNode.projection[1] 就是 h1 TNode,所以 cut and paste h1 到这个 <ng-content />。
我们再试一个例子,体会多一点
Card Template
<ng-content select="h1" /> <p>card works!</p> <ng-content select="h1" />
加入了 select="h1",问:最终的显示会是怎样?
效果
你答对了吗?h1 不是显示在第二个 <ng-content />,而是显示在第一个 <ng-content /> 哦,和上一题不一样的答案😓。
相关源码
最终返回的 index 是 0,于是 Card TNode.projection = [h1 TNode, null]
所以第一个 (index 0) <ng-content select="h1" /> 成功 cut and paste,第二个 (index 1) 是 null 无法 cut and paste。
从这两个例子也可以看出,Angular 完全没有考量到 multiple same <ng-content /> (duplicated) 的情况,总之千万不要这么写就对的,它一点都不逻辑。
好,再次回看第一道题 -- multiple @if + <ng-content />
请问,上面这两个 <ng-content /> 算不算 duplicated 了呢?
如果算的话,那最终只有第二个 shown2 的 <ng-content /> 会显示 h1。
依照我们上面测试的结果,确实是这样,所以算是 duplicated 了。
看看相关代码
虽然 wrap 了一层 @if,但并没有改变太多东西,ɵɵprojectionDef 依然在 Card renderView 时执行,参数全都一样。
ɵɵprojection 被挪到了 @if 产生的 template 里,但依然是 renderView 的时候执行,而且参数也都一样。
虽然 cut and paste 的时机换了,但是所有的 index,matching 规则都一摸一样,所以最终的结果自然也一样,肯定是 duplicated 了。
那要怎样破呢?
<ng-template #ngContentTemplate><ng-content /></ng-template> @if (shown1()) { <ng-container [ngTemplateOutlet]="ngContentTemplate" /> } <p>----------分割线----------</p> @if (shown2()) { <ng-container [ngTemplateOutlet]="ngContentTemplate" /> }
利用 ngTemplateOutlet 指令可以破。
原理很简单,我们只有 <ng-content />,这样就没有 duplicated 的问题了。
ng-template 被创建的时候,会执行 cut and paste。
效果
提醒:show 1, show 2 都有效,但它不可以同时两个一起出现哦,因为 <ng-content /> 始终是 cut and paste,而不是 copy and paste,h1 就只有 1 个而已。
solve the problem 2
回看第二道题 -- project @if wrap multiple element to <ng-content select />
为什么会 warning?
为什么最终没有显示 h1?
要解答这题,我们先思考这个 -- Only first layer can be select
select second layer 会失败。
原因和相关源码
匹配的动线是 Card TNode > .child > .next > .next > .next...
<app-card> <h1>Title</h1> <!--child--> <div> <!--next--> <h1>Title</h1> <!--not in scanning list--> </div> <p>Description</p> <!--next--> </app-card>
second layer 不会在配对列表中,所以 <ng-content /> 肯定 select 不到 second layer。
好,那我们回来看看
<app-card> @if (true) { <h1>title</h1> } </app-card>
compile 之后长这样
虽然它是 template,但是 tagName 是 h1。
我们加入 p
<app-card> @if (true) { <h1>title</h1> <p>Lorem ipsum, dolor sit amet consectetur adipisicing elit. Explicabo, recusandae?</p> } </app-card>
再 compile
最大的区别是,它不再是 tagName h1 了。
没有了 tagName h1,<ng-content select="h1" /> 自然就配对不到它了。
为什么加了一个 p,tagName h1 就没了呢?
@if (true) { <h1>title</h1> } @if (true) { <h1>title</h1> <p>Lorem ipsum, dolor sit amet consectetur adipisicing elit. Explicabo, recusandae?</p> }
其实也挺合理的,倘若 @if 里面只有一个 element,那我用它作为 tagName,make sense。
但如果 @if 里面有多个 elements 呢?我用 "第一个" 作为 tagName 吗?这视乎就没有那么 make sense 了。
好,搞清楚了原因,那我们要如何破解呢?
目前没有什么优雅的方案,我们只能强制迎合它的 limitation,硬把 @if 拆成多个。
<app-test> @if (true) { <h1>Hello World</h1> } @if (true) { <p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Dicta, maiores?</p> } </app-test>
@for @empty
这个是 NgForOf 指令的写法
<ng-template #noDataTemplate> <p>no datas</p> </ng-template> <ng-container *ngIf="people.length > 0 else noDataTemplate"> <ng-container *ngFor="let person of people; trackBy: trackByName; let index = index"> <p>index: {{ index }}</p> <p>name : {{ person.name }}</p> </ng-container> </ng-container>
这个是 Control Flow 的写法
@for (person of people; track person.name; let index = $index) { <p>index: {{ index }}</p> <p>name : {{ person.name }}</p> } @empty { <p>no datas</p> }
有 2 个点非常强
-
trackByFunction 不需要在 AppComponent 里定义了
-
@empty 取代了 NgIf 指令
@for 的原理我们就不翻源码了,它和 @if 一样都不依赖 Structure Directive,里面都是调用底层的接口,也因为这样 @for 比 NgForOf 指令速度快很多哦。
nested @for get parent $index
在 *ngFor,我们可以给 index 定义不同的 variable name,这样在子层中也可以拿到父层的 index。
在 @for,我们没有了这个能力。
因为 @for 底层不是 Structure Directive,它没有自定义 variable name 的能力。
幸好,Angular 在 v18.1 推出了 @let 语法。
这样就行啦。
@switch @case @default
这个是 NgSwitch 指令的写法
<ng-container [ngSwitch]="status"> <ng-template [ngSwitchCase]="'Completed'"> <p>complete</p> </ng-template> <ng-template [ngSwitchCase]="'Pending'"> <p>pending</p> </ng-template> <ng-template ngSwitchDefault> <p>none</p> </ng-template> </ng-container>
这个是 Control Flow 的写法
@switch (status) { @case ('Completed') { <p>complete</p> } @case ('Pending') { <p>pending</p> } @default { <p>none</p> } }
提醒:@switch 和 ngSwtich 指令一样,都没有 fallthrough 的概念,match 到后会自动 break。
@defer @placeholder @loading @error
@defer 是 Control Flow 独有的,不存在 Defer 指令。
@defer 的作用是延迟显示一段内容,并且内容里包含的组件/指令/Pipe 还会延迟加载。
@defer (on timer(5s)) { <app-say-hi /> <app-hello-world /> }
上面这段表示 5 秒中后才输出 SayHi 和 HelloWorld 组件。
我们可以这样去理解,@defer 会被 compile 成 ng-template,5 秒钟后会 createEmbeddedView 然后 insert。
此外 compile 时它还会找出涉及的组件,改成使用 import('/path/component.ts') 的方式动态加载,这样可以减少 first load 的 bundle size。
app.component.js
@loading,@placeholder,@error
@defer (on timer(2s)) { <app-say-hi /> <app-hello-world /> } @placeholder { <p>blank...</p> } @loading { <p>loading...</p> } @error { <p>something wrong</p> }
@defer 的显示流程是这样:
一开始先显示 @placeholder 的内容,等 on timer 触发后,显示 @loading 内容,这时会去 lazyload -> createEmbededView -> insert,最终显示 @defer 内容。
如果 lazyload 的过程失败,那就显示 @error 内容。
minimum 和 after options
有时候 lazyload 或许会太快,loading 一闪而过体验不好,这时可以设置 after 和 minimum。
@loading(after 100ms; minimum 1s) { <p>loading...</p> }
当开始 lazyload 后,Angular 会开始计时,超过 100ms 后,如果 lazyload 还没有结束,那就会显示 @loading 内容,
如果 lazyload 在 1 秒钟内完成,它不会马上输出 @defer 内容,而是会等到 minimum 1s 后才输出。
注:after 和 minimum 是同时开始计时的。
@placeholder 也支持 minimum options,不过不支持 after 哦。
Trigger
除了 on timer 以外,还有另外 5 个 Triggers。
-
@defer (on idle)
idle 指的是游览器的 requestIdleCallback 事件
-
@defer (on immediate)
immediate 表示一旦 Angular first render 结束后立马就触发。
-
@defer (on timer(500ms))
timer 就是 setTimeout,可以写 ms (millisecond) 或者 s (second)
-
@defer (on hover)
<div #hoverArea>hover area</div> @defer (on hover(hoverArea)) { <app-say-hi /> <app-hello-world /> } @defer(on hover) { <app-say-hi /> <app-hello-world /> } @placeholder { <div>hover area</div> }
hover element 默认是 @placeholder 内容,我们可以传入 element 作为指定的 hover area。
-
@defer(on viewport)
viewport 指的是当指定的 element 出现在 viewport 里,它是通过 IntersectionObserver 实现的。
和 on hover 一样,默认的 element 是 @placeholder,我们可以通过传入参数指定 element。
-
@defer (on interaction)
interaction 指的是 click 和 keydown 事件,和 on hover / on viewport 一样可以指定 element。
我们可以写 multiple trigger,它的关系是 "or",也就是说只要其中一个 trigger 触发就执行。
@defer (on viewport; on timer(5s)) { <app-say-hi /> <app-hello-world /> }
当 on viewport 或者超过 5 秒后显示。
Prefetching
prefetching 的作用是多一个 trigger 用于是否提早 lazyload。
@defer (on viewport; prefetch on timer(5s)) { <app-say-hi /> <app-hello-world /> } @placeholder { <p>placeholder</p> }
5 秒后,虽然 element 没有 on viewport 但会先去加载,只加载不显示,一直到 element on viewport 才显示。
prefetch 也可以写 multiple trigger,只要其中一个触发就执行
@defer (on viewport; on timer(5s); prefetch on timer(3s); prefetch on hover) { <app-say-hi /> <app-hello-world /> } @placeholder { <p>placeholder</p> }
总结
Control Flow 代码比起 Structure Directive + 微语法整齐很多,代价是无法扩展。
NgIf、NgForOf、NgSwtich 指令相信在不久的将来会被淘汰,但是 ng-template、Structure Directive 和微语法是不会被淘汰的,
而要掌握好 Structure Directive 和微语法,我个人觉得 NgIf、NgForOf、NgSwtich 指令是非常值得参考的。
目录
上一篇 Angular 18+ 高级教程 – Component 组件 の Structural Directive (结构型指令) & Syntax Reference (微语法)
下一篇 Angular 18+ 高级教程 – Component 组件 の @let Template Local Variables
想查看目录,请移步 Angular 18+ 高级教程 – 目录
喜欢请点推荐👍,若发现教程内容以新版脱节请评论通知我。happy coding 😊💻