前言
熟悉 Angular 的朋友都知道,Angular 有非常多的局限,许多事情它都做不好,打开 Github 一堆 2016 - 2017 的 Issues,时至今日都没有解决。
原因也很简单 -- Angular 团队的不作为😔。
通常我会把常见的 Angular 的局限记入在这篇 <<Angular 的局限和 Github Issues>>,但由于本篇要讲的问题篇幅比较大,所以特别把它分一篇出来。
本篇要讲的是 Angular Query Element 的局限。虽然我们已经在 <<Component 组件 の Query Elements>> 文章中,深入理解了 Angular 的 Query 机制。
但是!理解没有用啊。
如果它本来做的到,但由于我们不理解,所以以为做不到,那去理解它是对的。
但如果它本来就无能,我们即便理解了,也只是知道它为什么做不到,最终任然是做不到啊😔。
本篇,我们一起来看看 Angular 在 "Query Elements" 上有哪些局限,有没有什么方法可以去突破它。
不支持 viewChildren descendants
App Template 里有一个 Parent 组件
Parent Template 里有一个 Child 组件
结构
尝试在 App 组件里 query Child 组件
export class AppComponent { child = viewChild(ChildComponent); parent = viewChild(ParentComponent); constructor() { afterNextRender(() => { console.log(this.child()); console.log(this.parent()); }) } }
不奇怪,因为 Angular 也有 Shadow DOM 概念,组件是封闭的,上层无法直接 query 到 ShadowRoot 内的 elements。
by default 不行,OK!我可以接受,但如果我真的想要 query,你总要给我个方法吧?
Layer by layer query
方法是有,只是...
把 viewChild Child 移到 Parent 组件里
export class ParentComponent { child = viewChild(ChildComponent); }
现在变成 App query Parent query Child 😂
如果有很多层,那每一层组件都需要添加这个 viewChild 逻辑...
虽然我们确实做到了,但这种写法在管理上是很有问题的。
假设,有 A 到 Z 组件 (一层一层,共 26 层)。
A 想 query Z。
那在管理上,这件事就不可以影响到除了 A 和 Z 以外的人。
但按照上述的方法,B 到 Y 组件都需要添加 viewChild 逻辑才能让 A query 到 Z,这显然已经影响到了许多不相干的人,管理直接不及格😡!
原生的 Web Component ShadowRoot 都没有这么糟糕
虽然也是一层一层往下 query,但至少 B - Y 组件内不需要增加不相干的代码。
Use inject instead of query
Angular query child element 有限制,但很神奇的 query parent 却没有限制。(这和 ShadowRoot 不一样,ShadowRoot query parent 和 child 都限制)
于是我们可以尝试反过来做。
App 组件
export class AppComponent { public child!: ChildComponent; constructor() { afterNextRender(() => { console.log(this.child); }) } }
准备一个空的 child 属性
接着在 Child 组件 inject App,然后填入 Child 实例。
export class ChildComponent { constructor() { const app = inject(AppComponent); app.child = this; } }
这样 App 组件就 "query" 到 Child 了。
间中,Parent 组件完全不需要配合做任何事情。这个方案,管理...过👍
上难度 の 动态组件
看似我们好像是突破了 query 的局限,但其实不然...☹️
假设 Child 组件要支持动态插入。
首先,我们需要把 children 改成 signal (这样可以监听变化)
export class AppComponent { public children = signal<ChildComponent[]>([]); constructor() { effect(() => console.log(this.children())); // 监听变化 } }
接着
export class ChildComponent { constructor() { // 初始化时添加 const app = inject(AppComponent); app.children.set([...app.children(), this]); // destroy 时删除 const destroyRef = inject(DestroyRef); destroyRef.onDestroy(() => app.children.set(app.children().filter(c => c === this))) } }
嗯...只是改一改,方案视乎还可以,没有翻车...😨
上难度 の maintain sequence
假设,Parent 组件里有 3 个 Child 组件 (代号 A, B, C)
@if (show()) { <app-child /> } <app-child /> <app-child />
第一次 console.log 结果是 B, C,因为 A 被 @if hide 起来了。
接着我们 show = true。
第二次的 console.log 结果是 B, C, A。
注意不是 A, B, C 而是 B, C, A。
因为在 Child 初始化的时候,Child instance 是被 "push" 到 query results 里。
这就导致了 sequence 不一致。
为什么用 push?换成 unshift, splice 可以吗?
可以,但是没有用,因为 Child 不可能知道自己的位置,所以它也无法确定要用 push, unshift 还是 splice。
是的...这个方案翻车了😢,这种时候,我们还得用回 layer by layer query 方案...
好,这个问题我们先隔着,继续往下看看其它 Query Elements 的问题。
不支持 viewChildren <ng-container /> 和 <ng-content />
相关 Github Issue:
Github – ContentChildren doesn't get children created with NgTemplateOutlet (2017)
App Template
<ng-template #template> <app-child /> </ng-template> <ng-container #container />
一个 ng-template 和一个 <ng-container />
App 组件
export class AppComponent implements OnInit { readonly templateRef = viewChild.required('template', { read: TemplateRef }); readonly viewContainerRef = viewChild.required('container', { read: ViewContainerRef }); readonly child = viewChild(ChildComponent); constructor() { // 2. 查看是否可以 query 到 Child 组件 afterNextRender(() => console.log(this.child())) } ngOnInit() { // 1. create <ng-template> and insert to <ng-container /> this.viewContainerRef().createEmbeddedView(this.templateRef()); } }
App 组件成功 query 到 Child 组件。
好,我们改一改结构
App Template
<app-parent> <ng-template #template> <app-child /> </ng-template> </app-parent>
把 ng-template transclude 给 Parent 组件
Parent Template
<p>parent works!</p> <ng-container #container />
Parent 组件
export class ParentComponent implements AfterContentInit { readonly templateRef = contentChild.required(TemplateRef); readonly viewContainerRef = viewChild.required('container', { read: ViewContainerRef }); readonly child = viewChild(ChildComponent); constructor() { // 2. 查看是否可以 query 到 Child 组件 afterNextRender(() => console.log('Parent query Child succeeded?', this.child())); // undefined } ngAfterContentInit() { // 1. create <ng-template> and insert to <ng-container /> this.viewContainerRef().createEmbeddedView(this.templateRef()); } }
Parent 组件无法 query 到 Child 组件。
但是 App 组件任然可以 query 到 Child 组件。
Why?!
因为 Angular 的 query 机制是 based on declare 的地方,而不是 insert 的方法。
我们可以 query <ng-template>,但是不可以 query <ng-container />。
我们经常有一种错误,觉得 <ng-container /> 可以被 query。
<ng-container *ngIf="true"> <app-child /> </ng-container>
这是因为被 *ngIf,*ngFor 误导了。
上面 *ngIf 只是语法糖,它的正确解读是
<ng-template [ngIf]="true"> <ng-container> <app-child /> </ng-container> </ng-template>
我们之所以能 query 到 Child 是因为,它在 ng-template 里面,
另外上面这段代码里, <ng-container /> 只是一个摆设而已,正真作为 ViewContainerRef 的 element 也是 ng-template (任何一种 element 都可以成为 ViewContainerRef,不一定是 <ng-container />,<ng-template> 也可以的)。
除了 <ng-container /> 不能被 query,<ng-content /> 也不能被 query。
viewChildren ng-template 顺序的问题
相关 Github Issue – QueryList not sorted according to the actual state
我在 <<Angular 高级教程 – 学以致用>> 文章中详细讲解过这个问题了,这里不再复述。
How to solve?
<app-parent> <ng-template #template> <app-child /> </ng-template> </app-parent>
上面这种情况,虽然 Parent 无法 viewChild Child 组件,但是可以 contentChild Child 组件。
另外 Child 也可以 inject 到 Parent,所以也可以像上一 part 那样 use inject instead of query。
但这些方式也都有局限。假如 ng-template 被丢到千里之外的 <ng-container /> 那 contentChild 和 inject 都可能连不上它们,这样就真的无计可施了。
Direct DOM query 方案
即便是 ShadowRoot,我也可以一层一层的往里面进。