手寫一個 React 動畫元件

jump__jump發表於2022-12-15

在專案開發的過程中,設計師不免會做一些動畫效果來提升使用者體驗。如果當前效果不需要互動,只是用來展示的話,我們完全可以利用 GIF 或者 APNG 來實現效果。不瞭解 APNG 小夥伴可以看看這篇部落格 APNG 歷史、特性簡介以及 APNG 製作演示

但是如果當前動畫除了展示還需要其他互動,甚至是一個元件需要動畫效果,使用圖片格式就不合理了。於是我寫一個極其簡單的 css 動畫庫 rc-css-animate。這裡直接使用 animate.css 作為 css 動畫的依賴庫。 animate.css 不但提供了很多互動動畫樣式類,也提供了動畫執行速度,延遲,以及重複次數等樣式類。

可以看到,預設的 animate.css 構建動畫都需要攜帶字首 “animate__”。

<h1 class="animate__animated animate__bounce">An animated element</h1>

當然,該庫是對 css 動畫進行了一層封裝,依然支援其他動畫庫以及自己手寫的 css 動畫,但如果開發者需要對動畫進行各種複雜控制,不推薦使用此庫。

使用

可以利用如下方式使用:

import React, { useRef } from "react";
import ReactCssAnimate from "rc-css-animate";

// 引入 animate.css 作為動畫依賴
import "animate.css";

function App() {
  const animateRef = useRef(null);

  return (
    <div className="App">
      <ReactCssAnimate
        // 定義當前展示動畫的元件
        // 預設使用 div
        tag="div"
        // 當前元件的 className
        className=""
        // 當前元件的 style
        style={{}}
        // 當前元件的 ref
        ref={animateRef}
        // 動畫字首
        clsPrefix="animate__"
        // 當前動畫的 className
        animateCls="animated backInDown infinite"
        // 動畫開始時候是否處於展示狀態
        initialVisible={false}
        // 獲取動畫結束是否處理展示狀態
        getVisibleWhenAnimateEnd={(cls) => {
          // 如果當前 animateCls 中有 Out
          // 返回 false 則會在動畫結束後不再顯示
          if (cls.includes("Out")) {
            return false;
          }
          return true;
        }}
        // 動畫結束回撥
        onAnimationEnd={() => {
          console.log("done");
        }}
      >
        <div>
          測試動畫
        </div>
      </ReactCssAnimate>
    </div>
  );
}

ReactCssAnimate 使用了 React hooks,但是也提供了相容的類元件。同時也提供了全域性的字首設定。

import React from "react";
import {
  // 使用類元件相容之前的版本
  CompatibleRAnimate as ReactCssAnimate,
  setPrefixCls,
} from "rc-css-animate";

// 引入 animate.css 作為動畫依賴
import "animate.css";

// 設定全域性 prefix,會被當前元件覆蓋
setPrefixCls("animate__");

/** 構建動畫塊元件 */
function BlockWrapper(props) {
  // 需要獲取並傳入 className, children, style
  const { className, children, style } = props;
  return (
    <div
      className={className}
      style={{
        background: "red",
        padding: 100,
        ...style,
      }}
    >
      {children}
    </div>
  );
}

function App() {
  return (
    <div className="App">
      <ReactCssAnimate
        tag={BlockWrapper}
        // 當前動畫的 className
        animateCls="animated backInDown infinite"
      >
        <div>
          測試動畫
        </div>
      </ReactCssAnimate>
    </div>
  );
}

原始碼解析

原始碼較為簡單,是基於 createElment 和 forwardRef 構建完成。其中 forwardRef 會將當前設定的 ref 轉發到內部元件中去。對於 forwardRef 不熟悉的同學可以檢視一下官網中關於 Refs 轉發的文件

import React, {
  createElement,
  forwardRef,
  useCallback,
  useEffect,
  useState,
} from "react";
import { getPrefixCls } from "./prefix-cls";
import { AnimateProps } from "./types";

// 全域性的動畫字首
let prefixCls: string = "";

const getPrefixCls = (): string => prefixCls;

// 設定全域性的動畫字首
export const setPrefixCls = (cls: string) => {
  if (typeof cls !== "string") {
    return;
  }
  prefixCls = cls;
};

const Animate = (props: AnimateProps, ref: any) => {
  const {
    tag = "div",
    clsPrefix = "",
    animateCls,
    style,
    initialVisible,
    onAnimationEnd,
    getVisibleWhenAnimateEnd,
    children,
  } = props;

  // 透過 initialVisible 獲取元件的顯隱,如果沒有則預設為 true
  const [visible, setVisible] = useState<boolean>(initialVisible ?? true);

  // 當前不需要展示,返回 null 即可
  if (!visible) {
    return null;
  }

  // 沒有動畫類,直接返回子元件
  if (!animateCls || typeof animateCls !== "string") {
    return <>{children}</>;
  }

  useEffect(() => {
    // 當前沒獲取請求結束的設定顯示隱藏,直接返回,不進行處理
    if (!getVisibleWhenAnimateEnd) {
      return;
    }
    const visibleWhenAnimateEnd = getVisibleWhenAnimateEnd(animateCls);

    // 如果動畫結束後需要展示並且當前沒有展示,直接進行展示
    if (visibleWhenAnimateEnd && !visible) {
      setVisible(true);
    }
  }, [animateCls, visible, getVisibleWhenAnimateEnd]);

  const handleAnimationEnd = useCallback(() => {
    if (!getVisibleWhenAnimateEnd) {
      onAnimationEnd?.();
      return;
    }

    // 當前處於展示狀態,且動畫結束後需要隱藏,直接設定 visible 為 false
    if (visible && !getVisibleWhenAnimateEnd(animateCls)) {
      setVisible(false);
    }
    onAnimationEnd?.();
  }, [getVisibleWhenAnimateEnd]);

  let { className = "" } = props;

  if (typeof className !== "string") {
    className = "";
  }

  let animateClassName = animateCls;

  // 獲取最終的動畫字首
  const finalClsPrefix = clsPrefix || getPrefixCls();

  // 沒有或者動畫字首不是字串,不進行處理
  if (!finalClsPrefix || typeof finalClsPrefix !== "string") {
    animateClassName = animateCls.split(" ").map((item) =>
      `${finalClsPrefix}${item}`
    ).join(" ");
  }

  // 建立並返回 React 元素
  return createElement(
    tag,
    {
      ref,
      onAnimationEnd: handleAnimationEnd,
      // 將傳遞的 className 和 animateClassName 合併
      className: className.concat(` ${animateClassName}`),
      style,
    },
    children,
  );
};

// 利用 forwardRef 轉發 ref
// 第一個引數是 props,第二個引數是 ref
export default forwardRef(Animate);

以上程式碼全部在 rc-css-animate 中。這裡也歡迎各位小夥伴提出 issue 和 pr。

鼓勵一下

如果你覺得這篇文章不錯,希望可以給與我一些鼓勵,在我的 github 部落格下幫忙 star 一下。

部落格地址

參考資料

APNG 歷史、特性簡介以及 APNG 製作演示

rc-css-animate

animate.css

相關文章