Angular 17+ 高階教程 – Angular 的侷限 の Query Elements

兴杰發表於2024-04-22

前言

熟悉 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 元件

Angular 17+ 高階教程 – Angular 的侷限 の Query Elements
export class AppComponent {
  child = viewChild(ChildComponent);
  parent = viewChild(ParentComponent);

  constructor() {
    afterNextRender(() => {
      console.log(this.child());
      console.log(this.parent());
    })
  }
}
View Code

不奇怪,因為 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)

Github – Accessing @ViewChild and @ViewChildren of a template declared in a different component than the one where it is host (2021)

App Template

<ng-template #template>
  <app-child />
</ng-template>

<ng-container #container />

一個 ng-template 和一個 <ng-container />

App 元件

Angular 17+ 高階教程 – Angular 的侷限 の Query Elements
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());
  }
}
View Code

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 元件

Angular 17+ 高階教程 – Angular 的侷限 の Query Elements
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());
  }
}
View Code

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 都可能連不上它們,這樣就真的無計可施了。

問題小總結

問題:

  1. viewChild 不能 descendants

  2. query 不到 inside <ng-container /> 和 <ng-content />

  3. query ng-template 順序和 ViewContainerRef 不一致

這些問題歸根究底都是因為 Angular 的奇葩 query 機制。

Angular query child 查詢的是 Logical View Tree,而不是 DOM Tree。

Angular query parent 查詢的是 Injector Tree,而不是 DOM Tree。

Angular query 機制對靜態友好,對動態不友好 (一旦有動態元件,它要嘛完全 query 不到,要嘛順序不對...😔)

安全範圍

怎麼能不掉坑呢?

  1. viewChild 控制在 1 layer

    App 元件 viewChild 就只 query App Template 上的元素

  2. contentChild 控制在 1 layer

    <app-child>
      <ng-content />
    </app-child>

    像上面這樣就 multilayer 了,Child 元件無法 contentChild 到任何東西。

  3. ng-template 作為 ViewContainerRef

    要 query ng-template 內容,最好把 ng-template insert 到相同位置,讓 ng-template 自己作為 ViewContainerRef。

    只有這樣才能確保 query 的順序是正確的。

Direct DOM query 方案

既然我們心裡想的是 query DOM,而 Angular 又不是 query based on DOM Tree,那是不是一開始方向就錯了呢?

是的,我們完全可以換一個思路,我們就 query DOM,然後再想辦法讓這個 DOM element 關聯上 Angular。

App Template 裡有一個 Parent 元件

<app-parent />

Parent 元件裡有三個 Child 元件

<p>parent works!</p>

@if (show()) {
  <app-child />
}
<app-child />
<app-child />

第一個還是動態輸出的,三秒後 show 會從 false 轉為 true

export class ParentComponent {
  show = signal(false);

  constructor() {
    window.setTimeout(() => {
      this.show.set(true);
    }, 3000);
  }
}

我們的需求是,從 App 元件 query 出 Child 元件例項。

首先,我們做一個 Root Level Service

@Injectable({
  providedIn: 'root'
})
export class ChildCollector {
  private childComponentMap = new Map<HTMLElement, ChildComponent>();

  getChild(childElement: HTMLElement): ChildComponent {
    return this.childComponentMap.get(childElement)!;
  }

  addChild(childElement: HTMLElement, childInstance: ChildComponent) {
    this.childComponentMap.set(childElement, childInstance);
  }

  removeChild(childElement: HTMLElement) {
    this.childComponentMap.delete(childElement);
  }
}

它的職責是收集 Child 元件例項,順序不重要,只要把例項和 element 關聯起來就可以了。

在 Child 元件初始化做新增,在 destroy 時做移除

export class ChildComponent {
  constructor() {
    const childCollector = inject(ChildCollector);
    const host: HTMLElement = inject(ElementRef).nativeElement;
    childCollector.addChild(host, this);

    const destroyRef = inject(DestroyRef);
    destroyRef.onDestroy(() => childCollector.removeChild(host));
  }
}

App 元件

export class AppComponent {
  constructor() {
    const host: HTMLElement = inject(ElementRef).nativeElement;
    const childCollector = inject(ChildCollector);
    const destroyRef = inject(DestroyRef);
    const injector = inject(Injector);

    afterNextRender(() => {

      const childInstances$ = new Observable<ChildComponent[]>(subscriber => {
        // 1. 利用 MutationObserver 監聽 DOM 變化
        const mo = new MutationObserver(() => emitQuery());
        mo.observe(host, { childList: true, subtree: true });
        emitQuery();
        return () => mo.disconnect();

        function emitQuery() {
          // 2. 用原生 DOM querySelectorAll 做 query
          const childElements =  Array.from(host.querySelectorAll<HTMLElement>('app-child'));
          // 3. 把 element 替換成 Child 元件例項
          const childInstances = childElements.map(childElement => childCollector.getChild(childElement));
          subscriber.next(childInstances);
        }

      }).pipe(distinctUntilChanged((prev, curr) => prev.length === curr.length && prev.every((p, i) => p === curr[i])), takeUntilDestroyed(destroyRef));

      const childInstances = toSignal(childInstances$, { injector, requireSync: true });
      
      effect(() => console.log(childInstances()), { injector });
    })
  }
}

最關鍵的是有註釋的那三句,其它的只是 RxJS Obserable 和 Signal 的包裝。

我們直接用原生的 DOM querySelectorAll 作為 query,這樣就完全突破 Angular 的限制了。

接著把 query 到的 Child element 替換成 Child 元件例項,這樣就連結會 Angular 了。

效果

這個方案不僅僅可用於 query child,想用於 query parent 也是相同思路,只要把 querySelectorAll 換成 parentElement 或者 cloest 向上查詢就行了。(注:Angular CDK Scrolling 裡也使用了這個方案)

DOM query 方案要注意的事項

直接操作 DOM 通常是不順風水的,容易掉坑。但如果你熟悉 Angular 底層機制的話,一切是可以 under control 的,不必擔心。

我個人建議:

  1. 首選 Angular way -- viewChild, contentChild

  2. 次選 "use inject instead of query" 方案

  3. 真的擺不平,才出殺手鐧 -- DOM query 方案

總結

本篇介紹了一些 Angular 常見的 query 侷限,並提供了一些粗糙的解決方案,happy coding 💻😊

相關文章