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


2018年12月17日更新:

修復在qq瀏覽器下執行pop跳轉時頁面錯位問題

本文的代碼已封裝為npm包發布:react-slide-animation-router

 

React Router中,想要做基於路由的左右滑動,我們首先得搞清楚當發生路由跳轉的時候到底發生了什么,和路由動畫的原理。

 

首先我們要先了解一個概念:historyhistory原本是內置於瀏覽器內的一個對象,包含了一些關於歷史記錄的一些信息,但本文要說的historyReact-Router中內置的history,每一個路由頁面在props里都可以訪問到這個對象,它包含了跳轉的動作(action)、觸發跳轉的listen函數、監聽每次跳轉的方法、location對象等。其中的location對象描述了當前頁面的pathnamequerystring和表示當前跳轉結果的key屬性。其中key屬性只有在發生跳轉后才會有。

 

了解完history后,我們再來復習一下react router跳轉的流程。

當沒有使用路由動畫的時候,頁面跳轉的流程是:

用戶發出跳轉指令 -> 瀏覽器歷史接到指令,發生改變 -> 舊頁面銷毀,新頁面應用到文檔,跳轉完成

當使用了基於React-Transition-Group的路由動畫后,跳轉流程將變為:

用戶發出跳轉指令 -> 瀏覽器歷史接到指令,發生改變 -> 新頁面插入到舊頁面的同級位置之前 -> 等待時間達到在React-Transition-Group中設置的timeout后,舊頁面銷毀,跳轉完成。

當觸發跳轉后,頁面的url發生改變,如果之前有在historylisten方法上注冊過自己的監聽函數,那么這個函數也將被調用。但是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提供的延時和enterexit類名,和之前注冊的listen函數。

本文提供的左右滑動思路為:判斷跳轉action,如果是push,則一律為當前頁面左滑離開屏幕,新頁面從右到左進入屏幕,如果是replace則一律為當前頁面右滑,新頁面自左向右進入。如果是pop則要判斷是用戶點擊瀏覽器前進按鈕還是返回按鈕,還是調用了history.pop

由於無論用戶點擊瀏覽器的前進按鈕或是后退按鈕,在history.listen中獲得的action都將為pop,而react router也沒有提供相應的api,所以只能由開發者借助locationkey自行判斷。如果用戶先點擊瀏覽器返回按鈕,再點擊前進按鈕,我們就會獲得一個和之前相同的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,所以我們將這個divid設為'routeWrap'以便后續操作。提供給CSSTransitionkey的改變將直接決定是否產生路由動畫,所以這里就用了location中的key

為了實現路由左右滑動動畫和滾動位置記憶,本文的思路為:利用history.listen,在發生動畫時當前頁面position設置為fixedtop設置為當前頁面的滾動位置,通過transitionleft進行左滑/右滑,新頁面position設置為relative,也是通過transitionleft進行滑動進入頁面。所有動畫均記錄location.key到一個數組里,根據新的key和數組中的key並結合action判斷是左滑還是右滑。並且根據location.pathname記錄就頁面的滾動位置,當返回到舊頁面時滾動到原先的位置。

先對思路中一些不太好理解的地方先解釋一下:

Q:為什么當前頁面的position要設置為fixedtop

A:是為了讓當前頁面立即脫離文檔流,使其不影響滾動條,設置top是為了防止頁面因positionfixed而滾回頂部。

Q:為什么新頁面的position要設置為relative

A:是為了撐開頁面並出現滾動條。如果新頁面的高度足以出現滾動條卻將position設置為fixed或者absolute的話將導致滾動條不出現,即無法滾動。從而無法讓頁面滾動到之前記錄的位置。

Q:為什么不用transform而要使用left來作為動畫屬性?

A:因為transform會導致頁面內positionfixed的元素轉變為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>
  );
};

 

這樣路由動畫就大功告成了。整體沒有特別難的地方,只是對historycss相關的知識要求稍微嚴格了些。

附上本文的完整案例:https://github.com/axel10/react-router-slide-animation-demo

 
       


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM