一次react-router + react-transition-group實現轉場動畫的探索

SmallStoneSK發表於2019-04-13

原文地址

1. Introduction

在日常開發中,頁面切換時的轉場動畫是比較基礎的一個場景。在react專案當中,我們一般都會選用react-router來管理路由,但是react-router卻並沒有提供相應的轉場動畫功能,而是非常生硬的直接替換掉元件。一定程度上來說,體驗並不是那麼友好。

為了在react中實現動畫效果,其實我們有很多的選擇,比如:react-transition-groupreact-motionAnimated等等。但是,由於react-transition-group給元素新增的enter,enter-active,exit,exit-active這一系列勾子,簡直就是為我們的頁面入場離場而設計的。基於此,本文選擇react-transition-group來實現動畫效果。

接下來,本文就將結合兩者提供一個實現路由轉場動畫的思路,權當拋磚引玉~

2. Requirements

我們先明確要完成的轉場動畫是什麼效果。如下圖所示:

一次react-router + react-transition-group實現轉場動畫的探索 一次react-router + react-transition-group實現轉場動畫的探索

3. react-router

首先,我們先簡要介紹下react-router的基本用法(詳細看官網介紹)。

這裡我們會用到react-router提供的BrowserRouterSwitchRoute三個元件。

  • BrowserRouter:以html5提供的history api形式實現的路由(還有一種hash形式實現的路由)。
  • Switch:多個Route元件同時匹配時,預設都會顯示,但是被Switch包裹起來的Route元件只會顯示第一個被匹配上的路由。
  • Route:路由元件,path指定匹配的路由,component指定路由匹配時展示的元件。
// src/App1/index.js
export default class App1 extends React.PureComponent {
  render() {
    return (
      <BrowserRouter>
        <Switch>
          <Route exact path={'/'} component={HomePage}/>
          <Route exact path={'/about'} component={AboutPage}/>
          <Route exact path={'/list'} component={ListPage}/>
          <Route exact path={'/detail'} component={DetailPage}/>
        </Switch>
      </BrowserRouter>
    );
  }
}
複製程式碼

如上所示,這是路由關鍵的實現部分。我們一共建立了首頁關於頁列表頁詳情頁這四個頁面。跳轉關係為:

  1. 首頁 ↔ 關於頁
  2. 首頁 ↔ 列表頁 ↔ 詳情頁

來看下目前預設的路由切換效果:

一次react-router + react-transition-group實現轉場動畫的探索

4. react-transition-group

從上面的效果圖中,我們可以看到react-router在路由切換時完全沒有過渡效果,而是直接替換的,顯得非常生硬。

正所謂工欲善其事,必先利其器,在介紹實現轉場動畫之前,我們得先學習如何使用react-transition-group。基於此,接下來就將對其提供的CSSTransition和TransitionGroup這兩個元件展開簡要介紹。

4.1 CSSTransition

CSSTransition是react-transition-group提供的一個元件,這裡簡單介紹下其工作原理。

When the in prop is set to true, the child component will first receive the class example-enter, then the example-enter-active will be added in the next tick. CSSTransition forces a reflow between before adding the example-enter-active. This is an important trick because it allows us to transition between example-enter and example-enter-active even though they were added immediately one after another. Most notably, this is what makes it possible for us to animate appearance.

這是來自官網上的一段描述,意思是當CSSTransition的in屬性置為true時,CSSTransition首先會給其子元件加上xxx-enter的class,然後在下個tick時馬上加上xxx-enter-active的class。所以我們可以利用這一點,通過css的transition屬性,讓元素在兩個狀態之間平滑過渡,從而得到相應的動畫效果。

相反地,當in屬性置為false時,CSSTransition會給子元件加上xxx-exit和xxx-exit-active的class。(更多詳細介紹可以戳官網檢視)

基於以上兩點,我們是不是隻要事先寫好class對應的css樣式即可?可以做個小demo試試,如下程式碼所示:

// src/App2/index.js
export default class App2 extends React.PureComponent {

  state = {show: true};

  onToggle = () => this.setState({show: !this.state.show});

  render() {
    const {show} = this.state;
    return (
      <div className={'container'}>
        <div className={'square-wrapper'}>
          <CSSTransition
            in={show}
            timeout={500}
            classNames={'fade'}
            unmountOnExit={true}
          >
            <div className={'square'} />
          </CSSTransition>
        </div>
        <Button onClick={this.onToggle}>toggle</Button>
      </div>
    );
  }
}
複製程式碼
/* src/App2/index.css */
.fade-enter {
  opacity: 0;
  transform: translateX(100%);
}

.fade-enter-active {
  opacity: 1;
  transform: translateX(0);
  transition: all 500ms;
}

.fade-exit {
  opacity: 1;
  transform: translateX(0);
}

.fade-exit-active {
  opacity: 0;
  transform: translateX(-100%);
  transition: all 500ms;
}
複製程式碼

來看看效果,是不是和頁面的入場離場效果有點相似?

一次react-router + react-transition-group實現轉場動畫的探索

4.2 TransitionGroup

用CSSTransition來處理動畫固然很方便,但是直接用來管理多個頁面的動畫還是略顯單薄。為此我們再來介紹react-transition-group提供的TransitionGroup這個元件。

The component manages a set of transition components ( and ) in a list. Like with the transition components, is a state machine for managing the mounting and unmounting of components over time.

如官網介紹,TransitionGroup元件就是用來管理一堆節點mounting和unmounting過程的元件,非常適合處理我們這裡多個頁面的情況。這麼介紹似乎有點難懂,那就讓我們來看段程式碼,解釋下TransitionGroup的工作原理。

// src/App3/index.js
export default class App3 extends React.PureComponent {

  state = {num: 0};

  onToggle = () => this.setState({num: (this.state.num + 1) % 2});

  render() {
    const {num} = this.state;
    return (
      <div className={'container'}>
        <TransitionGroup className={'square-wrapper'}>
          <CSSTransition
            key={num}
            timeout={500}
            classNames={'fade'}
          >
            <div className={'square'}>{num}</div>
          </CSSTransition>
        </TransitionGroup>
        <Button onClick={this.onToggle}>toggle</Button>
      </div>
    );
  }
}
複製程式碼

我們先來看效果,然後再做解釋:

一次react-router + react-transition-group實現轉場動畫的探索

對比App3和App2的程式碼,我們可以發現這次CSSTransition沒有in屬性了,而是用到了key屬性。但是為什麼仍然可以正常工作呢?

在回答這個問題之前,我們先來思考一個問題:

由於react的dom diff機制用到了key屬性,如果前後兩次key不同,react會解除安裝舊節點,掛載新節點。那麼在上面的程式碼中,由於key變了,舊節點難道不是應該立馬消失,但是為什麼我們還能看到它淡出的動畫過程呢?

關鍵就出在TransitionGroup身上,因為它在感知到其children變化時,會先儲存住即將要被移除的節點,而在其動畫結束時才會真正移除該節點。

所以在上面的例子中,當我們按下toggle按鈕時,變化的過程可以這樣理解:

<TransitionGroup>
  <div>0</div>
</TransitionGroup>

⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️

<TransitionGroup>
  <div>0</div>
  <div>1</div>
</TransitionGroup>

⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️

<TransitionGroup>
  <div>1</div>
</TransitionGroup>
複製程式碼

如上所解釋,我們完全可以巧妙地借用key值的變化來讓TransitionGroup來接管我們在過渡時的頁面建立和銷燬工作,而僅僅需要關注如何選擇合適的key值和需要什麼樣css樣式來實現動畫效果就可以了。

5. Page transition animation

基於前文對react-router和react-transition-group的介紹,我們已經掌握了基礎,接下來就可以將兩者結合起來做頁面切換的轉場動畫了。

在上一小節的末尾有提到,用了TransitionGroup之後我們的問題變成如何選擇合適的key值。那麼在路由系統中,什麼作為key值比較合適呢?

既然我們是在頁面切換的時候觸發轉場動畫,自然是跟路由相關的值作為key值合適了。而react-router中的location物件就有一個key屬性,它會隨著瀏覽器中的地址發生變化而變化。然而,在實際場景中似乎並不適合,因為query引數或者hash變化也會導致location.key發生變化,但往往這些場景下並不需要觸發轉場動畫。

因此,個人覺得key值的選取還是得根據不同的專案而視。大部分情況下,還是推薦用location.pathname作為key值比較合適,因為它恰是我們不同頁面的路由。

說了這麼多,還是看看具體的程式碼是如何將react-transition-group應用到react-router上的吧:

// src/App4/index.js
const Routes = withRouter(({location}) => (
  <TransitionGroup className={'router-wrapper'}>
    <CSSTransition
      timeout={5000}
      classNames={'fade'}
      key={location.pathname}
    >
      <Switch location={location}>
        <Route exact path={'/'} component={HomePage} />
        <Route exact path={'/about'} component={AboutPage} />
        <Route exact path={'/list'} component={ListPage} />
        <Route exact path={'/detail'} component={DetailPage} />
      </Switch>
    </CSSTransition>
  </TransitionGroup>
));

export default class App4 extends React.PureComponent {
  render() {
    return (
      <BrowserRouter>
        <Routes/>
      </BrowserRouter>
    );
  }
}
複製程式碼

這是效果:

一次react-router + react-transition-group實現轉場動畫的探索

App4的程式碼思路跟App3大致相同,只是將原來的div換成了Switch元件,而且還用到了withRouter。

withRouter是react-router提供的一個高階元件,可以為你的元件提供location,history等物件。因為我們這裡要用location.pathname作為CSSTransition的key值,所以用到了它。

另外,這裡有一個坑,就是Switch的location屬性。

A location object to be used for matching children elements instead of the current history location (usually the current browser URL).

這是官網中的描述,意思就是Switch元件會用這個物件來匹配其children中的路由,而且預設用的就是當前瀏覽器的url。如果在上面的例子中我們不給它指定,那麼在轉場動畫中會發生很奇怪的現象,就是同時有兩個相同的節點在移動。。。就像下面這樣:

一次react-router + react-transition-group實現轉場動畫的探索

這是因為TransitionGroup元件雖然會保留即將被remove的Switch節點,但是當location變化時,舊的Switch節點會用變化後的location去匹配其children中的路由。由於location都是最新的,所以兩個Switch匹配出來的頁面是相同的。好在我們可以改變Switch的location屬性,如上述程式碼所示,這樣它就不會總是用當前的location匹配了。

6. Page dynamic transition animation

雖然前文用react-transition-group和react-router實現了一個簡單的轉場動畫,但是卻存在一個嚴重的問題。仔細觀察上一小節的示意圖,不難發現我們的進入下個頁面的動畫效果是符合預期的,但是後退的動畫效果是什麼鬼。。。明明應該是上個頁面從左側淡入,當前頁面從右側淡出。但是為什麼卻變成當前頁面從左側淡出,下個頁面從右側淡入,跟進入下個頁面的效果是一樣的。其實錯誤的原因很簡單:

首先,我們把路由改變分成forward和back兩種操作。在forward操作時,當前頁面的exit效果是向左淡出;在back操作時,當前頁面的exit效果是向右淡出。所以我們只用fade-exit和fade-exit-active這兩個class,很顯然,得到的動畫效果肯定是一致的。

因此,解決方案也很簡單,我們用兩套class來分別管理forward和back操作時的動畫效果就可以了。

/* src/App5/index.css */

/* 路由前進時的入場/離場動畫 */
.forward-enter {
  opacity: 0;
  transform: translateX(100%);
}

.forward-enter-active {
  opacity: 1;
  transform: translateX(0);
  transition: all 500ms;
}

.forward-exit {
  opacity: 1;
  transform: translateX(0);
}

.forward-exit-active {
  opacity: 0;
  transform: translateX(-100%);
  transition: all 500ms;
}

/* 路由後退時的入場/離場動畫 */
.back-enter {
  opacity: 0;
  transform: translateX(-100%);
}

.back-enter-active {
  opacity: 1;
  transform: translateX(0);
  transition: all 500ms;
}

.back-exit {
  opacity: 1;
  transform: translateX(0);
}

.back-exit-active {
  opacity: 0;
  transform: translate(100%);
  transition: all 500ms;
}
複製程式碼

不過光有css的支援還不行,我們還得在不同的路由操作時加上合適的class才行。那麼問題又來了,在TransitionGroup的管理下,一旦某個元件掛載後,其exit動畫其實就已經確定了,可以看官網上的這個issue。也就是說,就算我們動態地給CSSTransition新增不同的ClassNames屬性來指定動畫效果,但其實是無效的。

解決方案其實在那個issue的下面就給出了,我們可以藉助TransitionGroup的ChildFactory屬性以及React.cloneElement方法來強行覆蓋其className。比如:

<TransitionGroup childFactory={child => React.cloneElement(child, {
  classNames: 'your-animation-class-name'
})}>
  <CSSTransition>
    ...
  </CSSTransition>
</TransitionGroup>
複製程式碼

上述幾個問題都解決之後,剩下的問題就是如何選擇合適的動畫class了。而這個問題的實質在於如何判斷當前路由的改變是forward還是back操作了。好在react-router已經貼心地給我們準備好了,其提供的history物件有一個action屬性,代表當前路由改變的型別,其值是'PUSH' | 'POP' | 'REPLACE'。所以,我們再調整下程式碼:

// src/App5/index.js
const ANIMATION_MAP = {
  PUSH: 'forward',
  POP: 'back'
}

const Routes = withRouter(({location, history}) => (
  <TransitionGroup
    className={'router-wrapper'}
    childFactory={child => React.cloneElement(
      child,
      {classNames: ANIMATION_MAP[history.action]}
    )}
  >
    <CSSTransition
      timeout={500}
      key={location.pathname}
    >
      <Switch location={location}>
        <Route exact path={'/'} component={HomePage} />
        <Route exact path={'/about'} component={AboutPage} />
        <Route exact path={'/list'} component={ListPage} />
        <Route exact path={'/detail'} component={DetailPage} />
      </Switch>
    </CSSTransition>
  </TransitionGroup>
));
複製程式碼

再來看下修改之後的動畫效果:

一次react-router + react-transition-group實現轉場動畫的探索

7. Optimize

其實,本節的內容算不上優化,轉場動畫的思路到這裡基本上已經結束了,你可以腦洞大開,通過新增css來實現更炫酷的轉場動畫。不過,這裡還是想再講下如何將我們的路由寫得更配置化(個人喜好,不喜勿噴)。

我們知道,react-router在升級v4的時候,做了一次大改版。更加推崇動態路由,而非靜態路由。不過具體問題具體分析,在一些專案中個人還是喜歡將路由集中化管理,就上面的例子而言希望能有一個RouteConfig,就像下面這樣:

// src/App6/RouteConfig.js
export const RouterConfig = [
  {
    path: '/',
    component: HomePage
  },
  {
    path: '/about',
    component: AboutPage,
    sceneConfig: {
      enter: 'from-bottom',
      exit: 'to-bottom'
    }
  },
  {
    path: '/list',
    component: ListPage,
    sceneConfig: {
      enter: 'from-right',
      exit: 'to-right'
    }
  },
  {
    path: '/detail',
    component: DetailPage,
    sceneConfig: {
      enter: 'from-right',
      exit: 'to-right'
    }
  }
];
複製程式碼

透過上面的RouterConfig,我們可以清晰的知道每個頁面所對應的元件是哪個,而且還可以知道其轉場動畫效果是什麼,比如關於頁面是從底部進入頁面的,列表頁詳情頁都是從右側進入頁面的。總而言之,我們通過這個靜態路由配置表可以直接獲取到很多有用的資訊,而不需要深入到程式碼中去獲取資訊。

那麼,對於上面的這個需求,我們對應的路由程式碼需要如何調整呢?請看下面:

// src/App6/index.js
const DEFAULT_SCENE_CONFIG = {
  enter: 'from-right',
  exit: 'to-exit'
};

const getSceneConfig = location => {
  const matchedRoute = RouterConfig.find(config => new RegExp(`^${config.path}$`).test(location.pathname));
  return (matchedRoute && matchedRoute.sceneConfig) || DEFAULT_SCENE_CONFIG;
};

let oldLocation = null;
const Routes = withRouter(({location, history}) => {

  // 轉場動畫應該都是採用當前頁面的sceneConfig,所以:
  // push操作時,用新location匹配的路由sceneConfig
  // pop操作時,用舊location匹配的路由sceneConfig
  let classNames = '';
  if(history.action === 'PUSH') {
    classNames = 'forward-' + getSceneConfig(location).enter;
  } else if(history.action === 'POP' && oldLocation) {
    classNames = 'back-' + getSceneConfig(oldLocation).exit;
  }

  // 更新舊location
  oldLocation = location;

  return (
    <TransitionGroup
      className={'router-wrapper'}
      childFactory={child => React.cloneElement(child, {classNames})}
    >
      <CSSTransition timeout={500} key={location.pathname}>
        <Switch location={location}>
          {RouterConfig.map((config, index) => (
            <Route exact key={index} {...config}/>
          ))}
        </Switch>
      </CSSTransition>
    </TransitionGroup>
  );
});
複製程式碼

由於css程式碼有點多,這裡就不貼了,不過無非就是相應的轉場動畫配置,完整的程式碼可以看github上的倉庫。我們來看下目前的效果:

一次react-router + react-transition-group實現轉場動畫的探索

8. Summarize

本文先簡單介紹了react-router和react-transition-group的基本使用方法;其中還分析了利用CSSTransition和TransitionGroup製作動畫的工作原理;接著又將react-router和react-transition-group兩者結合在一起完成一次轉場動畫的嘗試;並利用TransitionGroup的childFactory屬性解決了動態轉場動畫的問題;最後將路由配置化,實現路由的統一管理以及動畫的配置化,完成一次react-router + react-transition-group實現轉場動畫的探索。

9. Reference

  1. A shallow dive into router v4 animated transitions
  2. Dynamic transitions with react router and react transition group
  3. Issue#182 of react-transition-group
  4. StackOverflow: react-transition-group and react clone element do not send updated props

本文所有程式碼託管在這兒,如果覺得不錯的,可以給個star

相關文章