原文釋出於我的 GitHub blog
前言
在 上一篇文章 中介紹了前端路由的實現及 react-router-v4(以下簡稱 rr4) 的原始碼分析,目前階段 rr4 已經基本壟斷了 react 生態圈的路由,雖然 v4 版本成功完成了一切皆元件的蛻變,但其實它本身還有諸多問題,比如 keep-alive。
keep-alive 的叫法取自 vue-keep-alive,在 vue 中,可以將某元件暫存於記憶體,然後跳轉到其他頁面再從記憶體中將這個元件拿出來。換算到路由中,我們可以想象這樣一個情景 —— 有一個商品列表頁,每個商品點進去都跳轉到對應的商品詳情頁面,使用者每次瀏覽完一個商品詳情之後回退,列表頁會重新渲染,那麼如果使用者已經往下劃了幾屏之後回退,那麼每次返回後都要先滑到上次瀏覽的位置,這種體驗可以說是災難性的。
現在的瀏覽器非常貼心的實現了 Scroll Restoration(後退時恢復滾動位置),這在非 SPA 頁面有非常好的體驗效果,但是在 SPA 中,會有以下問題:
- 瀏覽器試圖恢復滾動距離時,頁面可能還沒有載入完畢。因為回退的頁面需要重新 mount,可能存在非同步載入的部分,導致頁面出現跳動。
- 點選連結進入頁面就不會應用滾動恢復這一行為。只有在點選瀏覽器按鈕的前進後退按鈕時,才會觸發
popstate
事件並觸發 scroll restoration,通過點選連結無法觸發滾動恢復。 - 這是非規範的一個 API(詳見),所以各個瀏覽器的實現並不完全一致。
其實 iOS 和 Android 端的路由轉換是十分理想的 —— 支援轉場動畫,手勢返回,keep-alive。
本文中我們試圖解決為 rr4 實現一個可以快取的 Route 來解決上面例子中的問題,並藉此探索一下 rr4 目前階段的不足之處及可以加強的地方。說句題外話,rr4 的核心開發者又新搞了一個 reach-router 路由庫,針對 rr4 的缺點進行了針對性的改進,已經欽點了是下一代的路由旗艦管理庫。
輪子
先放上我造的輪子的倉庫地址 react-live-route 感受一下本文的最終目的,react-live-route 可以使路由在路徑不匹配時隱藏而不被解除安裝,在匹配路徑時完全恢復離開頁面時的樣子。歡迎 star 和提 issue。
PC 端可以預覽 demo
移動端掃碼試玩 (點一下玩一年)
思路
我們先重新將要解決的問題整理一下:
我們有列表頁面和詳情頁,在列表頁點選專案進入對應的詳情頁時,儘量保留列表頁的檢視與資料狀態(包括滾動位置)。在從詳情頁回退到列表頁的時候,希望列表頁能恢復到上次離開時的狀態。
其中我們要恢復的狀態:
- 頁面的滾動位置。
- 路由元件的一切狀態,包括路由的元件的所有子元素的狀態。
並且要做到無痛相容 rr4,侵入性越小越好。我們的目標是為 react-router 設計一個增強型的 Route 元件,可以像 iOS 和 Android 端的路由切換一樣“隱藏”上一個導航的頁面,在這裡有兩種解決問題的思路:
思路1
unmount 時儲存狀態,re-mount 時取回狀態
在列表頁將要 unmount 的時候,將需要保留的資料狀態存在 context(或者 window.sessionStorage 等等)
**優點:**可以在 unmount 和 re-mount 時利用生命週期。
缺點:
- 需要自己選擇要儲存的資訊。
- 父元件無法拿到子元件的狀態進行儲存。
- 會重新 unmount 和 re-mount,這其實是不應該發生的,被隱藏的列表頁應該是“潛伏”在詳情頁的下面,等到重新進入列表頁時才出現,而不是已經被 unmount 了。
思路2
不 unmount,只是根據路由隱藏/顯示對應頁面
在切換到詳情頁的路徑時,不將列表頁 unmount,而是 display: none
掉它,在從詳情頁返回列表頁的時候,再 display: block
將列表頁顯示回來。
優點: 簡單粗暴,因為沒有解除安裝元件,所以可以不用管頁面的資料狀態的儲存情況。只需要管理好恢復顯示、隱藏與正常 re-render,再恢復滾動位置即可。
缺點: 配合轉場動畫可能會有問題。
由於思路 1 的實現有很大的侷限性,所以按照思路 2 來進行實現。
實現
增強的 Route 元件稱為 LiveRoute,我們首先要確定,這個增強元件在什麼情況下起作用,以及它有哪幾種狀態,react-router 有一篇關於 Scroll Restoration 的文章 ,是關於 react-router 去除了滾動恢復的功能的原因,其中有提到原因:
What got tricky for me was defining an “opt-out” API for when I didn`t want the window scroll to be managed.
就是因為實際的應用情況太多變,他們無法合適的判斷什麼時候需要進行滾動恢復的管理。
在一開始我是打算使用成對的路由來實現,其中一個 LiveRoute 的存活狀態去控制另一個需要保留存活的 LiveRoute:
<LiveRoute path=`/list` liveKey=`listToItem` component={List}/>
<LiveRoute path=`/item/:id` onLiveKey=`listToItem` component={Item}/>
複製程式碼
但是路由間需要在 router 上建立 context 來輔助通訊,如下是 react-router 正常更新一次的流程,路由間的通訊會再一次觸發被通知的路由的 setState,這是無法避免的,但是 Route 作為整個應用中非常靠上的元件,副作用要儘可能的小。
換個思路,其實快取頁面的匹配規則就是控制頁面的隱藏/恢復顯示與正常解除安裝,而 rr4 正常的路由匹配規則就是控制渲染/解除安裝,通過 path
這個 props 來完成。那麼我們直接給 LiveRoute 一個額外的來控制隱藏/恢復顯示的 livePath
的路徑即可,其規則就可以直接套用 path
,當路由 livePath
匹配時,則處於隱藏狀態,其他路徑則按照 rr4 的規則正常渲染/解除安裝。呼叫方法:
<LiveRoute path=`/list` livePath=`/item/:id` component={List}/>
複製程式碼
如此一來,LiveRoute 顯示狀態的依賴變為 context.router
,這樣做的好處是依賴變的簡單,所有的路由都會“同時”獲得依賴的更新,並且相互之間沒有耦合。
LiveRoute 狀態
LiveRoute 內部有一個狀態機,有三種渲染元件的狀態:
-
HIDE_RENDER
:livePath 匹配則需要將 LiveRoute 渲染的元件隱藏掉。進入此狀態時需要備份頁面的滾動位置,然後通過ReactDOM.findDOMNode
來獲取路由渲染的元件的 DOM,將dom.style.display = `none`
,並備份修改之前的 display 的屬性。 -
NORMAL_RENDER_MATCH
:路由正常渲染並且匹配上了。呼叫原版 Route 的渲染方法即可if (component) return match ? React.createElement(component, props) : null; if (render) return match ? render(props) : null; 複製程式碼
但是在每次正常匹配渲染的時候都要儲存當前的 context.router
,作為之後隱藏渲染時需要保持渲染所需的 router,在 componnetDidUpdate 後檢視有沒有備份的滾動位置,如果有就恢復滾動位置並清除備份的滾動位置。
NORMAL_RENDER_UNMATCH
:正常渲染但是不匹配,即要解除安裝當前路由的元件。要做的就比較簡單了,清空 LiveRoute 中儲存的 DOM 的引用,清除掉儲存的滾動位置,然後呼叫原版的的 Route 的渲染方法(解除安裝)即可。
實現細節
如何保護路由渲染的元件存活
當 router
與 livePath 匹配
的時候需要將 LiveRoute 置為隱藏狀態。
但是新的 router 傳入必然會計算出一個新的 match 去 setState,而新的 setState 與當前的 path 並不匹配,所以 LiveRoute 每次隱藏渲染時需要在 componentWillReceiveProps 中計算上次的 prevMatch。
在 render 的部分,需要當前的 router 在計算傳遞給元件的 props,所以需要在最後一次正常渲染的時候儲存當前的 router。
最後,將 prevMatch 作為 setState 的 match,再拿出之前儲存的 _prevRoute 完成渲染,一句話說就是將最後一次正常渲染的引數給保留了下來並在需要隱藏的時候拿出來偽裝成最後一次正常渲染,再將 DOM 隱藏就完成了核心功能。
儲存滾動位置
由於 LiveRoute 攔截了路由的解除安裝,所以滾動位置不需要再儲存在全域性的 sessionStorage 中,LiveRoute 會一直存活,滾動位置直接可以儲存為 LiveRoute 的屬性。並且,相比 sessionStorage 必須先 JSON.stringify()
儲存物件的操作,有了更高的可擴充性。
Switch
有一個問題就是與 Switch 的不相容性,這個是採用 display:none
這種方法無法避免的,我也在 文件 中寫到了。因為 Switch 的目的就是僅渲染第一個匹配的子元素,而 LiveRoute 的目的是強行渲染不匹配的子元素,所以不能在 Switch 中直接巢狀一個 LiveRoute 來使用。解決方法也簡單,就是將 LiveRoute 從 Switch 中拿到外面來,不要讓 LiveRoute 和 Switch 相互干擾,但是要注意此時 LiveRoute 的渲染與否也失去了 Switch 的跳過功能了。
滾動位置的不變性
在一些情況下 LiveRoute 的 DOM 將會被直接修改,所以在切換路由時滾動位置將不會改變而介面已經發生改變。這並不是 react-live-route 帶來的問題,你可以手動將頁面滾動到頂部,這篇 react-router 提供的 教學文章 中可以提供一些幫助。另外,如果 LiveRoute 將要恢復滾動位置,由於 React 的渲染順序,它將發生在 LiveRoute 渲染的元件的滾動操作之後發生(滾動操作發生在 componentDidMount 或 componentDidUpdate 中)。
總結
react-live-route 實現了路由的快取及復原,但是還有一些其他的問題需要解決,比如與轉場動畫的相容性及給 LivePath 傳入一個陣列來實現多規則匹配的問題。
最後再放上 react-live-route 的倉庫地址 react-live-route,歡迎 star 和提出 issue。