天天看點

如何使用 IntersectionObserver API 來實作資料的懶加載?(以 Vue3 為例)

在前端開發中,有些時候我們需要等到目标元素出現在視口中(被使用者看到)才進行某些操作,最常見的就是資料懶加載,比如圖檔的懶加載,這樣做的意義是:

  • 其一:快速地呈現第一屏資料給使用者(假設一個頁面很多圖檔,如果一下子全部加載,接口傳回較慢,會阻塞頁面的渲染,使用者可能需要好幾秒甚至更久的時間才能看到内容,這是不能忍受的)
  • 其二:緩解伺服器的壓力(一個頁面有很多圖檔,使用者可能隻看了第一屏或者前幾屏的圖檔,後面就不往下看了,這時候後面的圖檔就失去了加載的意義,耗費了伺服器資源卻得不到相應的收益)

今天我們來使用 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

如何使用 IntersectionObserver API 來實作資料的懶加載?(以 Vue3 為例)

現在假設每個目标元素可見時,就加載其對應的資料填充到頁面上,我們補充一下代碼,為了保證個目标元素的資料隻加載一次,我們給每個目标元素一個控制變量,标志其是否已經進入過視口,當目标元素進入視口時,如果其資料已經加載過,則不再加載

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>

           

在高階元件中,我們隻需觀察

如何使用 IntersectionObserver API 來實作資料的懶加載?(以 Vue3 為例)

即可,其子元素即是通過 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>
           

上述使用高階元件來設定觀察器,我們觀察了

如何使用 IntersectionObserver API 來實作資料的懶加載?(以 Vue3 為例)

,這樣其實就在每個 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不可太大) 條來渲染在頁面上。

怎麼實作?以後再說。

繼續閱讀