my-lazy/src/Lazy.vue

<template>
  <transition-group tag="div" :class="classes" :name="name">
    <div v-if="isInit" class="my-lazy__component" key="component">
      <slot></slot>
    </div>
    <div v-else key="skeleton" class="my-lazy__skeleton">
      <slot name="skeleton"></slot>
    </div>
  </transition-group>
</template>

<script>

  /**
   * 懒加载组件
   * @module $ui/components/my-lazy
   */

  import {requestAnimationFrame} from '$ui/utils/util'

  // 该组件依赖 IntersectionObserver API,如需在较低版本浏览器运行,需要引入 IntersectionObserver API polyfill
  // https://github.com/w3c/IntersectionObserver/tree/master/polyfill

  if (typeof window !== 'undefined' && !window.IntersectionObserver) {
    require('intersection-observer');
  }

  /**
   * 插槽
   * @member slots
   * @property {string} default 需要懒加载的内容
   * @property {string} skeleton 骨架内容
   */

  export default {
    name: 'MyLazy',
    /**
     * 属性参数
     * @member props
     * @property {string} [name=my-lazy] transition名称,用来定制动画
     * @property {boolean} [fit=false] 适配父容器
     * @property {number} [timeout]  等待时间,如果指定了时间,不论可见与否,在指定时间之后自动加载
     * @property {HTMLElement} [viewport] 组件所在的视口,如果组件是在页面容器内滚动,视口就是该容器
     * @property {number|string} [threshold=0] 预加载阈值
     * @property {string} [direction=vertical] 视口的滚动方向, vertical代表垂直方向,horizontal代表水平方向
     */
    props: {
      name: {
        type: String,
        default: 'my-lazy'
      },
      // 适配父容器
      fit: Boolean,
      // 等待时间,如果指定了时间,不论可见与否,在指定时间之后自动加载
      timeout: Number,
      // 组件所在的视口,如果组件是在页面容器内滚动,视口就是该容器
      viewport: {
        type: typeof window !== 'undefined' ? window.HTMLElement : Object,
        default() {
          return null
        }
      },
      // 预加载阈值
      threshold: {
        type: [String, Number],
        default: 0
      },
      // 视口的滚动方向, vertical代表垂直方向,horizontal代表水平方向
      direction: {
        type: String,
        default: 'vertical',
        validator(val) {
          return ['vertical', 'horizontal'].includes(val)
        }
      },
      maxWaitingTime: {
        type: Number,
        default: 100
      }
    },
    data() {
      this.timerId = null
      this.io = null
      return {
        isInit: false
      }
    },
    computed: {
      classes() {
        return {
          'my-lazy': true,
          'is-fit': this.fit
        }
      }
    },
    methods: {
      // 交叉情况变化处理函数
      intersectionHandler(entries) {
        if (
          // 正在交叉
          entries[0].isIntersecting ||
          // 交叉率大于0
          entries[0].intersectionRatio > 0
        ) {
          this.init()
          this.io.unobserve(this.$el)
        }
      },
      // 处理组件和骨架组件的切换
      init() {
        // 由于函数会在主线程中执行,加载懒加载组件非常耗时,容易卡顿
        // 所以在requestAnimationFrame回调中延后执行
        this.requestAnimationFrame(() => {
          this.isInit = true
          /**
           * 开始加载懒加载模块,此时骨架组件开始消失
           * @event init
           */
          this.$emit('init')
        })
      },
      requestAnimationFrame(callback) {
        // 防止等待太久没有执行回调
        // 设置最大等待时间
        setTimeout(() => {
          if (this.isInit) return
          callback()
        }, this.maxWaitingTime)
        // 兼容不支持requestAnimationFrame 的浏览器
        return requestAnimationFrame(callback)
      }
    },
    mounted() {
      if (this.timeout) return
      // 根据滚动方向来构造视口外边距,用于提前加载
      let rootMargin
      switch (this.direction) {
        case 'vertical':
          rootMargin = `${parseInt(this.threshold)}px 0px`
          break
        case 'horizontal':
          rootMargin = `0px ${parseInt(this.threshold)}px`
          break
      }
      try {
        // 观察视口与组件容器的交叉情况
        this.io = new window.IntersectionObserver(this.intersectionHandler, {
          rootMargin,
          root: this.viewport,
          threshold: [0, Number.MIN_VALUE, 0.01]
        });
        this.io.observe(this.$el);
      } catch (e) {
        this.init()
      }

    },
    created() {
      // 如果指定timeout则无论可见与否都是在timeout之后初始化
      if (this.timeout) {
        this.timer = setTimeout(() => {
          this.init()
        }, this.timeout)
      }
    },
    beforeDestroy() {
      // 在组件销毁前取消观察
      this.io && this.io.unobserve(this.$el)
      this.timer && clearTimeout(this.timer)

    }

  }
</script>