Angular 18+ 高级教程 – Component 组件 の Control Flow

 

前言

Control Flow 是 Angular v17 版本后推出的新模板语法,用来取代 NgIf、NgForOf、NgSwitch 这 3 个 Structure Directive。

Structure Directive 的好处是比较灵活,原理简单,但是即便用了微语法,它看上去还是相当繁琐,而且不够优雅。

Conrol Flow 的好处是它的语法够美,缺点是不必 Structure Directive 灵活,开发者无法做任何 customize,只能看 Angular 给什么用什么。

 

参考

Docs – Built-in control flow

Docs – Deferrable Views

 

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

我们先看看日常会遇到的两个问题:

  1. 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🤔?

  2. 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 一模一样,我就不再赘述了。

知识点:

  1. Compiler

    有多少个 <ng-content />,_c0 就会有多少个 selectors,然后就会有多少个 ɵɵprojection 调用 (并且对应 selectorIndex),但是 ɵɵprojectionDef 只会有一个调用哦。

  2. ɵɵprojectionDef

    它负责搞匹配,把 _c0 selector [[["h1"]], [["p"]]] 拿去和 Card TNode.child.next.next 做配对,

    最后把结果记入在 Card TNode.projection array 里。

    [[["h1"]], [["p"]]] 配对成了 [h1 TNode, p TNode]。

    假如配对失败就是 null,假如配对超过一个,那会记入在 h1 TNode.projectionNext,串链子的概念。

  3. ɵɵ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 个点非常强

  1. trackByFunction 不需要在 AppComponent 里定义了

  2. @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。

  1. @defer (on idle)

    idle 指的是游览器的 requestIdleCallback 事件

  2. @defer (on immediate)

    immediate 表示一旦 Angular first render 结束后立马就触发。

  3. @defer (on timer(500ms))

    timer 就是 setTimeout,可以写 ms (millisecond) 或者 s (second)

  4. @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。

  5. @defer(on viewport)

    viewport 指的是当指定的 element 出现在 viewport 里,它是通过 IntersectionObserver 实现的。

    和 on hover 一样,默认的 element 是 @placeholder,我们可以通过传入参数指定 element。

  6. @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 😊💻

 

posted @ 2024-01-14 18:30  兴杰  阅读(678)  评论(0编辑  收藏  举报