vue實現(xiàn)虛擬列表組件解決長列表性能問題
最近項目中需要用到列表的展示,且不分頁。當(dāng)數(shù)據(jù)加載太多時會造成性能問題。因此采用虛擬列表來優(yōu)化
一、虛擬列表
真實列表:每條數(shù)據(jù)都展示到html上,數(shù)據(jù)越多,DOM元素也就越多,性能也就越差。
虛擬列表:只展示部分?jǐn)?shù)據(jù)(可見區(qū)域展示數(shù)據(jù)),當(dāng)屏幕滾動時替換展示的數(shù)據(jù),DOM元素的數(shù)量是固定的,相比較真實列表更高效。
二、實現(xiàn)思路

難點與思考:
1. 如何計算需要渲染的數(shù)據(jù)
- 數(shù)據(jù)可分為總數(shù)據(jù),與需要
渲染的數(shù)據(jù),需要渲染的數(shù)據(jù)包括了可見區(qū)域與緩沖區(qū)域的數(shù)據(jù) - 通過單條數(shù)據(jù)占位的高度與可見區(qū)域的高度,算出可見區(qū)域的列表條數(shù),再往上和往下擴展幾條緩沖區(qū)域的數(shù)據(jù)(本次代碼是以3倍可見區(qū)域的條數(shù)作為需要渲染的數(shù)據(jù)條數(shù))
2. 何時替換數(shù)據(jù)
- 監(jiān)聽滾動事件,渲染元素的第一條數(shù)據(jù)滾動出緩沖區(qū)域后(也就是可見區(qū)域第一個元素的
index大于緩沖區(qū)域的條數(shù)時),就開始替換數(shù)據(jù)了,每次往上滑動一個元素,就替換一次數(shù)據(jù)。
3. 為何需要空白占位,如何計算空白占位的高度
- 由于列表在滾動過程中會替換數(shù)據(jù),如果沒有空白占位的話,會導(dǎo)致第一個元素消失后,第二個元素立馬替換了第一個元素的位置,會導(dǎo)致錯位。如下圖所示:

- 因此滾動時,需要在元素消失后,補一個相同高度的空白占位
- 上方的空白占位 = 消失的元素個數(shù)(也就是第一個渲染元素的
index) * 單個元素的高度 - 下方的空白占位 = 剩下需要渲染的元素個數(shù)(也就是最后一個元素的
index與總數(shù)據(jù)條數(shù)的差值)* 單個元素的高度
其他注意事項:
- 在使用
v-for遍歷渲染數(shù)據(jù)時,key的值使用index,不用item的id,可以避免該dom元素被重新渲染,只替換數(shù)據(jù)。 - 下拉加載更多時,不要將整個數(shù)據(jù)替換了,而是追加到數(shù)據(jù)的后面,避免之前展示的數(shù)據(jù)被替換了。
- 空白占位可以使用
padding來占位,也可以使用DOM元素占位,使用DOM元素占位監(jiān)聽滾動事件時,應(yīng)使用touchmove或mousemove監(jiān)聽,避免dom元素高度變化后,又觸發(fā)了scroll滾動事件。 - 監(jiān)聽滾動事件應(yīng)該采用節(jié)流的方式,避免程序頻繁執(zhí)行。
- 監(jiān)聽滾動時加上
passive修飾符,可以提前告知瀏覽器需要執(zhí)行preventDefault,使?jié)L動更流暢,具體功能可以參考vue官網(wǎng)。 - 外層包裹的元素需要有固定高度,并且
overflow為auto,才能監(jiān)聽scroll滾動事件。
三、實現(xiàn)
最終實現(xiàn)效果

實現(xiàn)代碼
<template>
<div id="app">
<!-- 監(jiān)聽滾動事件使用passive修飾符 -->
<div class="container" ref="container" @scroll.passive="handleScroll">
<div :style="paddingStyle">
<!-- key使用index,可避免多次渲染該dom -->
<div class="box" v-for="(item, index) in showList" :key="index">
<h2>{{ item.title }} - {{ item.id }}</h2>
<h3>{{ item.from }}</h3>
</div>
<div>到低了~~~</div>
</div>
</div>
</div>
</template>
<script>
import axios from "axios";
export default {
name: "App",
data() {
return {
allList: [], // 所有數(shù)據(jù)
isRequest: false,// 是否正在請求數(shù)據(jù)
oneHeight: 150, // 單條數(shù)據(jù)的高度
showNum: 0, // 可見區(qū)域最多能展示多少條數(shù)據(jù)
startIndex: 0, // 渲染元素的第一個索引
canScroll: true, // 可以監(jiān)聽滾動,用于節(jié)流
scrollTop: 0,// 當(dāng)前滾動高度,再次返回頁面時能定位到之前的滾動高度
lower: 150,// 距離底部多遠(yuǎn)時觸發(fā)觸底事件
};
},
created() {
this.getData();// 請求數(shù)據(jù)
},
activited() {
this.$nextTick(()=>{
// 定位到之前的高度
this.$refs.container.scrollTop = this.scrollTop
})
},
mounted() {
this.canShowNum(); // 獲取可見區(qū)域能展示多少條數(shù)據(jù)
window.onresize = this.canShowNum; // 監(jiān)聽窗口變化,需要重新計算一屏能展示多少條數(shù)據(jù)
window.onorientationchange = this.canShowNum; // 監(jiān)聽窗口翻轉(zhuǎn)
},
computed: {
// 渲染元素最后的index
endIndex() {
let end = this.startIndex + this.showNum * 3; // 3倍是需要預(yù)留緩沖區(qū)域
let len = this.allList.length
return end >= len ? len : end; // 結(jié)束元素大于所有元素的長度時,就取元素長度
},
// 需要渲染的數(shù)據(jù)
showList() {
return this.allList.slice(this.startIndex, this.endIndex)
},
// 空白占位的高度
paddingStyle() {
return {
paddingTop: this.startIndex * this.oneHeight + 'px',
paddingBottom: (this.allList.length - this.endIndex) * this.oneHeight + 'px'
}
}
},
methods: {
// 請求數(shù)據(jù)
getData() {
this.isRequest = true // 正在請求中
axios.get("http://localhost:4000/data?num=10").then((res) => {
// 將結(jié)果追加到allList
this.allList = [...this.allList, ...res.data.list];
this.isRequest = false
});
},
// 計算可見區(qū)域能展示的條數(shù)
canShowNum() {
// ~~ 按位兩次取反,得到整數(shù)
this.showNum = ~~(this.$refs.container.offsetHeight / this.oneHeight) + 2;
},
// 監(jiān)聽滾動
handleScroll(e) {
if (this.canScroll) {
this.canScroll = false
// 處理數(shù)據(jù)
this.handleData(e)
// 節(jié)流
let timer = setTimeout(() => {
this.canScroll = true
clearTimeout(timer)
timer = null
}, 30)
}
},
handleData(e) {
// 記錄當(dāng)前元素滾動的高度
this.scrollTop = e.target.scrollTop
// 可見區(qū)域第一個元素的index
const curIndex = ~~(e.target.scrollTop / this.oneHeight)
// 渲染區(qū)域第一個元素的index,這里緩沖區(qū)域的列表條數(shù)使用的是this.showNum
this.startIndex = curIndex < this.showNum ? 0 : curIndex - this.showNum
// 滾動距離底部,還有this.lower距離時,觸發(fā)觸底事件,正在請求中不發(fā)送數(shù)據(jù)
if (e.target.scrollTop + e.target.clientHeight >= e.target.scrollHeight - this.lower && !this.isRequest) {
this.getData()
}
}
},
};
</script>
<style>
#app {
height: 100vh;
}
.container {
height: 100%;
/* 設(shè)置overflow為auto才能監(jiān)聽scroll滾動事件 */
overflow: auto;
}
.box {
width: 96vw;
height: 150px;
background: #eee;
border: 2px navajowhite solid;
box-sizing: border-box;
}
</style>模擬數(shù)據(jù)的后端代碼
- 這是本次用于模擬后端數(shù)據(jù)的代碼,采用
mock和express。
const Mock = require('mockjs')
const express = require('express')
const app = express()
let sum = 1 // mock的ID
// 根據(jù)入?yún)⑸蒼um條模擬數(shù)據(jù)
function generatorList(num) {
return Mock.mock({
[`list|${num}`]: [
{
'id|+1': sum,
title: "@ctitle(15,25)",
from: "@ctitle(3,10)",
}
]
})
}
// 允許跨域
app.all('*', function (req, res, next) {
res.setHeader("Access-Control-Allow-Origin", '*');
res.setHeader("Access-Control-Allow-Headers", '*');
res.setHeader("Access-Control-Allow-Method", '*');
next()
})
app.get('/data', function (req, res) {
const { num } = req.query
const data = generatorList(num)
sum += parseInt(num)
return res.send(data)
})
const server = app.listen(4000, function () {
console.log('4000端口正在監(jiān)聽~~')
})四、封裝為組件
也可以封裝為插件,此處為了方便就封裝為組件
props:
- allList : 所有數(shù)據(jù)
- oneHeight : 單條元素的高度
- lower : 距離底部多遠(yuǎn)時觸發(fā)觸底事件,默認(rèn)50
event:
- @scrollLower : 觸底時觸發(fā)
虛擬列表組件代碼
<template>
<!-- 監(jiān)聽滾動事件使用passive修飾符 -->
<div class="container" ref="container" @scroll.passive="handleScroll">
<div :style="paddingStyle">
<!-- key使用index,可避免多次渲染該dom -->
<div v-for="(item, index) in showList" :key="index">
<!-- 使用作用域插槽,將遍歷后的數(shù)據(jù)item和index傳遞出去 -->
<slot :item="item" :$index="index"></slot>
</div>
<div>到低了~~~</div>
</div>
</div>
</template>
<script>
export default {
name: "App",
props:{
// 所有數(shù)據(jù)
allList:{
type:Array,
default(){
return []
}
},
// 單條數(shù)據(jù)的高度
oneHeight:{
type:Number,
default:0
},
// 距離底部多遠(yuǎn)時觸發(fā)觸底事件
lower:{
type:Number,
default:50
}
},
data() {
return {
showNum: 0, // 可見區(qū)域最多能展示多少條數(shù)據(jù)
startIndex: 0, // 渲染元素的第一個索引
canScroll: true, // 可以監(jiān)聽滾動,用于節(jié)流
scrollTop: 0,// 當(dāng)前滾動高度,再次返回頁面時能定位到之前的滾動高度
};
},
activited() {
this.$nextTick(()=>{
// 定位到之前的高度
this.$refs.container.scrollTop = this.scrollTop
})
},
mounted() {
this.canShowNum(); // 獲取可見區(qū)域能展示多少條數(shù)據(jù)
window.onresize = this.canShowNum; // 監(jiān)聽窗口變化,需要重新計算一屏能展示多少條數(shù)據(jù)
window.onorientationchange = this.canShowNum; // 監(jiān)聽窗口翻轉(zhuǎn)
},
computed: {
// 渲染元素最后的index
endIndex() {
let end = this.startIndex + this.showNum * 3; // 3倍是需要預(yù)留緩沖區(qū)域
let len = this.allList.length
return end >= len ? len : end; // 結(jié)束元素大于所有元素的長度時,就取元素長度
},
// 需要渲染的數(shù)據(jù)
showList() {
return this.allList.slice(this.startIndex, this.endIndex)
},
// 空白占位的高度
paddingStyle() {
return {
paddingTop: this.startIndex * this.oneHeight + 'px',
paddingBottom: (this.allList.length - this.endIndex) * this.oneHeight + 'px'
}
}
},
methods: {
// 計算可見區(qū)域能展示的條數(shù)
canShowNum() {
// ~~ 按位兩次取反,得到整數(shù)
this.showNum = ~~(this.$refs.container.offsetHeight / this.oneHeight) + 2;
},
// 監(jiān)聽滾動
handleScroll(e) {
if (this.canScroll) {
this.canScroll = false
// 處理數(shù)據(jù)
this.handleData(e)
// 節(jié)流
let timer = setTimeout(() => {
this.canScroll = true
clearTimeout(timer)
timer = null
}, 30)
}
},
handleData(e) {
// 記錄當(dāng)前元素滾動的高度
this.scrollTop = e.target.scrollTop
// 可見區(qū)域第一個元素的index
const curIndex = ~~(e.target.scrollTop / this.oneHeight)
// 渲染區(qū)域第一個元素的index,這里緩沖區(qū)域的列表條數(shù)使用的是this.showNum
this.startIndex = curIndex < this.showNum ? 0 : curIndex - this.showNum
// 滾動距離底部,還有this.lower距離時,觸發(fā)觸底事件,正在請求中不發(fā)送數(shù)據(jù)
if (e.target.scrollTop + e.target.clientHeight >= e.target.scrollHeight - this.lower) {
this.$emit('scrollLower') // 傳遞觸底事件
}
}
},
};
</script>
<style>
.container {
height: 100%;
/* 設(shè)置overflow為auto才能監(jiān)聽scroll滾動事件 */
overflow: auto;
}
</style>使用代碼
<template>
<div id="app">
<VScroll :allList="allList" :oneHeight="150" :lower="150" @scrollLower="scrollLower">
<!-- 作用域插槽,使用slot-scope取出在組件中遍歷的數(shù)據(jù) -->
<template slot-scope="{item}">
<div class="box">
<h2>{{ item.title }} - {{ item.id }}</h2>
<h3>{{ item.from }}</h3>
</div>
</template>
</VScroll>
</div>
</template>
<script>
import axios from "axios";
import VScroll from "./components/VScroll.vue";
export default {
name: "App",
data() {
return {
allList: [], // 所有數(shù)據(jù)
isRequest: false // 是否正在請求數(shù)據(jù)
};
},
created() {
this.getData(); // 請求數(shù)據(jù)
},
methods: {
// 請求數(shù)據(jù)
getData() {
this.isRequest = true; // 正在請求中
axios.get("http://localhost:4000/data?num=10").then((res) => {
// 將結(jié)果追加到allList
this.allList = [...this.allList, ...res.data.list];
this.isRequest = false;
});
},
// 滾動到底部
scrollLower() {
if (!this.isRequest) this.getData()
}
},
components: { VScroll }
};
</script>
<style>
#app {
height: 100vh;
}
.box {
width: 96vw;
height: 150px;
background: #eee;
border: 2px navajowhite solid;
box-sizing: border-box;
}
</style>到此這篇關(guān)于在vue中實現(xiàn)虛擬列表組件,解決長列表性能問題的文章就介紹到這了,更多相關(guān)vue虛擬列表組件內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
解決vue2+vue-router動態(tài)路由添加及路由刷新后消失問題
這篇文章主要介紹了解決vue2+vue-router動態(tài)路由添加及路由刷新后消失問題,具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教2024-08-08
vuejs項目打包之后的首屏加載優(yōu)化及打包之后出現(xiàn)的問題
這篇文章主要介紹了vuejs項目打包之后的首屏加載優(yōu)化及打包之后可能出現(xiàn)的問題,需要的朋友可以參考下2018-04-04
Vue3+Three.js實現(xiàn)為模型添加點擊事件
本文主要介紹了如何在Vue3和Three.js環(huán)境中為模型添加點擊事件監(jiān)聽,具體方法是利用Three.js的Vector2和Raycaster,首先,創(chuàng)建一個Vector2對象來獲取鼠標(biāo)位置;然后,創(chuàng)建一個Raycaster對象來投射光線2024-10-10
electron中使用本地數(shù)據(jù)庫的方法詳解
眾所周知,electron是可以開發(fā)桌面端的框架,那我們有一些數(shù)據(jù)不想讓別人看到,只能在自己的電腦上展示時怎么辦呢,這個時候就可以用到本地數(shù)據(jù)庫,本文將以sqlite3為例介紹一下electron如何使用本地數(shù)據(jù)庫2023-10-10
vue2模擬vue-element-admin手寫角色權(quán)限的實現(xiàn)
本文主要介紹了vue2模擬vue-element-admin手寫角色權(quán)限的實現(xiàn),文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2022-07-07
詳解vue-meta如何讓你更優(yōu)雅的管理頭部標(biāo)簽
這篇文章主要介紹了詳解vue-meta如何讓你更優(yōu)雅的管理頭部標(biāo)簽,小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2018-01-01

