10

用React Hooks与Web Animation API实现动效组件

 3 years ago
source link: https://zhuanlan.zhihu.com/p/81954538
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

用React Hooks与Web Animation API实现动效组件

阿里巴巴集团 前端工程师

一个体验良好的动效完全可以吸引用户的更多停留,以一种通用的方式从侧面提升业务的转化效果,某些特定场景下,动画所能起到的作用甚至可以与业务的目标并驾齐驱。对于PC端产品来说,Web页面动画的价值主要增加体验舒适度、增加web页面活力、以及展示页面的层级关系或者提供反馈。如何做一个高效的动效组件,做到使用成本低、性能较优、灵活性较好,一直是前端(动画)工程师们持之以恒的追求。

React Hooks

随着 React 在 v16.8 的版本中正式推出了 React Hooks 新特性,关于 React Hooks 的讨论一直没有停歇,官方解读为这是下一个五年React与时俱进的开端。在 React Hook 出现之前,Function Component 往往用在 Stateless Component 中,有了 React Hook,Function Component的能力才得以向 Class Component 看齐。并且从代码上来看“更FP,更细粒度,更清晰”。

Web Animation API

W3C提出 Web Animation API ,简称 WAAPI。它致力于集合 CSS3 动画的性能,JavaScript的灵活,动画库的丰富等各家所长,将尽可能多的动画控制由原生浏览器实现,并添加许多CSS不具备的变量、控制以及调试选项等。WAAPI 上手比较简单,如果你熟悉 jQuery的.animate(),那么WAAPI的基本语法看起来应该很熟悉。animate方法接受两个参数:keyframes和options。来分别配置关键帧动画以及配置项,这与CSS Animation的操作步骤很类似。但Animation对象提供了我们更强大的API,比如play,pause,reverse,currentTime,timeline等等。

虽然现在浏览器对WAAPI的支持力度还不是很强,但我们应该深信,随着时间的推移,一定会得到完美的支持。尽管还未得到很好的支持,开源库中早就比较完善的polyfill解决方案,web-animations-js就是其中一个。

由浅入深走进React Hooks与WAAPI

本文通过编写几个在B端业务中常用的动效组件,帮助读者对React Hooks以及WAAPI有一个初步的认识。

1. WAAPI与Hook的初体验

为了让大家能快速了解WAAPI与React Hooks,我们搭建了一个比较简单的弹窗组件,如下图所示。

v2-5e7b5419730af6cd2eda4d82da696a0f_b.jpg
弹窗动画

demo地址如下:

对于弹窗这里的动效组件,我们需要考虑打开关闭两种动画效果。当然除了弹窗本身的元素之外,我们还需要制作一个蒙层,蒙层与弹窗是同进同出的。

编写视图层代码

为了提高代码的可读性,本文的demo统一采用 styled-components这种css-in-js的形式编写view层代码。

通过一个外层容器ModalContainer包裹住Overlay组件与ModalContent组件,并用visible属性来控制弹窗的开关。

 return (
    <ModalContainer>
      <div hidden={!visible}>
        <Overlay ref={overlayRef} onClick={onCancel} />
        <ModalContent ref={contentRef}>
          {props.children}
          <CloseButton onClick={onCancel}>×</CloseButton>
        </ModalContent>
      </div>
    </ModalContainer>
  );

初始化DOM引用值

useRef 跟 createRef 类似,都可以用来生成对 DOM 对象的引用。useRef 返回的值传递给组件或者 DOM 的 ref 属性,就可以通过 ref.current 值访问组件或真实的 DOM 节点,这样就可以对 DOM 使用 animate 方法了。

const overlayRef = useRef(null);
const contentRef = useRef(null);

配置keyframes数组以及时间函数列表

animate函数接受2个参数。第一个是KeyframeEffects数组,设置你想让动画发生的位置,这个与和CSS中的 @keyframes 声明相似。第二个参数是一个时间函数列表,类似于CSS动画属性,尽管命名稍有不同。

// effects数组
const contentAnimation = [
   { opacity: 0, transform: "translateY(-30%)" },
   { opacity: 1, transform: "translateY(-50%)" }
];
const overlayAnimation = [{ opacity: 0 }, { opacity: 0.3 }];
// 时间函数列表
const animationSettings = { duration: 150, fill: "both" };
const reverseAnimationSettings = {
    ...animationSettings,
    direction: "reverse"
};

FLIP Your Animations

最后需要我们设置动画的淡入和淡出的方法,并使用React Hooks来触发,即可完成完整的弹窗动画效果。

// 使用useState保存组件状态
const [visible, setVisible] = useState(props.visible || false);

// 动画开始
const animateIn = useCallback(() => {
    contentRef.current.animate(contentAnimation, animationSettings);
    overlayRef.current.animate(overlayAnimation, animationSettings);
    setVisible(true);
  }, [animationSettings, overlayAnimation, contentAnimation]);

// 动画结束
const animateOut = useCallback(async () => {
    await Promise.all([
      contentRef.current.animate(contentAnimation, reverseAnimationSettings)
        .finished,
      overlayRef.current.animate(overlayAnimation, reverseAnimationSettings)
        .finished
    ]);
    setVisible(false);
  }, [contentAnimation, overlayAnimation, reverseAnimationSettings]);

在类组件中,我们一般使用this.state来保存组件状态,并对其修改触发组件重新渲染。而在React Hooks 中,我们是通过传入useState参数后,会拿到带有默认状态和改变状态函数的数组。相对于非覆盖式更新状态的setState方法,useState则为覆盖式的更新状态,让开发者自己处理组件逻辑。

通过触发animateIn与animateOut方法,可以比较轻松地调用动画的淡入与淡出效果。finished为仅读属性,返回当前动画的Finish Promise。同时使用 Promise.all 可以将多个动画合并在一起执行。这里使用了useCallback来进行依赖检查,下文会做更详细的讲解。

那么我们在何时触发动画呢?通过父组件传递的visible属性来决定是animetIn还是animateOut。在类组件中,我们往往是在componentDidUpdate中来随时监听visble的变化,触发动画。

componentDidUpdate(){
	if (this.props.visible) {
      animateIn();
	} else {
  		animateOut();
	}
}

而在React Hooks里,我们一般这样实现,利用 useEffect 代替一些生命周期:

  useEffect(() => {
    if (props.visible) {
      animateIn();
    } else {
      animateOut();
    }
  }, [props.visible, animateIn, animateOut]);

useEffects官方定义是帮助开发者处理函数组件的副作用。对于理解Effects,同步是最理想的心智模型。抛开类组件的思维方式,Function Component其实每一次渲染都会有自己的props与state以及自己的事件处理方法(称为Capture Value)。同样的,useEffects也有Capture Value的特性。所以在每一次render时,useEffects拿到的visible属性都是固定的值。

关于useEffects更多的功能,下面的例子也许会让你更加深入理解。

2. 用React Hooks玩转WAAPI中的“可变属性”

本章节我们用Tween组件来举例。这里的Tween组件主要是针对单个元素的可操作型动画,如图所示:

v2-ea61891087cc6a9635bbd13ada5269ae_b.jpg
单个元素动画

demo地址如下:

使用useRef保存“可变属性”

除了上个例子React中的DOM refs,React Hooks使用相同的概念来保存任意可变值。ref 就像一个盒子,你可以放任何东西( 可以理解为class component中的this),useRef()返回一个有带有current可变属性的普通对象,它可以在 renders 间共享。

function Tween(props) {
  let timerRef = useRef(null);
  const tweenRef = useRef(null);
  useEffect(
    let player = tweenRef.current.animate(
        keyframes,settings
      );
      timerRef.current = player;
   );
   // ...
   return <div ref={tweenRef}>{props.children}</div>;
}

我们可以通过useRef来保存该元素的animations对象。这样我们就可以通过refs来调用其他属性来控制该元素的动画了。

控制动画

WAAPI最核心的方法就是element.animate(keyframes, settings)其中keyframes指的是需要有动效的元素,它是一个数组,对应的就是css animation 中的@keyframes,我们可以在数组中配置transform,opacity等属性,其中offset取值为[0-1],表示动画的进度。而settings对应的是css3中的animation-*属性,包括duration,delay,direction等。如下所示:

// KeyframeEffect
const keyframes = [
  { transform: "translate3d(-100px,0,0)" },
  { transform: "translate3d(-70px,0,0)", offset: 0.2 },
   { transform: "translate3d(90px,0,0)", offset: 0.9 },
  { transform: "translate3d(100px,0,0)" }
];
 // 动画属性
const settings = {
  id: "anime",
  duration: 1000,
  delay: 0,
  fill: "both",
  easing: "ease",
  direction: "alternate",
  iterations: Infinity
};

WAAPI还有其他的属性: currentTime:表示动画播放或者暂停的当前时间;playbackRate:获取或设置动画的播放速度;play:播放动画;pause:暂停动画;finish:停止动画,等等。

介绍了这么多属性,那么我们该如何利用React Hooks控制这些动画属性呢?

我们依然用useEffects来处理动画中的副作用,但有一个头疼的问题:这么多属性,有些属性并不是我们这次渲染所关注的,不应该重执行我们的efftct,换句话说就是如何避免effects不必要的重复调用。在这里,我们使用了useEffects提供的依赖数组参数deps[],deps[]告诉React只有当依赖数组参数的值发生变化时,才重新渲染。如下所示,组件只有在pause,keyframes,settings,reverse,playbackRate参数变化时,才会重新渲染:

useEffect(() => {
    if (settings && keyframes) {
      // 设置一个动画的播放速度。动画提供了一个比例因子,
      // 将会改变动画timeline和currentTime的变化比率。其初始值为1。
      timerRef.current.playbackRate = playbackRate;
      if (!pause) {
         [timerRef.current.play](http://timerref.current.play/) ();
      } else {
        timerRef.current.pause();
      }
    }
  }, [pause, keyframes, settings, reverse, playbackRate]);

使用useCallback进行依赖检查

useEffect依赖数组除了接收普通参数外,函数也是依赖的一部分。因为每次渲染,组件内定义的函数都不一样。所以我们需要对React诚实。

const getPlay = () => {
    let player = tweenRef.current.animate(
      keyframes,
      reverse ? { ...settings, direction: "reverse" } : settings
    );
    timerRef.current = player;
  };
  useEffect(() => {
    getPlay();
  }, [getPlay]);

如果getPlay这个函数不在依赖数组中,当我们点击reverse按钮,其实是无效的。读者可以在demo中尝试修改。

有时,在组件内,可能有几个effect使用了相同的函数,每一个effect的都需要用getPlay这个方法,但依赖的值不一样。这样会导致重复渲染。

使用useCallback记忆函数可以解决这个问题,它的本质上其实是添加了一层依赖检查,这样函数本身只有在需要的时候才会返回一个新的记忆函数供后面进行渲染。

同样的, useMemo 可以让我们对复杂对象做类似的事情。在demo中,使用useCallback,同时子组件为React.Memo时可以避免不必要的渲染,React.memo中默认的浅比较来避免不必要的“污染”,提高性能。

本文并不是React Hooks的最佳实践,也不是推荐大家使用WAAPI来构建动效组件。我们只是想通过两种思想的碰撞来帮助读者更快地了解React Hooks与WAAPI,仅此而已。而关于这两者:React Hooks是React官方推荐的编程模式,而WAAPI也是W3C正大力去倡导的动画解决方案,两种思想的碰撞,相信在不久的将来定会产生良好的化学反应。最后,也希望本文能给读者带来一些帮助。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK