React Router+React-Transition-Group實現頁面左右滑動+滾動位置記憶

2018年12月17日更新:
修復在qq瀏覽器下執行pop跳轉時頁面錯位問題
本文的代碼已封裝為npm包發布: react-slide-animation-router
在React Router中,想要做基於路由的左右滑動,我們首先得搞清楚當發生路由跳轉的時候到底發生了什麼,和路由動畫的原理。
首先我們要先了解一個概念:history。 history原本是內置於瀏覽器內的一個對象,包含了一些關於歷史記錄的一些訊息,但本文要說的history是React-Router中內置的history,每一個路由頁面在props裡都可以訪問到這個對象,它包含了跳轉的動作(action)、觸發跳轉的listen函數、監聽每次跳轉的方法、location對像等。其中的location對象描述了當前頁面的pathname、querystring和表示當前跳轉結果的key屬性。其中key屬性只有在發生跳轉後才會有。
了解完history後,我們再來複習一下react router跳轉的流程。
當沒有使用路由動畫的時候,頁面跳轉的流程是:
用戶發出跳轉指令-> 瀏覽器歷史接到指令,發生改變-> 舊頁面銷毀,新頁面應用到文檔,跳轉完成
當使用了基於React-Transition-Group的路由動畫後,跳轉流程將變為:
用戶發出跳轉指令-> 瀏覽器歷史接到指令,發生改變-> 新頁面插入到舊頁面的同級位置之前-> 等待時間達到在React-Transition-Group中設置的timeout後,舊頁面銷毀,跳轉完成。
當觸發跳轉後,頁面的url發生改變,如果之前有在history的listen方法上註冊過自己的監聽函數,那麼這個函數也將被調用。但是hisory要在組件的props裡才能獲取到,為了能在組件外部也能獲取到history對象,我們就要安裝一個包: https:// github.com/ReactTrainin g/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添加進路由組件:
<Routerhistory={history}><Routerender={(params)=>{const{location}=paramsreturn(<React.Fragment><TransitionGroupid={'routeWrap'}><CSSTransitionclassNames={'router'}timeout={350}key={location.pathname}><Switchlocation={location}key={location.pathname}><Routepath='/'component={Index}/></Switch></CSSTransition></TransitionGroup></React.Fragment>)}}/></Router>
TransitionGroup組件會產生一個div,所以我們將這個div的id設為'routeWrap'以便後續操作。提供給CSSTransition的key的改變將直接決定是否產生路由動畫,所以這裡就用了location中的pathname。如果pathname發生變化則默認產生路由動畫。 (search/querystring不屬於pathname,所以修改了也不會產生動畫。)
為了實現路由左右滑動動畫和滾動位置記憶,本文的思路為:利用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{position:fixed;opacity:0;transition:left1s;}.router-enter-active{position:relative;opacity:0;/*js执行到到timeout函数后再出现,防止页面闪烁*/}.router-exit-active{position:relative;z-index:1000;}
這裡有個問題:為什麼enter的時候新頁面position要設成fixed呢?是因為qq瀏覽器下如果執行history.pop會導致新頁面先撐開文檔再執行listen函數從而導致獲取不到舊頁面的滾動位置。為了在transition group提供的鉤子函數onEnter中獲得舊頁面的滾動位置只能先將enter設為fixed。
然後是最主要的listen函數:
constconfig={routeAnimationDuration:350,};lethistoryKeys:string[]=JSON.parse(sessionStorage.getItem('historyKeys'));//記錄history.location.key的列表。存儲進sessionStorage以防刷新丟失if(!historyKeys){historyKeys=history.location.key?[history.location.key]:[''];}letlastPathname=history.location.pathname;constpositionRecord={};letisAnimating=false;letbodyOverflowX='';letcurrentHistoryPosition=historyKeys.indexOf(history.location.key);//記錄當前頁面的location.key在historyKeys中的位置currentHistoryPosition=currentHistoryPosition===-1?0:currentHistoryPosition;history.listen((()=>{if(lastPathname===history.location.pathname){return;}if(!history.location.key){//目標頁為初始頁historyKeys[0]='';}constdelay=50;//適當的延時以保證動畫生效if(!isAnimating){//如果正在進行路由動畫則不改變之前記錄的bodyOverflowXbodyOverflowX=document.body.style.overflowX;}constoriginPage=document.getElementById('routeWrap').children[0]asHTMLElement;constoPosition=originPage.style.position;setTimeout(()=>{//動畫結束後還原相關屬性document.body.style.overflowX=bodyOverflowX;originPage.style.position=oPosition;isAnimating=false;},config.routeAnimationDuration+delay+50);//多50毫秒確保動畫執行完畢document.body.style.overflowX='hidden';//防止動畫導致橫向滾動條出現if(history.location.state&&history.location.state.noAnimate){//如果指定不要發生路由動畫則讓新頁面直接出現setTimeout(()=>{constwrap=document.getElementById('routeWrap');constnewPage=wrap.children[0]asHTMLElement;constoldPage=wrap.children[1]asHTMLElement;newPage.style.opacity='1';oldPage.style.display='none';});return;}const{action}=history;constcurrentRouterKey=history.location.key?history.location.key:'';constoldScrollTop=window.scrollY;originPage.style.position='fixed';originPage.style.top=-oldScrollTop+'px';//防止頁面滾回頂部setTimeout(()=>{//新頁面已插入到舊頁面之前isAnimating=true;constwrap=document.getElementById('routeWrap');constnewPage=wrap.children[0]asHTMLElement;constoldPage=wrap.children[1]asHTMLElement;if(!newPage||!oldPage){return;}constcurrentPath=history.location.pathname;constisForward=historyKeys[currentHistoryPosition+1]===currentRouterKey;//判斷是否是用戶點擊前進按鈕if(action==='PUSH'||isForward){positionRecord[lastPathname]=oldScrollTop;//根據之前記錄的pathname來記錄舊頁面滾動位置window.scrollTo(0,0);//如果是點擊前進按鈕或者是history.push則滾動位置歸零if(action==='PUSH'){historyKeys=historyKeys.slice(0,currentHistoryPosition+1);historyKeys.push(currentRouterKey);//如果是history.push則清除無用的key}}else{//如果是點擊回退按鈕或者調用history.pop、history.replace則讓頁面滾動到之前記錄的位置window.scrollTo(0,positionRecord[currentPath]);//刪除滾動記錄列表中所有子路由滾動記錄for(constkeyinpositionRecord){if(key===currentPath){continue;}if(key.startsWith(currentPath)){deletepositionRecord[key];}}}if(action==='REPLACE'){//如果為replace則替換當前路由key為新路由keyhistoryKeys[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 :
letcurrentScrollPosition=0constsyncScrollPosition=()=>{//由於x5內核會先撐開文檔再執行listen函數,所以要在onEnter的時候就去獲得滾動條位置。currentScrollPosition=window.scrollY}exportconstroutes=()=>{return(<Routerhistory={history}><Routerender={(params)=>{const{location}=params;return(<React.Fragment><TransitionGroupid={'routeWrap'}><CSSTransitionclassNames={'router'}timeout={config.routeAnimationDuration}key={location.pathname}onEnter={syncScrollPosition}><Switchlocation={location}key={location.pathname}><Routepath='/'exact={true}component={Page1}/><Routepath='/2'exact={true}component={Page2}/><Routepath='/3'exact={true}component={Page3}/></Switch></CSSTransition></TransitionGroup></React.Fragment>);}}/></Router>);};
這樣路由動畫就大功告成了。整體沒有特別難的地方,只是對history和css相關的知識要求稍微嚴格了些。
附上本文的完整案例: axel10/react-router-slide-animation-demo