Vue3 自定义指令实现元素平滑上升

240 阅读2分钟

动画原理

animate API

效果预览

完整源码

<script setup>
import vSlideIn from './directives/vSlideIn.js';
</script>

<template>
  <div class="container">
    <div v-slide-in class="item">1</div>
    <div v-slide-in class="item">2</div>
    <div v-slide-in class="item">3</div>
    <div v-slide-in class="item">4</div>
    <div v-slide-in class="item">5</div>
    <div v-slide-in class="item">6</div>
    <div v-slide-in class="item">7</div>
    <div v-slide-in class="item">8</div>
    <div v-slide-in class="item">9</div>
    <div v-slide-in class="item">10</div>
  </div>
</template>

<style scoped>
.item {
  color: #fff;
  width: 60%;
  height: 400px;
  line-height: 400px;
  text-align: center;
  background: orange;
  margin: 20px auto;
  font-size: 50px;
}
</style>
const DISTANCE = 150;
const DURATION = 1000;

// 存放元素与动画对象的映射关系
const animationMap = new WeakMap();
const ob = new IntersectionObserver((entries) => {
  // console.log(entries);
  for (let entry of entries) {
    // 如果和视口有重叠
    if (entry.isIntersecting) {
      const animation = animationMap.get(entry.target);
      animation.play();
      // 播放完成后,就没有必要再播放了
      ob.unobserve(entry.target);
    }
  }
});

// 判断元素是否在视口之下
const isBelowViewport = (el) => {
  const rect = el.getBoundingClientRect();
  return rect.top > window.innerHeight;
};

export default {
  mounted(el) {
    if (!isBelowViewport(el)) {
      return;
    }

    const animation = el.animate(
      [
        {
          transform: `translateY(${DISTANCE}px)`,
          opacity: 0.5,
        },
        {
          transform: 'translateY(0px)',
          opacity: 1,
        },
      ],
      {
        duration: DURATION,
        easing: 'ease',
      }
    );
    animation.pause();
    ob.observe(el);
    animationMap.set(el, animation);
  },
  unmounted(el) {
    ob.unobserve(el);
  },
};

实现解析

第一步

使用animation API实现过渡动画

const DISTANCE = 150;
const DURATION = 1000;

export default {
  mounted(el) {
    const animation = el.animate(
      [
        {
          transform: `translateY(${DISTANCE}px)`,
          opacity: 0.5,
        },
        {
          transform: 'translateY(0px)',
          opacity: 1,
        },	
      ],
      {
        duration: DURATION,
        easing: 'ease',
      }
    );
  },
};

第二步

const DISTANCE = 150;
const DURATION = 1000;

// 存放元素与动画对象的映射关系
const animationMap = new WeakMap();
const ob = new IntersectionObserver((entries) => {
  // console.log(entries);
  for (let entry of entries) {
    // 如果和视口有重叠
    if (entry.isIntersecting) {
      const animation = animationMap.get(entry.target);
      animation.play();
      // 播放完成后,就没有必要再播放了
      ob.unobserve(entry.target);
    }
  }
});

export default {
  mounted(el) {
    const animation = el.animate(
      [
        {
          transform: `translateY(${DISTANCE}px)`,
          opacity: 0.5,
        },
        {
          transform: 'translateY(0px)',
          opacity: 1,
        },
      ],
      {
        duration: DURATION,
        easing: 'ease',
      }
    );
    // 先暂停动画
    animation.pause();
    // 然后观察该元素
    ob.observe(el);
    // 最后建立 Dom 和 animation 的映射关系
    animationMap.set(el, animation);
  },
  unmounted(el) {
    ob.unobserve(el);
  },
};

  • IntersectionObserver 实例可以通过 observe 方法添加监听的元素,也可以通过 unobserve 解除监听
  • 在创建 IntersectionObserver 实例的时候,可以传入一个回调函数,这个回调函数在监听的元素进入视口或者离开视口的时候调用,然后回调函数的参数是一个数组,数组项通过 isIntersecting 判断是否进入视口,数组项通过 target 属性可以拿到监听的 Dom
  • 为什么要使用 WeakMap

第三步

在视口以下的元素才有动画

const DISTANCE = 150;
const DURATION = 1000;

// 存放元素与动画对象的映射关系
const animationMap = new WeakMap();
const ob = new IntersectionObserver((entries) => {
  // console.log(entries);
  for (let entry of entries) {
    // 如果和视口有重叠
    if (entry.isIntersecting) {
      const animation = animationMap.get(entry.target);
      animation.play();
      // 播放完成后,就没有必要再播放了
      ob.unobserve(entry.target);
    }
  }
});

// 判断元素是否在视口之下
const isBelowViewport = (el) => {
  const rect = el.getBoundingClientRect();
  return rect.top > window.innerHeight;
};

export default {
  mounted(el) {
    if (!isBelowViewport(el)) {
      return;
    }

    const animation = el.animate(
      [
        {
          transform: `translateY(${DISTANCE}px)`,
          opacity: 0.5,
        },
        {
          transform: 'translateY(0px)',
          opacity: 1,
        },
      ],
      {
        duration: DURATION,
        easing: 'ease',
      }
    );
    animation.pause();
    ob.observe(el);
    animationMap.set(el, animation);
  },
  unmounted(el) {
    ob.unobserve(el);
  },
};

  • 获取元素的尺寸
    • getComputedStyle(dom).width,这个尺寸读取的是 CSSOM 树,也就是整个浏览器绘制管线的第二个步骤,这种尺寸一般情况下不会读取,因为拿到的尺寸不一定是界面上的尺寸,比如我设置宽度为 100px,那么在界面上就不一定是 100px,比如加了 padding 或者 border。所以读取元素尺寸的时候最好不要用
    • dom.style.width 这里读取的 dom 元素上的 style 属性的值,如果没有这个属性就读取不到,也不建议使用这种方式去读取元素尺寸
    • clientWidth 包含元素的 content +padding,不包含边框和滚动条。读取的是 layout tree 的尺寸
    • offsetWidth 包含元素的 content + padding + scroll + border。读取的是 layout tree 的尺寸
    • scrollWidth 不是元素本身的尺寸,表示元素内部可滚动区域的宽度,如果没有滚动条和 clientWidth 的值一样。读取的是 layout tree 的尺寸
    • const rect = dom.getBoundingClientRect(),读取的是 GPU 进程绘制出来的尺寸,是实际在界面中展示的尺寸