Angular 17+ 高级教程 – Component 组件 の Query Elements

前言

Angular 是 MVVM 框架。

MVVM 的宗旨是 "不要直接操作 DOM"。

在 Component 组件 の Template Binding Syntax 文章中,我们列举了一些常见的 DOM Manipulation。

const element = document.querySelector<HTMLElement>('.selector')!; // query element
element.textContent = 'value'; // update text
element.title = 'title'; // update property
element.setAttribute('data-value', 'value'); // set attribute (note: attribute and property are not the same thing)
element.style.padding = '16px'; // change style
element.classList.add('new-class'); // add class

const headline = document.createElement('h1'); // create element
headline.textContent = 'Hello World';
element.appendChild(headline); // append a element
element.innerHTML = `<h1>Hello World</h1>`; // write raw HTML

element.addEventListener('click', () => console.log('clicked')); // listen and handle a event

Template Binding Syntax 替代了上面许多的 DOM Manipulation,但任然有些 DOM Manipulation 是它没有覆盖到的。

比如说

  1. Query Child Elements

    e.g. document.querySelectorAll

  2. Query Parent Element

    e.g. document.body.parentNode 或者 document.body.closest

  3. Query Content Projection (a.k.a slot) Elements

    e.g. slot.assignedElements 

这篇,我们就来朴上这些 DOM Manipulation 替代方案,看看在 Angular 要如何 Query Elements。

 

Query Parent Element

在强调组件化的项目,query 往往和 Shadow DOM 隔离息息相关。

Query parent element in Shadow DOM

上图是一个 W3C Web Components 的例子,有两个组件 my-parent 和 my-child,它们都有 Shadow DOM 概念。

假如我们 select my-child,然后尝试 query my-parent,结果是这样

因为有 Shadow DOM 隔离,my-child 无法直接 query 到 my-parent,唯一的方法是一层一层往上拿

先找到 shadowRoot 然后 .host 才可以越出 Shadow DOM 的界线。

Angular 也有 Shadow DOM,虽然实现手法是模拟的,但基本规则是一样的。

那 Angular 也需要一层一层往上拿吗?当然不用!

inject Parent 组件

首先,Angular 并没有提供一个直接和完整的 query parent element 方案。

Angular 只是借助 NodeInjector 依赖注入的机制,让我们可以 query parent 组件实例(注:是 parent 组件实例,而不是 parent element)

在 Component 组件 の Dependency Injection & NodeInjector 文章中,我们就学习过了,子组件可以 inject 祖先组件的实例。

inject Parent Element

如果不想要组件实例,想要 element 的话,可以用一个很蠢的方法。

首先在 Parent 组件里,通过 inject ElementRef 拿到 element,然后把它存起来。

接着在 Child 组件,inject Parent 组件实例,然后再从实例中调出 element。

注:ElementRef 是 Angular 对原生 DOM element 的 wrapper。目的是不让 Angular 直接对 DOM 有依赖,就像 RNode interface 那样。

那这么蠢的方式,难道没有人抱怨吗?当然有!

Github Issue – Ability to request injection from a specific parent injector

有人提议可以通过 read options 来表达想要 inject 的是 element 而不是组件实例。

const parentElement = inject(ParentComponent, { read: ElementRef });  // 表明想要获取的是 ElementRef

但这个提议被 Angular 团队否决了。

我个人是觉得这个提议在表达上是 ok 的,但若想在目前 DI 的机制上加入这个新概念视乎不太容易。

我们在 NodeInjector 文章里学习过 inject 函数的查找规则,inject(ElementRef) 是一个特殊对待

它不像组件、指令、providers 那样把 NodeInjectorFactory 存在 LView 里,只要找到它,调用就可以了。

inject(ElementRef) 是依赖 current TNode 生成的,如果在 Parent constructor 里 inject,此时的 TNode 是 Parent,如果去到了 Child constructor 那 TNode 就是 Child 了。

从目前 DI 机制来看,想让 Child constructor 直接能 inject 到 Parent ElementRef 并不会那么容易。

NodeInjector Tree !== DOM Tree

我们通过 inject 拿到的 parent 组件,在 DOM Tree 中未必就是 parent element。

因为 inject 依据的是 NodeInjector Tree,而不是 DOM Tree。

有两种情况会导致它们不一致

第一种是 Content Projection

对于 query parent element,这种情况下两棵树虽然不一致,但不要紧,因为 Angular 出来的效果是和 W3C Shadow DOM 的 slot 效果是一样的。

第二种是 Dynamic Component

Dynamic Component 也可能导致两棵树不一致,这时我们只能倒退回去使用 DOM Manipulation 了。

关于 Dynamic Component 具体内容,下一篇才会教。

总结

1. Shadow DOM 需要一层一层 parentNode.host 才能 query 到 parent element,Angular 不需要这么麻烦,它可以直接 inject 祖先组件实例。

2. 虽然 Angular inject 祖先组件实例很方便,但那不是 element,要拿到 element 需要在祖先组件 inject(ElementRef),这个超级麻烦,代码管理也严重扣分。

3. DI 走的是 NodeInjector Tree,但我们或许想要的是 DOM Tree 的 parent element,当这两棵树结构不一致时,这就是个难题。

 

Query Child Elements

在强调组件化的项目,query 往往和 Shadow DOM 隔离息息相关。

Query child elements in Shadow DOM

上图是一个 W3C Web Components 的例子,有两个组件 my-parent 和 my-child,它们都有 Shadow DOM 概念。

假如我们尝试从 body query h1 elements,结果是这样

因为有 Shadow DOM 隔离,我们无法从 body 直接 query 到 Shadow DOM 内的 elements。

我们需要先进入 shadowRoot 再 query。(提醒:要进入 shadowRoot,attachShadow mode 必须是 'open' 哦)

即便如此,my-parent 的 shadowRoot.querySelectorAll 也只能 query 到 my-parent shadowRoot 的范围,

my-child 任然被另一个 shadowRoot 隔离着。如果我们想 query 所有后裔的 h1 elements,那就必须一层一层进入 shadowRoot。

这个体验和上面 query parent in Shadow DOM 是一样的麻烦。

Angular 也有 Shadow DOM,虽然实现手法是模拟的,但基本规则是一样的。

那 Angular 也需要一层一层往下 query 吗?是的!这一点 Angular 选择了和 Shadow DOM 保持一致。

Query child elements in Angular

首先,与其说是 query child elements 更贴切的说法是 query view elements。

如同上面的例子一样,当我们说 query 的时候指的是在 my-parent 的 shadowRoot 执行 querySelectorAll,

它查找的范围是 my-parent shadowRoot (a.k.a View) 而已,并不包含子组件 my-child shadowRoot (a.k.a View)。

好,我们先看一个简单的例子,然后再去逛源码理解它背后的原理和机制。

下图是 App Template

我们要 query 出里头的两个 <p>,#paragraph 是啥,下面会讲解。

@ViewChildren

下图是 App 组件

属性 + @ViewChildren decorator = query 语句。(又是 decorator 黑魔法...)

这个语句的字面意思是,query 'paragraph',然后赋值给属性 paragraphQueryList,类型是 QueryList,

黑魔法后 QueryList 对象里就装着 2 个 <p> ElementRef<HTMLParagraphElement>。

Template Variables

上面最重要的一点是,query 并不是用 CSS Selector!

@ViewChildren('.class'),@ViewChildren('tag') 都是错误的语法。

@ViewChildren('paragraph') 对应的是 <p #paragraph>

这个叫 Template Variables,是 Angular 的设计。

简单的说 Template Variables 的用途就是让我们在节点上打个标签,然后我们就可以引用这个节点去做点事情。

这个变量要取什么名字都可以。

ngAfterViewInit

这个 QueryList 并不是马上就被赋值的哦,要等到组件生命周期 AfterViewInit,QueryList 才可以使用。

@ViewChild

如果我们只是想 query 一个 element 我们可以用 @ViewChild。
@ViewChildren 和 @ViewChild 的区别,类似于 querySelectorAll 和 querySelector 的区别。

@ViewChild 的类型不是 QueryList,而是直接拿到 ElementRef。

Query 组件 / 指令 / Provider

Angular 不仅仅可以 query element。

组件、指令、甚至是 providers 也是可以 query 的。😲

App 组件

两个重点:

  1. 只要是 App LView 里的 Provider 都可以被 query 到。

  2.  Query 组件、指令、Provider 可以不需要使用 Template Variables,用 class 或 InjectionToken 也可以。

Template Variables & read options

两个 variables,一个 #component,一个 #paragraph。

问:下面 query 出来的类型是啥?

答案是一个组件实例,一个 ElementRef

如果 Template Variable 标记的是组件,那 query 出来的是组件实例,如果标记的是 element(哪怕 element 上有 apply 指令),query 出来的依然会是 ElementRef。

这是 Angular 默认的规则。

那如果我们想拿的和默认的不一样呢?比如 Template Variable 虽然标记在组件上,但我想拿 ElementRef,怎么办?

这时,我们可以使用 read options

read 可以指定 query value 最终的类型。

Template Variables 是对 TNode 做标签,query 找到 TNode 后,再通过 read 选择要从这个 TNode 中拿什么资料。

比如 ElementRef、Provider、组件、指令 等等。

Template Variables & Template Binding Syntax & exportAs

效果

首先,(input) 监听 input event 是为了让 Zone.js 感知到,然后 tick。0 只是一个无效的表达式,放 null、undefined、false 都是可以的。

重点是 #input 可以直接用于 Template Binding Syntax,而且它不是 wrapper ElementRef,而是原生 DOM node,可以直接使用。

组件也是可以直接引用

点击 alert 的效果

当 element 配上指令,如果我们想引用指令的话,需要在指令声明 exportAs

 

Angular Query View Elements 源码逛一逛

要想深入理解 Angular query view elements 机制,最好的方式自然是翻一翻源码咯。

经过 Change DetectionNodeInjectorLifecycle Hooks 的源码洗礼,我们对 Angular 渲染引擎源码已经不陌生了,让我们直接进入重点吧。

首先,先说明一点,

Template Variables 和 Query 是可以相互独立使用的。

Template Variables 可以配搭 Template Binding Syntax。

Query 也可以直接 query by 组件 class,不一定要 query by Template Variables。

不过,下面为了不罗嗦,我们就不单独介绍了,两个一起看呗。

下图是 App Template

App 组件

compile 之后的 app.component.js

App Definition 多了一个 viewQuery 方法,它和 template 方法一样都有分 create mode 和 update mode。

这个 viewQuery 方法会被保存到 App TView 里,通过 getOrCreateComponentTView 函数,源码在 shared.ts

在创建 App TView 时,viewQuery 方法被保存到 TView。

viewQuery 方法在什么时候被执行呢?自然是大名鼎鼎的 renderView 函数。

renderView 函数的源码在 render.ts

viewQuery 比 template 方法还要早被执行。

回到 viewQuery 方法

ɵɵviewQuery 函数的源码在 query.ts

和 TView LView 概念类似,这里创建了 TQuery 和 LQuery。

如果组件有多个 @ViewChildren,那这里就有多个 TQuery。

执行 viewQuery 方法 by create mode 之后就到 template 方法 by create mode。

我们来看 App Definition template 方法。

Template Variables 被记入到 consts 里,然后 ɵɵelementStart 和 consts 关联。

ɵɵelementStart 函数源码在 element.ts(提醒:这个函数之前我们研究过的)

elementStartFirstCreatePass 函数

resolveDirectives 函数源码在 shared.ts

initializeDirectives 函数

saveNameToExportMap 函数

回到 resolveDirectives 函数

cacheMatchingLocalNames 函数

回到 elementStartFirstCreatePass 函数

TQueries.elementStart 方法源码在 query.ts

TQuery.elementStart 方法

matchTNode 方法

继续

matchTNodeWithReadOption 方法

小总结

  1. @ViewChildren 在 compile 后变成了 viewQuery 方法。它和 template 方法有点像,都有分 create mode 和 update mode。

    create mode 在 renderView 函数中执行,update mode 则在 refreshView 函数中执行。

  2. viewQuery 会比 template 方法早执行。

  3. viewQuery create mode 会创建 TQueries 和 LQueries,它们是 ArrayLike 对象。

    里面装了 TQuery 和 LQuery。一个 @ViewChildren 就会产生一个 TQuery 和 LQuery。

  4. TQuery 记入了我们要 query 什么,要 read 什么,比如:Template Variables、组件、指令、Provider 等等。

  5. 组件 template 方法是用来创建 TView 里的 TNode、NodeInjector、Template Variables 的。在创建这些后,TQuery 会顺便做 matching。

    比如说:

    TQuery 要 query Template Variables,这时就拿 TNode 的标记的 Template Variables 来对比。

    TQuery 要 query 组件、指令、Provider,这时就拿 TNode 的 NodeInjector 资料来对比。

  6. 当执行完 template 方法,TQuery 也 match 完了,TQuery 会记入 match 到的 TNode、组件、指令、Provider 在 LView 的 index。(注:只是记入 index 而已哦)

回到 ɵɵelementStart 函数

saveResolvedLocalsInData 函数的源码在 share.ts

这个 saveResolvedLocalsInData 是 Template Variables 用于 Template Binding Syntax 的,不需要 @ViewChildren 也是会有。

看例子

有 2 个 Template Variables

下面是 App LView

28,31 是多出来的,如果没有 Template Variables 的话是没有的。每一给 Template Variables 都会增加一个 LView。

一个组件有 3 个 Template Variables

LView 就多 3 个。TView.data 为了要配合 LView 也会多 3 个 null。

TView.data 的数量是依据组件 Definition 的 decls 而定的,当有 Template Variables 时,这个号会相应增加。

好,create mode 结束,现在到 update mode。

为了更好的展示,我们加多一个 query。

App 组件 Definition

viewQuery 方法 by update mode 会在 renderView 函数中执行。

在 ViewHooks 之前一步执行。

ɵɵloadQuery 函数的源码在 query.ts

回到 App 组件 Definition

ɵɵloadQuery 返回的 QueryList 被赋值给了 _t 变量,然后 _t 又被赋值给了 ctx.paragraphQueryList,这个 ctx 就是组件实例。

也就是说 QueryList 最终是赋值给了 AppComponent.paragraphQueryList。

注:此时此刻,TQuery 里面只是记入了 matched query 的 index 而已哦,而 LQuery 和 QueryList 里面都还是空的。

ɵɵqueryRefresh 函数

materializeViewResults 函数

createResultForNode 函数

Matching Index 大总结

关于这个 matchingIdx 虽然上面源码都有提及,但是它很绕。这里做一个总结。

注: <ng-template> 和 <ng-container> 是 Dynamic Component 的内容,下一篇会教,这里大概懂就好了。

Template Variables の TNode.localNames index & value

先不讲 @ViewChildren,我们单单看 Template Variables 用在 Template Binding Syntax 情况下,它会匹配什么 value。

<p #var1></p>                       <!-- localNames : ['var1', -1], value: HTMLParagraphElement 实例 -->
<p appDir1 #var2></p>               <!-- localNames : ['var2', -1], value: HTMLParagraphElement 实例 -->
<p appDir1 #var3="appDir1"></p>     <!-- localNames : ['var3', 57], value: Dir1Directive 实例 -->

<app-c1 #var4 />                    <!-- localNames : ['var4', 41], value: C1Component 实例 -->
<app-c1 appDir1 #var5 />            <!-- localNames : ['var5', 41], value: C1Component 实例 -->
<app-c1 appDir1 #var6="appDir1" />  <!-- localNames : ['var6', 57], value: Dir1Directive 实例 -->

<ng-template #var7></ng-template>   <!-- localNames : ['var7', -1], value: TemplateRef 实例 -->
<ng-container #var8></ng-container> <!-- localNames : ['var8', -1], value: Comment 实例 --> 

几个要点:

  1. 如果 Template Variables 有声明 exportAs,index 是组件或指令 NodeInjectorFactory 在 LView 里的位置,最终 value 是组件或指令实例。

  2. 如果 Template Variables apply 在组件,index 是组件 NodeInjectorFactory 在 LView 里的位置,最终 value 是组件实例。

  3. 如果 Template Variables apply 在 ng-template element,index 是 -1,最终 value 是 TemplateRef 实例。

  4. 如果 Template Variables apply 在 ng-container element,index 是 -1,最终 value 是 Comment Node (这个 Comment  就是 DOM 节点 <!--这个 Comment 哦-->)。

    注:最终 value 是 Comment 而不是 ViewContainerRef 实例,这一点我个人是觉得有点反直觉的,没能理解 Angular 的深意。

  5. 如果 Template Variables apply 在 element,index 是 -1,最终 value 是 RNode (也就是 DOM 节点)。

@ViewChildren('templateVariable') without read options

用上面同一个例子

<p #var1></p>                       <!-- @ViewChildren('var1') value: ElementRef<HTMLParagraphElement> 实例 -->
<p appDir1 #var2></p>               <!-- @ViewChildren('var2') value: ElementRef<HTMLParagraphElement> 实例 -->
<p appDir1 #var3="appDir1"></p>     <!-- @ViewChildren('var3') value: Dir1Directive 实例 -->

<app-c1 #var4 />                    <!-- @ViewChildren('var4') value: C1Component 实例 -->
<app-c1 appDir1 #var5 />            <!-- @ViewChildren('var5') value: C1Component 实例 -->
<app-c1 appDir1 #var6="appDir1" />  <!-- @ViewChildren('var6') value: Dir1Directive 实例 -->

<ng-template #var7></ng-template>   <!-- @ViewChildren('var7') value: TemplateRef 实例 -->
<ng-container #var8></ng-container> <!-- @ViewChildren('var8') value: ElementRef<Comment> 实例 --> 

value 是一样的,唯一的区别是 RNode 被 ElementRef 包裹了起来而已。

@ViewChildren('templateVariable') with read options

参数一 templateVariable 是定位 TNode,参数二 read options 是从定位了的 TNode 身上拿最终要的 value。

这里有多种匹配的可能,我举一些比较奇葩的

  1. read:ElementRef
    所有 TNode 都可以 read as ElementRef。因为 ElementRef 就是拿 RNode 嘛。

    唯一需要注意的是 <ng-template> 和 <ng-container> 这两个最终 RNode 是 Comment 节点,而不是 HTMLElement 哦。

  2. read:TemplateRef

    只有 <ng-template> 可以 read as TemplateRef。

    <app-c1> read TemplateRef,value 会变成 undefined,这显然是逻辑错误。

    建议:永远不要 read as TemplateRef,因为 by default,它的 value 就是 TemplateRef 里,我们不需要多此一举,除非你想统一所有 @ViewChildren 一定要设置 read options。

  3. read:ViewContainerRef

    所有 TNode 都可以 read as ViewContainerRef,比如:<p>、<app-c1> 甚至是 <ng-template>。

    因为 ViewContainer 是一个卡位的概念,只要是节点就可以满足卡位的要求,所以所有 node 都可以成为 ViewContainer。

  4. read:组件/指令/Provider
    如果 TNode 是一个组件,或者它包含指令,那就可以 read as 组件/指令/Provider。

    找不到的话,value 会变成 undefined。

@ViewChildren(TemplateRef/组件/指令/Provider

上面我们都是 query Template Variables,我们也可以直接 query TemplateRef/组件/指令/Provider (注:ElementRef 和 ViewContainerRef 不行哦)。

它的规则是这样的

1. 参数一是什么,参数二的 read 默认就是什么。

下面两个写法是完全等价的

@ViewChild(Service1)
value!: Service1;
// 上面等同于下面
@ViewChild(Service1, { read: Service1 })
value!: Service1;

2. 它依然是先 query TNode 在 read from TNode。

Best Practice

如果你经常搞错 matching,那我的建议是:

  1. 总是使用 query Template Variables,不要 query TemplateRef/组件/指令/Provider。

  2. 总是使用 read options。

虽然你没有经常搞错,那就能少声明就少声明呗。

 

Query Content Projection

在强调组件化的项目,query 往往和 Shadow DOM 隔离息息相关。

Query content elements in Shadow DOM

上图是一个 W3C Web Components 的例子,有一个 C1 组件。

h1 和 p 被 transclude 到 C1 组件。

DOM 结构长这样

假如我们尝试从 C1 shadowRoot query h1 element,结果是这样

class C1Component extends HTMLElement {
  connectedCallback() {
    const shadow = this.attachShadow({ mode: 'open' });
    const template = document.querySelector<HTMLTemplateElement>('template[name="app-c1"]')!;
    const view = template.content.cloneNode(true);
    shadow.append(view);

    // 1. 结果是 0
    console.log(shadow.querySelectorAll('h1').length);
  }
}
customElements.define('app-c1', C1Component);

因为被 <slot> 隔离了,下面是 C1 的 template。

<template name="app-c1">
  <slot></slot>
</template>

我们需要先进入 <slot> element 然后用特殊方法 assignedElements 才可以 query 到 h1。

console.log(
  shadow
    .querySelector('slot')!
    .assignedElements()
    .filter(el => el.matches('h1')).length,
); // 1

Angular 也有 Shadow DOM,虽然实现手法是模拟的,但基本规则是一样的。

那 Angular 也需要先 query 到 <ng-content> 然后再往内 query 吗?不是的,Angular 体验好多了。

Query content elements in Angular

Angular query content 和 query view elements 原理几乎是一样的。

App Template

h1 被 transclude 进 C1 组件,同时被标记上一个 #var1 Template Variables。

问:假如我们在 App 组件里 @ViewChildren 可以拿到 #var1 吗?

答:当然可以

只要是在 App TView 里的 TNode 资料,一律都可以被 @ViewChildren query 到。

h1 虽然被 transclude 进 C1 组件里,但是 h1 TNode 是被记入在 App TView 的。

@ContentChildren & ngAfterContentInit

query view elements 和 query content elements 使用方式完全一模一样,只要把 "View” 换成 “Content” 就可以了。

一样有 @ContentChild for query first element

一样可以 query 组件 / 指令 / Provider

一样有 read options

TemplateRef、ViewContainerRef、ElementRef 这些 matching 机制通通都一样。

它们不同的地方是:

  1. query 的范围

  2.  query 的生命周期

    @ContentChildren 在 ngAfterContentInit 就可以获取到了,比 ngAfterViewInit 早。

descendants options

这是一个冷门的 options,默认是 true。

把 #var1 wrap 一层 element。

然后设置 descendants: false

结果 value 变成了 undefined。

这是因为 descendants: false 表示,只匹配第一层 TNode。

我是没有理解为什么要搞这个 "第一层" 的概念啦,如果分层是依据组件,那还可以理解,但它 div 也算一层,用意何在呢😕?

 

Angular Query Content Elements 源码逛一逛

Query Content 是建立在 Query View 之上的,因为子组件能 @ContentChildren 到的东西,父组件一定可以 @ViewChildren 到。

所以子组件的 @ContentChildren 只是范围被缩小了而已。

Angular 只需要加多一个缩小的概念到 Query View 的基础上即可实现 Query Content。

一个简单的场景

App Template

C1 组件

Template Variables 的部分和 Query View 是一样,这里就不再看源码了。(提醒:Template Variables 机制本来就是独立的,Query View 或 Content 不会对其有任何区别)

compile 之后的 C1 Definition

Content 和 View 有很多相似的地方,而且 Content 是建立在 View 基础上的,所以接下来,我们主要看它们不一样的地方就可以了。

viewQuery 方法会被存入 TView,contentQueries 方法不会。

viewQuery 方法是在 renderView 一开始时被执行,contentQueries 是在 ɵɵelementStart 函数中被执行的。

ɵɵelementStart 函数的源码在 element.ts

为什么是在这里执行 contentQueries 方法呢?

我们温习一下 View Query 的机制是:

在 renderView 函数,template 方法执行之前,执行 viewQuery 方法,把所有 TQuery 做好。

然后在 template 方法中,会执行 elementStart。

elementStart 会创建 TNode,就在这个时候,拿 TNode 和 TQuery 做 matching,如果 matched 就记入 index 到 TQuery。

顺序是:先有 TQuery > elementStart create TNode > TQuery matching with TNode。

回到 Content Query 机制

Content Query 有范围的概念,所以它不能像 View Query 那样,一开始就 create TQuery,然后把所有 TNode 都做 matching,它必须限制范围。

Content Query 机制如下:

分三段解释

  1. 第一段,ɵɵelementStart 中调用了 ɵɵcontentQuery 函数,它的源码在 query.ts

  2. 第二段,因为 C1 Content TQuery 和 App View TQuery 都存放在 App TView.queries 里,

    所以它们是一同被执行的,源码在上面讲 View Query 时已经讲解过了,这里不复述。 

  3. 第三段 ɵɵelementEnd 函数的源码在 element.ts

至此 Content TQuery 就 matching 完毕了。接下来是 update mode。

renderView 函数的源码在 render.ts

refreshContentQueries 函数源码在 shared.ts

回到 C1 Definition

结束。

 

Query static options

由于这个 options 通常只用于 <ng-template> (Dynamic Component 章节得内容,下一篇教),所以我放到本篇的结尾。

我们看一看省略版的流程

  1. 执行 renderView 函数

  2. 创建 TQuery

  3. 执行 template 方法

  4. 创建 TNode,并且与 TQuery 做 matching。

  5. 递归 renderView for descendant

  6. refreshView

  7. PreOrderHooks (e.g. OnInit)

  8. ViewHooks (e.g. AfterViewInit)

在 refreshView 之前,TQuery 就已经 matching 完毕了。

假如 Angular 在 PreOrderHooks 之前就执行 ɵɵloadQuery 和 ɵɵqueryRefresh 函数,

那在 PreOrderHooks 阶段,QueryList 就已经会有 results 了,不管是 ElementRef、组件、指令、Provider,通通都拿得到。

只是这个阶段 binding 还没有开始,子组件的 @Input,RNode 的 Interpolation 这些通通都还没有 binding,所以即便可以拿到,也只是一个半成品。

但是,如果它不需要 binding,是一个 "static" 的话,那能早一点拿到它也不是坏事儿。

于是 Angular 提供了一个 static options.

App Template

App 组件

使用 static options 就可以在 PreOrderHooks 阶段拿到 query value,但要记得这个 value 是还没有经过 binding 的。@Input 或 Interpolation 这些都还没有 binding 进去。

逛一逛源码

没有 static options

是 5 号。

有 static options

变 7 号。

在 renderView 里执行 ɵɵviewQuery 函数时

在 renderView 函数

关键就是这里提早执行了 viewQuery 方法 by update mode。本来应该是 refreshView 环节才执行的,现在是提早到 renderView 环节就执行了。

ɵɵqueryRefresh 函数

Limitation & Risk

只有 @ViewChild 和 @ContentChild 有 static options,@Viewchildren 和 @ContentChildren 都没有。

static 只会在 renderView 时,执行一次 ɵɵqueryRefresh,此后每一次 refreshView 执行,它都不会执行。

所以当我们声明式 static,那就真的要是 static,否者可能会掉坑。

Angular 设计这个 static 是专门给 <ng-template> 用的,其它地方最好还是别用了。相关提问 Stack Overflow – How should I use the new static option for @ViewChild in Angular 8?

 

Query Dynamic Component

Dynamic Component 下一篇才教,不过它和 Query 有一点点关系,不得不说,所以我把这部非放到本篇的结尾。

所谓的 Dynamic Component 就是 document.createElement,document.append,document.removeChild,动态 创建 / 插入 / 移除 组件或者 HTML 元素。

试想想,App Template 原本有 2 个 <p>,我们 Query View 得到了这 2 个 <p>,

后来某 action 通过 Dynamic Component 的方式动态 create + append 了另一个 <p>,现在变成有 3 个 <p> 了。

问:我们的 QueryList 会更新成 3 个 <p> 吗?

答:QueryList 的刷新依赖 refreshView,只要当前 LView 被 refresh 那 QueryList 就会更新变成 3 个 <p>,至于 create + append 是否会导致 LView refresh,下一篇 Dynamic Component 会讲解。

问:我们可以监听到 QueryList 的变化吗?

答:可以,通过 QueryList.changes 方法,它会返回一个 RxJS Observable,subscribe 它就可以了,每当 QueryList 有变化 (append / removeChild) 它就会发布。

console.log('Old Length', this.titleQueryList.length);

this.titleQueryList.changes.subscribe(() => {
  console.log('New Length', this.titleQueryList.length);
});

 

Signal-based Query (a.k.a Signal Queries)

Signal-based Query 是 Angular v17.2.0 推出的新 Query View 和 Query Content 写法。

大家先别慌,它只是上层写法换了,底层逻辑还是上面教的那一套。

viewChild

before Signal

@ViewChild('title', { read: ElementRef })
titleElementRef!: ElementRef<HTMLHeadingElement>;

after Signal

titleElementRef2 = viewChild.required('title', {
  read: ElementRef<HTMLHeadingElement>,
});

有 3 个变化:

  1. Decorator 没了,改成了函数调用。从 v14 的 inject 函数取代 @Inject Decorator 开始,大家都预料到了,有朝一日 Angular Team 一定会把 Decorator 赶尽杀绝的😱。

  2. titleElementRef 类型从 ElementRef<HTMLHeadingElement> 变成了 Signal 对象 -- Signal<ElementRef<HTMLHeadingElement>>。

    不过目前 TypeScript 类型推导好像有点问题,titleElementRef2 的类型是 Signal<ElementRef<any>>,它没有办法推导出泛型,所以 read ElementRef 时不够完美。

    我们只能自己声明类型来解决

    titleElementRef2 = viewChild.required<string, ElementRef<HTMLHeadingElement>>('title', {
      read: ElementRef,
    });

    泛型第一个参数是 'title' 的类型,第二个是 read 的类型。

  3. titleElementRef! 结尾的 ! 惊叹号变成了 viewChild.required。没有惊叹号就不需要 .required。

    惊叹号或 required 表示一定能 Query 出 Result,不会出现 undefined。

viewChildren

// before Signal
@ViewChildren('title', { read: ElementRef })
titleQueryList!: QueryList<ElementRef<HTMLHeadingElement>>;

// after Signal
titleArray = viewChildren<string, ElementRef<HTMLHeadingElement>>('title', {
  read: ElementRef,
});

2 个知识点:

  1. before Signal 返回的类型是 QueryList 对象,after Signal 类型变成了 Signal Array -- Signal<readonly ElementRef<HTMLHeadingElement>[]>。

  2. ! 惊叹号不需要 viewChildren.required,因为 @ViewChild 和 viewChildren 即便 Query 不出 Result,也会返回 QueryList 对象或 Signal Empty Array。

contentChild 和 contentChildren

content 的写法和 view 是一样的。把 view 改成 content 就可以了。这里就不给例子了。

Replacement for QueryList and Lifecycle Hook

我们先理一下 QueryList 的特性:

  1. QueryList 是在 renderView 阶段创建的,理论上来说,组件在 constructor 阶段肯定还拿不到 QueryList,但从 OnInit Lifecycle Hook 开始就应该可以拿到 QueryList 了。

    但是

    这是因为 Angular 是在 refreshView 阶段才将 QueryList 赋值到组件属性的,所以 OnInit 和 AfterContentInit 时组件属性依然是 undefined。

  2. QueryList Result Index 是在 renderView 结束时收集完毕的。理论上来说,只要在这个时候调用 ɵɵqueryRefresh 函数,QueryList 就可以拿到 Result 了。

    但是 Angular 一直等到 refreshView 结束后才执行 ɵɵqueryRefresh 函数。

  3. 综上 2 个原因,我们只能在 AfterViewInit 阶段获取到 QueryList 和 Query Result。

  4. Angular 这样设计的主要原因是不希望让我们拿到不完整的 Result,尽管 renderView 结束后已经可以拿到 Result,但是这些 Result 都是还没有经过 refreshView 的,

    组件没有经过 refreshView 那显然是不完整的,所以 Angular 将时间推迟到了最后,在 AfterViewInit 阶段所有 Query 到的组件都是已经 refreshView 了的。

  5. QueryList.changes 只会在后续的改动中发布,第一次是不发布的。

Replacement for QueryList

Signal-based Query 不再曝露 QueryList 对象了 (这个对象依然还在,只是不在公开而已),取而代之的是 Signal 对象,那我们要怎样监听从前的 QueryList.changes 呢?

QueryList 没了,不要紧,我们多了个 Signal 嘛,Signal 也可以监听丫,要监听 Signal 可以使用 effect 函数。

export class AppComponent {
  titles = viewChildren<string, ElementRef<HTMLHeadingElement>>('title', {
    read: ElementRef,
  });

  constructor() {
    effect(() => {
      console.log(this.titles());
    });
  }
}

每当内部的 QueryList 发生变化 (包括第一次哦,这点和 QueryList.changes 不同),Signal 就会发布新值,监听 Signal 值的 effect 就会触发。

提醒:effect 只能在 constructor 里执行,因为它依赖 inject 函数。

Replacement for Lifecycle Hook

除了隐藏 QueryList 之外,Signal-based Query 也修改了执行顺序。

export class AppComponent implements OnInit, AfterContentInit, AfterViewInit {
  titles = viewChildren<string, ElementRef<HTMLHeadingElement>>('title', {
    read: ElementRef,
  });

  constructor() {
    console.log(this.titles().length);   // 0
    effect(() => {
      console.log(this.titles().length); // 1
    });
  }
  ngOnInit(): void {
    console.log(this.titles().length);    // 1
  }
  ngAfterContentInit(): void {
    console.log(this.titles().length);    // 1
  }
  ngAfterViewInit(): void {
    console.log(this.titles().length);    // 1
  }
}

在 renderView 结束后,Angular 就执行了 ɵɵqueryRefresh,所以从 OnInit 开始就可以获取到 Query Result 了。(注:此时的 Query Result 依然属于不完整状态,组件还没有 refreshView 的)

Angular 修改这个顺序主要是因为它想把职责交还给我们,它提早给,我们可以选择要不要用,它不给,我们连选择的机会都没有。

Signal-based Query 源码逛一逛

App 组件

export class AppComponent {
  titles = viewChildren<string, ElementRef<HTMLHeadingElement>>('title', {
    read: ElementRef,
  });

  @ViewChildren('title', { read: ElementRef })
  titleQueryList!: ElementRef<HTMLHeadingElement>;
}

一个 Signal-based,一个 Decorator-based,我们做对比。

yarn run ngc -p tsconfig.json

app.component.js

2 个区别:

  1. Decorator-based 在 refreshView 阶段做了 2 件事,Signal-based 一件也没有。

    第一件事是赋值给组件属性,Signal-based 改成了在 constructor 阶段完成。

    所以在 constructor 阶段 Decorator-based 的 QueryList 属性是 undefined,而 Signal-based 的 Signal 属性是有 Signal 对象的。

    第二件事是刷新 Query Result,Signal-based 改成了去监听 Dyanmic Component 的 append 和 removeChild,当插入和移除时就会刷新 Query Result。

  2. 在 renderView 阶段,Decorator-based 会创建 QueryList,然后收集 Query Result Index,这些 Signal-based 也都会做,做法也都一模一样。

    Signal-based 唯一多做了的事是关联 QueryList 和 Signal。具体流程大致上是这样:

    当 Dynamic Component append 和 removeChild 时,它会 set QueryList to Dirty,Signal 会监听 QueryList Dirty,当 QueryList Dirty 后 Signal 会刷新 Query Result。

viewChildren 函数的源码在 queries.ts

createMultiResultQuerySignalFn 函数的源码在 query_reactive.ts

createQuerySignalFn 函数的源码在 query_reactive.ts

createQuerySignalFn 函数有点绕,一行一行很难讲解,我分几个段落讲解吧。

createComputed 函数是我们常用的 Signal computed 函数的 internal 版本

Computed Signal 的特色是它内部会依赖其它 Signal。

Computed Signal 内部

回到 app.component.js,ɵɵviewQuerySignal 函数的源码在 queries_signals.ts

createViewQuery 函数负责创建 TQuery、LQuery、QueryList。

Signal-based 和 Decorator-based 调用的是同一个 createViewQuery 函数,所以 Signal-based 的区别是在 bindQueryToSignal 函数。

bindQueryToSignal 函数的源码在 query_reactive.ts

总结

  1. 有 2 个主要阶段

    第一个是 constructor 

    第二个是 renderView

  2. 有 2 个主要对象

    第一个是 QueryList

    第二是 Computed Signal

  3. constructor 阶段创建了 Computed Signal

    renderView 阶段创建了 QueryList

  4. Computed Signal 负责刷新 Query Result,但刷新 Query Result 需要 QueryList (当然还有其它的,比如 LView 我就不一一写出来的,用 QueryList 做代表)。

    所以在 renderView 创建 QueryList 后,Computed Signal 和 QueryList 需要关联起来。

  5. _dirtyCounter Signal 是一个小配角,因为 QueryList on Dirty 的时候要刷新 Query Result,

    而刷新 Query Result 是 Computed Signal 负责的,要触发一个 Signal 只能通过给它一个依赖的 Signal,所以就有了 _dirtyCounter Signal。

  6. 最后:QueryList on Dirty 时 -> 通知 _dirtyCounter Signal -> Computed Signal 依赖 _dirtyCounter Signal -> Computed Signal 刷新 Query Result。

  7. QueryList on Dirty 是什么时候触发的呢?

    LQueries 负责 set QueryList to Dirty 

    LQueries 的 insertView、detachView 方法是在 Dynamic Component 插入/移除时被调用的,下一篇才会叫 Dyanmic Component。

    finishViewCreation 会在 LView renderView 后,Child LView renderView 之前被调用。

Might be a bug

export class AppComponent {
  constructor() {
    const titles = viewChildren<string, ElementRef<HTMLHeadingElement>>('title', {
      read: ElementRef,
    });

    effect(() => {
      console.log(titles());
    })
  }
}

如果我们把 viewChildren 返回的 Signal assign to 一个 variable 而不是一个属性的话,compilation 出来的 App Definition 不会有 viewQuery 方法。

也不只是 assign to variable 才出问题,写法不一样它也 compile 不了。

export class AppComponent {
  titles: Signal<readonly ElementRef<HTMLHeadingElement>[]>;

  constructor() {
    this.titles = viewChildren<string, ElementRef<HTMLHeadingElement>>(
      'title', { read: ElementRef, }
    );

    effect(() => {
      console.log(this.titles());
    });
  }
}

像上面这样分开写也是不可以的。提交了 Github Issue,我猜 Angular Team 会说:是的,必须按照官方的 code style 去写,不然 compiler 解析不到。

这也是 compiler 黑魔法常见的问题,因为语法设计本来就是很复杂的,框架如果要支持各做逻辑会很耗精力。

 

目录

上一篇 Angular 17+ 高级教程 – Component 组件 の 生命周期钩子 (Lifecycle Hooks)

下一篇 Angular 17+ 高级教程 – Component 组件 の Dynamic Component 动态组件

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

 

 

posted @ 2023-12-26 14:57  兴杰  阅读(184)  评论(0编辑  收藏  举报