Angular 18+ 高级教程 – 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。

把 #var1 wrap 一层 element。

结果 value 变成了 undefined。

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

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

另外,@ContentChild 默认 descendants 是 true,@ContentChildren 默认 descendants 是 false (注意哦!它俩的默认值竟然是不一致的...😩)

export class ParentComponent {
  @ContentChild('whatever', { descendants: true })
  a1: unknown;

  @ContentChild('whatever', { descendants: false })
  a2: unknown;

  @ContentChild('whatever') // ContentChild 没有设置 descendants
  a3: unknown;

  @ContentChildren('whatever', { descendants: true })
  b1: unknown;

  @ContentChildren('whatever', { descendants: false })
  b2: unknown;

  @ContentChildren('whatever') // ContentChildren 没有设置 descendants
  b3: unknown;
}

第一个是 descendants: true,第二个是 false,第三个是默认,另外三个是 @ContentChildren。

compile 之后长这样

@ContentChild 默认 descendants 是 true,@ContentChildren 默认 descendants 是 false。⚠️大家别掉坑里丫⚠️

 

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

结束。

 

小考题:指令 (Directive) 可以 query child / content 吗?

App Template

<app-test appRoot>
  <p appItem>Lorem ipsum dolor sit amet consectetur adipisicing elit. Tempora, inventore.</p>
</app-test>

问:Root 指令可以 content query 到 Item 指令吗?

答:可以

Root 指令和 Test 组件的 content query 都是被注册到 App TView,本质上这两者没有什么区别,所以既然 Test 组件可以 content query 到 Item 指令,那 Root 指令自然同样可以 content query 到 Item 指令。

Test Template

<p appItem>Lorem ipsum dolor sit amet consectetur adipisicing elit. Tempora, inventore.</p>

问:Root 指令可以 view query 到 Test Template 里的 Item 指令吗?

答:不可以

原因很简单,Root 指令和 Test 组件并不共享 TView。

Test 组件的 view query 被注册到 Test TView,而 Root 指令的 view query 不会被注册到任何地方。

简而言之,指令没有 Template 和 View 概念,所以自然就不能 view query。

 

当 Query Content Projection 遇上 wrapper

这是一个实战中很常见的状况。

App Template

<app-menu>
  <app-menu-item>Item A</app-menu-item>
</app-menu>

一个 Menu 组件,一个 MenuItem 组件。

在 Menu 组件里 query MenuItem

export class MenuComponent implements AfterContentInit {

  @ContentChildren(MenuItemComponent)
  menuItemQueryList!: QueryList<MenuItemComponent>;

  ngAfterContentInit() {
    console.log(this.menuItemQueryList.length); // 1
  }
}

ok👌,没有任何问题。

现在我们加一个 wrapper。

<app-menu>
  <app-menu-item-wrapper>Item A</app-menu-item-wrapper>
</app-menu>

MenuItemWrapper 组件把原本的 MenuItem 包了起来,它的 Template 长这样

<app-menu-item>
  <ng-content />
</app-menu-item>

此时,Menu 就 query 不到 MenuItem 了

console.log(this.menuItemQueryList.length); // 0

因为隔了一层。注:它不是 descendants options 可以解决的哦,因为隔着这一层是 View 而不是 DOM。

要解决这个问题,我们可以利用 NodeInjector

首先在 MenuItemWrapper 里用 view query 把 MenuItem query 上来。

export class MenuItemWrapperComponent {
  @ViewChild(MenuItemComponent)
  menuItem!: MenuItemComponent;
}

接着通过组件 providers 把它提供出去。

@Component({
  selector: 'app-menu-item-wrapper',
  standalone: true,
  imports: [MenuItemComponent],
  templateUrl: './menu-item-wrapper.component.html',
  styleUrl: './menu-item-wrapper.component.scss',
  // 1. 把 MenuItem 提供出去
  providers: [{ 
    provide: MenuItemComponent, 
    useFactory: () => {
      const wrapper = inject(MenuItemWrapperComponent, { self: true });
      return wrapper.menuItem;
    } 
  }]
})
export class MenuItemWrapperComponent {
  @ViewChild(MenuItemComponent)
  menuItem!: MenuItemComponent;
}

效果

console.log(this.menuItemQueryList.length); // 1

提醒:

虽然是成功 content query 到了 MenuItem,但是它们的 lifecycle 已经不同了。

原本的顺序是

当 Menu query 到 MenuItem 时,MenuItem 已经 init 好了。

但 wrapper 出现以后

MenuItem 的 init 就慢掉了。

至于这对我们实际需求有没有实质影响就不好说了,假如我们是在 afterViewRender 才需要用到 MenuItem,那就没有问题咯。

总结

Angular 的 query 经常会被 wrapper 给当着,这里给了一个 NodeInjector 方案来突破这个 wrapper,大家可以谨慎使用,注意它的 lifecycle。

 

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)

以后会教。

 

 

目录

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

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

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

喜欢请点推荐👍,若发现教程内容以新版脱节请评论通知我。happy coding 😊💻

 

 

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