从IntersectionObserver到el-image源码,剖析图片懒加载那些事

1,022 阅读4分钟

前言

  大家好,我是沐浴在曙光下的贰货道士。最近在处理收单系统的过程中,发现虽然有使用阿里云去压缩订单列表的图片,但由于列表图片数目过多,导致网页渲染较慢。为此,我研究了利用IntersectionObserverelement源码实现图片懒加载的思路。本文只提供一个解决懒加载问题的小demo,如果掘友们有更好的处理方式,欢迎在评论区指点江山。同时,有喜欢本文的朋友,也欢迎一键三连哦~

图片懒加载

  何为图片懒加载?图片懒加载是一种优化网页加载速度的技术,它可以推迟图片的加载时间,直到图片进入用户的视野范围内才会进行加载。这样能够减少页面的初始加载时间和带宽使用,提升用户体验。

  通常情况下,网页中的图片会在页面加载完成后立即进行加载。这意味着即使用户并没有看到这些图片,它们仍然会占用带宽和加载时间,导致页面加载速度变慢。而图片懒加载技术则可以解决这个问题,它只会加载用户当前视野范围内的图片,而不会加载其他图片,从而减少页面的加载时间和带宽使用。

  理解图片懒加载的概念后,我们就能掌握实现图片懒加载的核心思想。

   判断图片是否出现在可视区域:

  • 如果图片未出现在可视区域,则不需要加载图片。我们可以通过使用提前占位(给定和需要懒加载图片同样大小的容器,类似没有样式的骨架屏。或者直接为图片加上loading效果,防止由于后续出现的图片导致页面抖动) 的方式去处理。

  • 如果图片出现在可视区域,则展示图片。

图片懒加载方法

1. 使用 IntersectionObserver

核心思想:

  • 预先渲染所有dom,加载出现在视图区域的图片和一张loading图片
  • 为需要懒加载的图片添加src属性,默认展示loading状态下的图片
  • 为需要懒加载的图片添加自定义属性data-src, 用于接收图片的真实地址
  • 找到页面上所有img标签对应的dom元素, 使用IntersectionObserver对它们进行监听
  • 如果图片dom出现在可视区域,则将src属性替换为图片的真实地址

a. 简单实现

`对于占位的loading图片,因为他们时相同图片,所以浏览器只会加载一次`

<template>
  <div class="lazy-warpper">
    <img 
      ref="img" 
      v-for="src in imageList" 
      :key="src" 
      :src="require('@/assets/images/loading.gif')" 
      :data-src="src" 
    />
  </div>
</template>

<script>
import { getPrototypeList } from '@/api/product'
import { REQUEST_ALL_DATA } from '@/utils/constant'
import { throttle, map } from 'lodash'

export default {
  data() {
    return {
      imageList: []
    }
  },
  async mounted() {
    await this.getImageList()
    this.initObserver()
  },

  methods: {
    async getImageList() {
      const res = await awaitResolve(getPrototypeList({ ...REQUEST_ALL_DATA }))
      if (!res) return
      this.imageList = map(res.detail, `styleList[0].displayImageUrl`)
    },

    initObserver() {
      const observer = new IntersectionObserver(
        `节流函数可用可不用,因为回调方法并不复杂`
        throttle((entries) => {
          entries.forEach(({ isIntersecting, target }) => {
            `isIntersecting用于判断dom元素target是否进入了视口`
            if (isIntersecting) {
              `获取dom元素自定义属性的方式一:`
              target.src = target.dataset.src
              `获取dom元素自定义属性的方式二:`
              // target.src = target.getAttribute('data-src')
              `移除的意义在于,防止加载过的图片再次出现在视口时,重新执行回调函数`
              observer.unobserve(target)
            }
          })
        }, 200),
        { 
         `root的指向:需要懒加载元素的最近具有滚动条的祖先dom元素`
          root: document.querySelector('.topic-page')
        }
      )
      
      this.$refs.img.forEach((image) => {
        `开始监听之后,才会触发回调`
        observer.observe(image)
      })
    }
  }
}
</script>

<style lang="scss" scoped>
.lazy-warpper {
  img {
    width: 300px;
    height: 300px;
    object-fit: contain;
    display: block;
    border: 1px solid #ccc;
    margin-bottom: 20px;
  }
}
</style>

实现效果:

image.png

b. 从图像懒加载,过渡到万物皆可懒加载

核心思想:

  • 需要懒加载的dom元素最好使用v-if来控制。如果使用v-show来控制,其实页面还是渲染了这些dom, 只不过通过display: none给隐藏掉了。
  • 在懒加载的dom元素未出现之前,使用等高等宽的loading图片进行占位,防止页面闪烁
  • 因为需要懒加载的dom在页面初始化时是隐藏的,所以无法通过IntersectionObserver去监听页面上需要懒加载的dom。既然无法监听需要懒加载的dom, 我们可以换一种思路:去监听它的父级(即整个子组件)。只要这个子组件出现在了可视区域,我们就将visible置为true, 需要懒加载的dom自然就显现真容了。
  • 为什么循环需要放在懒加载组件上? 因为懒加载组件下,默认插槽的内容会被视为需要懒加载的dom元素。如果循环放在具有lazy-warpperdiv元素上,因为页面初始化时,懒加载组件就出现在可视区域,此时所有循环的卡片都会渲染,会失去懒加载的效果。而单独放在一个懒加载组件下,只是在当前懒加载组件出现在可视区域时,加载当前需要渲染的某个卡片,也就有了懒加载的效果。
`公共方法:`

`是否是数字`
export function isNumber(val){
  `非负浮点数`
  var regPos = /^\d+(\.\d+)?$/
  `负浮点数`
  var regNeg = /^(-(([0-9]+\.[0-9]*[1-9][0-9]*)|([0-9]*[1-9][0-9]*\.[0-9]+)|([0-9]*[1-9][0-9]*)))$/ 
  return regPos.test(val) || regNeg.test(val)
}

`设置px`
export function setPx(val, defval) {
  if (validatenull(val)) {
    val = defval
  }
  val = val + ''
  if (val.indexOf('%') === -1 && isNumber(val)) {
    val = val + 'px'
  }
  return val
}
`子组件: lazyload.vue`

<template>
  <div ref="container">
    <slot v-if="visible"></slot>
    <el-image
      v-else
      fit="contain"
      :src="require('@/assets/images/loading.gif')"
      :style="{
        width: setPx(width),
        height: setPx(height),
        marginBottom: setPx(mb)
      }"
    ></el-image>
  </div>
</template>

<script>
import { throttle } from 'lodash'
import { setPx } from '@/components/avue/utils/util'

export default {
  props: {
    `IntersectionObserver的第二个参数,因为其本身就具备默认值,就没有用计算属性finalOption`
    `一般的组件封装,会给定一个默认配置项,比如defaultOption,以及传入的option`
    `那么计算属性finalOption({defaultOption, option})的值,merge({}, defaultOption, option)`
    `这样既不会影响defaultOption,后面传入的option如果和defaultOption有冲突,也会以后面的为准`
    `因为这个栗子比较特殊,获取最近的滚动父级元素需要在mounted之后,就没做类似处理`
    option: Object,
    width: String | Number,
    height: String | Number,

    mb: {
      type: Number,
      default: 20
    },
    
    `节流时间控制`
    throttleTime: {
      type: Number,
      default: 200
    }
  },

  data() {
    return {
      visible: false
    }
  },

  mounted() {
    this.initObserver()
  },

  methods: {
    setPx,
    initObserver() {
      const observer = new IntersectionObserver(
        throttle((entries) => {
          entries.forEach(({ isIntersecting, target }) => {
            if (isIntersecting) {
              this.visible = true
              observer.unobserve(target)
            }
          })
        }, this.throttleTime),
        this.option
      )
      ;[this.$refs.container].forEach((dom) => {
        observer.observe(dom)
      })
    }
  }
}
</script>
`父组件`

<template>
  <div class="lazy-container">
    <lazyLoad v-for="(src, index) in imageList" :key="index" width="300" height="330" :option="option">
      <div class="lazy-warpper">
        <el-image class="image" :src="src" fit="contain" />
        <div class="mb20">{{ getTitle(index) }}</div>
      </div>
    </lazyLoad>
  </div>
</template>

<script>
import lazyLoad from './LazyLoad'
import { getPrototypeList } from '@/api/product'
import { REQUEST_ALL_DATA } from '@/utils/constant'
import { map } from 'lodash'

export default {
  components: { lazyLoad },
  data() {
    return {
      imageList: [],
      option: {}
    }
  },

  mounted() {
    this.option = {
      `.topic-page为最近的滚动父级元素类`
      root: document.querySelector('.topic-page')
    }
    this.getImageList()
  },

  methods: {
    async getImageList() {
      const res = await awaitResolve(getPrototypeList({ ...REQUEST_ALL_DATA }))
      if (!res) return
      this.imageList = map(res.detail, `styleList[0].displayImageUrl`)
    },

    getTitle(index) {
      return `产品 ${index + 1}`
    }
  }
}
</script>

<style lang="scss" scoped>
.lazy-warpper {
  .image {
    width: 300px;
    height: 300px;
    display: block;
    border: 1px solid #ccc;
    margin-bottom: 10px;
  }
}
</style>

实现效果: image.png

2. 使用element源码懒加载的思路(getBoundingClientRect()与碰撞思想的花火)

  核心思想和使用IntersectionObserver这一小节半斤八两,只不过判断需要懒加载的dom元素是否有进入可视区域的方法不一样。

  • 当我们使用IntersectionObserve监听需要懒加载的dom元素后,其回调函数中提供了一个内置参数isIntersecting,用于判断当前dom元素是否有出现在可视区域中。由于在监听需要懒加载的dom元素,设置root(不设置即为视口)后,这个api的回调函数会自动帮我们实时计算当前懒加载元素是否出现在root中,所以不需要额外去监听滚动事件。
  • 而使用lazyDom.getBoundingClientRect()时,由于需要懒加载的dom是实时变化的,所以需要在页面初始化时监听滚动事件,实时监听当前需要懒加载的dom元素是否有出现在视口中。而且由于页面初始化时,并未触发滚动事件,但是那些出现在视口中的dom元素,是需要一开始就加载的。为此,我们需要在页面初始化时,手动调用一次监听页面滚动事件的方法,以期正常加载出现在视口中的dom元素,而且需要在beforeDestroy钩子函数中移除监听的滚动事件。
image.png
`以下是element内部封装的一个方法,用于判断当前组件的dom元素el是否有出现在可视区域container中`

export const isInContainer = (el, container) => {
  if (isServer || !el || !container) return false;

  const elRect = el.getBoundingClientRect();
  let containerRect;
  
  `当container是全局对象或者未定义时,会将整个浏览器窗口视为containerRect`
  `此时containerRect铺满整个可视区域,top和left与浏览器视口的距离自然为0`
  `bottom即为浏览器的高,right即为浏览器的宽`
  if ([window, document, document.documentElement, null, undefined].includes(container)) {
    containerRect = {
      top: 0,
      right: window.innerWidth,
      bottom: window.innerHeight,
      left: 0
    };
  } else {
    `当container是特定dom元素时,就用getBoundingClientRect()这个api,`
    `计算这个dom元素与浏览器视口的距离`
    containerRect = container.getBoundingClientRect();
  }
  
  `判断elRect是否与containerRect碰撞的核心思想:`
  ``
  return elRect.top < containerRect.bottom &&
    elRect.bottom > containerRect.top &&
    elRect.right > containerRect.left &&
    elRect.left < containerRect.right;
}

浅析两个dom元素的碰撞原理:

  • el如果要想出现在container垂直方向内部,必须同时满足两个条件:elbottom必须大于containertop,此时elcontainer上方进入container。为了避免el在竖直方向上离开container(即出现在container下方),此时需要满足containerbottom大于eltop;
  • 两个dom元素想要碰撞,仅仅在竖直方向上满足条件是不行的。因为elcontainer垂直方向内部,但是它可能向container左边或者右边偏移,此时无法达到这两个dom有相交重叠的目的。el除了需要满足在container垂直方向内部, 还需要满足在container水平方向内部,这时才能达到真正意义上的相交和重叠。同理,在水平方向上,需要满足elleft需要小于containerright,elright需要大于containerleft

a. 简单实现

<template>
  <div class="lazy-warpper">
    <img ref="img" v-for="src in imageList" :key="src" :src="require('@/assets/images/loading.gif')" :data-src="src" />
  </div>
</template>

<script>
import { getPrototypeList } from '@/api/product'
import { REQUEST_ALL_DATA } from '@/utils/constant'
import { isInContainer } from 'element-ui/src/utils/dom'
import { map } from 'lodash'

export default {
  data() {
    return {
      imageList: []
    }
  },
  async mounted() {
    await this.getImageList()
    `一般情况下,是监听dom上某个滚动元素的滚动事件, 即document.querySelector('.topic-page')上绑定`
    `但是我们可以指定addEventListener的第三个参数,来判断事件处理函数是在捕获阶段还是冒泡阶段被调用`
    `true: 捕获。从最顶层的元素开始传播,沿着 DOM 树向下传播,直到到达最具体的元素。`
    `false: 冒泡。事件首先被触发在最具体的元素上,然后沿着DOM树依次向上传播,直到到达最顶层的元素(一般window)`
    document.addEventListener('scroll', this.onScroll, true)
    this.onScroll()
    this.$once('hook:beforeDestroy', () => document.removeEventListener('scroll', this.onScroll, true))
  },

  methods: {
    async getImageList() {
      const res = await awaitResolve(getPrototypeList({ ...REQUEST_ALL_DATA }))
      if (!res) return
      this.imageList = map(res.detail, `styleList[0].displayImageUrl`)
    },

    onScroll() {
      const root = document.querySelector('.topic-page')
      this.$refs.img.forEach((image) => {
        const isIntersecting = isInContainer(image, root)
        if (isIntersecting && !image.dataset.load) {
          image.src = image.dataset.src
          image.dataset.load = true
        }
      })
    }
  }
}
</script>

<style lang="scss" scoped>
.lazy-warpper {
  img {
    width: 300px;
    height: 300px;
    object-fit: contain;
    display: block;
    border: 1px solid #ccc;
    margin-bottom: 20px;
  }
}
</style>

b. 从图像懒加载,过渡到万物皆可懒加载

`子组件:`

<template>
  <div ref="container">
    <slot v-if="visible"></slot>
    <el-image
      v-else
      fit="contain"
      :src="require('@/assets/images/loading.gif')"
      :style="{
        width: setPx(width),
        height: setPx(height),
        marginBottom: setPx(mb)
      }"
    ></el-image>
  </div>
</template>

<script>
import { isInContainer } from 'element-ui/src/utils/dom'
import { setPx } from '@/components/avue/utils/util'

export default {
  props: {
    option: {
      type: Object,
      default: {
        root: document.querySelector('.app-container')
      }
    },
    width: String | Number,
    height: String | Number,

    mb: {
      type: Number,
      default: 20
    }
  },

  data() {
    return {
      visible: false
    }
  },

  mounted() {
    document.addEventListener('scroll', this.onScroll, true)
    this.onScroll()
    this.$once('hook:beforeDestroy', () => document.removeEventListener('scroll', this.onScroll, true))
  },

  methods: {
    setPx,
    onScroll() {
      ;[this.$refs.container].forEach((dom) => {
        const isIntersecting = isInContainer(dom, this.option.root)
        if (isIntersecting && !dom.dataset.load) {
          this.visible = true
          dom.dataset.load = true
        }
      })
    }
  }
}
</script>
`父组件和使用IntersectionObserver b小节的父组件一样,就不再赘述`

c. 灵魂拷问

通过对比两种方法,我们不难发现,相比getBoundingClientRect,使用IntersectionObserve操作起来会更加简单。那我们为什么不使用IntersectionObserve呢?答案其实很简单:条条大路通罗马,解决问题的方式有成千上万种。但是作为前端开发,除了完成基本功能之外,我们还需要考虑很多其它东西,比如兼容性。IntersectionObserve是一个比较新的api,相比于getBoundingClientRect, 它的兼容性要更差。

来自caniuse的灵魂对比:

image.png

image.png

3. 利用element懒加载组件, 实现万物皆可懒加载

  在这一小节,我们利用element懒加载组件的部分propsmethods, 结合我们封装在组件中的默认插槽,实现万物皆可懒加载的效果,其实核心原理就是我们第2小节讲述的思路。

image.png

image.png

1. element源码分析

概览

  • lazy:表示是否启用懒加载,类型为布尔值。如果设置为 true,则启用懒加载功能。
  • show:表示图片是否应当显示,类型为布尔值。如果 lazytrue,则初始值为 false(即不显示图片);如果 lazyfalse,则初始值为 true(即立即显示图片)。

数据属性和计算属性

javascript
复制代码
data() {
  return {
    loading: true,
    error: false,
    show: !this.lazy, // 如果 lazy  true,则 show  false;如果 lazy  false,则 show  true
    imageWidth: 0,
    imageHeight: 0,
    showViewer: false
  };
}

showlazy 的关系

  • show 的初始值根据 lazy 的取反值来决定:!this.lazy

    • 如果 lazytrue,则 showfalse,表示初始时不显示图片。
    • 如果 lazyfalse,则 showtrue,表示初始时立即显示图片。

懒加载的实现

  1. mounted 生命周期钩子
mounted() {
  if (this.lazy) {
    this.addLazyLoadListener(); // 如果启用了懒加载,则添加懒加载监听器
  } else {
    this.loadImage(); // 否则立即加载图片
  }
}
  • 在组件挂载后,如果 lazytrue,则调用 addLazyLoadListener 方法,添加懒加载监听器。
  • 如果 lazyfalse,则立即调用 loadImage 方法加载图片。
  1. 添加懒加载监听器
addLazyLoadListener() {
  if (this.$isServer) return;

  const { scrollContainer } = this;
  let _scrollContainer = null;

  if (isHtmlElement(scrollContainer)) {
    _scrollContainer = scrollContainer;
  } else if (isString(scrollContainer)) {
    _scrollContainer = document.querySelector(scrollContainer);
  } else {
    _scrollContainer = getScrollContainer(this.$el);
  }

  if (_scrollContainer) {
    this._scrollContainer = _scrollContainer;
    this._lazyLoadHandler = throttle(200, this.handleLazyLoad);
    on(_scrollContainer, 'scroll', this._lazyLoadHandler);
    this.handleLazyLoad(); // 初始时检查图片是否在可视区域内
  }
}
  • 检查 scrollContainer 是否为 HTML 元素或选择器字符串,如果不是则尝试获取包含该元素的滚动容器。
  • 设置 _scrollContainer 并为其添加 scroll 事件监听器,该监听器使用 throttle 函数节流,每 200 毫秒检查一次图片是否在可视区域内。
  • 初始时调用 handleLazyLoad 方法,检查图片是否在可视区域内。
  1. 懒加载处理
handleLazyLoad() {
  if (isInContainer(this.$el, this._scrollContainer)) {
    this.show = true;
    this.removeLazyLoadListener();
  }
}
  • 使用 isInContainer 检查图片元素是否在滚动容器的可视区域内。
  • 如果在可视区域内,则设置 showtrue,显示图片,并移除懒加载监听器。
  1. 移除懒加载监听器
removeLazyLoadListener() {
  const { _scrollContainer, _lazyLoadHandler } = this;

  if (this.$isServer || !_scrollContainer || !_lazyLoadHandler) return;

  off(_scrollContainer, 'scroll', _lazyLoadHandler);
  this._scrollContainer = null;
  this._lazyLoadHandler = null;
}
  • _scrollContainer 中移除 scroll 事件监听器。
  • 清除 _scrollContainer_lazyLoadHandler 引用。

懒加载流程

  1. 组件挂载时,检查 lazy 属性:

    • 如果 lazytrue,调用 addLazyLoadListener 添加懒加载监听器。
    • 如果 lazyfalse,立即调用 loadImage 加载图片。
  2. addLazyLoadListener 方法添加 scroll 事件监听器,并立即检查图片是否在可视区域内。

  3. 当图片进入可视区域时,handleLazyLoad 方法将 show 设置为 true,触发图片加载,并移除懒加载监听器。

  4. show 设置为 true 时,图片元素显示并开始加载。

通过这些步骤,el-image 组件实现了懒加载功能,有效减少了页面加载时的资源开销,提高了性能。

2. 组件封装

`子组件:`

<template>
  <div class="lazy-warpper">
    <slot v-if="show" v-on="$listeners" v-bind="$attrs" />
    <!--采用无样式的骨架屏进行占位-->
    <div
      v-else
      :style="{
        width: setPx(width),
        height: setPx(height)
      }"
    >
    </div>
  </div>
</template>

<script>
`imageData为element全局注册的组件`
import imageData from 'element-ui/packages/image'
import { setPx } from '@/components/avue/utils/util'

`拿到el-image组件实例的部分props和methods,并挂载到自定义组件中`
`本组件只用到了addLazyLoadListener这个方法,为什么还引入handleLazyLoad和removeLazyLoadListener?`   
`因为这两个方法有在addLazyLoadListener中调用`
function createExtendOption() {
  const DEFAULT_EXTEND = {
    props: ['lazy', 'scrollContainer'],
    methods: ['addLazyLoadListener', 'handleLazyLoad', 'removeLazyLoadListener']
  }
  return Object.keys(DEFAULT_EXTEND).reduce((cur, prev) => {
    const val = imageData[prev]
    const props = DEFAULT_EXTEND[prev]
    const extendVal = (cur[prev] = {})
    props.map((prop) => {
      extendVal[prop] = val[prop]
    })
    return cur
  }, {})
}

const option = createExtendOption()

export default {
  props: {
    ...option.props,

    lazy: {
      type: Boolean,
      default: true
    },

    width: {
      type: String | Number,
      default: '68'
    },

    height: {
      type: String | Number,
      default: '68'
    }
  },

  data() {
    return {
      show: !this.lazy
    }
  },

  mounted() {
    if (this.lazy) {
      this.addLazyLoadListener()
    }
  },

  methods: {
    ...option.methods,

    setPx
  }
}
</script>

<style lang="scss" scoped></style>
`父组件:`

<template>
  <div class="lazy-container">
    <lazyLoad v-for="(src, index) in imageList" :key="index" width="300" height="330">
      <div class="lazy-warpper">
        <el-image class="image" :src="src" fit="contain" />
        <div class="mb20">{{ getTitle(index) }}</div>
      </div>
    </lazyLoad>
  </div>
</template>

<script>
import lazyLoad from './lazyLoad'
import { getPrototypeList } from '@/api/product'
import { REQUEST_ALL_DATA } from '@/utils/constant'
import { map } from 'lodash'

export default {
  components: { lazyLoad },
  data() {
    return {
      imageList: []
    }
  },

  mounted() {
    this.getImageList()
  },

  methods: {
    async getImageList() {
      const res = await awaitResolve(getPrototypeList({ ...REQUEST_ALL_DATA }))
      if (!res) return
      this.imageList = map(res.detail, `styleList[0].displayImageUrl`)
    },

    getTitle(index) {
      return `产品 ${index + 1}`
    }
  }
}
</script>

<style lang="scss" scoped>
.lazy-warpper {
  .image {
    width: 300px;
    height: 300px;
    display: block;
    border: 1px solid #ccc;
    margin-bottom: 10px;
  }
}
</style>

实现效果:

image.png

结语

往期精彩推荐(强势引流):

  面试不面试,你都必须得掌握的vue知识

  无论如何,你都必须得掌握的JS知识

  无论如何,你都必须得掌握的JS知识(续)

  我的css世界

  什么?都2022年了,你还在一遍又一遍重复写form表单?

  大概就这样吧, 有兴趣的掘友们可以去试试~ 更多精彩内容,正在努力摸鱼创作中,尽请期待。