---
# System prepended metadata

title: 如何在React製作跳轉動畫

---

# 如何在React製作跳轉動畫

:::info
:bulb: 我會將這次分享分為大三部份，盡可能把這幾天學到的做個匯總，有興趣可以再深入了解。
:::



## :book: 關於我在miniproject做的

:::success
我在這次miniproject中，用的方式是利用CSS寫好一個利用==keyframes==做的小動畫，再讓它3秒後fade out。
:::


### 📑 代碼
```jsx=
import $ from 'jquery'; 
import {useState, useEffect} from 'react';
import {useLocation} from 'react-router-dom';
import './LoadingAnimation.css';


const LoadingAnimation = () => {    
    // 當URL改變時useState會回傳一個新的包含目前URL的狀態和位置的物件函數。
    // 每當URL有變更，則useLocation資訊也將更新。
    const location = useLocation();
    
    const [dis, setDis] = useState(true);
    useEffect(() => {
        setTimeout(() => {setDis(false);}, 3000);
    },[location]);
    if(dis === false) $(".loader-wrapper").fadeOut("slow");

    return(
        <div className="loader-wrapper" >
            // span主要是用作inline容器，如果是要包裹一個block的情況，則是使用div。
            <span className="loader">
                <span className="loader-inner"></span>
            </span>
        </div>
    );
}

export default LoadingAnimation;
```
關於CSS的[animation屬性](https://www.oxxostudio.tw/articles/201803/css-animation.html)的補充

```CSS=
.loader-wrapper{
    width: 100%;
    height: 100%;
    position: absolute;
    top: 0;
    left: 0;
    background-color: #242f3f;
    display:flex;
    justify-content: center;
    align-items: center;
}
.loader{
    display: inline-block;
    width: 30px;
    height: 30px;
    position: relative;
    border: 4px solid #Fff;
    animation: loader 2s infinite ease;
}
.loader-inner{
    vertical-align: top;
    display: inline-block;
    width: 100%;
    background-color: #fff;
    animation: loader-inner 2s infinite ease-in;
}
@keyframes loader{
    0% {transform: rotate(0deg);}
    25% {transform: rotate(180deg);}
    50% {transform: rotate(180deg);}
    75% {transform: rotate(360deg);}
    100% {transform: rotate(360deg);}
}
@keyframes loader-inner{
    0% {height: 0%;}
    25% {height: 0%;}
    50% {height: 100%;}
    75% {height: 100%;}
    100% {height: 0%;}
}
```


### ✒️ 實際效果

#### 🖼〈圖一〉我的日程本頁面載入動畫。
![](https://img1.imgtp.com/2023/03/24/ZdG5o2OE.gif)



## :book: react-transition-group轉場動畫

:::success
為甚麼要用==react-transition-group==？
react-transition-group給元素添加的enter，enter-active，exit，exit-active這一系列hook，簡直就是為我們的頁面入場離場而設計的。
:::

當我們使用react-router在路由切換時，是完全沒有過渡效果，而是直接替換的，會顯得非常生硬。  

在介紹實現轉場動畫之前，我們先來講講react-transition-group提供的==CSSTransition==和==TransitionGroup==這兩個組件。


### 💾 CSSTransition
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樣式就行了。
```jsx=
// src/App2/index.js
export default class App1 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>
    );
  }
}
```
```css=
/* 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;
}
```

#### 🖼〈圖二〉用紅色正方形div示範左淡進右淡出。
![](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2019/4/13/16a16e34c05aa03c~tplv-t2oaga2asx-zoom-in-crop-mark:4536:0:0:0.awebp)


### 💾 TransitionGroup
TransitionGroup組件就是用來管理一堆節點==mounting==和==unmounting==過程的組件，非常適合處理我們這裡多個頁面的情況。
```jsx=
// src/App3/index.js
export default class App2 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>
    );
  }
}
```
#### 🖼〈圖三〉用紅色正方形div示範左淡進右淡出。
![](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2019/4/13/16a16e34dec2ae0e~tplv-t2oaga2asx-zoom-in-crop-mark:4536:0:0:0.awebp)

這次CSSTransition沒有in屬性了，而是用到了key屬性。  

我們先來想想一個問題
**react的[dom diff](https://juejin.cn/post/6844904165026562056)機制用到了key屬性，如果前後兩次key不同，react會卸載舊節點，掛載新節點。那為何上面的代碼key變了，舊節點難道不是應該立馬消失嗎？為何我們還能看到它淡出的動畫過程？**

答案就在TransitionGroup身上，它在感知到其children變化時，會先保存住即將要被移除的節點，而在其動畫結束時才會真正移除該節點。

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

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

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

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

<TransitionGroup>
  <div>1</div>
</TransitionGroup>
```
因此我們能夠利用key值的變化來讓TransitionGroup接管我們在過渡時的頁面創建和銷毀工作，而在製作動畫效果時，僅僅需要關注如何選擇合適的key值以及需要什麼樣css樣式來實現就行了。

### :rocket: 頁面轉場動畫

剛剛提到，用了TransitionGroup後我們的重點就放在如何選擇合適的key值。

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

key值的選取還是得根據不同的項目而視。大部分情況下，推薦用==location.pathname==作為key值，因為它恰是我們不同頁面的路由。

```jsx=
// src/App3/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>
    );
  }
}
```

#### 🖼〈圖四〉頁面跳轉右進左出。
![](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2019/4/13/16a16e34ff2602f7~tplv-t2oaga2asx-zoom-in-crop-mark:4536:0:0:0.awebp)

App3的代碼思路跟App2大致相同。

[withRouter](https://stackoverflow.com/questions/69934351/withrouter-is-not-exported-from-react-router-dom)是react-router提供的一個高階組件，可以為你的組件提供location，history等對象。因為我們這裡要用location.pathname作為CSSTransition的key值，所以用到了它。

這邊還有個細節，Switch組件會用這個對象來匹配其children中的路由，而且默認用的就是當前瀏覽器的url。如果在上面的例子中我們不給它[指定](https://www.delftstack.com/zh-tw/howto/react/exact-path-react-router/)，那麼在轉場動畫中會發生很奇怪的現象，就是同時有兩個相同的節點在移動。

#### 🖼〈圖五〉兩個相同的節點在移動。
![](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2019/4/13/16a16e355a80e3b4~tplv-t2oaga2asx-zoom-in-crop-mark:4536:0:0:0.awebp)

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

### :rocket: 頁面動態過渡動畫

雖然那個動畫其實還存在一個嚴重的問題。明明應該是上個頁面從左側淡入，當前頁面從右側淡出。但是為什麼卻變成當前頁面從左側淡出，下個頁面從右側淡入，跟進入下個頁面的效果是一樣的。

要解決這問題其實也很簡單：
我們原本是把路由改變分成forward和back兩種操作。在forward操作時，當前頁面的exit效果是向左淡出；在back操作時，當前頁面的exit效果是向右淡出。所以我們只用fade-exit和fade-exit-active這兩個class，得到的動畫效果肯定是一致的。

因此我們只要用兩套class來分別管理forward和back操作時的動畫效果就可以了。

```css=
/* src/App4/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屬性來指定動畫效果，但其實是無效的。

如何解決呢？我們可以藉助TransitionGroup的ChildFactory屬性以及React.cloneElement方法來強行覆蓋其className。比如：

```jsx=
<TransitionGroup childFactory={child => React.cloneElement(child, {
  classNames: 'your-animation-class-name'
})}>
  <CSSTransition>
    ...
  </CSSTransition>
</TransitionGroup>
```

上述幾個問題都解決之後，剩下的問題就是如何選擇合適的動畫class了。而這個問題的實質在於如何判斷當前路由的改變是forward還是back操作了。好在react-router已經貼心地給我們準備好了，其提供的history對像有一個action屬性，代表當前路由改變的類型，其值是 =='PUSH'== | =='POP'== | =='REPLACE'==。
```jsx=
// src/App4/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>
));
```

#### 🖼〈圖六〉成品。
![](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2019/4/13/16a16e34bf80c976~tplv-t2oaga2asx-zoom-in-crop-mark:4536:0:0:0.awebp)

雖然說是完成了，但還有更多的細節可以學，有興趣可以參考這兩篇：
[文章一](https://juejin.cn/post/6844903818073899022)
[文章二](https://juejin.cn/post/6887471865720209415)

#### 🖼〈圖七〉增加一個可由下而上淡入淡出的頁面。
![](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2019/4/13/16a16e34bfdbd299~tplv-t2oaga2asx-zoom-in-crop-mark:4536:0:0:0.awebp)