zl程序教程

您现在的位置是:首页 >  其他

当前栏目

Angular 复习与进阶系列 – Component 组件 の Dependency Injection & Query Elements

ampAngular组件 系列 进阶 Component Query 复习
2023-09-27 14:23:55 时间

前言

在 Dependency Injection 依赖注入 的结尾, 我们学习了如何在项目中, 组件中使用 DI.

但那些只是一小部分而已. Angular DI 在组件内的用途非常广, 而且挺复杂的. 这篇我们将详细的去理解它.

 

用途

inject service

在 上一篇 DI 介绍中, 我们用 DI 来注入 Service, 这个在 ASP.NET Core, Java 也可以看到, 这也是 DI 最主要的用途.

但是在 Angular, DI 还有另一个大用途, 那就是 query component / elements.

inject component / DOM element

Component 组件 の Template Binding Syntax 中, 我们学习了用 binding syntax 替代各种 DOM Manipulation, 但唯独没有学习到如何替代 dom query

const childComponents = document.querySelectorAll('app-child'); // query children
const parent = childComponents[0].parentElement; // query parent

在 Angular 我们不这样子 query element, 取而代之的是通过 Dependency Injection.

 

搭环境

创建一个 LogService 和 ChildComponent

ng g s log
ng g c child

LogService 用 Shakeable InjectionToken 的方式 provide 到 project root injector.

这样我们不需要在 app.config.ts 里去写 provider.

接着在 AppComponent 中引入 ChildComponent

 

Hierarchical Injectors in Component

Hierarchical 概念之前就有讲过了, 但真正能体现出它运用方式的地方是组件.

我们知道 provider + injector = 一个 pair.

app.config.ts 或 provide: 'root' 属于 global (AKA root) provider

root injector 则是 Angular 替我们创建的. 

除了 root provider 和 root injector 外, 其实每一个组件都有属于自己的 injector 和 provider.

组件是 DOM, DOM 是树, 树有 parent child 概念, 所以组件也有 parent child 概念. 而组件有自己的 injector, 所以 injector 也有 parent child 概念.

Component has own injector

我们来证明一下, 每个组件有自己的 Injector.

在 app.component.ts inject injector 然后存入全局变量

export class AppComponent {
  constructor(injector: Injector) {
    window.injector = injector;
  }
}

在 child.component.ts 作比较

export class ChildComponent {
  constructor(injector: Injector) {
    console.log(window.injector === injector); // false
  }
}

答案是 false, 这证明了它们有不同的 injector.

Component has own provider

我们来证明一下, 每个组件有自己的 provider.

在 app.component.ts 组件 metadata 中 provide LogService

并且在 AppComponent 中注入 LogService

在 child.component.ts 也 provide LogService 和 inject LogService 并且进行对比

答案是 false, 这说明了两个组件 inject 的是自己 provide 的 service.

Injector is inheritance

继续上面的例子, 我们把 child.component.ts 的 provider 移除, 再对比一次.

答案变成了 true, 这说明, ChildComponent inject 的 service 是从它的 parent injector (AppComponent) 的 provider 得来的.

所以 ChildComponent 的 injector 继承了它 parent AppComponent 的 injector.

 

Query Parent Component

MVVM 不鼓励我们直接操作 DOM, 那要怎样 querySelector 和 .parentElement 呢?

Angular 给的答案是用 DI.

ChildComponent 想获取到 parent AppComponent 可以这样直接 inject

由此可见, Angular 会把所有的组件实例都丢进去 providers 池中. 子组件直接 inject 就可以了.

p.s. 因为 injector 是原型链查找, 所以不管多少层 parent (ancestor) 都可以被 inject 到.

 

Query Child Component

query child 和 query parent 差别很大.

第一, DI 不是原型链查找吗? 原型链只能向上查找丫, 怎么能向下查呢? 

首先我们要知道, 在 Dependency Injection 依赖注入 文章中, 我们学习的 Injector 是 R3Injector, 而组件内的 Injector 是 NodeInjector.

Angular 没有公开 R3Injector 和 NodeInjector, 只公开了它俩的抽象 Injector 让我们用, 但 Angular 内部使用它们俩是不同的.

NodeInjector 内部有 lView 和 tNode, 它就是利用这些来实现向下查找子层 provider 的. 有兴趣了解更多的, 可以看这篇 Medium – Angular DI: Getting to know the Ivy NodeInjector

第二, DI 是在 constructor 阶段执行的. 但在这个阶段 ChildComponent 还没有被实例化丫, 怎么获取到呢?

没办法, 所以只能用其它 lifecycle hook, 在其它时机才能获取到 child.

既然差别这么大, 为了好区分, 我们把 "query" parent 改名为 inject parent, 把 "inject" child 改名为 query child 吧.

但要记得, query child 任然是用 DI 实现的, 只是它是一种 "往下" 的 "注入".

好, 我们来看具体怎么做, 首先, 在 app.component.ts 中

export class AppComponent {
  @ViewChild(ChildComponent)
  childComponent!: ChildComponent;
}

通过 @ViewChild decorator "声明" 我们想要 query child component. (因为上面提到的原因, 这里和 inject parent 的方式完全不一样, 用的是 decorator 声明式, 而不是 constructor inject)

注: Angular 有 Shadow DOM 概念 (即便是 Emulated mode), 这里 query child 只能拿到 app.component.html 看的见的组件. 比如 ChildComponent

但是 ChildComponent 内的组件则是 query 不到的哦. 这个以前 Web Compoennts 文章中我们就学过了.

use ChildComponent in correct lifecycle hook

虽然我们 "声明" 了 query ChildComponent, 但是什么时候它可以使用也是很讲究的.

export class AppComponent implements OnInit, AfterViewInit {
  constructor() {
    console.log(this.childComponent); // undefined
  }

  @ViewChild(ChildComponent)
  childComponent!: ChildComponent;

  ngOnInit(): void {
    console.log(this.childComponent); // undefined
  }

  ngAfterViewInit(): void {
    console.log(this.childComponent); // ready to use child component
  }
}

constructor 阶段

这阶段肯定是不能用的, 因为此时 AppComponent 在实例化中, 而 ChildComponent 自然连实例化都还没有开始,

OnInit 阶段

这个阶段默认情况下是获取不到 ChildComponent 的, 但是我们可以 "声明" static : true 让它获取到.

@ViewChild(ChildComponent, { static: true })

它的意思是, 我们只是需要 "静态" 的 ChildComponent 实例.

什么叫 "静态"? 那就是 ChildComponent 完成了实例化, 但还没有开始 OnInit lifecycle, 也就是说这时 @Input 还没有赋值.

ngOnInit(): void {
  // ready to use, but ChildComponent OnInit haven't start yet, all @Input still undefined
  console.log(this.childComponent);
}

这个阶段的 ChildComponent 是个半成品, 是否足够使用取决于项目需求. Angular 只是给了一个使用它的机会.

AfterViewInit 阶段

在这个阶段, 子层已经完全 complete 了. 自然也就可以拿到完整的 ChildComponent 了.

 

Query Child Element

上面例子中, query child component 使用了 decorator @ViewChild 然后传入 ChildComponent class

@ViewChild(ChildComponent, { static: true })

这个表达很符合 DI 概念. 但如果我想 query 的不是 component 呢? 总不能硬把所有 element 换成 component 只为了 query 吧.

于是直觉告诉我们, 可能它支持 CSS selector. 毕竟 Component metadata 不也有 CSS selector 的影子吗?

在 HTML 加上 h1 element

<h1 class="title">Title</h1>

然后

@ViewChild('.title', { static: true })

结果...undefined

为什么呢? 因为虽然它叫 query, 但它不是 querySelector 概念, 它是 DI 概念. 而 DI 必然是一个 provide 一个 inject 的形式.

template variable

要 query child element 我们得先学一个新概念叫 template variable.

我们把 template variable 理解为 DI 中的一种 provide 手法.

首先把 class="title" 换成 #title

<!-- <h1 class="title">Title</h1> -->
<h1 #title>Title</h1>

这个 #title 就是 template variable, 可以理解为 provide: 'title'

接着把 @ViewChild('.title') 换成 @ViewChild('title'), 这个 title 对应的就是 template variable.

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

ngOnInit(): void {
  console.log;
  this.titleElementRef.nativeElement instanceof HTMLHeadingElement; // true
}

Angular 会用 ElementRef 把 HTML Element 包裹起来. 我们需要调用 .nativeElement 才能获取到 HTML Element 哦.

当 template variable 遇上组件

在一个组件上使用 template variable

<app-child #target></app-child>

query child 会得到 ChildComponent 还是 ElementRef 呢?

export class AppComponent implements AfterViewInit {
  @ViewChild('target')
  target!: ElementRef<HTMLElement> | ChildComponent;

  ngAfterViewInit(): void {
    console.log('target : ', this.target); // ChildComponent
  }
}

答案是 ChildComponent

那如果我就是想拿到 ElementRef 怎么办? 用 read option

read option 可以指定最终要的类型.

Inject parent with read option

read option 是 query child 独有的功能哦. inject parent 是没有办法做到的. 相关 Issue – Ability to request injection from a specific parent injector

有人提议在 @Inject 加入 read option

但最后被否决了.

理由很简单, inject parent 和 query child 概念区别很大. 虽然都是 DI, 但是用途, 实现手法都不太一样. Angular 不想把 inject parent 也搞得像 query child 那样复杂所以就不管了...

那有 workaround 吗?

有, 在 AppComponent inject ElementRef 然后 public 给 ChildComponent

 

虽然不优雅, 但至少是可以做到的.

 

 

@ViewChild Service? 

既然 @ViewChild 是 DI 概念, 那么它是否可以 "inject" child provider?

在 ChildComponent 提供一个 ValueProvider

在 AppComponent @ViewChild 它

export class AppComponent implements AfterViewInit {
  @ViewChild('value')
  value!: string;

  ngAfterViewInit(): void {
    console.log('value : ', this.value); // value123
  }
}

确实是可以的.

 

@ViewChild 源码 Quick View

源码在 query.ts 和 di.ts

Example 1

首先它是一个 node 一个 node 查找的

 

 

 然后 match

handle read option

具体找的方法

provider 都在 tNode 或 tView 里

Example 2

重点一: 'value1' 是 provider token, 'value2' 也是 provider token 来的.

我们不引入任何 template variable 概念, 单纯看 DI

ChildComponent providers

 

重点二: 它是一个一个 element node 查找的

重点三: 当查找 ChildComponent 时, 它先看第一个 token 'value1' 找到了以后发现有 read option 

于是它又找 token 'value2', 最终得到的是 value2 的值.

providers 里 value1 value2 缺一不可.

 

 

Query Children

@ViewChild 只能获取到一个 element. 那如果我们想 querySelectorAll 呢?

<app-child></app-child>
<app-child></app-child>
<app-child></app-child>

把 @ViewChild 改成 @ViewChildren 就可以了, 其它概念是一样的.

export class AppComponent implements AfterViewInit {
  @ViewChildren(ChildComponent)
  queryList!: QueryList<ChildComponent>;

  ngAfterViewInit(): void {
    console.log(this.queryList); // QueryList 对象
    console.log(this.queryList.length); // total component count
    console.log(this.queryList.first); // first component
    console.log([...this.queryList]); // convert to ChildComponent[]
    console.log(this.queryList.toArray()); // same as above convert to ChildComponent[]
  }
}

QueryList 是 Angular 封装的 Iterable 对象, 里面有一些 friendly interface 供我们使用.

Watch children added / removed

类似 MutationObserver 功能. 我们可以监听 children component 被添加或移除

ngAfterViewInit(): void {
  this.queryList.changes.subscribe(() => {
    this.queryList.length; // new length
  });
}

.changes 是 RxJS Observable, 当 ChildComponent 被添加或移除时, callback 函数会被调用. 通过 this.queryList 可以获取到最新的 information.

 

Query slotted element

在 Web Components Shadow DOM, 要想 query slotted element 是比较麻烦的, 但在 Angular 就比较简单. 因为 Angular 统一了 query 的方式.

我们可以像 query child 那样去 query slotted (AKA ng-content or transclude) element.

在 app.component.html 中, 有一个 ChildComponent, 同时我们把 CardComponent transclude 进去

<app-child>
  <app-card></app-card>
</app-child>

在 child.component.ts

export class ChildComponent implements AfterContentInit {
  constructor() {}

  @ContentChild(CardComponent)
  cardComponent!: CardComponent;

  ngAfterContentInit() {
    console.log(this.cardComponent);
  }
}

@ContentChild 类似于 @ViewChild 用来声明我们想 query

ngAfterContentInit 类似于 ngAfterViewInit, 它发生于 OnInit 和 AfterViewInit 之间

OnInit > AfterContentInit > AfterViewInit, 所以它可以更早的获取到完整的 CardComponent.

它和 ViewChild 都支持 read option, @ContentChildren, QueryList 等等

有一点例外要特别注意

by default, @ContentChild 只会查看 first child layer element

比如, 我们把 CardComponent 用一个 div wrap 起来

<app-child>
  <div class="container">
    <app-card></app-card>
  </div>
</app-child>

wrap 了以后 @ContentChild 就 query 不到 CardComponent 了 ... 很玄乎... 但只要加上 descendants option 就可以了

@ContentChild(CardComponent, { descendants: true })

descendants 不限制第一层. 只要在 <app-child> 内看得见的 elements 就可以查找. 不过, 不要误会哦, 它同样收 Shadow DOM 保护, card.component.html 内的 element 那依然是 query 不到了.

 

Future (Signal-based Components)

参考: Github – Sub-RFC 3: Signal-based Components

@ViewChild @ContentChild 这些 decorator 在未来都会变成 Signal-based 写法.

但只是写法上换了, 但是内在查找原理并没有换, 而且 decorator 写法依然会保留很长一段时间. 所以可以安心学, 安心用.

 

重点总结

1. Angular inject parent 和 query child 都是用 DI 概念做的

2. query child 受 Shadow DOM 保护. query 不能深入其它组件内.

3. 要注意 lifecycle hook, AfterViewInit 才能获取到 complete 的 child component

4. QueryList 可以监听 added / removed element, 类似 mutation 功能

5. @ContentChild 可以 query slotted element, 和 @ViewChild 接口几乎一致, 这个比 Web Components Shadow DOM 的 assignedElements 方便多了.