虚拟滚动使用场景
当在一个页面或者某个功能中,需要展现大量的数据(如:移动端新闻列表、移动端地区列表)时,如果全部渲染会导致页面的性能变得很差,这时候就可以使用虚拟滚动来对页面进行优化。
什么是虚拟滚动
只给用户渲染需要看到的数据,其他数据不渲染。
如果有1w条数据,但是屏幕中最多只能看到20条数据,这种时候除了这20条数据之外其他都是没有意义的,只会消耗浏览器的性能。所以虚拟滚动中,把不需要渲染的数据使用了空白填充。当用户进行滚动操作时,渲染出对应区域的数据即可。
dom结构区别
不使用虚拟滚动的dom结构:
使用虚拟滚动:
首次渲染的性能区别:
左侧为渲染全部数据,右侧为虚拟滚动
实现虚拟滚动
HTML结构分析
因为虚拟滚动需要把视觉效果和普通渲染保持一致,所以我们需要一个占位元素将父容器撑开,这个占位元素的高度需要和普通渲染中列表的高度相同,这样滚动条的表现才会一致。
当我们向下滚动的时候,因为表格中数据的实际高度只有一点,所以很快就会出现空白部分,如图所示:
所以这时候我们需要将列表元素与可视区域一样向下移动(可以设置top属性或者使用transform属性),这样就可以一致保持用户所看到的是有数据的:
样式实现
css文件
.screen {
position: absolute;
width: 300px;
height: 200px;
}
#scroll {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background-color: #4DC0EB;
overflow-y: scroll;
}
.background {
position: absolute;
left: 0;
right: 0;
top: 0;
z-index: -1;
}
.list {
position: absolute;
left: 0;
right: 0;
top: 0;
}
.line {
color: #24305E;
line-height: 2;
padding-left: 10px;
}
html
screen为视口区域,可自定义,background为占位元素,list为需要渲染的列表数据
<div class="screen">
<div id="scroll">
<div class="background"></div>
<div class="list"></div>
</div>
</div>
js实现
数据处理
初始化数据
首先我们需要初始化一些基本变量,后续的效果实现需要这些变量支持
const ITEM_HEIGHT = 32 // 每项列表元素的高度(不一样的高度取最小值)
const COUNT = 10000 // 列表的数据量(一般由接口获取)
// 一个可视区域最多可以出现的数据个数
const MAX_COUNT = Math.ceil(document.querySelector('.screen').clientHeight / ITEM_HEIGHT)
const listData = [] // 存放全部的列表数据
let runData = [] // 实际渲染的列表数据
const container = document.querySelector('.list')
setBackgroundHeight()
runData = listData.slice(0, MAX_COUNT * 2) // 设置初始渲染数据
// 列表中的数据元素
const line = createElement('div', {
className: 'line'
})
appendElement(runData, container, line)
工具函数:
// 设置background(占位元素)的高度
function setBackgroundHeight () {
document.querySelector('.background').style.height
= getListHeight(ITEM_HEIGHT, COUNT) + 'px'
}
// 创建元素
function createElement (tag, options = { style: {} }) {
const dom = document.createElement('div')
changeDomStyle(dom, options)
return dom
}
// 将元素添加到指定dom上
function appendElement (dataList, container, child) {
container.innerHTML = ''
const fragment = document.createDocumentFragment()
dataList.forEach(item => {
child = initInnerHTMLData(child.cloneNode(true), item.text)
fragment.appendChild(child)
})
container.append(fragment)
}
// 生成假数据
function initInnerHTMLData (dom, text) {
dom.innerHTML = text
return dom
}
// 改变元素样式
function changeDomStyle (dom, options) {
const { className, style } = options
dom.className = className || ''
for (const key in style) {
dom.style[key] = style[key]
}
}
列表数据处理
因为在列表滚动的时候我们需要精确的定位到当前可视区域第一个元素的索引,所以需要给每个数据添加一个标记,标记为该元素的scrollTop,在移动列表数据的时候也需要用到这个数据。
// 如果元素高度不一样,则需要定义一个变量进行累加
listData.forEach((item, index) => {
item.top = index * ITEM_HEIGHT
})
listData处理完后:
渲染列表
根据业务需求自行定义元素样式生成并且挂载
逻辑实现
通过监听父元素的scroll事件触发相关逻辑,为了节省性能,这里使用了节流,requestAnimationFrame函数简单来说就是一个定时器(setTimeout),触发的时间不同
点击链接:(深入理解 requestAnimationFrame_前端大全的博客-CSDN博客)。
let tick = false // 节流开关
document.querySelector('#scroll').addEventListener('scroll', (e) => {
if (!tick) {
tick = true
window.requestAnimationFrame(() => {
tick = false
})
getRunDataList(getScrollDistance(e))
}
})
getScrollDistance:
function getScrollDistance (event) {
return event.target.scrollTop
}
getRunDataList 最关键的函数:
beforeList,afterList这两个数组的意义是为了增加渲染列表的上下衔接的数据。这样会避免因为用户滚动太快而数据还没加载出现的空白列表现象。
function getRunDataList (distance) {
// distance为传入的scrollTop,通过scrollTop精确定位到当前视口顶部的元素索引
const startIndex = getStartIndex(distance)
// 兜底数据
const beforeList = listData.slice(getBeforeIndex(startIndex), startIndex)
const nowList = listData.slice(startIndex, startIndex + MAX_COUNT)
const afterList
= listData.slice(getAfterIndex(startIndex), getAfterIndex(startIndex) + MAX_COUNT)
// 移动list元素至对应的scrollTop
changeListTop(startIndex, beforeList[0] || listData[startIndex])
runData = [...beforeList, ...nowList, ...afterList]
// 将数据渲染到页面上
appendElement(runData, container, line)
}
优化后的函数:
function getRunDataList (distance) {
// 因为有beforeList和afterList的数据兜底
// 所以可以在scrollTop处于nowList的数据上的时候不渲染数据
// 等到滚动出了安全区的时候再渲染数据
if (!switchScroll(distance)) {
const startIndex = getStartIndex(distance)
const beforeList = listData.slice(getBeforeIndex(startIndex), startIndex)
const nowList = listData.slice(startIndex, startIndex + MAX_COUNT)
const afterList
= listData.slice(getAfterIndex(startIndex), getAfterIndex(startIndex) + MAX_COUNT)
changeListTop(startIndex, beforeList[0] || listData[startIndex])
// 改变安全区
changeSwitchScale(startIndex, getBeforeIndex(startIndex), getAfterIndex(startIndex))
runData = [...beforeList, ...nowList, ...afterList]
appendElement(runData, container, line)
}
}
工具函数:
// 移动list元素到指定位置
function changeListTop (startIndex, { top }) {
document.querySelector('.list').style.transform = `translate3d(0, ${top}px, 0)`
}
// 判断是否在安全区
function switchScroll (scrollTop) {
return scrollTop > switchScrollScale[0] && scrollTop < switchScrollScale[1]
}
// 改变安全区
function changeSwitchScale (startIndex, beforeIndex, afterIndex) {
const beforeScale = Math.ceil(startIndex) * ITEM_HEIGHT
const afterScale = Math.floor((afterIndex)) * ITEM_HEIGHT
switchScrollScale = [beforeScale, afterScale]
}
// 二分法查找
function getStartIndex (scrollTop) {
let start = 0
let end = listData.length - 1
while (start < end) {
const mid = Math.floor((end + start) / 2)
const { top } = listData[mid]
if (scrollTop >= top && scrollTop < top + ITEM_HEIGHT) {
start = mid
break
} else if (scrollTop >= top + ITEM_HEIGHT) {
start = mid + 1
} else if (scrollTop < top) {
end = mid - 1
}
}
return start < 0 ? 0 : start
}
function getBeforeIndex (startIndex) {
return startIndex - MAX_COUNT < 0 ? 0 : startIndex - MAX_COUNT
}
function getAfterIndex (startIndex) {
return startIndex + MAX_COUNT > COUNT ? COUNT : startIndex + MAX_COUNT
}
实现效果
代码以及实现效果:
文章部分引用:vue轻松实现虚拟滚动 - 知乎 (zhihu.com)
实际应用场景:element-ui transfer组件优化