React 中常見動畫的實現方式

有贊前端發表於2018-01-10

現在,使用者對於前端頁面的要求已經不能滿足於實現功能,更要有顏值,有趣味。除了整體 UI 的美觀,在合適的地方新增合適的動畫效果往往比靜態頁面更具有表現力,達到更自然的效果。比如,一個簡單的 loading 動畫或者頁面切換效果不僅能緩解使用者的等待情緒,甚至通過使用品牌 logo 等形式,默默達到品牌宣傳的效果。

React 作為最近幾年比較流行的前端開發框架,提出了虛擬 DOM 概念,所有 DOM 的變化都先發生在虛擬 DOM 上,通過 DOM diff 來分析網頁的實際變化,然後反映在真實 DOM 上,從而極大地提升網頁效能。然而,在動畫實現方面,React 作為框架並不會直接給元件提供動畫效果,需要開發者自行實現,而傳統 web 動畫大多數都通過直接操作實際 DOM 元素來實現,這在 React 中顯然是不被提倡的。那麼,在 React 中動畫都是如何實現的呢?

所有動畫的本質都是連續修改 DOM 元素的一個或者多個屬性,使其產生連貫的變化效果,從而形成動畫。在 React 中實現動畫本質上與傳統 web 動畫一樣,仍然是兩種方式: 通過 css3 動畫實現和通過 js 修改元素屬性。只不過在具體實現時,要更為符合 React 的框架特性,可以概括為幾類:

  1. 基於定時器或 requestAnimationFrame(RAF) 的間隔動畫;
  2. 基於 css3 的簡單動畫;
  3. React 動畫外掛 CssTransitionGroup
  4. 結合 hook 實現複雜動畫;
  5. 其他第三方動畫庫。

一、基於定時器或 RAF 的間隔動畫

最早,動畫的實現都是依靠定時器 setIntervalsetTimeout 或者 requestAnimationFrame(RAF) 直接修改 DOM 元素的屬性。不熟悉 React 特性的開發者可能會習慣性地通過 ref 或者 findDOMNode() 獲取真實的 DOM 節點,直接修改其樣式。然而,通過 ref 直接獲取真實 DOM 並對其操作是是不被提倡使用,應當儘量避免這種操作。

因此,我們需要將定時器或者 RAF 等方法與 DOM 節點屬性通過 state 聯絡起來。首先,需要提取出與變化樣式相關的屬性,替換為 state,然後在合適的生命週期函式中新增定時器或者 requestAnimationFrame 不斷修改 state,觸發元件更新,從而實現動畫效果。

示例

以一個進度條為例,程式碼如下所示:

// 使用requestAnimationFrame改變state
import React, { Component } from 'react';

export default class Progress extends Component {
    constructor(props) {
        super(props);
        this.state = {
            percent: 10
        };
    }

    increase = () => {
        const percent = this.state.percent;
        const targetPercent = percent >= 90 ? 100 : percent + 10;
        const speed = (targetPercent - percent) / 400;
        let start = null;
        const animate = timestamp => {
            if (!start) start = timestamp;
            const progress = timestamp - start;
            const currentProgress = Math.min(parseInt(speed * progress + percent, 10), targetPercent);
            this.setState({
                percent: currentProgress
            });
            if (currentProgress < targetPercent) {
                window.requestAnimationFrame(animate);
            }
        };
        window.requestAnimationFrame(animate);
    }

    decrease = () => {
        const percent = this.state.percent;
        const targetPercent = percent < 10 ? 0 : percent - 10;
        const speed = (percent - targetPercent) / 400;
        let start = null;
        const animate = timestamp => {
            if (!start) start = timestamp;
            const progress = timestamp - start;
            const currentProgress = Math.max(parseInt(percent - speed * progress, 10), targetPercent);
            this.setState({
                    percent: currentProgress
                });
            if (currentProgress > targetPercent) {
                window.requestAnimationFrame(animate);
            }
        };
        window.requestAnimationFrame(animate);
    }

    render() {
        const { percent } = this.state;

        return (
            <div>
                <div className="progress">
                    <div className="progress-wrapper" >
                        <div className="progress-inner" style = {{width: `${percent}%`}} ></div>
                    </div>
                    <div className="progress-info" >{percent}%</div>
                </div>
                <div className="btns">
                    <button onClick={this.decrease}>-</button>
                    <button onClick={this.increase}>+</button>
                </div>
            </div>
        );
    }
}
複製程式碼

在示例中,我們在 increasedecrease 函式中構建線性過渡函式 animationrequestAnimationFrame 在瀏覽器每次重繪前執行會執行過渡函式,計算當前進度條width 屬性並更新該 state,使得進度條重新渲染。該示例的效果如下所示:

RAF實現進度條效果

這種實現方式在使用 requestAnimationFrame 時效能不錯,完全使用純 js 實現,不依賴於 css,使用定時器時可能出現掉幀卡頓現象。此外,還需要開發者根據速度函式自己計算狀態,比較複雜。

二、基於 css3 的簡單動畫

當 css3 中的 animationtransition 出現和普及後,我們可以輕鬆地利用 css 實現元素樣式的變化,而不用通過人為計算實時樣式。

示例

我們仍以上面的進度條為例,使用 css3 實現進度條動態效果,程式碼如下所示:

import React, { Component } from 'react';

export default class Progress extends Component {
    constructor(props) {
        super(props);
        this.state = {
            percent: 10
        };
    }

    increase = () => {
        const percent = this.state.percent + 10;
        this.setState({
            percent: percent > 100 ? 100 : percent,
        })
    }

    decrease = () => {
        const percent = this.state.percent - 10;
        this.setState({
            percent: percent < 0 ? 0 : percent,
        })
    }

    render() {
        // 同上例, 省略
        ....
    }
}
複製程式碼
.progress-inner {
  transition: width 400ms cubic-bezier(0.08, 0.82, 0.17, 1);
  // 其他樣式同上,省略
  ...
}
複製程式碼

在示例中,increasedecrease 函式中不再計算 width,而是直接設定增減後的寬度。需要注意的是,在 css 樣式中設定了 transition 屬性,當 width 屬性發生變化時自動實現樣式的動態變化效果,並且可以設定不同的速度效果的速度曲線。該示例的效果如下圖所示,可以發現,與上一個例子不同的是,右側的進度資料是直接變化為目標數字,沒有具體的變化過程,而進度條的動態效果因為不再是線性變化,效果更為生動。

進度條效果

基於 css3 的實現方式具有較高的效能,程式碼量少,但是隻能依賴於 css 效果,對於複雜動畫也很難實現。此外,通過修改 state 實現動畫效果,只能作用於已經存在於 DOM 樹中的節點。如果想用這種方式為元件新增入場和離場動畫,需要維持至少兩個 state 來實現入場和離場動畫,其中一個 state 用於控制元素是否顯示,另一個 state 用於控制元素在動畫中的變化屬性。在這種情況下,開發者需要花費大量精力來維護元件的動畫邏輯,十分複雜繁瑣。

三、React 動畫外掛 CssTransitionGroup

React 曾為開發者提供過動畫外掛 react-addons-css-transition-group,後交由社群維護,形成現在的 react-transition-group,該外掛可以方便地實現元件的入場和離場動畫,使用時需要開發者額外安裝。react-transition-group 包含 CSSTransitionGroupTransitionGroup 兩個動畫外掛,其中,後者是底層 api,前者是後者的進一步封裝,可以較為便捷地實現 css 動畫。

示例

以一個動態增加tab的為例,程式碼如下:

import React, { Component } from 'react';
import { CSSTransitionGroup } from 'react-transition-group';

let uid = 2;
export default class Tabs extends Component {
    constructor(props) {
        super(props);
        this.state = {
            activeId: 1,
            tabData: [{
                id: 1,
                panel: '選項1'
            }, {
                id: 2,
                panel: '選項2'
            }]
        };
    }

    addTab = () => {
        // 新增tab程式碼
        ...
    }

    deleteTab = (id) => {
        // 刪除tab程式碼
        ...
    }

    render() {
        const { tabData, activeId } = this.state;

        const renderTabs = () => {
            return tabData.map((item, index) => {
                return (
                    <div
                        className={`tab-item${item.id === activeId ? ' tab-item-active' : ''}`}
                        key={`tab${item.id}`}
                    >
                        {item.panel}
                        <span className="btns btn-delete" onClick={() => this.deleteTab(item.id)}>✕</span>
                    </div>
                );
            })
        }

        return (
            <div>
                <div className="tabs" >
                    <CSSTransitionGroup
                      transitionName="tabs-wrap"
                      transitionEnterTimeout={500}
                      transitionLeaveTimeout={500}
                    >
                      {renderTabs()}
                    </CSSTransitionGroup>
                    <span className="btns btn-add" onClick={this.addTab}>+</span>
                </div>
                <div className="tab-cont">
                    cont
                </div>
            </div>
        );
    }
}
複製程式碼
/* tab動態增加動畫 */
.tabs-wrap-enter {
  opacity: 0.01;
}

.tabs-wrap-enter.tabs-wrap-enter-active {
  opacity: 1;
  transition: all 500ms ease-in;
}

.tabs-wrap-leave {
  opacity: 1;
}

.tabs-wrap-leave.tabs-wrap-leave-active {
  opacity: 0.01;
  transition: all 500ms ease-in;
}
複製程式碼

CSSTransitionGroup 可以為其子節點新增額外的 css 類,然後通過 css 動畫達到入場和離場動畫效果。為了給每個 tab 節點新增動畫效果,需要先將它們包裹在 CSSTransitionGroup 元件中。 當設定 transitionName 屬性為 'tabs-wrapper'transitionEnterTimeout 為400毫秒後,一旦 CSSTransitionGroup 中新增節點,該新增節點會在出現時被新增上 css 類 'tabs-wrapper-enter',然後在下一幀時被新增上 css 類 'tabs-wrapper-enter-active'。由於這兩個 css 類中設定了不同的透明度和 css3 transition 屬性,所以節點實現了透明度由小到大的入場效果。400毫秒後 css 類 'tabs-wrapper-enter''tabs-wrapper-enter-active' 將會同時被移除,節點完成整個入場動畫過程。離場動畫的實現類似於入場動畫,只不過被新增的 css 類名為 'tabs-wrapper-leave''tabs-wrapper-leave-active'。該示例效果如下圖所示:

動態增加tab效果

CSSTransitionGroup 支援以下7個屬性:

React 中常見動畫的實現方式

其中,入場和離場動畫是預設開啟的,使用時需要設定 transitionEnterTimeouttransitionLeaveTimeout。值得注意的是,CSSTransitionGroup 還提供出現動畫(appear),使用時需要設定 transitionAppearTimeout。那麼,出現動畫和入場動畫有什麼區別呢?當設定 transitionAppeartrue 時,CSSTransitionGroup初次渲染時,會新增一個出現階段。在該階段中,CSSTransitionGroup 的已有子節點都會被相繼新增 css 類 'tabs-wrapper-appear''tabs-wrapper-appear-active',實現出現動畫效果。因此,出現動畫僅適用於 CSSTransitionGroup 在初次渲染時就存在的子節點,一旦 CSSTransitionGroup 完成渲染,其子節點就只可能有入場動畫(enter),不可能有出現動畫(appear)。

此外,使用 CSSTransitionGroup 需要注意以下幾點:

  • CSSTransitionGroup 預設在 DOM 樹中生成一個 span 標籤包裹其子節點,如果想要使用其他 html 標籤,可設定 CSSTransitionGroupcomponent 屬性;
  • CSSTransitionGroup 的子元素必須新增 key 值才會在節點發生變化時,準確地計算出哪些節點需要新增入場動畫,哪些節點需要新增離場動畫;
  • CSSTransitionGroup 的動畫效果只作用於直接子節點,不作用於其孫子節點;
  • 動畫的結束時間不以 css 中 transition-duration 為準,而是以 transitionEnterTimeouttransitionLeaveTimeoutTransitionAppearTimeout 為準,因為某些情況下 transitionend 事件不會被觸發,詳見MDN transitionend

CSSTransitionGroup 實現動畫的優點是:

  • 簡單易用,可以方便快捷地實現元素的入場和離場動畫;
  • 與 React 結合,效能比較好。

CSSTransitionGroup 缺點也十分明顯:

  • 侷限於出現動畫,入場動畫和離場動畫;
  • 由於需要制定 transitionName,靈活性不夠;
  • 只能依靠 css 實現簡單的動畫。

四、結合 hook 實現複雜動畫

在實際專案中,可能需要一些更炫酷的動畫效果,這些效果僅依賴於 css3 往往較難實現。此時,我們不妨藉助一些成熟的第三方庫,如 jQuery 或 GASP,結合 React 元件中的生命週期鉤子方法 hook 函式,實現複雜動畫效果。除了 React 元件正常的生命週期外,CSSTransitionGroup 的底層 api TransitonGroup 還為其子元素額外提供了一系列特殊的生命週期 hook 函式,在這些 hook 函式中結合第三方動畫庫可以實現豐富的入場、離場動畫效果。

TransisitonGroup 分別提供一下六個生命週期 hook 函式:

  • componentWillAppear(callback)
  • componentDidAppear()
  • componentWillEnter(callback)
  • componentDidEnter()
  • componentWillLeave(callback)
  • componentDidLeave()

它們的觸發時機如圖所示:

TransitionGroup元件生命週期與自元件生命週期的關係

示例

GASP 是一個 flash 時代發展至今的動畫庫,借鑑視訊幀的概念,特別適合做長時間的序列動畫效果。本文中,我們用 TransitonGroupreact-gsap-enhancer(一個可以將 GSAP 應用於 React 的增強庫)完成一個圖片畫廊,程式碼如下:

import React, { Component } from 'react';
import { TransitionGroup } from 'react-transition-group';
import GSAP from 'react-gsap-enhancer'
import { TimelineMax, Back, Sine } from 'gsap';

class Photo extends Component {
    constructor(props) {
        super(props);
    }

    componentWillEnter(callback) {
        this.addAnimation(this.enterAnim, {callback: callback})
    }

    componentWillLeave(callback) {
        this.addAnimation(this.leaveAnim, {callback: callback})
    }

    enterAnim = (utils) => {
        const { id } = this.props;
        return new TimelineMax()
            .from(utils.target, 1, {
                x: `+=${( 4 - id ) * 60}px`,
                autoAlpha: 0,
                onComplete: utils.options.callback,
            }, id * 0.7);
    }

    leaveAnim = (utils) => {
        const { id } = this.props;
        return new TimelineMax()
            .to(utils.target, 0.5, {
                scale: 0,
                ease: Sine.easeOut,
                onComplete: utils.options.callback,
            }, (4 - id) * 0.7);
    }

    render() {
        const { url } = this.props;
        return (
            <div className="photo">
                <img src={url} />
            </div>
        )
    }
}

const WrappedPhoto = GSAP()(Photo);

export default class Gallery extends Component {
    constructor(props) {
        super(props);
        this.state = {
            show: false,
            photos: [{
                id: 1,
                url: 'http://img4.imgtn.bdimg.com/it/u=1032683424,3204785822&fm=214&gp=0.jpg'
            }, {
                id: 2,
                url: 'http://imgtu.5011.net/uploads/content/20170323/7488001490262119.jpg'
            }, {
                id: 3,
                url: 'http://tupian.enterdesk.com/2014/lxy/2014/12/03/18/10.jpg'
            }, {
                id: 4,
                url: 'http://img4.imgtn.bdimg.com/it/u=360498760,1598118672&fm=27&gp=0.jpg'
            }]
        };
    }

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

    render() {
        const { show, photos } = this.state;

        const renderPhotos = () => {
            return photos.map((item, index) => {
                return <WrappedPhoto id={item.id} url={item.url} key={`photo${item.id}`} />;
            })
        }

        return (
            <div>
                <button onClick={this.toggle}>toggle</button>
                <TransitionGroup component="div">
                    {show && renderPhotos()}
                </TransitionGroup>
            </div>
        );
    }
}
複製程式碼

在該示例中,我們在子元件 PhotocomponentWillEntercomponentWillLeave 兩個 hook 函式中為每個子元件新增了入場動畫 enterAnim 和 離場動畫 LeaveAnim。在入場動畫中,使用 TimeLineMax.from(target, duration, vars, delay) 方式建立時間軸動畫,指定了每個子元件的動畫移動距離隨 id 增大而減小,延期時間隨著 id 增大而增大,離場動畫中每個子元件的延期時間隨著 id 增大而減小,從而實現根據元件 id 不同具有不同的動畫效果。實際使用時,你可以根據需求對任一子元件新增不同的效果。該示例的效果如下圖所示:

圖片畫廊效果

在使用 TransitionGroup 時,在 componentnWillAppear(callback)componentnWillEntercallback)componentnWillLeave(callback) 函式中一定要在函式邏輯結束後呼叫 callback,以保證 TransitionGroup 能正確維護子節點的狀態序列。關於 GASP 的詳細使用方法可參考GASP官方文件和博文GSAP,專業的Web動畫庫,本文不再贅述。

結合 hook 實現動畫可以支援各種複雜動畫,如時間序列動畫等,由於依賴第三方庫,往往動畫效果比較流暢,使用者體驗較好。但是第三方庫的引入,需要開發者額外學習對應的 api,也提升了程式碼複雜度。

五、其他第三方動畫庫

此外,還有很多優秀的第三方動畫庫,如 react-motion,Animated,velocity-react等,這些動畫庫在使用時也各有千秋。

Animated

Animated 是一個跨平臺的動畫庫,相容 React 和 React Native。由於在動畫過程中,我們只關心動畫的初始狀態、結束狀態和變化函式,並不關心每個時刻元素屬性的具體值,所以 Animated 採用宣告式的動畫,通過它提供的特定方法計算 css 物件,並傳入 Animated.div 實現動畫效果。

示例

我們使用 Animated 實現一個圖片翻轉的效果,程式碼如下。

import React, { Component } from 'react';
import Animated from 'animated/lib/targets/react-dom';

export default class PhotoPreview extends Component {
    constructor(props) {
        super(props);
        this.state = {
            anim: new Animated.Value(0)
        };
    }

    handleClick = () => {
        const { anim } = this.state;
        anim.stopAnimation(value => {
            Animated.spring(anim, {
                toValue: Math.round(value) + 1
            }).start();
        });
    }

    render() {
        const { anim } = this.state;

        const rotateDegree = anim.interpolate({
            inputRange: [0, 4],
            outputRange: ['0deg', '360deg']
        });

        return (
            <div>
                <button onClick={this.handleClick}>向右翻轉</button>
                <Animated.div
                    style={{
                        transform: [{
                            rotate: rotateDegree
                        }]
                    }}
                    className="preivew-wrapper"
                >
                    <img
                        alt="img"
                        src="http://img4.imgtn.bdimg.com/it/u=1032683424,3204785822&fm=214&gp=0.jpg"
                    />
                </Animated.div>
            </div>
        );
    }
}
複製程式碼

在該示例中,我們希望實現每點選一次按鈕,圖片向右旋轉90°。在元件初始化時新建了一個初始值為 0 的 Animated 物件 this.state.animAnimated 物件中有插值函式 interpolate,當設定輸入區間 inputRange 和輸出區間 outputRange 後,插值函式可以根據 Animated 物件的當前值進行線性插值,計算得到對應的對映值。

在本例中,我們假設每點選一次按鈕,this.state.anim 的值加 1,影象需要轉動90°。在 render 函式中,我們設定插值函式 this.state.anim.interpolate 的輸入區間為[0, 4],輸出區間為['0deg', '360deg']。當執行動畫時,this.state.anim 的值發生變化,插值函式根據 this.state.anim 當前值,計算得到旋轉角度 rotateDegree,觸發元件的重新渲染。因此,如果 Animated 物件當前值為 2,對應的旋轉角度就是 180deg。在元件渲染結構中,需要使用 Animated.div 包裹動畫節點,並將 rotateDegree 封裝為 css 物件作為 stlye 傳入 Animated.div 中,實現節點 css 屬性的變化。

在點選事件中,考慮到按鈕可能連續多次點選,我們首先使用 stopAnimation 停止當前正在進行的動畫,該函式會在回撥函式中返回一個 {value : number} 物件,value 對應最後一刻的動畫屬性值。根據獲取的 value 值,隨後使用 Animated.spring 函式開啟一次新的彈簧動畫過程,從而實現一個流暢的動畫效果。由於每次轉動停止時,我們希望圖片的翻轉角度都是90°的整數倍,所以需要對 Animated.spring 的終止值進行取整。最終我們實現瞭如下效果:

image

使用時需要注意一下幾點:

  • Animated 物件的值和其插值結果只能作用於 Animated.div 節點;
  • interpolate 預設會根據輸入區間和輸出區間進行線性插值,如果輸入值超出輸入區間不受影響,插值結果預設會根據輸出區間向外延展插值,可以通過設定 extrapolate 屬性限制插值結果區間。

Animated 在動畫過程中不直接修改元件 state,而是通過其新建物件的元件和方法直接修改元素的屬性,不會重複觸發 render 函式,是 React Native 中非常穩定的動畫庫。但是在 React 中存在低版本瀏覽器相容問題,且具有一定學習成本。

結語

當我們在 React 中實現動畫時,首先要考量動畫的難易程度和使用場景,對於簡單動畫,優先使用 css3 實現,其次是基於 js 的時間間隔動畫。如果是元素入場動畫和離場動畫,則建議結合 CSSTransitionGroup 或者 TransitionGroup 實現。當要實現的動畫效果較為複雜時,不妨嘗試一些優秀的第三方庫,開啟精彩的動效大門。

Ps. 本文所有示例程式碼可訪問 github 檢視

參考資料:

react-transition-group

react-gsap-enhancer

A Comparison of Animation Technologies

React Animations in Depth

本文首發於有贊技術部落格

相關文章