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/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{ position: fixed; opacity: 0; transition : left 1s; } .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函數:
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 (lastPathname === history.location.pathname) { return; }
if (!history.location.key) { // 目標頁為初始頁
historyKeys[0] = '';
}
const delay = 50; // 適當的延時以保證動畫生效
if (!isAnimating) { // 如果正在進行路由動畫則不改變之前記錄的bodyOverflowX
bodyOverflowX = document.body.style.overflowX;
}
const routerWrap = document.getElementById(wrapId);
const originPage = routerWrap.children[routerWrap.children.length - 1] as HTMLElement;
const oPosition = originPage.style.position;
setTimeout(() => { // 動畫結束后還原相關屬性
document.body.style.overflowX = bodyOverflowX;
originPage.style.position = oPosition;
isAnimating = false;
}, routeAnimationDuration + delay + 50); // 多50毫秒確保動畫執行完畢
document.body.style.overflowX = 'hidden'; // 防止動畫導致橫向滾動條出現
if (history.location.state && history.location.state.noAnimate) { // 如果指定不要發生路由動畫則讓新頁面直接出現
setTimeout(() => {
const wrap = document.getElementById(wrapId);
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;
originPage.style.top = -oldScrollTop + 'px'; // 防止頁面滾回頂部
originPage.style.position = 'fixed';
setTimeout(() => { // 新頁面已插入到舊頁面之前
isAnimating = true;
const wrap = document.getElementById(wrapId);
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(0, 0); // 如果是點擊前進按鈕或者是history.push則滾動位置歸零
if (action === 'PUSH') {
historyKeys = historyKeys.slice(0, currentHistoryPosition + 1);
historyKeys.push(currentRouterKey); // 如果是history.push則清除無用的key
}
} else {
if (isRememberPosition) {
setTimeout(() => {
window.scrollTo(0, positionRecord[currentPath]); // 滾動到之前記錄的位置
console.log('scrollto' + positionRecord[currentPath]);
}, 50);
}
// 刪除滾動記錄列表中所有子路由滾動記錄
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';
newPage.style.transition = `left ${(routeAnimationDuration - delay) / 1000}s`;
newPage.style.webkitTransition = `left ${(routeAnimationDuration - delay) / 1000}s`;
oldPage.style.transition = `left ${(routeAnimationDuration - delay) / 1000}s`;
oldPage.style.webkitTransition = `left ${(routeAnimationDuration - delay) / 1000}s`;
setTimeout(() => {
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 ${(routeAnimationDuration - delay) / 1000}s`;
oldPage.style.webkitTransition = `left ${(routeAnimationDuration - delay) / 1000}s`;
newPage.style.transition = `left ${(routeAnimationDuration - delay) / 1000}s`;
newPage.style.webkitTransition = `left ${(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作為滾動位置的鍵
}, 50);
dPage.style.transition = `left ${(routeAnimationDuration - delay) / 1000}s`;
oldPage.style.webkitTransition = `left ${(routeAnimationDuration - delay) / 1000}s`;
setTimeout(() => {
newPage.style.opacity = '1'; // 防止頁面閃爍
newPage.style.left = '0';
oldPage.style.left = '-100%';
console.log(newPage.style.left);
console.log(oldPage.style.left);
}, delay);
} else {
newPage.style.left = '-100%';
oldPage.style.left = '0';
setTimeout(() => {
oldPage.style.transition = `left ${(routeAnimationDuration - delay) / 1000}s`;
oldPage.style.webkitTransition = `left ${(routeAnimationDuration - delay) / 1000}s`;
newPage.style.transition = `left ${(routeAnimationDuration - delay) / 1000}s`;
newPage.style.webkitTransition = `left ${(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 :
let currentScrollPosition = 0 const syncScrollPosition = () => { // 由於x5內核會先撐開文檔再執行listen函數,所以要在onEnter的時候就去獲得滾動條位置。 currentScrollPosition = window.scrollY } 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} onEnter={syncScrollPosition}> <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-demo