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

前言

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

图片懒加载

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

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

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

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

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

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

图片懒加载方法

1. 使用 IntersectionObserver

核心思想:

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

a. 简单实现

html 复制代码
`对于占位的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>

实现效果:

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

核心思想:

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

`是否是数字`
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
}
html 复制代码
`子组件: 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})的值,结果就是{...defaultOption, ...option}`
    `这样既不会影响defaultOption,后面传入的option如果和defaultOption有冲突,也会以后面的为准`
    `因为这个栗子比较特殊,就没做类似处理`
    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>
html 复制代码
`父组件`

<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>

实现效果:

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

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

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

js 复制代码
<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. 从图像懒加载,过渡到万物皆可懒加载

html 复制代码
`子组件:`

<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>
html 复制代码
`父组件和使用IntersectionObserver b小节的父组件一样,就不再赘述`

c. 灵魂拷问

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

来自caniuse的灵魂对比:

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

在这一小节,我们利用element懒加载组件的部分propsmethods, 结合我们封装在组件中的默认插槽,实现万物皆可懒加载的效果,其实核心原理就是我们第2小节讲述的思路。具体细节我就不赘述了,有兴趣的小伙伴可以自行查看element源码。

html 复制代码
`子组件:`

<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>
html 复制代码
`父组件:`

<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>

实现效果:

结语

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

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

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

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

我的css世界

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

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

相关推荐
陈随易43 分钟前
农村程序员-关于小孩教育的思考
前端·后端·程序员
云深时现月44 分钟前
jenkins使用cli发行uni-app到h5
前端·uni-app·jenkins
昨天今天明天好多天1 小时前
【Node.js]
前端·node.js
亿牛云爬虫专家1 小时前
Puppeteer教程:使用CSS选择器点击和爬取动态数据
javascript·css·爬虫·爬虫代理·puppeteer·代理ip
2401_857610031 小时前
深入探索React合成事件(SyntheticEvent):跨浏览器的事件处理利器
前端·javascript·react.js
_xaboy1 小时前
开源项目低代码表单设计器FcDesigner扩展自定义的容器组件.例如col
vue.js·低代码·开源·动态表单·formcreate·低代码表单·可视化表单设计器
_xaboy1 小时前
开源项目低代码表单设计器FcDesigner扩展自定义组件
vue.js·低代码·开源·动态表单·formcreate·可视化表单设计器
雾散声声慢1 小时前
前端开发中怎么把链接转为二维码并展示?
前端
熊的猫1 小时前
DOM 规范 — MutationObserver 接口
前端·javascript·chrome·webpack·前端框架·node.js·ecmascript
天农学子1 小时前
Easyui ComboBox 数据加载完成之后过滤数据
前端·javascript·easyui