Angular RouteReuseStraegy 多層路由時的注意事項

Eric Li
hefemk
Published in
9 min readMay 1, 2021

--

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 後進行觀察的結果。

這個實驗定義了 ab 兩個畫面,皆允許暫存。讓我們看看 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 屬性來設定,如下的二層路由範例,1a 的子路由,若你要存取它,則需要路由至 a/1

const routes = [
{
path: 'a',
children: [
{
path: '1',
component: AComponent,
},
],
},
];

這個實驗定義了 a/1b/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 的奇怪現象,高機率使得暫存失去意義。

要解決這個問題有兩個部份需要努力:

  1. 使用不重複的 Key。可以透過 route 物件內的各種屬性加以組合,湊出完成的、絕對的路徑。
  2. 只允許特定的、重要的、被標記的頁面被暫存。多層路由會出現 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;
}

--

--