iT邦幫忙

2024 iThome 鐵人賽

DAY 26
0
自我挑戰組

30 天 vueuse 原始碼閱讀與實作系列 第 26

[Day 26] useInfiniteScroll

  • 分享至 

  • xImage
  •  

官方 Demo:https://vueuse.org/core/useInfiniteScroll/#useinfinitescroll

useInfiniteScroll 參數

  • element:必要參數,通常會傳入 scoll 容器的 DOM 元素。
  • onLoadMore:必要參數,在滾動到接近目標位置時,要執行的 function。
  • options
    • direction:可以是 toprightbottomleft,預設為 bottom,決定目標位置的方向。例如 bottom 的話就是滾動到底部的目標位置時,觸發 onLoadMore
    • distance:預設為 0。假設設定 10,direction 是 bottom 的話,滾動到離底部 10px 的距離就會觸發 onLoadMore
    • interval:決定等待多少 ms 後,才能再次觸發 onLoadMoreÍ,預設為 100ms。假如設定為 500ms,即使 user 快速滾動到底部,系統也會確保每次執行 onLoadMore 之間至少間隔 500 毫秒。
    • canLoadMore:預設為 () => true。舉例來說,假設已經從 API 知道沒有下一頁的資料可以拿了,可以把判斷寫在 canLoadMore 讓他 return false,這樣就不會進一步執行 onLoadMore
    • 其餘的 options 之前看 useScroll 的時候有稍微帶到,等等如果遇到會在大略說明。

Demo code

先看官方 Demo 的 source code,這部分搭配官方 Demo 畫面會比較有感覺:

<!-- src/components/UseInfiniteScrollDemo.vue -->

<script setup>
import { ref } from 'vue'
import { useInfiniteScroll } from '@/compositions/useInfiniteScroll'

const el = ref(null)
const data = ref([])

const { reset } = useInfiniteScroll(
  el,
  () => {
    const length = data.value.length + 1
    data.value.push(...Array.from({ length: 5 }, (_, i) => length + i))
  },
  { distance: 10 },
)

function resetList() {
  data.value = []
  reset()
}
</script>

<template>
  <h2>UseInfiniteScroll Demo</h2>
  <div ref="el" class="flex flex-col gap-2 p-4 w-300px h-300px m-auto overflow-y-scroll bg-gray-500/5 rounded">
    <div v-for="item in data" :key="item" class="h-15 bg-gray-500/5 rounded p-3">
      {{ item }}
    </div>
  </div>
  <button @click="resetList()">
    Reset
  </button>
</template>

可以看到傳給 useInfiniteScroll 的參數,第一個參數 el 就是畫面中那個 scroll container,第二個先跳過,第三個參數設定 distance 為 10,用途參考前面參數介紹。接著來看重點,第二個參數:

() => {
    const length = data.value.length + 1
    data.value.push(...Array.from({ length: 5 }, (_, i) => length + i))
}

在滾動到底部的時候,會執行這個 function。
這邊使用到 Array.from({ length: 5 }, (_, i) => length + i),第一次執行的時候會拿到 [1, 2, 3, 4, 5],第二次執行會拿到 [6, 7, 8, 8, 10],依此類推,詳細用法可以再參考 mdn~
這邊因為是用 push,所以會有 Demo 那種一直往下增加的效果。

另外可以看到 useInfiniteScroll 有回傳 reset 這個 function,在 data.value = [] 後,scroll container 這個 DOM 的相關數值會變動,需要透過 reset 來重新計算。

useInfiniteScroll source code

useInfiniteScroll 的原始碼行數不多,這邊就先貼全部,再分段來看:

// src/compositions/useInfiniteScroll.js

import { computed, nextTick, reactive, ref, watch } from 'vue'
import { tryOnUnmounted } from '@/utils/shared'
import { resolveElement, toValue } from '@/helper'
import { useElementVisibility } from '@/compositions/useElementVisibility'
import { useScroll } from '@/compositions/useScroll'

export function useInfiniteScroll(
  element,
  onLoadMore,
  options = {},
) {
  const {
    direction = 'bottom',
    interval = 100,
    canLoadMore = () => true,
  } = options

  const state = reactive(useScroll(
    element,
    {
      ...options,
      offset: {
        [direction]: options.distance ?? 0,
        ...options.offset,
      },
    },
  ))

  const promise = ref()
  const isLoading = computed(() => !!promise.value)

  const observedElement = computed(() => {
    return resolveElement(toValue(element))
  })

  const isElementVisible = useElementVisibility(observedElement)

  function checkAndLoad() {
    state.measure()

    if (!observedElement.value || !isElementVisible.value || !canLoadMore(observedElement.value))
      return

    const { scrollHeight, clientHeight, scrollWidth, clientWidth } = observedElement.value
    const isNarrower = (direction === 'bottom' || direction === 'top')
      ? scrollHeight <= clientHeight
      : scrollWidth <= clientWidth

    if (state.arrivedState[direction] || isNarrower) {
      if (!promise.value) {
        promise.value = Promise.all([
          onLoadMore(state),
          new Promise(resolve => setTimeout(resolve, interval)),
        ])
          .finally(() => {
            promise.value = null
            nextTick(() => checkAndLoad())
          })
      }
    }
  }

  const stop = watch(
    () => [state.arrivedState[direction], isElementVisible.value],
    checkAndLoad,
    { immediate: true },
  )

  tryOnUnmounted(stop)

  return {
    isLoading,
    reset() {
      nextTick(() => checkAndLoad())
    },
  }
}

接下來從觸發點開始看:

const stop = watch(
    () => [state.arrivedState[direction], isElementVisible.value],
    checkAndLoad,
    { immediate: true },
)

假設 direction 為 bottomdistance 為 0;

state.arrivedState[direction] 的值是 true 或是 false,如果是 true 的話代表當下已經滾動到最底部。詳細可以參考之前看過的 Day 21 useScroll

isElementVisible.value 的值是 true 或是 false,如果是 false,以 Demo 為例的話,就是 scroll container 不在畫面中,既然不在畫面中,就不需要進行計算(等等在核心程式碼中會看到)。isElementVisible 詳細可以參考之前看過的 Day 24 useElementVisibility

以上這兩個值變動時,都會觸發 useInfiniteScroll 的核心 - checkAndLoad

接著聚焦在 checkAndLoad,最一開始呼叫的 state.measure() 也是 useScroll 回傳的東西,主要是強制 useScroll 做一些 scroll container DOM 相關數值的計算與更新,像是剛剛提到的 state.arrivedState 也是其中之一,沒有做這個更新的話,state.arrivedState 會拿到 useScroll 設定的預設回傳值,Day 22 的 return measure 區塊有大概提到。

接著來看判斷式:

if (!observedElement.value || !isElementVisible.value || !canLoadMore(observedElement.value))
      return

!observedElement.value 這個滿明顯的,沒有 scroll container 好像就沒得玩了 XD
!isElementVisible.value 剛剛有提到,scroll container 不在畫面中的時候,不需要做計算。
!canLoadMore(observedElement.value) 這個可以參考最前面的參數介紹~

關於 isNarrower 的判斷:

const { scrollHeight, clientHeight, scrollWidth, clientWidth } = observedElement.value
const isNarrower = (direction === 'bottom' || direction === 'top')
      ? scrollHeight <= clientHeight
      : scrollWidth <= clientWidth

if (state.arrivedState[direction] || isNarrower) {
    // ... 略
}

isNarrower 看起來是 scroll container 本來就不用 scroll 的時候會為 true,不過這段我有點疑問,因為理論上在這種情境,state.arrivedState[direction] 也會是 true 才對,不太確定為什麼還要多這個 isNarrower 的判斷。以 Demo code 來說,把 isNarrower 判斷拿掉效果是一樣的。可能有我沒想到的邊界情境要考慮吧(?)

接著看核心最重要的一段:

if (state.arrivedState[direction] || isNarrower) {
      if (!promise.value) {
        promise.value = Promise.all([
          onLoadMore(state),
          new Promise(resolve => setTimeout(resolve, interval)),
        ])
          .finally(() => {
            promise.value = null
            nextTick(() => checkAndLoad())
          })
      }
}

假設 interval 為 300ms

這邊是用 Promise.all 來處理,假設我們傳入的 onLoadMore 是一個要 call API 拿資料的 async function(會 return Promise),用 Promise.all 的好處是,如果 API 比 300ms 還久,會以 async function 執行完的時間為主,如果小於 300ms,那最少也要等到 300ms 才能觸發下一次的 checkAndLoad

這邊需要使用 nextTick(() => checkAndLoad()) 是因為在執行下一次的 checkAndLoad 時,要確保 checkAndLoad 是拿最新狀態的 DOM 來做計算。

GitHub PR:https://github.com/RhinoLee/30days_vue/pull/25/files


useInfiniteScroll 就到這邊告一段落啦~ 明天來看 useInfiniteScroll 的 unit test 是怎麼測的。


上一篇
[Day 25] useElementVisibility - unit test
下一篇
[Day 27] useInfiniteScroll - unit test
系列文
30 天 vueuse 原始碼閱讀與實作30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言