使用IntersectionObserver实现动画懒加载

2024/01/14

今天学习了动画懒加载,IntersectionObserver是经典的主流方案,它是浏览器的原生API,采用异步执行,不会阻塞主线程。通过entry.isIntersecting 判断被观察的元素是否与视口(或根元素)相交,为true则触发动画,实现懒加载的效果。

一、JS原生实现

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>动画懒加载示例</title>
    <style>
      @keyframes slideUp {
        from {
          opacity: 0;
          transform: translateY(50px);
        }
        to {
          opacity: 1;
          transform: translateY(0);
        }
      }
      @keyframes slideLeft {
        from {
          opacity: 0;
          transform: translateX(-50px);
        }
        to {
          opacity: 1;
          transform: translateX(0);
        }
      }
      @keyframes slideRight {
        from {
          opacity: 0;
          transform: translateX(50px);
        }
        to {
          opacity: 1;
          transform: translateX(0);
        }
      }
      @keyframes bounceInTopLeft {
        0% {
          opacity: 0;
          transform: translate(-100%, -100%) scale(0.3);
        }
        50% {
          opacity: 1;
          transform: translate(0, 0) scale(1.05);
        }
        70% {
          opacity: 1;
          transform: translate(0, 0) scale(0.95);
        }
        100% {
          opacity: 1;
          transform: translate(0, 0) scale(1);
        }
      }

      .slide-left {
        animation: slideLeft 0.6s ease-out forwards;
      }
      .slide-right {
        animation: slideRight 0.6s ease-out forwards;
      }
      .slide-up {
        animation: slideUp 0.6s ease-out forwards;
      }
      .bounce-in-left {
        animation: bounceInTopLeft 2s cubic-bezier(0.68, -0.55, 0.265, 1.55)
          forwards;
      }

      .ani-hid {
        opacity: 0;
      }

      .right {
        animation-delay: 1000ms;
      }

      .placeholder {
        height: 100vh;
        background: linear-gradient(to bottom, #667eea 0%, #764ba2 100%);
        display: flex;
        align-items: center;
        justify-content: center;
      }
      .placeholder h1 {
        color: white;
        font-size: 2rem;
      }
      .left-right-wrap {
        padding: 40px;
        display: flex;
        justify-content: space-between;
        gap: 2.5rem;
      }
      .left-right-wrap .left,
      .left-right-wrap .right {
        flex: 1;
        height: 300px;
        border: 1px solid #ad1010;
      }
      .btn-wrap {
        padding: 0 40px;
      }
    </style>
  </head>
  <body>
    <div class="placeholder">
      <h1>占位内容 - 滚动查看动画效果</h1>
    </div>

    <div class="left-right-wrap">
      <div class="left ani-hid" data-animation="slide-left"></div>
      <div class="right ani-hid" data-animation="bounce-in-left"></div>
    </div>

    <script>
      document.addEventListener('DOMContentLoaded', function () {
        const observer = new IntersectionObserver(
          (entries) => {
            entries.forEach((entry) => {
              if (entry.isIntersecting) {
                const element = entry.target;
                const animationName = element.dataset.animation || 'slide-up';

                // 添加动画类
                element.classList.add(animationName);

                // 只执行一次,停止观察已激活的元素
                observer.unobserve(element);
              }
            });
          },
          {
            threshold: 0.1, // 当元素10%可见时触发
            rootMargin: '0px 0px -50px 0px', // 提前50px触发
          }
        );

        // 观察所有包含 data-animation 属性的元素
        const animatedElements = document.querySelectorAll('[data-animation]');
        animatedElements.forEach((el) => {
          observer.observe(el);
        });
      });
    </script>
  </body>
</html>

二、潜在问题

首屏可见元素或接近底部的元素可能永远不会触发 isIntersecting: true,这就导致元素动画类不会添加上。

1.首屏

2.底部

在body标签内底部添加按钮,效果是元素存在不显示(opacity:0):

<div class="btn-wrap">
  <button class="ani-hid" data-animation="slide-up">
    如果此按钮在最底部,不会显示
  </button>
</div>

应该是与rootMargin设置有关,解决办法是设置底部padding/margin,值需要测试,与rootMargin值的大小相关:

<div class="btn-wrap" style="margin-bottom: 30px">
  <button class="ani-hid" data-animation="slide-up">
    如果此按钮在最底部,会显示
  </button>
</div>

三、拓展

1.动画多参数配置

2.动态内容使用 MutationObserver + IntersectionObserver 组合

// 监听 DOM 变化,对新元素应用 IntersectionObserver
const mutationObserver = new MutationObserver((mutations) => {
  mutations.forEach((mutation) => {
    mutation.addedNodes.forEach((node) => {
      if (node.nodeType === 1 && node.classList.contains('lazy-animate')) {
        intersectionObserver.observe(node);
      }
    });
  });
});

mutationObserver.observe(document.body, {
  childList: true,
  subtree: true
});

四、框架下的封装

1.Vue:useIntersectionObserver

基于 IntersectionObserver 的 Vue 封装

import { useIntersectionObserver } from '@vueuse/core';

const { stop } = useIntersectionObserver(
  target,
  ([{ isIntersecting }], observerElement) => {
    if (isIntersecting) {
      // 触发动画
      stop(); // 可选的停止观察
    }
  },
)

2.React:Intersection Observer Hook

基于 IntersectionObserver,封装了 React 生命周期

import { useInView } from 'react-intersection-observer';

function MyComponent() {
  const { ref, inView } = useInView({
    threshold: 0,
    triggerOnce: true
  });

  return <div ref={ref} className={inView ? 'animate-in' : ''} />;
}