
先前有對大資料渲染進行調察研究,得知瞬間大資料的渲染,會造成瀏覽器卡頓不回應,並得出可以透過分時渲染、捲動式渲染、虛擬列表,解決高併發渲染的問題。
而籃足球比賽比分列表,一遇假日就會有1000~2000 筆的比賽需要同時顯示,且依據使用者期待,不要使用分頁顯示。

設計概念,一開始先傳入高度,並將每一筆 item 的 邊界 getBoundingClientRect 結果快取起來,直到渲染出來會重新計算快取高度,透過容器的卷軸事件,css translate3d,操作 y 軸,達到到平移捲動的效果,只有利用實體捲軸的高度及捲動事件,但不是透過實體捲軸顯示,因此這就是為什麼稱之虛擬捲軸的原因。
跟虛擬捲軸最大的差異,分段捲軸一開始採用的用的真實的實體捲軸,除buffer item 其餘都僅渲染空 div 支撐捲軸高度,直到捲到 buffer item 時,才開始渲染該區間的元件。
拿 1000 筆左右的數據,資料採用分段渲染和一般炫染效能做比較,因為要渲染的筆數少了,其實可以預期效能差了快1x倍之多。


<template>
<div
id="virtual-list"
ref="scroller"
class="virtual-scroll-list-container"
@scroll="scrollEvent($event)"
@touchstart="touchstartHandle"
@touchmove="touchmoveHandle"
@touchend="touchendHandle"
>
<div
class="virtual-scroll-list-phantom"
:style="{ 'min-height': minListHeight + (enableScrollUp ? 40 : 0)+ 'px' }"
/>
<div
ref="actualContentRef"
class="virtual-scroll-list"
:style="{ transform: getTransform }"
>
<div v-show="isShow.isRefresh" ref="refresh" class="refresh">
<div class="flex justify-center align-center">
<span class="circle-rotate " />
<span>
重新整理
</span>
</div>
</div>
<slot
v-for="item in visibleData"
:start="start"
:index="getCache(item[uniKey]).index"
:end="end"
:uniKey="uniKey"
:item="item"
:height="getCache(item[uniKey]).height"
/>
<div v-show="isShow.isLoading" class="load m-t-4">
<div class="flex justify-center align-center">
<span class="circle-rotate " />
<span>
加載中
</span>
</div>
</div>
<span v-if="!isChatMode" class="finished-text">
<slot name="finishedText">沒有更多內容</slot>
</span>
</div>
</div>
</template>
<script>
import { scrollElementToBottom, debounce } from '@/utils'
export default {
name: 'VirtualList',
props: {
// 所有列表數據
list: {
type: Array,
default: () => []
},
// 每項預設的高度
itemDefaultHeight: {
type: Number,
default: 200
},
// 唯一值
uniKey: {
type: String,
default: function() {
return 'seq'
},
required: false
},
// 可視範圍外多渲染幾筆
bufferSize: {
type: Number,
default: 0
},
isChatMode: {
type: Boolean,
required: false,
default: function() {
return false
}
},
enableScrollDown: {
// 開啟上拉功能 refresh
type: Boolean,
required: false,
default: function() {
return false
}
},
enableScrollUp: {
// 開啟下拉功能 loading
type: Boolean,
required: false,
default: function() {
return false
}
},
autoLoadMore: {
// 自動捲到最底就刷新 / 捲到定點,在上拉才刷新
type: Boolean,
required: false,
default: function() {
return true
}
}
},
data() {
return {
// scrollTop: 0, // 卷軸位址
lastScrollTop: 0, // 紀錄最後卷軸位址
isScrolling: false,
isLoadMoreEnd: false,
// 列表預估總高度
minListHeight: 0,
// 可視區域高度
screenHeight: 0,
// 起始索引
start: 0,
// 結束索引
end: null,
// 快取高度
cachedPositions: [],
// 每一項只記算一次動態高度
calculateOnce: true,
// 是不是己捲動到最下面
autoScrollLoaded: false,
firstRender: false,
refreshLoginStatus: 'normal', // 組件當前狀態:正常瀏覽模式normal,下拉刷新模式refresh,上拉加載模式loading
isShow: {
// 加載動劃控制開關
isRefresh: false,
isLoading: false
},
startPos: {
// 手指初始按壓位置
pageY: 0,
pageX: 0
},
dis: {
// 手移動距離
pageY: 0,
pageX: 0
},
last: {
pageY: 0,
pageX: 0
}
}
},
computed: {
// 預期可視範圍可顯示的列表數
visibleCount() {
return Math.ceil(this.screenHeight / this.itemDefaultHeight)
},
// 偏移量對應的style
getTransform() {
const currentCachedPositions = this.cachedPositions[this.start - 1]
return `translate3d(0,${
this.start >= 0 && currentCachedPositions
? currentCachedPositions.bottom
: 0
}px,0)`
},
// 獲取可視範圍的資料筆數
visibleData() {
if (this.cachedPositions.length === 0) {
return []
}
return this.list.slice(this.start, this.end + 1)
}
},
watch: {
list: {
handler(val) {
if (val) {
this.init()
}
this.isLoadMoreEnd = false
},
immediate: false,
deep: true
}
},
mounted() {
this.init()
},
// activated生命鉤子在keep-alive被激活時調用
activated() {
// 如果曾滾動過,則還原位置
if (this.lastScrollTop) {
const page = this.$refs.scroller
page.scrollTop = this.lastScrollTop
}
},
updated() {
// 當每一次 component 更新時重新計算一下,目前渲染出來的項目高度,放進 cache 計算
const that = this
if (that.$refs.actualContentRef.childElementCount > 0) {
const childNodes = that.$refs.actualContentRef.childNodes
that.hasLastNode = false
childNodes.forEach((node, index) => {
if (!node || !node.id || node.id.indexOf('-') === -1) {
return
}
const elementIdArray = node.id.split('-')
if (elementIdArray.length === 2) {
const elementId = Number(elementIdArray[1])
if (elementId) {
const currentCachedPositions = that.cachedPositions.find(
(x) => x.id === elementId
)
if (currentCachedPositions) {
if (currentCachedPositions.isLast === true) {
that.hasLastNode = true
}
}
// 每個 item的高度只會重算一次
if (
that.calculateOnce &&
currentCachedPositions.updated &&
currentCachedPositions.updated === true
) {
return
}
const rect = node.getBoundingClientRect()
const { height } = rect
const oldHeight = currentCachedPositions.height
const dValue = oldHeight - height
if (dValue) {
currentCachedPositions.bottom -= dValue
currentCachedPositions.top -= dValue
currentCachedPositions.height = height
currentCachedPositions.dValue = dValue
currentCachedPositions.updated = true
that.minListHeight -= dValue
for (
let i = currentCachedPositions.index;
i < that.cachedPositions.length;
i++
) {
const cacheItem = that.cachedPositions[i]
if (cacheItem) {
cacheItem.top -= dValue
cacheItem.bottom -= dValue
}
}
}
}
}
})
if (that.isChatMode) {
that.$nextTick(function() {
if (that.firstRender === false) {
that.firstRender = true
that.$refs.scroller.scrollTop = that.$refs.scroller.scrollHeight
} else if (that.autoScrollLoaded === false) {
scrollElementToBottom('virtual-list')
if (that.hasLastNode) {
that.autoScrollLoaded = true
}
}
})
}
}
},
methods: {
init() {
this.autoScrollLoaded = false
this.initCachedPositions()
this.initPosition()
this.screenHeight = this.$el.clientHeight || this.$el.parentElement.clientHeight
this.scrollEvent()
// 給 list 預設的高度
this.minListHeight = this.list.length * this.itemDefaultHeight
},
touchstartHandle(e) {
// 記錄起始位置 和 組件距離window頂部的高度
this.startPos.pageY = e.touches[0].pageY
this.startPos.pageX = e.touches[0].pageX
// 內容頁在可視視窗最頂端或者在指定的位置(父級元素的頂部)
},
touchmoveHandle(e) {
const disY = e.touches[0].pageY - this.startPos.pageY
const disX = e.touches[0].pageX - this.startPos.pageX
// for android 預設下拉刷新的問題
if (this.isAndroid) {
this.preventAndriodRefreshEevnt(e)
}
this.last.pageY = e.changedTouches[0].pageY
this.last.pageX = e.changedTouches[0].pageX
if (disX > 100 && !this.isScrolling) {
this.dis.pageX = disX
this.$emit('scrollRight')
this.refreshLoginStatus = 'right'
} else if (disX < -100 && !this.isScrolling) {
this.$emit('scrollLeft')
this.refreshLoginStatus = 'left'
} else {
if (this.$refs.scroller.scrollTop <= 0 && disY > 100) {
this.dis.pageY = disY
this.refreshLoginStatus = 'refresh'
this.refreshMove(disY, e)
} else if (disY < 100) {
/* //觸發上拉加載 */
if (this.isShow.isLoading) return
this.refreshLoginStatus = 'loading'
this.loadingMove(disY)
}
}
},
preventAndriodRefreshEevnt(e) {
// 阻止 android 原生事件
var direction = e.changedTouches[0].pageY > this.last.pageY ? 1 : -1
const scrollTop = this.$refs.scroller.scrollTop
if (direction > 0 && scrollTop <= 0) {
e.preventDefault()
}
},
loadingMove(dis) {
// 計算內容頁底部距離可視視窗頂部的距離
if (this.enableScrollUp && !this.autoLoadMore) {
const disToTop = this.$refs.actualContentRef.getBoundingClientRect()
.bottom
// 計算可視視窗的高度
const clientHeight = document.documentElement.clientHeight
if (disToTop <= clientHeight) {
if (this.refreshLoginStatus === 'loading' && this.dis.pageY < 0) {
this.isShow.isLoading = true
}
}
}
},
refreshMove(dis, e) {
if (this.enableScrollDown) {
if (this.isShow.isRefresh) return
if (this.refreshLoginStatus === 'refresh' && this.dis.pageY > 0) {
// 下拉刷新成立條件
this.isShow.isRefresh = true
// 下拉到一定距離後,內容頁不隨touchmove移動
this.$refs.actualContentRef.style.transform = `translateY(${
dis < 8 ? dis : 8
}px)`
// for android 預設下拉刷新的問題
if (this.isAndroid) {
e.preventDefault()
}
}
}
},
touchendHandle(e) {
if (this.refreshLoginStatus === 'left' || this.refreshLoginStatus === 'right') {
this.isShow.isRefresh = false
this.isShow.isLoading = false
}
this.refreshLoginStatus === 'refresh' && this.refreshToucnend(e)
this.refreshLoginStatus === 'loading' && this.loadingTouchend(e)
},
refreshToucnend(e) {
// 加上限定條件,防止不在刷新狀態,後面的代碼執行
if (!this.isShow.isRefresh) return
// 必須下拉一定距離,才進行異步加載數據
this.dis.pageY > 10 && (this.$emit('scrollDown'))
// 松手後加載動劃消失,並且內容頁回到原位置
this.isShow.isRefresh = false
this.$refs.actualContentRef.style.transform = `translateY(0px)`
this.refreshLoginStatus = 'normal'
},
loadingTouchend(e) {
// 加上限定條件,防止不在刷新狀態,後面的代碼執行
if (!this.isShow.isLoading) return
if (this.isLoadMoreEnd === false) {
this.$emit('scrollUp')
this.isLoadMoreEnd = true
}
this.isShow.isLoading = false
this.refreshLoginStatus = 'normal'
},
getCache(uniId) {
const cache = this.cachedPositions.find(x => x.id === uniId)
return cache
},
initPosition() {
if (this.isChatMode) {
this.end = this.list.length
var range = this.visibleCount + this.bufferSize
if (this.end - range < 0) {
this.start = 0
} else {
this.start = this.end - range
}
} else {
this.start = 0
this.end = this.start + this.visibleCount + this.bufferSize
}
},
// 依照預設每一筆資料都給計算 bottom 及給預設高度
initCachedPositions() {
const { itemDefaultHeight } = this
this.cachedPositions = []
for (let i = 0; i < this.list.length; ++i) {
this.cachedPositions[i] = {
id: this.list[i][this.uniKey],
index: i,
height: itemDefaultHeight,
top: i * itemDefaultHeight,
bottom: (i + 1) * itemDefaultHeight,
dValue: 0,
isLast: i + 1 === this.list.length
}
}
},
scrollEvent(e) {
// 當前滾動位置
const scrollTop = this.$refs.scroller.scrollTop
// 綁定事件,滾動時,儲存位置到this.scrollTop
this.lastScrollTop = scrollTop
// 處理滑動不可以切換tab
this.isScrolling = true
var scroll
clearTimeout(scroll)
scroll = setTimeout(() => {
this.isScrolling = false
}, 100)
let index = 0
// 此時的開始索引
const currentCachePostion = this.cachedPositions.filter(
(x) => scrollTop < x.bottom
)[0]
if (currentCachePostion) {
index = currentCachePostion.index
}
if (index === 0) {
this.start = 0
this.end = index + this.visibleCount + this.bufferSize
}
// debugger
// 此時的結束索引
this.start = index - this.bufferSize
this.end = index + this.visibleCount + this.bufferSize
if (this.list.length > 0 && this.end >= this.list.length) {
// 自動加載
if (this.enableScrollUp && this.autoLoadMore) {
this.isShow.isLoading = true
this.loadingTouchend(e)
}
this.end = Math.max(this.list.length, this.visibleCount)
this.start = Math.min(this.end - this.visibleCount - this.bufferSize, 0)
}
if (this.start < 0) this.start = 0 // 起始筆
},
debounceScroll(e) {
debounce(() => { this.scrollEvent(e) }, 16.6) // 60Hz
}
}
}
</script>
<style scoped lang="scss">
.virtual-scroll-list-container {
position: relative;
overflow: auto;
height: 100%;
-webkit-overflow-scrolling: touch;
// scroll-behavior: smooth;
}
// .virtual-scroll-list-container::-webkit-scrollbar {
// display: none;
// }
.virtual-scroll-list-phantom {
position: absolute;
top: 0;
right: 0;
left: 0;
z-index: -1;
}
.virtual-scroll-list {
position: absolute;
top: 0;
right: 0;
left: 0;
z-index: 998;
}
/*
::-webkit-scrollbar {
width: 10px;
}
::-webkit-scrollbar-track {
background-color: darkgrey;
}
::-webkit-scrollbar-thumb {
box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.2);
} */
.circle-rotate {
position: relative;
border: 10px solid #CCC;
border-right-color: transparent;
border-radius: 50%;
width: 20px;
height: 20px;
animation: loadingAnimation 0.75s infinite;
}
@keyframes loadingAnimation {
0% {
transform: translateX(-50%) rotate(0deg);
}
100% {
transform: translateX(-50%) rotate(360deg);
}
}
.finished-text {
display: block;
padding: 24px 0 32px 0;
font-size: 22px;
text-align: center;
color: $text-grey-darken;
}
.disable-hover {
pointer-events: none;
}
</style>
使用方式
<VirtualScroller
:list="news"
class="news-list-wrapper"
:item-default-height="100"
:uni-key="'id'"
:enable-scroll-up="!isLastPage"
:auto-load-more="true"
:enable-scroll-down="true"
:buffer-size="4"
@scrollDown="scrollDown"
@scrollUp="scrollUp"
>
<template #default="slotScope">
<NewsCard
:id="'news-' + slotScope.item[slotScope.uniKey]"
:key="'news-' + slotScope.item[slotScope.uniKey]"
:start="slotScope.start"
:end="slotScope.end"
:index="slotScope.index"
:h="slotScope.height"
:news="slotScope.item"
:sport-id="slotScope.item.sportId"
/>
</template>
</VirtualScroller>
<template>
<div
ref="part-render-container"
class="part-render-scroll-list-container"
:style="{ height: minListHeight +'px' }"
>
<div
v-for="(item, index) in list"
:key="item[uniKey]"
:[uniKey]="item[uniKey]"
:index="index"
class="part-render-item"
:class="[{ visiable: checkVisible(index) }, itemClass]"
:style="{ 'min-height': cachedPositions[index].height + 'px' }"
>
<transition-group name="fade">
<slot
v-if="checkVisible(index)"
:index="index"
:uniKey="uniKey"
:item="item"
:height="cachedPositions[index].height"
/>
</transition-group>
</div>
</div>
</template>
<script>
export default {
name: 'VirtualList',
props: {
scrollElementId: { // 沒傳預設抓 body
type: String,
required: false,
default: function() {
return ''
}
},
// 所有列表數據
list: {
type: Array,
default: () => []
},
// 每項預設的高度
itemDefaultHeight: {
type: Number,
default: 200
},
// 唯一值
uniKey: {
type: String,
default: function() {
return 'id'
},
required: false
},
// 可視範圍外多渲染幾筆
bufferSize: {
type: Number,
default: 20
},
itemClass: {
type: String,
required: false,
default: function() {
return ''
}
}
},
data() {
return {
// 列表預估總高度
minListHeight: 0,
// 起始索引
start: 0,
// 結束索引
end: null,
// 快取高度
cachedPositions: [],
// 每一項只記算一次動態高度
defaultTopOffset: 0,
maxListHeight: 0,
currentScrollTop: 0,
currentEleTop: 0,
currentEleBottom: 0,
screenHeight: 0,
scrollElement: null,
isBodyScroller: false // 是不是 body 的捲軸
}
},
computed: {
// 一頁預估可以顯示幾筆
visibleCount() {
return Math.ceil(this.screenHeight / this.itemDefaultHeight)
},
// 己進入預渲染的範圍
isEnterPreload() {
const isEnterPreload = this.currentScrollTop + this.screenHeight > this.currentEleTop && this.currentScrollTop - this.screenHeight < this.currentEleBottom
return isEnterPreload
}
},
watch: {
list: {
handler(val) {
if (
val &&
val.length > 0
) {
// 給 list 預設的高度
this.minListHeight = this.list.length * this.itemDefaultHeight
this.initCachedPositions()
var that = this
that.$nextTick(function() {
const rect = this.$el.getBoundingClientRect()
that.defaultTopOffset = rect.top
})
}
},
immediate: true,
deep: true
}
},
mounted() {
this.screenHeight =
window.innerHeight ||
document.documentElement.clientHeight ||
document.body.clientHeight
this.recalculateCurrentEleBoundary()
// 給初始值
this.start = 0
this.end = this.start + this.visibleCount + this.bufferSize
this.isBodyScroller = this.scrollElementId.length === 0
if (this.isBodyScroller) {
window.addEventListener('scroll', this.handleScroll)
} else {
this.getScrollElement().addEventListener('scroll', this.handleScroll, false)
}
},
updated() {
// 當每一次 component 更新時重新計算一下,目前渲染出來的項目高度,放進 cache 計算
const that = this
const el = this.$el
if (el) {
const childNodes = el.querySelectorAll('.visiable')
childNodes.forEach((node, index) => {
if (!node) {
return
}
const elementIndex = Number(node.getAttribute('index'))
// 重算高度
const currentCachedPositions = that.cachedPositions[elementIndex]
if (currentCachedPositions.updated === true) {
return
}
currentCachedPositions.updated = true
if (currentCachedPositions) {
// slot 只能放一筆
const rect = node.children[0].getBoundingClientRect()
const { height } = rect
const oldHeight = currentCachedPositions.height
const dValue = oldHeight - height
if (dValue) {
currentCachedPositions.bottom -= dValue
currentCachedPositions.top -= dValue
currentCachedPositions.height = height
currentCachedPositions.dValue = dValue
that.minListHeight -= dValue
// 重算快取 Cache 的 上邊界 & 下邊界
this.recalculateCache(currentCachedPositions.index, dValue)
}
}
})
this.recalculateCurrentEleBoundary()
}
},
beforeDestroy() {
if (this.isBodyScroller) {
window.removeEventListener('scroll', this.handleScroll)
} else {
this.getScrollElement().removeEventListener('scroll', this.handleScroll, false)
}
},
methods: {
getScrollElement() {
const scrollElement = document.getElementById(this.scrollElementId)
if (this.isBodyScroller) {
return document.documentElement || document.body
} else {
if (!scrollElement) {
console.log('找不到捲軸物件')
}
return document.getElementById(this.scrollElementId)
}
},
// 重算快取 Cache 的 上邊界 & 下邊界
recalculateCache(index, dValue) {
for (let i = index; i < this.cachedPositions.length; i++) {
const cacheItem = this.cachedPositions[i]
if (cacheItem) {
cacheItem.top -= dValue
cacheItem.bottom -= dValue
}
}
},
// 重新計算上邊界和下邊界的距離
recalculateCurrentEleBoundary() {
var rect = this.$el.getBoundingClientRect()
this.currentEleTop = rect.top + this.currentScrollTop
this.currentEleBottom = rect.top + this.currentScrollTop + rect.height
},
checkVisible(nowIndex) {
if (this.isEnterPreload) {
return (
nowIndex >= this.start &&
nowIndex <= Math.min(this.end, this.list.length)
)
} else {
return false
}
},
handleScroll() {
this.currentScrollTop = this.getScrollElement().scrollTop
let index = 0
// 此時的開始索引
const currentCachePostion = this.cachedPositions.filter(
(x) => this.currentScrollTop - this.currentEleTop < x.top
)[0]
if (currentCachePostion) {
index = currentCachePostion.index
}
if (index === 0) {
this.start = 0
this.end = index + this.visibleCount + this.bufferSize
}
this.start = index - this.bufferSize
this.end = index + this.visibleCount + this.bufferSize
if (this.start < 0) {
this.start = 0
}
if (this.end > this.list.length) {
this.end = this.list.length - 1
this.start = this.end - this.visibleCount - this.bufferSize
}
},
// 依照預設每一筆資料都給計算 bottom 及給預設高度
initCachedPositions() {
const { itemDefaultHeight } = this
this.cachedPositions = []
for (let i = 0; i < this.list.length; ++i) {
this.cachedPositions[i] = {
index: i,
height: itemDefaultHeight,
top: i * itemDefaultHeight,
bottom: (i + 1) * itemDefaultHeight,
dValue: 0,
isLast: i === this.list.length - 1,
updated: false // 曾經渲染過
}
}
}
}
}
</script>
<style scoped>
.part-render-scroll-list-container {
box-sizing: border-box;
}
.part-render-item {
box-sizing: border-box;
}
</style>
使用方式
<PartRenderingScroller :scroll-element-id="'schedule-container'" :list="early.early" :item-default-height="100" uni-key="matchId" class="schedule-list p-l-20 p-r-20" :buffer-size="50">
<template #default="slotScope">
<ScheduleCard
:id="'live-' + slotScope.item[slotScope.uniKey]"
:key="'live-' + slotScope.item[slotScope.uniKey]"
:class="{'p-t-20':slotScope.index===0}"
:start="slotScope.start"
:end="slotScope.end"
:index="slotScope.index"
:h="slotScope.height"
:sport-id="sportId"
:schedule="slotScope.item"
:lottery-type="lotteryType"
/>
</template>
</PartRenderingScroller>
原始碼也不易理解及改動,最後決定自己刻了。
此時情境可能不適合用虛擬捲軸,而是要分段渲染卷軸。
元件初始化時,先給預設高,並快取起來,直到渲染出來,在去更新高度,postion 的快取。
聊天室最後將渲染索引反過來渲染,最先渲染使用者看得到的,直到使用者往上捲動。
上述都是類似虛擬捲軸機制的套件,如果要整合捲軸類型的套件,最好還是自己擴充虛擬捲軸,不然要解決兩個虛擬捲軸機制在裡面衝突。