使用React Hooks构建CSS的transition和animation

发布于 大漠

Web动效在Web应用中所起的作用就不说了。有人说,Web动效可以给Web起到锦上添花效果,也有人说,Web动效可以增加用户的粘性和吸引力。就目前来说,在Vue框架体系下,可以使用<transition>组件来构建Web动画效果,其实在React体系下,也可以使用类似的方式来给Web元素添加动画效果。接下来,就和大家一起探讨,在React框架如何将CSS的trasitionanimation运用到元素中,让Web元素动起来。如果你对这方面知识感兴趣的话,欢迎继续往下阅读。

React构建的组件运用动效时常碰到的问题

不知道你是否像我一样,在使用React构建的组件,在添加动效的时候,总是和自己期望的有所差异。比如说,构建一个弹窗,弹窗出来的时候有动效,弹窗移除的时候没有动效。就像下面这样的一个效果:

你可能已经发现了,弹窗窗现的时候,会有一个fadeIn效果,但弹窗移除时并看不到fadeOut效果。这主要是因为,弹窗移除时,整个弹窗的DOM直接就删除了,因此也没有机会让你能看到fadeOut效果。那么在React构建的应用中,如何来改善这一点呢?这也是接下来要和大家一起探讨的话题。

使用react-transition-group给React组件添加动效

在Vue框架中提供了<transition>组件,在不同的生命周期中添加相应的类名,并在相应的类名中设置样式,从而给Vue组件添加动效:

在React中,我们可以使用一个类似Vue的<transition>组件,即 react-transition-group 可以给React组件添加动画效果。

react-transition-group提供了四个组件,<Transition><CSSTransition><SwitchTransition><TransitionGroup>。比如下面这个示例,就是采用<CSSTransition>组件构建的一个带有动效的组件:

// src/index.js
import React, { useState } from 'react';
import ReactDOM from 'react-dom';
import { Container, Button, Alert } from 'react-bootstrap';
import { CSSTransition } from 'react-transition-group';

import './styles.css';

function ModalComponent() {
    const [showButton, setShowButton] = useState(true);
    const [showMessage, setShowMessage] = useState(false);
    return (
        <Container style={{ paddingTop: '2rem' }}>
            {showButton && (
                <Button
                onClick={() => setShowMessage(true)}
                size="lg"
                >
                Show Message
                </Button>
            )}
            <CSSTransition
                in={showMessage}
                timeout={300}
                classNames="alert"
                unmountOnExit
                onEnter={() => setShowButton(false)}
                onExited={() => setShowButton(true)}
            >
                <Alert
                variant="primary"
                dismissible
                onClose={() => setShowMessage(false)}
                >
                    <Alert.Heading>
                        Animated alert message
                    </Alert.Heading>
                    <p>
                        This alert message is being transitioned in and
                        out of the DOM.
                    </p>
                    <Button onClick={() => setShowMessage(false)}>
                        Close
                    </Button>
                </Alert>
            </CSSTransition>
        </Container>
    );
}

ReactDOM.render(
    <ModalComponent />,
    document.getElementById('root')
);

// src/style.css
.alert-enter {
    opacity: 0;
    transform: scale(0.9);
}
.alert-enter-active {
    opacity: 1;
    transform: translateX(0);
    transition: opacity 300ms, transform 300ms;
}
.alert-exit {
    opacity: 1;
}
.alert-exit-active {
    opacity: 0;
    transform: scale(0.9);
    transition: opacity 300ms, transform 300ms;
}

效果如下:

有关于react-transition-group更详细的介绍,还可以阅读:

构建transition的React钩子函数

在组件生命周期中,有关于transition的主要阶段有:mountedenteringenteredexitingexitedunmounted

当一个组件被加载时,意味着它以DOM元素的形式出现,而不管它的样式如何。当它被卸载时,则从DOM中删除。传统上,在一个元素加载之后,动画在enteredentering阶段并没有问题,但是在卸载元素时,动画就比较棘手,因为React会在transition之前删除元素。因此,我们需要一个时间来过渡,在删除元素之前隐藏它。

接下来,我们从构建一个React的钩子函数useTransitionState开始,它从entering » enteredexiting » exited过程中提供延迟时间duration来改变状态。在这里会使用useEffect钩子函数,在DOM更新后执行一个计时器。另外,useTransitionState作为一个清理函数,我们将确保计时器被清除,以避免内存泄漏。

const STATE = {
    ENTERING: "entering",
    ENTERED: "entered",
    EXITING: "exiting",
    EXITED: "exited"
};

const useTransitionState = (duration = 1000) => {
    const [state, setState] = useState();

    useEffect(() => {
        let timerId;

        if (state === STATE.ENTERING) {
            timerId = setTimeout(() => setState(STATE.ENTERED), duration);
        } else if (state === STATE.EXITING) {
            timerId = setTimeout(() => setState(STATE.EXITED), duration);
        }

        return () => {
            timerId && clearTimeout(timerId);
        };
    });

    return [state, setState];
};

接下来,再把useTransitionState钩子函数放在另一个钩子函数中useTransitionControl,它为用户提供了两个回调,以便进入和退出状态之间进行切换。

const useTransitionControl = (duration) => {
    const [state, setState] = useTransitionState(duration);

    const enter = () => {
        if (state !== STATE.EXITING) {
            setState(STATE.ENTERING);
        }
    };

    const exit = () => {
        if (state !== STATE.ENTERING) {
            setState(STATE.EXITING);
        }
    };

    return [state, enter, exit];
};

现在我们使用它来实现一个具有渐隐渐现过渡效果的组件。

const defaultStyle = {
    transition: "opacity 2000ms ease-in-out",
    opacity: 0
};

const transitionStyles = {
    entering: {
        opacity: 1
    },

    entered: {
        opacity: 1
    },

    exiting: {
        opacity: 0
    },

    exited: {
        opacity: 0
    }
};

const FadeTransitionComponent = (props) => {
    const [state, enter, exit] = useTransitionControl();

    const style = {
        ...defaultStyle,
        ...(transitionStyles[state] ?? {})
    };

    return (
        <>
            <h2>State: {state}</h2>
            <div className="alert" style={style}>
                useTransitionState and useTransitionControl Hooks
            </div>
            <button className="btn btn__primary" onClick={enter}>
                Enter
            </button>
            <button className="btn btn__danger" onClick={exit}>
                Exit
            </button>
        </>
    );
};

export default function App() {
    return (
        <div className="App">
        <FadeTransitionComponent />
        </div>
    );
}

效果如下:

使用CSS animation给元素添加动效

前面示例,我们看到的是在React中使用CSS的transition给元素添加动画效果,另外,你可能在react-transition-group中也发现了,也可以使用CSS的animation给React中的组件(或元素)添加动效。

在React中,使用animation给React元素添加动效和transition类似,同样需要在DOM元素删除之前要有一个延迟时间,让我们能看到对应的动画效果。简单地说,需要一个时间控制器,让我们在React组件销毁之前就可以看到相应的动画效果。我们先来看一个简单的示例,也就是我们时常会碰到的一个效果,顶部导航栏淡入淡出的一个效果。

const HeaderBar = (props) => {
    const { title = "Page Title", isHidden } = props;

    const HEADERBAR_VISIBILITY_CLASSES = {
        hidden: "hide",
        visible: ""
    };

    const ANIMATION_CLASSES = {
        fadeIn: "fade-in",
        fadeOut: "fade-out"
    };

    // 默认状态使用淡入效果,对应的类名是visible
    const [animationClass, setAnimationClass] = useState(
        ANIMATION_CLASSES.fadeIn
    );
    const [headerBarHiddenClass, setHeaderBarHiddenClass] = useState(
        HEADERBAR_VISIBILITY_CLASSES.visible
    );

    // 每当isHidden发生变化时,它就会运行
    useEffect(() => {
        let timerId = null;

        if (isHidden) {
            setAnimationClass(ANIMATION_CLASSES.fadeOut);
            timerId = setTimeout(() => {
                setHeaderBarHiddenClass(HEADERBAR_VISIBILITY_CLASSES.hidden);
            }, 1000);
        } else {
            setAnimationClass(ANIMATION_CLASSES.fadeIn);
            setHeaderBarHiddenClass(HEADERBAR_VISIBILITY_CLASSES.visible);
        }

        return () => {
            clearTimeout(timerId);
        };
    }, [isHidden]);

    return (
        <div className={`header  ${animationClass} ${headerBarHiddenClass}`}>
            <h1>{title}</h1>
        </div>
    );
};

可以像下面这样引用HeaderBar组件:

export default function App() {
    const [isHidden, setIsHidden] = useState(false);
    return (
        <div className="App">
            <HeaderBar isHidden={isHidden} title="欢迎来到W3cplus!" />

            <button
                className="button button__primary"
                onClick={(e) => setIsHidden(!isHidden)}
            >
                Toggle HeaderBar
            </button>
        </div>
    );
}

效果如下:

这个效果中我们通过改变div.header的类名,fade-infade-out之间切换:

实现上面的效果,离不开下面的CSS代码:

.fade-in {
    animation-duration: 1s;
    animation-name: fadeIn;
}

.fade-out {
    animation-duration: 1s;
    animation-name: fadeOut;
}

.hide {
    display: none;
}

@keyframes fadeIn {
    from {
        opacity: 0;
        transform: translate3d(0, -100%, 0);
    }

    to {
        opacity: 1;
        transform: translate3d(0, 0, 0);
    }
}

@keyframes fadeOut {
    from {
        opacity: 1;
    }

    to {
        opacity: 0;
        transform: translate3d(0, -100%, 0);
    }
}

看到上面的代码,特别是@keyframes定义的fadeInfadeOut特别的熟悉。他们其实就是Animate.css中定义的动效。换句话说,我们对上面的代码稍做修改,就可以更灵活的使用Animate.css定义的动效,甚至还可扩展任何@keyframes定义的动效。

import React, { useEffect, useState } from "react";
import "./styles.css";

const HeaderBar = (props) => {
    const {
        title = "Page Title",
        isHidden,
        entrancesAnimationName,
        exitsAnimationName
    } = props;

    const HEADERBAR_VISIBILITY_CLASSES = {
        hidden: "hide",
        visible: ""
    };

    const ANIMATION_CLASSES = {
        entrancesAnimationName: `${entrancesAnimationName}`,
        exitsAnimationName: `${exitsAnimationName}`
    };

    // 默认状态使用淡入效果,对应的类名是visible
    const [animationClass, setAnimationClass] = useState(
        ANIMATION_CLASSES.entrancesAnimationName
    );
    const [headerBarHiddenClass, setHeaderBarHiddenClass] = useState(
        HEADERBAR_VISIBILITY_CLASSES.visible
    );

    // 每当isHidden发生变化时,它就会运行
    useEffect(() => {
        let timerId = null;

        if (isHidden) {
            setAnimationClass(ANIMATION_CLASSES.exitsAnimationName);
            timerId = setTimeout(() => {
                setHeaderBarHiddenClass(HEADERBAR_VISIBILITY_CLASSES.hidden);
            }, 1000);
        } else {
            setAnimationClass(ANIMATION_CLASSES.entrancesAnimationName);
            setHeaderBarHiddenClass(HEADERBAR_VISIBILITY_CLASSES.visible);
        }

        return () => {
            clearTimeout(timerId);
        };
    }, [isHidden]);

    return (
        <div
        className={`header animated  ${animationClass} ${headerBarHiddenClass}`}
        >
            <h1>{title}</h1>
        </div>
    );
};

export default function App() {
    const [isHidden, setIsHidden] = useState(false);
    return (
        <div className="App">
            <HeaderBar
                isHidden={isHidden}
                title="欢迎来到W3cplus!"
                entrancesAnimationName="bounceInDown"
                exitsAnimationName="bounceOutUp"
            />

            <button
                className="button button__primary"
                onClick={(e) => setIsHidden(!isHidden)}
            >
                Toggle HeaderBar
            </button>
        </div>
    );
}

在CSS中添加bounceInDownbounceOutUp对应的动画效果的代码:

:root {
    --animate-duration: 1s;
}

.animated {
    animation-duration: var(--animate-duration);
    animation-fill-mode: both;
}

@keyframes bounceInDown {
    from,
    60%,
    75%,
    90%,
    to {
        animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);
    }

    0% {
        opacity: 0;
        transform: translate3d(0, -3000px, 0) scaleY(3);
    }

    60% {
        opacity: 1;
        transform: translate3d(0, 25px, 0) scaleY(0.9);
    }

    75% {
        transform: translate3d(0, -10px, 0) scaleY(0.95);
    }

    90% {
        transform: translate3d(0, 5px, 0) scaleY(0.985);
    }

    to {
        transform: translate3d(0, 0, 0);
    }
}

.bounceInDown {
    animation-name: bounceInDown;
}

@keyframes bounceOutUp {
    20% {
        transform: translate3d(0, -10px, 0) scaleY(0.985);
    }

    40%,
    45% {
        opacity: 1;
        transform: translate3d(0, 20px, 0) scaleY(0.9);
    }

    to {
        opacity: 0;
        transform: translate3d(0, -2000px, 0) scaleY(3);
    }
}

.bounceOutUp {
    animation-name: bounceOutUp;
}

这个时候动效变成下面这样的:

用同样的方式,可以使用Animate.css中定义所有动效。

构建animation的React钩子函数

在React中同样可以使用React的Hooks为CSS的animation构建相应的钩子函数。这个也有点类似于transition的钩子函数,在介绍React构建transition的钩子函数时,我们创建了useTransitionStateuseTransitionControl两个钩子函数,构建animation的钩子函数也有点类似。实现animation效果,需要useAnimationuseAnimationTimer两个钩子函数,先来看useAnimationTimer钩子函数:

import { useState, useEffect } from 'react';

// Usage
function App() {
    const animation1 = useAnimation('elastic', 600, 0);
    const animation2 = useAnimation('elastic', 600, 150);
    const animation3 = useAnimation('elastic', 600, 300);

    return (
        <div style={{ display: 'flex', justifyContent: 'center' }}>
            <Ball
                innerStyle={{
                marginTop: animation1 * 200 - 100
                }}
            />

            <Ball
                innerStyle={{
                marginTop: animation2 * 200 - 100
                }}
            />

            <Ball
                innerStyle={{
                marginTop: animation3 * 200 - 100
                }}
            />
        </div>
    );
}

const Ball = ({ innerStyle }) => (
    <div
        style={{
        width: 100,
        height: 100,
        marginRight: '40px',
        borderRadius: '50px',
        backgroundColor: '#4dd5fa',
        ...innerStyle
        }}
    />
);

// Hook 
function useAnimation(
    easingName = 'linear',
    duration = 500,
    delay = 0
    ) {
    
    const elapsed = useAnimationTimer(duration, delay);
    const n = Math.min(1, elapsed / duration);
    return easing[easingName](n);
}

// Some easing functions copied from:
// https://github.com/streamich/ts-easing/blob/master/src/index.ts
// Hardcode here or pull in a dependency
const easing = {
    linear: n => n,
    elastic: n =>
        n * (33 * n * n * n * n - 106 * n * n * n + 126 * n * n - 67 * n + 15),
    inExpo: n => Math.pow(2, 10 * (n - 1))
};

function useAnimationTimer(duration = 1000, delay = 0) {
    const [elapsed, setTime] = useState(0);

    useEffect(
        () => {
        let animationFrame, timerStop, start;

        function onFrame() {
            setTime(Date.now() - start);
            loop();
        }

        function loop() {
            animationFrame = requestAnimationFrame(onFrame);
        }

        function onStart() {
            timerStop = setTimeout(() => {
                cancelAnimationFrame(animationFrame);
                setTime(Date.now() - start);
            }, duration);

            start = Date.now();
            loop();
        }

        const timerDelay = setTimeout(onStart, delay);

        return () => {
                clearTimeout(timerStop);
                clearTimeout(timerDelay);
                cancelAnimationFrame(animationFrame);
            };
        },
        [duration, delay] 
    );

    return elapsed;
}

特别声明:上面的代码来自于useHooks()中的useAnimation

小结

在React中给组件添加动画效果,除了上面提供的一些方案之外,还有很多其他的方案以及一些优秀的动画库。这篇文章中我们从react-transition-group开始到如何使用React Hooks专门为transitionanimation定制相应的Hooks。不管是哪种方式,他们都解决同一个问题,那就是在React删除DOM之前先看到相应的动效。实现该功能主要是在组件对应的生命周期中使用setTimeout()和定时器timerId让DOM延迟销毁。如果你不想花时间了解他们,那可以考虑直接使用一些优秀的库,如果你感兴趣的话,我们在后续的教程中和大家一起聊聊如何使用这些库在React中给组件或元素添加动效。