Angular 可以透過實作 RouteReuseStrategy
介面來達成控制路由重用(Reuse)之目的,看似實作容易,但是在多層次路由下潛藏危機。如果您的應用場景更複雜,甚至在 JavaScript 基礎上存取 Angular Route 系列物件中的未公開成員,可能會遇上更多驚奇。
本文基於的實驗同時跑在 Angular 10 與 Angular 11 之上,並得到相同的結果。有興趣的讀者也可以透過我在 stackblitz.io 建置的實驗專案 進行觀察。
定義
RouteReuseStrategy
介面共有 5 個方法需要實作,你可以先看看官方文件的定義。
shouldDetach()
判斷此路由(與子樹)是否可被分離,以便後續重用store()
將分離的路由儲存shouldAttach()
判斷是否要附加路由(與子樹)retrieve()
檢索(取出)之前儲存的路由shouldReuseRoute()
判斷是否要重用路由
我在 stackblitz.io 有準備範例專案,欲查看 RouteReuseStrategy
的實作可以參考。
先用想的
讓我們看著這些方法的命名,試著揣摩它的運作邏輯,大概是這樣的。
一開始由 shouldReuseRoute()
來判斷是否該重用,若回傳 true
則不會觸發任何路由動作,回傳 false
則有可能觸發其他的方法。由於路由牽涉到「離開」與「進入」,依照 shouldReuseRoute()
定義的參數名稱,你也可以說「現在」與「未來」,因此後續的行為會分岔為兩條路,一是處理「現在/離開」,二是處理「未來/進入」。
在「現在/離開」的路線上,相當容易理解,shouldDeatch()
判斷是否要分離供未來重用,回傳 true
則會再觸發 store()
,你必須在 store()
當中實作暫存邏輯,把 DetachedRouteHandle
暫存起來。
而在「未來/進入」的路線上,先透過 shouldAttach()
判斷是否要附加,回傳 true
表示要,則再觸發 retrieve()
讓你從之前的暫存中挑出適當的對象;反之,shouldAttach()
回傳 false
表示不要附加,Angular 會自己建立全新的 Component 顯示在畫面上。
實際情況
然而實際情況並不像上面想像那般正常。下圖是實際加上 log 後進行觀察的結果。
這個實驗定義了 a
與 b
兩個畫面,皆允許暫存。讓我們看看 logs。
1. 初次進入 a
頁面
- [retrieve] 取出 a 的暫存 =
undefined
- [shouldAttach] 是否 a 有可用暫存 =
false
- [constructor] 建立 AComponent 實例
2. 前往 b
頁面
- [retrieve] 取出 b 的暫存 =
undefined
- [shouldDetach] 是否允許 a 脫離
- [store] 暫存 a,存入
[object Object]
- [shouldAttach] 是否 b 有可用暫存 =
false
- [constructor] 建立 BComponent 實例
3. 再回到 a
頁面
- [retrieve] 取出 a 的暫存 =
[object Object]
- [shouldDetach] 是否允許 b 脫離 =
true
- [store] 暫存 b,存入
[object Object]
- [shouldAttach] 是否 a 有可用暫存 =
true
- [retrieve] 取出 a 的暫存 =
[object Object]
- [store] 暫存 a,存入
null
有什麼奇怪的地方呢?我們可以發現,再次回到 a 頁面時:
retrieve()
方法被執行了兩次。如果你沒有在這個暫存過程中進行變化,通常不會有問題。但若你想依各種條件暫存、依條件復原,甚至依條件復原後想再觸發額外的操作,則需要小心,執行兩次可能是你沒預期到的。- 最後多了一次
store()
,而且這時候帶入store()
的handle
參數會是null
。但這個危害可能不大,因為你已經路由到正確的目標頁面,只是暫存的DatachedRouteHandle
被清除了的感覺。
再更實際的情況:子路由
當我們看向更實際的應用情境--「具有子路由的環境」時,故事又出現了驚奇。
所謂子路由,是指你在定義路由的時候,透過 children
屬性來設定,如下的二層路由範例,1
為 a
的子路由,若你要存取它,則需要路由至 a/1
。
const routes = [
{
path: 'a',
children: [
{
path: '1',
component: AComponent,
},
],
},
];
這個實驗定義了 a/1
與 b/1
兩個畫面,皆允許暫存。一樣看看 logs。
1. 初次進入 a/1
頁面
- [retrieve] 取出 a 的暫存 =
undefined
- [retrieve] 取出 1 的暫存 =
undefined
- [shouldAttach] 是否 1 有可用暫存 =
false
- [constructor] 建立 AComponent 實例
2. 前往 b/1
頁面
- [retrieve] 取出 b 的暫存 =
undefined
- [retrieve] 取出 1 的暫存 =
undefined
- [shouldDetach] 是否允許 a 脫離 =
true
- [store] 暫存 a,存入
[object Object]
- [shouldAttach] 是否 1 有可用暫存 =
false
- [constructor] 建立 BComponent 實例
3. 回到 a/1
頁面
- [retrieve] 取出 a 的暫存 =
[object Object]
- [shouldDetach] 是否允許 b 脫離 =
true
- [store] 暫存 b,存入
[object Object]
- [shouldAttach] 是否 1 有可用暫存 =
false
- [constructor] 建立 AComponent 實例
我們可以發現,又多了些奇怪現象:
store()
的呼叫被父路由搶走。傳入的ActivatedRouteSnapshot
會是父路由,接下來就沒有子路由呼叫的store()
了。通常我們會使用路由路徑(route path) 作為暫存的依據(key),這時候你可能會以預期之外的 key 進行暫存。- 執行
retrieve()
的次數漂浮不定。前往b/1
做了兩次,看起來是遞迴行為,但回到a/1
時只做了一次。 retrieve()
與shouldAttach()
處在不同路由層次。這引發了一個大問題,在前述我們可以發現到「暫存」與「取出暫存」使用的 Key 互不相等,以致現在找不到正確的暫存,Angular 將重建 Component。針對這個問題的解決方法將後面章節說明。
三層路由呢?
發揮實驗精神,來試試看當路由定義為 3 層時會有什麼變化。當我們要到 AComponent
,則需路由至 a/1/a-1
。
const routes = [
{
path: 'a',
children: [
{
path: '1',
children: [
{
path: 'a-1',
component: AComponent,
},
],
},
],
},
];
1. 初次進入 a/1/a-1
頁面
- [retrieve] 取出 a 的暫存 =
undefined
- [retrieve] 取出 1 的暫存 =
undefined
- [retrieve] 取出 a-1 的暫存 =
undefined
- [shouldAttach] 是否 a-1 有可用暫存 =
false
- [constructor] 建立
AComponent
實例
我們可以從中得知,retrieve()
遞迴執行,逐層拆解路由定義,由於是初次進入,自然沒有任何東西被存下來,因此 retrieve()
都只找到 undefined
。而 shouldAttach()
自然也是找不到暫存,最後 Angular 建立了新的 Component 實例,一切是這麼的合理。
2. 前往 b/1/b-1
頁面 (只截錄關於 a 的部分)
- [shouldDetach] 是否允許 a 脫離 =
true
- [store] 暫存 a,存入
[object Object]
與前面兩層路由問題一樣,脫離的時候明明要遞迴,卻只到 a
這層 (父層) 馬上就呼叫了 store()
,就像是搶走了暫存權一般。於是我們明明要以 a-1
的名義存下東西,卻糊塗的變成了 a
的名義。
3. 回到 a/1/a-1
頁面 (只截錄關於 a 的部分)
- [retrieve] 取出 a 的暫存 =
[object Object]
- [shouldAttach] 是否 a-1 有可用暫存 =
false
- [constructor] 建立
AComponent
實例
如同預期的復原失敗,因為根本沒有以 a-1
名義暫存的東西。
多層次路由的應對方案
在多層次的路由上將面臨 Key 重複,或儲存與取出時使用不同 Key 的奇怪現象,高機率使得暫存失去意義。
要解決這個問題有兩個部份需要努力:
- 使用不重複的 Key。可以透過
route
物件內的各種屬性加以組合,湊出完成的、絕對的路徑。 - 只允許特定的、重要的、被標記的頁面被暫存。多層路由會出現
store()
呼叫被搶佔的情況。若我們能夠識別出哪一些路由是不需要被暫存的,並加以忽略,則可以避免這個問題的發生。使用route.data
作為標示是個可行的方案。
例如:
const routes = [
{
path: 'a',
children: [
{
path: '1',
component: ChildComponent,
data: {
cacheable: true
},
},
],
},
];
並可以在 shouldDeatch()
方法進行判斷,這麼做將阻止一些不需要被暫存的 DetachedRouteHandle
消失在視野中,進而不會去搶佔 store()
的呼叫,存下真正該存的 Handle。
shouldDetach(route: ActivatedRouteSnapshot): boolean {
const cachable: boolean = route.data['cacheable'] ?? false
return cachable;
}