【组件封装】为元素出现在视口添加动画

一、实现目标

为元素出现在视口时添加动画,这是很常见的需求,最近在工作中遇到了,项目里面使用的是React框架。因此自己封装了一个组件。如果是Vue框架,可以封装一个自定义指令来实现,更方便一点。

二、实现思路

使用IntersectionObserver API 监听元素是否出现在视口,使用animate API为元素添加动画

三、实现过程

TypeScript 复制代码
import classNames from 'classnames';
import { CSSProperties, useEffect, useRef } from 'react';

interface IntersectionWithAnimationBoxProp {
  /**
   * 子节点
   *
   * @type {JSX.Element}
   * @memberOf AnimationBoxProp
   */
  children: JSX.Element;
  /**
   * 动画效果
   *
   * @type {{ duration: number; fill: string }}
   * @memberOf AnimationBoxProp
   */
  optionalEffectTiming?: OptionalEffectTiming;
  /**
   * 关键帧数组
   *
   * @type {Keyframe[]}
   * @memberOf AnimationBoxProp
   */
  keyframes?: Keyframe[];
  /**
   * 类名
   *
   * @type {string}
   * @memberOf AnimationBoxProp
   */
  className?: string;
  /**
   * 样式
   *
   * @type {CSSProperties}
   * @memberOf AnimationBoxProp
   */
  style?: CSSProperties;
  /**
   * 默认关键帧, 可选
   *
   * @type {('left' | 'right' | 'top' | 'bottom')}
   * @memberOf AnimationBoxProp
   */
  defaultKeyFramePosition?: 'left' | 'right' | 'top' | 'bottom';
}

/**
 * 给元素添加效果(元素出现在视口, 执行动画)的容器, 函数re-render阶段不会重复执行动画, 只在挂载阶段执行一次
 */
export default function IntersectionWithAnimationBox(
  prop: IntersectionWithAnimationBoxProp
) {
  const {
    children,
    optionalEffectTiming = { duration: 300, fill: 'forwards' },
    keyframes,
    className,
    style,
    defaultKeyFramePosition = 'bottom',
  } = prop;
  const container = useRef<HTMLDivElement>(null);

  // 获取关键帧数组
  const getKeyFrames = (): Keyframe[] => {
    if (keyframes && !!keyframes.length) return keyframes;

    switch (defaultKeyFramePosition) {
      case 'bottom':
        return [
          {
            opacity: 0,
            transform: 'translateY(100%)',
          },
          {
            opacity: 1,
            transform: 'translateY(0)',
          },
        ];
      case 'top':
        return [
          {
            opacity: 0,
            transform: 'translateY(-100%)',
          },
          {
            opacity: 1,
          },
        ];
      case 'left':
        return [
          {
            opacity: 0,
            transform: 'translateX(-100%)',
          },
          {
            opacity: 1,
            transform: 'translateX(0)',
          },
        ];
      case 'right':
        return [
          {
            opacity: 0,
            transform: 'translateX(100%)',
          },
          {
            opacity: 1,
            transform: 'translateX(0)',
          },
        ];
    }
  };

  const addOpacity = () => {
    container.current && container.current.classList.add('opacity-100');
  };

  // 执行动画
  const animate = (element: Element) => {
    if (element instanceof HTMLElement) {
      const animation = element.animate(getKeyFrames(), optionalEffectTiming);

      animation.finished.then(() => {
        addOpacity();
      });
    }
  };

  // 通过IntersectionObserver API 监听元素是否出现在视口
  useEffect(() => {
    if (!container.current) return;

    const io = new IntersectionObserver((entries) => {
      entries.forEach(async (item) => {
        if (item.isIntersecting) {
          io.unobserve(item.target);
          animate(item.target);
        }
      });
    });
    io.observe(container.current);

    return () => {
      container.current && io.unobserve(container.current);
    };
  }, []);

  return (
    <div ref={container} className={classNames(['opacity-0', className])} style={style}>
      {children}
    </div>
  );
}

四、如何使用

1. 单个元素场景

使用封装好的组件直接包裹就可以

TypeScript 复制代码
  <IntersectionWithAnimationBox optionalEffectTiming={{ duration: 2000 }}>
     <div className="h-40 w-40 rounded-lg bg-theme-primary"></div>
  </IntersectionWithAnimationBox>

2. 多个元素场景

一般来说,多个元素场景需要有一个动画时间的递进关系,这个只需要改变delay就可以,可以利用index动态改变deley时间

TypeScript 复制代码
      <div className="flex gap-x-5">
        {DATA.map((data, index) => (
          <IntersectionWithAnimationBox
            optionalEffectTiming={{
              duration: 2000,
              delay: index * 150 + 150,
              easing: 'cubic-bezier(0.250, 0.460, 0.450, 0.940)',
            }}
            className="h-40"
            key={data}
          >
            <div className="rounded-lg bg-theme-primary p-6 text-center">{data}</div>
          </IntersectionWithAnimationBox>
        ))}
      </div>

五、总结

优点

封装为slot的形式,在使用上会方便很多,使用者无需去关心内部实现的逻辑,只需将动画传入或者选中默认的动画效果即可。

缺点

使用该组件包裹后,最终渲染会多了一个div标签。但是一般来说不会影响,如果有class的问题,可以使用prop:className将样式传递进去。

相关推荐
敢敢J的憨憨L8 小时前
GPTL(General Purpose Timing Library)使用教程
java·服务器·前端·c++·轻量级计时工具库
喝拿铁写前端8 小时前
Vue 组件通信的两种世界观:`.sync` 与普通 `props` 到底有什么不同?
前端·vue.js·前端框架
美酒没故事°9 小时前
npm源管理器:nrm
前端·npm·npm源
用户22152044278009 小时前
vue3组件间的通讯方式
前端·vue.js
三十_A9 小时前
【实录】使用 patch-package 修复第三方 npm 包中的 Bug
前端·npm·bug
下位子9 小时前
『AI 编程』用 Claude Code 从零到一开发全栈减脂追踪应用
前端·ai编程·claude
tyro曹仓舒9 小时前
Vue单文件组件到底需不需要写name
前端·vue.js
用户47949283569159 小时前
面试官:讲讲2FA 双因素认证原理
前端·后端·安全
乐影9 小时前
TS 模板字符串类型:从基础到进阶的类型编程魔法
前端·typescript
龙在天9 小时前
CSS 属性值的计算与过程
前端