React+React Router+React-Transition-Group實現頁面左右滑動+滾動位置記憶
在React Router中,想要做基於路由的左右滑動,我們首先得搞清楚當發生路由跳轉的時候到底發生了什麼,和路由動畫的原理。
首先我們要先了解一個概念:history。history原本是內置於瀏覽器內的一個物件,包含了一些關於歷史記錄的一些資訊,但本文要說的history是React-Router中內建的history,每一個路由頁面在props裡都可以訪問到這個物件,它包含了跳轉的動作(action)、觸發跳轉的listen函式、監聽每次跳轉的方法、location物件等。其中的location物件描述了當前頁面的pathname、querystring和表示當前跳轉結果的
瞭解完history後,我們再來複習一下react router跳轉的流程。
當沒有使用路由動畫的時候,頁面跳轉的流程是:
使用者發出跳轉指令 -> 瀏覽器歷史接到指令,發生改變 -> 舊頁面銷燬,新頁面應用到文件,跳轉完成
當使用了基於React-Transition-Group的路由動畫後,跳轉流程將變為:
使用者發出跳轉指令 -> 瀏覽器歷史接到指令,發生改變 -> 新頁面插入到舊頁面的同級位置之前 -> 等待時間達到在React-Transition-Group中設定的timeout
當觸發跳轉後,頁面的url發生改變,如果之前有在history的listen方法上註冊過自己的監聽函式,那麼這個函式也將被呼叫。但是hisory要在元件的props裡才能獲取到,為了能在元件外部也能獲取到history物件,我們就要安裝一個包:https://github.com/ReactTraining/history。用這個包為我們建立的history替換掉react router自帶的history物件,我們就能夠在任何地方訪問到history物件了。
import { Router } from 'react-router-dom'; import { createBrowserHistory } from'history'; const history = createBrowserHistory() <Router history={history}> .... </Router>
這樣替換就完成了。註冊listener的方法也很簡單:history.listen(你的函式)即可。
這時我們能控制的地方有兩個:跳轉發生時React-Transition-Group提供的延時和enter、exit類名,和之前註冊的listen函式。
本文提供的左右滑動思路為:判斷跳轉action,如果是push,則一律為當前頁面左滑離開螢幕,新頁面從右到左進入螢幕,如果是replace則一律為當前頁面右滑,新頁面自左向右進入。如果是pop則要判斷是使用者點選瀏覽器前進按鈕還是返回按鈕,還是呼叫了history.pop。
由於無論使用者點選瀏覽器的前進按鈕或是後退按鈕,在history.listen中獲得的action都將為pop,而react router也沒有提供相應的api,所以只能由開發者藉助location的key自行判斷。如果使用者先點選瀏覽器返回按鈕,再點選前進按鈕,我們就會獲得一個和之前相同的key。
知道了這些後,我們就可以開始編寫程式碼了。首先我們先按照react router官方提供的路由動畫案例,將react transition group新增進路由元件:
<Router history={history}> <Route render={(params) => { const { location } = params return ( <React.Fragment> <TransitionGroup id={'routeWrap'}> <CSSTransition classNames={'router'} timeout={350} key={location.pathname}> <Switch location={location} key={location.pathname}> <Route path='/' component={Index}/> </Switch> </CSSTransition> </TransitionGroup> </React.Fragment> ) }}/> </Router>
TransitionGroup元件會產生一個div,所以我們將這個div的id設為'routeWrap'以便後續操作。提供給CSSTransition的key的改變將直接決定是否產生路由動畫,所以這裡就用了location中的key。
為了實現路由左右滑動動畫和滾動位置記憶,本文的思路為:利用history.listen,在發生動畫時當前頁面position設定為fixed,top設定為當前頁面的滾動位置,通過transition、left進行左滑/右滑,新頁面position設定為relative,也是通過transition和left進行滑動進入頁面。所有動畫均記錄location.key到一個數組裡,根據新的key和陣列中的key並結合action判斷是左滑還是右滑。並且根據location.pathname記錄就頁面的滾動位置,當返回到舊頁面時滾動到原先的位置。
先對思路中一些不太好理解的地方先解釋一下:
Q:為什麼當前頁面的position要設定為fixed和top?
A:是為了讓當前頁面立即脫離文件流,使其不影響滾動條,設定top是為了防止頁面因position為fixed而滾回頂部。
Q:為什麼新頁面的position要設定為relative?
A:是為了撐開頁面並出現滾動條。如果新頁面的高度足以出現滾動條卻將position設定為fixed或者absolute的話將導致滾動條不出現,即無法滾動。從而無法讓頁面滾動到之前記錄的位置。
Q:為什麼不用transform而要使用left來作為動畫屬性?
A:因為transform會導致頁面內position為fixed的元素轉變為absolute,從而導致排版混亂。
明白了這些之後,我們就可以開始動手寫樣式和listen函數了。由於篇幅有限,這裡就直接貼程式碼,不逐行解釋了。
先從動畫基礎樣式開始:
.router-enter-active{ position: relative; opacity: 0; /*js執行到到timeout函式後再出現,防止頁面閃爍*/ } .router-exit-active{ position: relative; z-index: 1000; }
然後是最主要的listen函式:
const config = { routeAnimationDuration: 350, }; let historyKeys: string[] = JSON.parse(sessionStorage.getItem('historyKeys')); // 記錄history.location.key的列表。儲存進sessionStorage以防重新整理丟失 if (!historyKeys) { historyKeys = history.location.key ? [history.location.key] : ['']; } let lastPathname = history.location.pathname; const positionRecord = {}; let isAnimating = false; let bodyOverflowX = ''; let currentHistoryPosition = historyKeys.indexOf(history.location.key); // 記錄當前頁面的location.key在historyKeys中的位置 currentHistoryPosition = currentHistoryPosition === -1 ? 0 : currentHistoryPosition; history.listen((() => { if (!history.location.key) { // 目標頁為初始頁 historyKeys[0] = ''; } const delay = 50; // 適當的延時以保證動畫生效 if (!isAnimating) { // 如果正在進行路由動畫則不改變之前記錄的bodyOverflowX bodyOverflowX = document.body.style.overflowX; } setTimeout(() => { // 動畫結束後還原相關屬性 document.body.style.overflowX = bodyOverflowX; isAnimating = false; }, config.routeAnimationDuration + delay); document.body.style.overflowX = 'hidden'; // 防止動畫導致橫向滾動條出現 if (history.location.state && history.location.state.noAnimate) { // 如果指定不要發生路由動畫則讓新頁面直接出現 setTimeout(() => { const wrap = document.getElementById('routeWrap'); const newPage = wrap.children[0] as HTMLElement; const oldPage = wrap.children[1] as HTMLElement; newPage.style.opacity = '1'; oldPage.style.display = 'none'; }); return; } const {action} = history; const currentRouterKey = history.location.key ? history.location.key : ''; const oldScrollTop = window.scrollY; const originPage = document.getElementById('routeWrap').children[0] as HTMLElement; originPage.style.position = 'fixed'; originPage.style.top = -oldScrollTop + 'px'; // 防止頁面滾回頂部 setTimeout(() => { // 新頁面已插入到舊頁面之前 isAnimating = true; const wrap = document.getElementById('routeWrap'); const newPage = wrap.children[0] as HTMLElement; const oldPage = wrap.children[1] as HTMLElement; if (!newPage || !oldPage) { return; } const currentPath = history.location.pathname; const isForward = historyKeys[currentHistoryPosition + 1] === currentRouterKey; // 判斷是否是使用者點選前進按鈕 if (action === 'PUSH' || isForward) { positionRecord[lastPathname] = oldScrollTop; // 根據之前記錄的pathname來記錄舊頁面滾動位置 window.scrollTo({top: 0}); // 如果是點選前進按鈕或者是history.push則滾動位置歸零 if (action === 'PUSH') { historyKeys = historyKeys.slice(0, currentHistoryPosition + 1); historyKeys.push(currentRouterKey); // 如果是history.push則清除無用的key } } else { window.scrollTo({ // 如果是點選回退按鈕或者呼叫history.pop、history.replace則讓頁面滾動到之前記錄的位置 top: positionRecord[currentPath] }); // 刪除滾動記錄列表中所有子路由滾動記錄 for (const key in positionRecord) { if (key === currentPath) { continue; } if (key.startsWith(currentPath)) { delete positionRecord[key]; } } } if (action === 'REPLACE') { // 如果為replace則替換當前路由key為新路由key historyKeys[currentHistoryPosition] = currentRouterKey; } window.sessionStorage.setItem('historyKeys', JSON.stringify(historyKeys)); // 對路徑key列表historyKeys的修改完畢,儲存到sessionStorage中以防重新整理導致丟失。 // 開始進行滑動動畫 newPage.style.width = '100%'; oldPage.style.width = '100%'; newPage.style.top = '0px'; if (action === 'PUSH' || isForward) { newPage.style.left = '100%'; oldPage.style.left = '0'; setTimeout(() => { newPage.style.transition = `left ${(config.routeAnimationDuration - delay) / 1000}s`; oldPage.style.transition = `left ${(config.routeAnimationDuration - delay) / 1000}s`; newPage.style.opacity = '1'; // 防止頁面閃爍 newPage.style.left = '0'; oldPage.style.left = '-100%'; }, delay); } else { newPage.style.left = '-100%'; oldPage.style.left = '0'; setTimeout(() => { oldPage.style.transition = `left ${(config.routeAnimationDuration - delay) / 1000}s`; newPage.style.transition = `left ${(config.routeAnimationDuration - delay) / 1000}s`; newPage.style.left = '0'; oldPage.style.left = '100%'; newPage.style.opacity = '1'; }, delay); } currentHistoryPosition = historyKeys.indexOf(currentRouterKey); // 記錄當前history.location.key在historyKeys中的位置 lastPathname = history.location.pathname;// 記錄當前pathname作為滾動位置的鍵 }); }));
完成後我們再將路由中的延時配置為當前定義的config.routeAnimationDuration :
export const routes = () => { return ( <Router history={history}> <Route render={(params) => { const { location } = params; return ( <React.Fragment> <TransitionGroup id={'routeWrap'}> <CSSTransition classNames={'router'} timeout={config.routeAnimationDuration} key={location.pathname}> <Switch location={location} key={location.pathname}> <Route path='/' exact={true} component={Page1} /> <Route path='/2' exact={true} component={Page2} /> <Route path='/3' exact={true} component={Page3} /> </Switch> </CSSTransition> </TransitionGroup> </React.Fragment> ); }}/> </Router> ); };
這樣路由動畫就大功告成了。整體沒有特別難的地方,只是對history和css相關的知識要求稍微嚴格了些。
附上本文的完整案例:https://github.com/axel10/react-router-slide-animation