Vue3實(shí)現(xiàn)虛擬列表的示例代碼
前言
本文的虛擬列表基于上一篇動(dòng)態(tài)高度虛擬列表原理解析中的核心代碼和Vue3實(shí)現(xiàn),上文是看懂思考&設(shè)計(jì)部分內(nèi)容的前提條件。
使用
安裝
npm install @e.yen/virtual-scroll-vue
在main.js中顯式導(dǎo)入或者在組件中按需導(dǎo)入
// main.ts import VirtualScroll from '@e.yen/virtual-scroll-vue' app.use(VirtualScroll) // 或者 // AnyComponent.vue import { VirtualScroll } from '@e.yen/virtual-scroll-vue'
導(dǎo)入樣式
// main.ts import '@e.yen/virtual-scroll-vue/dist/style.css'
Github倉庫:virtual-scroll-vue
props參數(shù)
參數(shù) | 類型 | 默認(rèn)值 | 是否必須 | 描述 |
---|---|---|---|---|
items | VirtualScrollItem[] | - | ?? | 列表數(shù)據(jù) |
placeholder | VirtualScrollItem | - | ? | 最小子項(xiàng)的模擬數(shù)據(jù) |
startPosition | [number, number] | [0, 0] | ? | 列表初始位置 |
preserved | number | - | ? | 子項(xiàng)的最小高度 |
padding | number | 100 | ? | 預(yù)渲染區(qū)域高度 |
type VirtualScrollItem = { key?: any height?: number // 可以指定元素高度,具有最高優(yōu)先級(jí) [k: string | symbol]: any }
注意:preserved
的優(yōu)先級(jí)高于 placeholder
expose方法
方法 | 參數(shù) | 返回值類型 | 描述 |
---|---|---|---|
scroll | delta: number, duration?: number | - | 滾動(dòng)指定距離 |
transport | newStartPosition: [number, number] | - | 傳送到指定位置 |
getPosition | - | [number, number] | 獲取列表當(dāng)前位置 |
注意事項(xiàng)
讓子項(xiàng)擁有唯一的key
由于列表基于v-for渲染子項(xiàng),因此為子項(xiàng)擁有唯一的key能夠大幅度提升性能表現(xiàn):
const items = [ { key: 'ABC', }, { key: 'BCD', }, ]
任何時(shí)候都不要使最小高度為0
由于元素在被渲染之前無法確認(rèn)其高度,因此列表依賴于子項(xiàng)目的最小高度確定渲染索引范圍。雖然能夠通過 placeholder
將最小高度設(shè)為0,但這會(huì)導(dǎo)致列表渲染后續(xù)所有子項(xiàng):
<!-- preserved默認(rèn)具有最小值5px,設(shè)為0不會(huì)有任何效果 --> <VirtualScroll :items="data" :preserved="0"> <template #default="{ item }"> <!-- 子項(xiàng)目結(jié)構(gòu) --> </template> </VirtualScroll> <!-- 通過placeholder將最小高度設(shè)為0,會(huì)導(dǎo)致列表一次性渲染所有元素 --> <VirtualScroll :items="data" :placeholder="{}"> <template #default="{ item }"> <!-- 高度為0的子項(xiàng)目 --> <div></div> </template> </VirtualScroll>
不要向起始元素之前添加新數(shù)據(jù)
由于列表的渲染索引范圍由起始元素索引、起始元素偏移量和最小項(xiàng)目高度共同決定,因此向起始元素之前的位置添加新元素會(huì)導(dǎo)致意料之外的結(jié)果:
// 假設(shè) startIndex 為 1 // 向items頭部添加新數(shù)據(jù),會(huì)導(dǎo)致列表渲染的起始元素變?yōu)榕f數(shù)組中索引為 0 的項(xiàng) items.unshift({ key: 'CDE', })
不要修改預(yù)渲染區(qū)內(nèi)的元素高度
具體來說是不要修改靠近列表排列起始方向一側(cè)的元素高度(例如列表從上往下排列,則不要修改上方預(yù)渲染區(qū)域內(nèi)的元素高度)
這不是強(qiáng)制要求的,相反,列表仍能正常工作,但是對(duì)于具有過渡效果的高度變化,受制于 ResizeObserver
的滯后性,列表可能出現(xiàn)微小的抖動(dòng)導(dǎo)致用戶體驗(yàn)變差
僅在觸屏設(shè)備上使用
雖然列表支持滾輪滾動(dòng),但是暫不支持滾動(dòng)條,在PC等非觸屏設(shè)備上應(yīng)考慮使用分頁
示例
列表會(huì)自動(dòng)獲取可視區(qū)域大小,寬高默認(rèn)為 100%
,建議通過 .virtual-scroll_container
進(jìn)行覆蓋,或者在組件外部包裹一個(gè)容器
列表會(huì)將要渲染的數(shù)據(jù)通過默認(rèn)作用域插槽傳遞出來,天然支持動(dòng)態(tài)高度
<script setup lang="ts"> import { ref } from 'vue' import DynamicItem from '@/components/DynamicItem/DynamicItem.vue' import { VirtualScroll, type VirtualScrollInstance } from '@e.yen/virtual-scroll-vue' import { generateRandomFirstWord, generateRandomWord, lorem, } from '@/utils/helper' const defaultItem = { name: 'ab', comment: 'abc', index: -1 } const items = ref( new Array(10000).fill(0).map((_, i) => ({ key: i.toString(), name: generateRandomFirstWord() + (Math.random() > 0.5 ? ' ' + generateRandomWord(Math.floor(Math.random() * 8) + 2) : ''), comment: lorem(Math.floor(Math.random() * 5) + 1), index: i, })), ) const vlist = ref<VirtualScrollInstance>() function lighteningScroll(delta: number) { vlist.value!.scroll(delta) } </script> <template> <div class="page"> <div class="scroll_container"> <VirtualScroll ref="vlist" :items="items" :placeholder="defaultItem" :start-position="[1000, 0]" :padding="0" > <template #default="{ item }"> <DynamicItem :index="item.index" :name="item.name" :comment="item.comment" ></DynamicItem> </template> </VirtualScroll> </div> <button @click="lighteningScroll(-100000)">向上極速滾動(dòng)測試</button> <button @click="lighteningScroll(100000)">向下極速滾動(dòng)測試</button> </div> </template>
思考&設(shè)計(jì)
虛擬列表的關(guān)鍵在于如何獲取列表項(xiàng)高度,確定了每項(xiàng)的高度,就能確定渲染多少個(gè)元素。即如何獲取列表項(xiàng)高度決定了虛擬列表的實(shí)際表現(xiàn),在這里給出三個(gè)思路:
1.固定步長
在瀏覽器每一幀渲染之前進(jìn)行判斷,若虛擬列表中的元素不足以占滿整個(gè)可視區(qū)域且仍有未被渲染的后續(xù)元素,則將渲染結(jié)束的索引后移 n
位。
- 優(yōu)點(diǎn):實(shí)現(xiàn)簡單直觀
- 缺點(diǎn):引入了超參數(shù)
n
,需要依照實(shí)際情況確定一個(gè)較為合理的值,較大則造成較多性能浪費(fèi),較小則容易導(dǎo)致用戶快速滾動(dòng)時(shí)出現(xiàn)空白頁
2.預(yù)渲染 + 固定步長
原理與上述思路沒有區(qū)別,優(yōu)缺點(diǎn)與上面一致,可以認(rèn)為是在計(jì)算元素是否足以占滿可視區(qū)域時(shí),將參與計(jì)算的可視區(qū)域進(jìn)行擴(kuò)大,從而讓列表提前渲染元素。能夠在一定程度上緩解空白問題,但治標(biāo)不治本,當(dāng)以更快的速度滾動(dòng)(比如通過代碼觸發(fā))時(shí)仍會(huì)出現(xiàn)空白頁。
3.預(yù)渲染 + 高度預(yù)測
觀察發(fā)現(xiàn),出現(xiàn)空白頁的根本原因是無法確定究竟最多還需要多少個(gè)元素才能占滿可視區(qū)域,為此,可以通過每項(xiàng)的最小高度預(yù)測最多需要向后渲染多少個(gè)子項(xiàng),從而保證始終有足夠的元素占滿可視區(qū)域。
- 優(yōu)點(diǎn):通過高度預(yù)測徹底解決了空白問題
- 缺點(diǎn):在快速滾動(dòng)時(shí),由于實(shí)際高度與預(yù)測高度可能不同,可能會(huì)導(dǎo)致落點(diǎn)位置與預(yù)期不符
預(yù)測實(shí)現(xiàn)
高度預(yù)測主要有兩種實(shí)現(xiàn)方式:
- 在設(shè)計(jì)時(shí)就確定好最小高度,單位為CSS像素。實(shí)現(xiàn)最為簡單且性能最高,但是對(duì)于一些使用了相對(duì)單位的項(xiàng)目結(jié)構(gòu)不友好
- 根據(jù)項(xiàng)目結(jié)構(gòu)自動(dòng)獲取最小高度。通過傳入一個(gè)具有最小高度的元素的模擬數(shù)據(jù),列表動(dòng)態(tài)地獲取最小高度,通用性最好
錯(cuò)位處理
維持前文的約定:
起始位置 startPosition
由 [startIndex, offset]
二元組構(gòu)成
渲染信息 renderInfo
由 [viewHeight, paddingHeight, listHeight]
三元組構(gòu)成
函數(shù) move(startPosition, delta, renderInfo, getHeight) => void
通過起始位置、移動(dòng)距離、渲染信息和高度獲取函數(shù)計(jì)算本次移動(dòng)后的新起始位置
高度預(yù)測會(huì)導(dǎo)致快速滾動(dòng)時(shí)出現(xiàn)到達(dá)的位置與預(yù)期不同的錯(cuò)位問題。粗略地看,當(dāng)一幀內(nèi)列表滾動(dòng)到了未渲染區(qū)域,就會(huì)轉(zhuǎn)為使用預(yù)測高度繼續(xù)計(jì)算下一幀的起始位置,預(yù)測高度與實(shí)際高度不一致時(shí)就會(huì)導(dǎo)致列表“移動(dòng)過頭”。舉個(gè)例子,假設(shè)某個(gè)未被渲染的元素實(shí)際高度為 110px
,但在計(jì)算時(shí)將其視為 100px
,那么剩余滾動(dòng)距離就多了 10px
,這是造成錯(cuò)位的根本原因。
既然知道了問題,那么研究其發(fā)生條件變得十分重要:
根據(jù)移動(dòng)函數(shù) move
的計(jì)算方式得知:
在單次移動(dòng)中,如果:
- 向上移動(dòng)距離大于
max(offset - paddingHeight, 0)
+ 預(yù)測高度 - 向下移動(dòng)距離大于
listHeight
+ 預(yù)測高度
就可能導(dǎo)致錯(cuò)位
由于虛擬列表是由起始位置決定的,因此向上滾動(dòng)時(shí)的錯(cuò)位將是致命的。原因是計(jì)算新的 offset
時(shí)使用了預(yù)測的高度,但實(shí)際高度大于預(yù)測高度,導(dǎo)致后續(xù)所有元素都下移。在這里使用了自定義指令 + ResizeObserver的方式解決,處理過程分為3步:
v-auto-record
在元素被掛載時(shí),緩存本次計(jì)算時(shí)使用的高度
v-watch-size
在元素高度被緩存后調(diào)用 elementResize
進(jìn)行處理
elementResize
根據(jù)情況更新高度緩存,以及選擇修改 offset
或重新渲染
// vAutoRecord.ts export default <Directive>{ mounted(el, binding) { if (binding.arg && binding.arg === 'mounted') binding.value?.(el) }, unmounted(el, binding) { if (binding.arg && binding.arg === 'unmounted') binding.value?.(el) }, } // vWatchSize.ts export default <Directive>{ mounted(el, binding) { // ! nextTick保證vWatchSize在vAutoRecord之后執(zhí)行 nextTick(() => { if (binding.value instanceof Function) binding.value(el) el.observer = new ResizeObserver(() => { if (binding.value instanceof Function) binding.value(el) }) el.observer.observe(el) }) }, beforeUnmount(el) { if (el.observer) { el.observer.disconnect() delete el.observer } }, }
<li v-for="(i, index) in renderRange" :key="props.items[i].key || index" class="virtual-scroll_item" v-watch-size="el => elementResize(i, el)" v-auto-record:mounted="el => elementMap.set(i, el)" v-auto-record:unmounted="() => elementMap.delete(i)" > <slot :item="props.items[i]"></slot> </li>
const elementResize = (index: number, element: HTMLElement) => { const cur = element.getBoundingClientRect().height const pre = getHeight(index) // 取出高度緩存 let isInPaddingRange = false if (cur === pre) return // 判斷高度變化的元素是否在預(yù)加載區(qū)間 let offset = startPosition.value[1] let itemIndex = startPosition.value[0] let height = getHeight(itemIndex) while (height >= 0 && offset > 0) { if (itemIndex === index) { isInPaddingRange = true break } offset -= height height = getHeight(++itemIndex) } // 更新高度緩存 updateHeight(index) if (isInPaddingRange) { // 如果高度變化的元素在預(yù)加載區(qū)間內(nèi),將offset加上高度變化量 startPosition.value[1] += cur - pre } else if (cur < pre) { // 如果高度變化的元素不在預(yù)加載區(qū)間內(nèi),重新渲染 renderTrigger.value = !renderTrigger.value } }
至于向下滾動(dòng)時(shí)的錯(cuò)位問題,這是高度預(yù)測的固有局限,因此沒有很好的解決方法,一種可能的蒙混過關(guān)的解決方式是:快速滾動(dòng)時(shí)用戶無法分辨頁面上到底呈現(xiàn)了什么,可以在滾動(dòng)結(jié)束的下一幀立即將起始位置修改為目標(biāo)位置,實(shí)現(xiàn)向下快速滾動(dòng)到指定位置的錯(cuò)覺。但如果在列表項(xiàng)中出現(xiàn)了編號(hào)這樣容易讓小把戲穿幫的內(nèi)容,可能需要考慮用 transport
定制滾動(dòng)效果。
以上就是Vue3實(shí)現(xiàn)虛擬列表的示例代碼的詳細(xì)內(nèi)容,更多關(guān)于Vue3虛擬列表的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
vue中實(shí)現(xiàn)div可編輯并插入指定元素與樣式
這篇文章主要給大家介紹了關(guān)于vue中實(shí)現(xiàn)div可編輯并插入指定元素與樣式的相關(guān)資料,文中通過代碼以及圖文將實(shí)現(xiàn)的方法介紹的非常詳細(xì),對(duì)大家學(xué)習(xí)或者使用vue具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2023-09-09vue.js過濾器+ajax實(shí)現(xiàn)事件監(jiān)聽及后臺(tái)php數(shù)據(jù)交互實(shí)例
這篇文章主要介紹了vue.js過濾器+ajax實(shí)現(xiàn)事件監(jiān)聽及后臺(tái)php數(shù)據(jù)交互,結(jié)合實(shí)例形式分析了vue.js前臺(tái)過濾器與ajax后臺(tái)數(shù)據(jù)交互相關(guān)操作技巧,需要的朋友可以參考下2018-05-05一文詳解Vue的響應(yīng)式原則與雙向數(shù)據(jù)綁定
使用 Vue.js 久了,還是不明白響應(yīng)式原理和雙向數(shù)據(jù)綁定的區(qū)別?今天,我們就一起來學(xué)習(xí)一下,將解釋它們的區(qū)別,快跟隨小編一起學(xué)習(xí)學(xué)習(xí)吧2022-08-08vue3語法中使用vscode打開滿屏紅線報(bào)錯(cuò)的完美解決方法
這篇文章主要介紹了vue3語法中使用vscode打開滿屏紅線報(bào)錯(cuò)的完美解決方法,本文通過圖文并茂的形式給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2023-06-06vue生成初始化名字相近的變量并放到數(shù)組中的示例代碼
項(xiàng)目上有一個(gè)需求,頁面上有50、60個(gè)數(shù)據(jù)變量,是依次排序遞增的變量,中間有個(gè)別變量用不到,不想把這些變量直接定義在data() { }內(nèi),這篇文章主要介紹了vue生成初始化名字相近的變量并放到數(shù)組中的示例代碼,需要的朋友可以參考下2024-08-08