在前端开发中,有些时候我们需要等到目标元素出现在视口中(被用户看到)才进行某些操作,最常见的就是数据懒加载,比如图片的懒加载,这样做的意义是:

  • 其一:快速地呈现第一屏数据给用户(假设一个页面很多图片,如果一下子全部加载,接口返回较慢,会阻塞页面的渲染,用户可能需要好几秒甚至更久的时间才能看到内容,这是不能忍受的)
  • 其二:缓解服务器的压力(一个页面有很多图片,用户可能只看了第一屏或者前几屏的图片,后面就不往下看了,这时候后面的图片就失去了加载的意义,耗费了服务器资源却得不到相应的收益)

今天我们来使用 IntersectionObserver 来实现一下数据的懒加载

IntersectionObserver 名为交叉观察器,是浏览器提供的一个“观察”元素是否可见的 API,具体介绍可阅读阮一峰老师的这篇文章:IntersectionObserver API 使用教程

我们通过观察目标元素的可见性来加载数据,那么这里的目标元素分为两种,一种是固定的(即在页面上始终存在,个数是确定),一种是动态生成的(通过数据来生成的,个数不确定),下面以 Vue3 为例来介绍如何观察这两种元素

观察固定的目标元素

现在页面上有6个固定的元素(content1~content6)

<template>
  <div class="warpper">
    <div id="content1" class="content">
      content1
    </div>
    <div id="content2" class="content">
      content2
    </div>
    <div id="content3" class="content">
      content3
    </div>
    <div id="content4" class="content">
      content4
    </div>
    <div id="content5" class="content">
      content5
    </div>
    <div id="content6" class="content">
      content6
    </div>
  </div>
</template>

现在观察这六个目前元素(注意:一定要等到目标元素渲染在页面上的时候才可以调用 IntersectionObserver 的 observe 方法,这里是在 mounted 生命周期钩子里来初始化观察者并观察目标元素,如果你要观察的 DOM 依赖于某些条件为 true 的时候才渲染,那么得在该条件为 true 的时候再观察它)

从如下代码中可以看到,observe 方法接受一个 DOM 作为参数,这里我通过 getElementById 方法来获取目标 DOM,也可以使用模板引用(ref)来获取目标 DOM

<script setup>
  import {onMounted, onBeforeUnmount, ref} from 'vue'

  const observer = ref(null)

  onMounted(() => {
    const observerCallback = (entries) => {
      entries.forEach(entry => {
        if (entry.intersectionRatio > 0) { // 被观察者进入视口
          console.log(entry.target.id)
        }
      });
    }
    // 初始化观察者实例
    observer.value = new IntersectionObserver(observerCallback)

    // 开始观察目标元素
    observer.value.observe(document.getElementById('content1'))
    observer.value.observe(document.getElementById('content2'))
    observer.value.observe(document.getElementById('content3'))
    observer.value.observe(document.getElementById('content4'))
    observer.value.observe(document.getElementById('content5'))
    observer.value.observe(document.getElementById('content6'))
  })

  onBeforeUnmount(() => {
     // 停止观察目标元素
    observer.value.unobserve(document.getElementById('content1'))
    observer.value.unobserve(document.getElementById('content2'))
    observer.value.unobserve(document.getElementById('content3'))
    observer.value.unobserve(document.getElementById('content4'))
    observer.value.unobserve(document.getElementById('content5'))
    observer.value.unobserve(document.getElementById('content6'))
  })
</script>

测试结果如下:

从视频中可以看出,一开始前三个元素已经出现在视口中,所以打印了content1~content3,随着滚动条向下滚动,随后 content4~content6 依次出现在视口中,依次打印 content4~content6

intersectionRatio 属性(目标元素与视口的交叉比例) 的取值区间为 [0-1],为0则目标元素完全不可见,为1则目标元素完全可见,其具体取值可根据实际需要来调整,比如如果希望目标元素的一半以上出现在视口中再进行业务逻辑操作,则 entry.intersectionRatio > 0.5

在这里插入图片描述

现在假设每个目标元素可见时,就加载其对应的数据填充到页面上,我们补充一下代码,为了保证个目标元素的数据只加载一次,我们给每个目标元素一个控制变量,标志其是否已经进入过视口,当目标元素进入视口时,如果其数据已经加载过,则不再加载

const hasLoadedData = ref({
    'content1':false,
    'content2':false,
    'content3':false,
    'content4':false,
    'content5':false,
    'content5':false,
  })

const loadData = (target) => {
   console.log(target)
   // 具体的请求逻辑
   // ...
 }

const oberverCallback = (entries) => {
      entries.forEach(entry => {
        if (entry.intersectionRatio > 0) { // 被观察者进入视口
          switch (entry.target.id) {
            case 'content1':
              {
                if (!hasLoadedData.value['content1']) { // 第一次加载
                  loadData('content1') // 加载数据
                  hasLoadedData.value['content1'] = true  // 加载完数据后,把其标记为已加载
                }
              }
            break
            case 'content2':
              {
                if (!hasLoadedData.value['content2']) { 
                  loadData('content2')
                  hasLoadedData.value['content2'] = true
                }
              }
            break
            case 'content3':
              {
                if (!hasLoadedData.value['content3']) { 
                  loadData('content3')
                  hasLoadedData.value['content3'] = true
                }
              }
            break
            case 'content4':
              {
                if (!hasLoadedData.value['content4']) {
                  loadData('content4')
                  hasLoadedData.value['content4'] = true
                }
              }
            break
            case 'content5':
              {
                if (!hasLoadedData.value['content5']) { 
                  loadData('content5')
                  hasLoadedData.value['content5'] = true
                }
              }
            break
            case 'content6':
              {
                if (!hasLoadedData.value['content6']) { 
                  loadData('content6')
                  hasLoadedData.value['content6'] = true
                }
              }
            break
            default:
              break
          }
          
        }
      });
    }

观察上述代码,可发现 content1 ~ content6 出现在了很多地方,比较优雅的写法是建立一个 map,这样就能统一管理 content1 ~ content6,如果需要改动,则只需改动 map 即可,如下:

 const targetIdMap = {
    content1: 'content1',
    content2: 'content2',
    content3: 'content3',
    content4: 'content4',
    content5: 'content5',
    content6: 'content6',
  }

观察动态生成的目标元素

方式1: 直接在子组件中设置观察器

父组件

<template>
  <div v-for="contentData in data" class="warpper">
   <Content :content-data="contentData"/>
  </div>
</template>

<script setup>
  import { computed } from 'vue'
  import Content from './components/Content.vue'

  const data = computed(() => {
    const res = []
    for (let i=0; i<15; i++) {
      res.push({
        id: `content${i+1}`,
        content: `content${i+1}`
      })
    }
    return res
  })

</script>

父组件循环了 15 条 data,将生成 15 个 content

子组件(Content 组件)

<template>
    <div :id="props.contentData.id" class="dataItem">
          {{props.contentData.content}}
    </div>
</template>

<script setup>

 import { ref,onMounted, onBeforeUnmount } from 'vue'

 const observer = ref(null)
 const hasLoadedData = ref(false)

  const props = defineProps({
    contentData: {
      type: Object,
      require: true
    }
  })

  const loadData = (target) => {
    console.log(target)
    // 具体的请求逻辑
    // ...
  }

  onMounted(() => {
    const observerCallBack = (entries) => {
      entries.forEach((entry) => {
        if (entry.intersectionRatio > 0 && !hasLoadedData.value) {
          hasLoadedData.value = true // 当前 content数据 记录为已加载
          loadData(entry.target.id)
        }
      })
    }

    // 初始化观察者实例
    observer.value = new IntersectionObserver(observerCallBack)

    // 开始观察目标元素
    observer.value.observe(document.getElementById(`${props.contentData.id}`))
  })

  onBeforeUnmount(() => {
    // 停止观察目标元素
    observer.value.unobserve(document.getElementById(`${props.contentData.id}`))
  })

</script>

方式2: 使用一个高阶组件包住子组件,然后在高阶组件中设置观察器即可

这样做的好处是:

  • 其一:子组件只管渲染内容,不涉及任何自身的状态的管理,即子组件是无状态组件
  • 其二:逻辑可复用,任何其他地方需要用到观察器,则只需要使用高阶组件包住即可

以下代码使用了 vue 提供的 slot 插槽特性,如果对插槽不太熟悉的朋友可以到官网看看插槽 Slots

高阶组件


<template>
  <div ref="warpperRef">
    <slot name="content"></slot>
  </div>
</template>

<script setup>
  import {ref, onMounted, onBeforeUnmount } from 'vue'

  const warpperRef = ref(null)
  const oberver = ref(null)
  const hasLoadedData = ref(false)

  const loadData = (target) => {
    console.log(target)
    // ...这里需要传递状态给该高阶组件的父组件,让父组件去执行请求,拿到数据后传递给 content 子组件
  }

  onMounted(() => {
    const oberverCallback = (entries) => {
      entries.forEach(entry => {
        if (entry.intersectionRatio > 0 && !hasLoadedData.value) { // 被观察者进入视口
          hasLoadedData.value = true
          loadData(entry.target)
        }
      });
    }
    // 初始化观察者实例
    oberver.value = new IntersectionObserver(oberverCallback)

    // 开始观察目标元素
    oberver.value.observe(warpperRef.value)
  })

  onBeforeUnmount(() => {
    // 停止观察目标元素
    oberver.value.unobserve(warpperRef.value)
  })
</script>

在高阶组件中,我们只需观察
即可,其子元素即是通过 slot 渲染出来的 Content 组件

父组件

<template>
  <div v-for="contentData in data" class="warpper">
    <ContentWrapper>
      <template #content>
        <Content :content-data="contentData"/>
      </template>
    </ContentWrapper>
  </div>
</template>

<script setup>
  import { computed } from 'vue'
  import Content from './components/Content.vue'
  import ContentWrapper from './components/ContentWrapper.vue'

  const data = computed(() => {
    const res = []
    for (let i=0; i<15; i++) {
      res.push({
        id: `content${i+1}`,
        content: `content${i+1}`
      })
    }
    return res
  })

</script>

然后 content 组件便可删掉内部的观察逻辑,只管渲染,调整如下:

<template>
    <div :id="props.contentData.id" class="dataItem">
          {{props.contentData.content}}
    </div>
</template>

<script setup>

  const props = defineProps({
    contentData: {
      type: Object,
      require: true
    }
  })
  
</script>

上述使用高阶组件来设置观察器,我们观察了
,这样其实就在每个 Content 组件内容上多包了一层 div,如果不希望多这么一层 div 的话,可以把观察器的逻辑写成一个 hook(react 中的概念),在 vue3 中叫**组合式函数**,写法和 hook 相似,具体如下:

import { onBeforeUnmount, ref } from 'vue'

/**
 * @param dom 要观察的目标元素
 */
export const useIntersectionObserver = (dom) => {
  const isInViewPort = ref(false)

  const observerCallback = (entries:any) => {
    entries.forEach((entry:any) => {
      if (entry.isIntersecting) {
        if (entry.intersectionRatio > 0) { // 被观察者进入视口
          isInViewPort.value = true
        }
      } 
    })
  }

  const observer = new IntersectionObserver(observerCallback)
  observer.observe(dom)

  onBeforeUnmount(() => observer.unobserve(dom))

  return {
    isInViewPort,
  }
}

这样我们在组件中使用这个组合式函数,通过监听(watch)其传递出来的 isInViewPort 来判断目标元素是否已经进入视口,从而进行相应的业务逻辑操作

在我们上述的代码示例中,我们用的是原生的 IntersectionObserver API,但目前它的兼容性还不够友好,所以在实际的业务场景中,我们一般使用
intersection-observer 这个 npm 包。

好了,至此我们已经使用 IntersectionObserver 实现了数据的懒加载,可以结束了吗?答案是还没。

我们在 观察动态生成的目标元素 示例中动态生成了 15 个 Content,那如果是 100 个 Content 甚至更多呢?而且在实际的业务场景中,我们的 Content 组件的 DOM 结构要复杂得多,可能会有深层嵌套,如果我们把所有的 Content 都渲染出来,那么页面可能会崩掉。

解决办法是使用虚拟列表,不管用户滚动加载了多少条数据,我始终取其中的 n(n不可太大) 条来渲染在页面上。

怎么实现?以后再说。

Logo

前往低代码交流专区

更多推荐