Angular 17+ 高階教程 – 學以致用

兴杰發表於2024-04-16

前言

讀這麼多原理,到底為了什麼?真實專案中真的會用得到嗎?

你正在疑惑 "知識的力量" 嗎?

本篇會給一個非常非常好的案例,讓你感悟 -- 知識如何用於實戰。

記住,我的目的是讓你感悟,而不是要你盲目相信知識。

很久很久以前的問題 (疑難雜症)

下面是我在 2020-11-06 記入的一個問題。

一模一樣的問題也有人在 Github 提問 Github Issue – QueryList not sorted according to the actual state (提問於:2021-06-08)

這個 Issue 很特別,它沒有被關閉,也沒有任何的回覆,提問者也沒有繼續追問。

沒有被關閉是因為,它是一個事實,而且直覺告訴他們 (Angular Team) 這可能是一個 Bug 或者是一個可以嘗試去調查的 Issue。

沒有回覆是因為,他們 (Angular Team) 沒有一眼看出原因,然後他們懶得去調查。

提問者沒有追問是因為,這個問題可以避開,並不是非要解決不可的問題。

從這裡我們也可以看出 Angular 社群 (Angular Team and User) 對待 Issue 的態度🙈。

所以,千萬不要把那群人 (Angular Team) 想得太高,其實他們和你周圍的工作夥伴 level 差不多而已。

如何對待這類問題?(疑難雜症)

首先,如果你是 Angular 新手,那你不會遇到這類問題,因為你用的不深。

如果你不是新手,但專案不夠複雜,那你也不會遇到這類問題,還是因為你用的不夠深。

當有一天你遇到這類問題的時候,如果你不夠等級,那你只能傻傻的 debug 半天,找半天的資料,最後傻傻的去提問。

幾天後,等了一個寂寞,於是要嘛你避開這個問題,要嘛繼續追問...並期待有個熱心人士會為你解答。

很多年以後你會意識到,這世上沒有那麼多熱心人士,你的疑問任然是疑問。

很多年以後你發現,你需要避開的問題越來越多,最後連 Angular 你都避開了,逃到了 Vue,React,但終究沒有逃出問題的魔掌。

最後你意識到原來問題與框架無關,問題來自於專案的複雜度和你掌握知識的深度。

結論:直面問題,問題解決 33%,理解問題,問題解決 +33%,最後的 33% 就靠你的智慧了,而這 3 步都離不開知識。

當 ngForTemplate 遇上 Query Elements (疑難雜症)

上述的例子不夠簡單,這裡我做一個更直觀的例子來凸顯同一個原因導致的問題。

NgForOf 指令 和 Query Elements

App 元件

export class AppComponent {
  names = signal(["Alice", "Bob", "Charlie", "David", "Eve", "Frank", "Grace", "Hannah", "Isaac", "Jane"]);
  
  trackByNameFn(_index: number, name: string): string {
    return name;
  }
}

一組名字和一個 trackByNameFn 方法準備給 NgForOf 指令。

App Template

<h1 *ngFor="let name of names(); trackBy: trackByNameFn">{{ name }}</h1>

注:問題的原因並不出在 NgForOf 指令,它只是作為例子而已,請耐心往下看。

使用 NgForOf 指令 for loop 出所有名字。

效果

接著我們給 h1 新增一個 Template Variable

然後在 App 元件新增 Query Elements

export class AppComponent {

  // 1. Query Elements
  h1List = viewChildren<string, ElementRef<HTMLElement>>('h1', { read: ElementRef });
  constructor(){
    afterNextRender(() => {

      // 2. Log Query Results
      console.log(this.h1List().map(el => el.nativeElement.textContent));
    });
  }
}

效果

目前為止一切正常,接下來,我們換個位置。

在 App Template 新增一個 change sort button

<button (click)="changeSort()">Change Sort</button>

在 App 元件新增 changeSort 方法

changeSort() {
  this.names.set([
    ...this.names().slice(1, 5),
    this.names()[0],
    ...this.names().slice(5),
  ])

  setTimeout(() => {
    console.log('after', this.h1List().map(el => el.nativeElement.textContent)); 
  }, 1000);
}

換位置後,我們檢視 Query Results 的順序是否也跟著換了位置。

效果

完全正確。

ngForTemplate 和 Query Elements

現在,我們做一些調整,改用 ngForTemplate。

<ng-template #template let-name>
  <h1 #h1>{{ name }}</h1>
</ng-template>

<ng-template ngFor [ngForOf]="names()" [ngForTrackBy]="trackByNameFn" [ngForTemplate]="template"></ng-template> 

我在 NgForOf 指令教程中沒有提到 @Input ngForTemplate 是因為它比較冷門,而且可能會遇到本篇的問題。

ngForTemplate 允許我們把 ng-template 定義在另一個地方,然後傳入到 NgForOf 指令裡。

這樣的好處是 ng-template 可以另外封裝,更 dynamic,更靈活,當然也更容易掉坑😅。

接著,我們做回相同的測試

注意看,DOM render 是正確的,但是 Query Results 的順序是完全錯誤的。

Smallest reproduction

首先,不要誤會,這不是 NgForOf 指令的問題,更不是 @Input ngForTemplate 的問題。

這是 ng-template 和 ViewContainerRef 的問題。

如果你已經忘記了 ng-template 和 ViewContainerRef 的原理,你可以先複習這篇 Component 元件 の ng-template

我們用 ng-template 和 ViewContainerRef 來重現上述的問題。

首先在 App Template 新增 ng-container

<button (click)="changeSort()">Change Sort</button>

<ng-template #template let-name>
  <h1 #h1>{{ name }}</h1>
</ng-template>

<!-- 1. 加上 ng-container -->
<ng-container #container /> 

注:NgForOf 指令可以刪除了。

接著在 App 元件 Query TemplateRef 和 ViewContainerRef,然後 for loop createEmbeddedView 輸出所有名字。

export class AppComponent implements OnInit {
  viewContainerRef = viewChild.required('container', { read: ViewContainerRef });
  templateRef = viewChild.required('template', { read: TemplateRef });

  ngOnInit() {
    for (const name of ["Alice", "Bob", "Charlie", "David", "Eve", "Frank", "Grace", "Hannah", "Isaac", "Jane"]) {
      this.viewContainerRef().createEmbeddedView(this.templateRef(), { $implicit: name })
    }
  }

  h1List = viewChildren<string, ElementRef<HTMLElement>>('h1', { read: ElementRef });

  trackByNameFn(_index: number, name: string): string {
    return name;
  }
}

接著實現 changeSort 方法

changeSort() {
  this.viewContainerRef().move(this.viewContainerRef().get(0)!, 4);
  
  setTimeout(() => {
    console.log('after', this.h1List().map(el => el.nativeElement.textContent)); 
  }, 1000);
}

透過 ViewContainerRef.move 換位置

效果

注意看,DOM render 是正確的,但是 Query Results 的順序是錯誤的。它和 ngForTemplate 都出現了順序錯誤的問題。

The reason behind

我們逛過 Angular 原始碼,所以我們知道:

ng-template 會生成 LContainer (type = 4 號 Container)。

ng-container + Query ViewContainerRef 也會生成 LContainer (type = 8 號 ElementContainer)。

ViewContainerRef.createEmbededView 會生成一個 LView,

這個 LView 會被記入到 2 個地方:

  1. ng-container LContainer

    LVIew 會被記入到 ng-container LContainer[8 ViewRefs] 的 array 裡,和 LContainer[10 以上],這兩個始終是一致的啦,我們下面關注 LContainer[8 ViewRefs] 就好。

  2. ng-template LContainer

    LVIew 會被記入到 ng-template LContainer[9 MovedViews] 裡頭

    index 9 裝的是 Moved Views,意思是說,用這個 ng-template 建立出來的 LView 卻沒有被插入到這個 ng-template 的 LContainer 裡,而是被插入到了其它的 LContainer。

好,重點來了。

第一個重點,此時 ng-template LContainer[9 MovedViews] 和 ng-container LContainer[8 ViewRefs] 的 LView array 順序是一模一樣的。

第二個重點,Query Elements 查詢的是 ng-template LContainer[9 MovedViews] 裡頭的 LView,所以 Query Results 的順序是依據 ng-template LContainer[9 MovedViews] array 的順序。

接著,我們 change sort 看看

ng-template LContainer

ng-container LContainer

DOM render 是依據 ng-container LContainer[8 ViewRefs],Query 則是依據 ng-template LContainer[9 MovedViews],而這 2 個 array 的順序在 change sort 以後竟然不一樣了😱。

好,原因算是找到了。

逛一逛 ViewContainerRef.move 原始碼

我們知道 ViewContainerRef.move 後,ng-container LContainer[8 ViewRefs] 和 ng-template LContainer[9 MovedViews] 的 array 順序就不同了,但具體是哪一行程式碼導致的呢?

ViewContainerRef 原始碼在 view_container_ref.ts

ViewContainerRef.move 方法內部其實是呼叫了 insert 方法。

insert 內呼叫了 insertImpl

首先檢檢視要 insert 的 LView 是否已經在 LContainer 裡,如果已經在,那就先 detach。

提醒:只是 detach,沒有 destroy 哦。

detach 以後,ng-container LContainer[8 ViewRefs] 就少了這個 LView,同時 ng-template LContainer[9 MovedViews] 也少了這個 LView

然後再重新插入回 LContainer。注:順便留意那個要插入的 index 位置。

addLViewToLContainer 函式的原始碼在 view_manipulation.ts

insertView 函式的原始碼在 node_manipulation.ts

到這裡,ng-container LContainer[10 以上] 就有正確的 LView 了。

繼續往下

我們的例子就是 LView 來自其它地方。

ng-template 本身也可以作為 ViewContainerRef。

ng-template create LView 插入回自己作為 ViewContainerRef 叫做來自同一個地方。

ng-template create LView 插入到其它的 LContainer 叫做來自不同地方。

來自不同地方就需要呼叫 trackMovedView 函式

到這裡,ng-template LContainer[9 MovedViews] 就有了 LView,但是順序和 ng-container[10 以上] 是不同的。

因為它只是 push,完全沒有依據 insert 指定的 index。

好,我們直接跳回 ViewContainerRef.insertImpl 方法

在 addLViewToLContainer 後,會跑一個 addToArray。它的作用是把 LView 新增到 LContainer[8 ViewRefs] array 裡面。

它是有依據 insert 指定的 index 的。

總結:

  1. 先 detach LView

    ng-container LContainer 和 ng-template LContainer 都移除這個 LView

  2. 新增 LView 到 ng-container LContainer[10 以上]

    這裡會依據 insert 指定的 index

  3. 新增 LView 到 ng-template LContainer[9 MovedViews]

    這裡不會依據 insert 指定的 index,它一律只是 push。

    這也是這個問題出錯的地方。

  4. 新增 LView 到 ng-container LContainer[8 ViewRefs]

    這裡會依據 insert 指定的 index

對這個問題的思考

目前的行為是:ng-template 建立的 LView 被插入到 ng-container LContainer 後,ng-template LContainer[9 MovedViews] 只是一味的 push,沒有顧慮到順序。

要維護一個順序其實也不難,只是我們也要考慮到 ng-template 的 LView 是可以插入到不同的 LContainer 的。

試想 ng-template 建立了 9 個 LView,分別插入到 3 個不同的 ng-container LContainer 裡。

LView 的順序可以 follow 個別的 ng-container LContainer[8 ViewRefs],但是 3 個 ng-container LContainer 的順序呢?哪一個先?

這個就需要思考一下,最簡單的選擇或許是依據插入的順序。

總之這樣至少已經可以解決 80% 常見了,畢竟 ng-template 插入到多個 LContainer 是罕見的。

總結

本篇給了一個例子,示範當面對疑難雜症時如何面對,如何理解,如何一步一步思考,並且選出最合適的方案。

同時,讓你對知識的力量有所感悟,以後你就知道什麼時候需要深入學習,什麼後該划水。happy coding...💻😊

目錄

上一篇 Angular 17+ 高階教程 – Prettier, ESLint, Stylelint

下一篇 Angular 17+ 高階教程 – 盤點 Angular v14 到 v17 的重大改變

想檢視目錄,請移步 Angular 17+ 高階教程 – 目錄

相關文章