前言
讀這麼多原理,到底為了什麼?真實專案中真的會用得到嗎?
你正在疑惑 "知識的力量" 嗎?
本篇會給一個非常非常好的案例,讓你感悟 -- 知識如何用於實戰。
記住,我的目的是讓你感悟,而不是要你盲目相信知識。
很久很久以前的問題 (疑難雜症)
下面是我在 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 個地方:
-
ng-container LContainer
LVIew 會被記入到 ng-container LContainer[8 ViewRefs] 的 array 裡,和 LContainer[10 以上],這兩個始終是一致的啦,我們下面關注 LContainer[8 ViewRefs] 就好。
-
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 的。
總結:
-
先 detach LView
ng-container LContainer 和 ng-template LContainer 都移除這個 LView
-
新增 LView 到 ng-container LContainer[10 以上]
這裡會依據 insert 指定的 index
-
新增 LView 到 ng-template LContainer[9 MovedViews]
這裡不會依據 insert 指定的 index,它一律只是 push。
這也是這個問題出錯的地方。
-
新增 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+ 高階教程 – 目錄