问题
像一些新闻、文章论坛列表都会涉及到一些大量数据的渲染的情况,当后端响应大量的数据,前端直接拿来渲染到列表上时,会造成页面的严重卡顿,常见的处理就是使用分片渲染和虚拟列表渲染的方式,这里简单实现定高的虚拟列表
虚拟列表 对需要展示的数据添加进展示列表,在页面发生滚动时,动态的添加当前屏幕可视区位置需要展示的列表,因为初始渲染无需渲染所有数据(只渲染可视区数据),导致列表展示效率非常高。
比较原始渲染和虚拟渲染
这里以渲染8000条数据为例
渲染速度
渲染数据量
渲染速度上虚拟列表渲染比原始渲染速度上要快很多,并且随着列表的滚动,发现虚拟列表渲染的数据长度是基本固定的。
拆解虚拟列表
实现思路
1、获取元素视口的高度,获取列表元素的高度,得到视口内可展示的元素数量,设置头尾缓冲元素数量
// 每条数据的高度
const itemHeight = 120;
// 可视区高度
const viewPointHeight = 640;
// 页面展示条数
const limitSize = Math.ceil(viewPointHeight / itemHeight);
// 可视区 头部+尾部索引
let startIndex = 0;
let endIndex = 0;
//头部+尾部 缓冲偏移
const startOffset = 3,
endOffset = 3;
2、设置render方法, 进行初始化渲染(传入可视区第一个元素索引,和最后一个元素索引,方法内会调用createlist方法生成结构,并动态添加可视区,缓冲区,留白区[具体的render,createlist见完整代码])
// 初次渲染
endIndex = limitSize;
render(startIndex, limitSize, true);
3、监听页面滚动,根据scrollTop动态生成可视区首尾元素索引,调用render,createlist生成可视区,缓冲区,留白区元素。
// 监听滚动
view.addEventListener('scroll', e => {
// 获取最新可视区头部元素索引
let start = Math.floor(e.target.scrollTop / itemHeight);
// 当滚动距离超过元素高度时触发
if (start !== startIndex) {
startIndex = start;
// 生成虚拟列表
render(startIndex, startIndex + limitSize);
}
})
最后
完整代码(定高)
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>渲染大量数据 createDocumentFragment和requestAnimationFrame</title>
<style>
.popup-box {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
display: none;
box-sizing: border-box;
background-color: rgba(0, 0, 0, 0.3);
}
.popup-box .view {
width: 360px;
height: 640px;
overflow-x: hidden;
overflow-y: auto;
background-color: azure;
margin: 100px auto;
padding: 10px;
box-sizing: inherit;
}
.popup-box .view .popup {
width: 100%;
}
.popup-box .view::-webkit-scrollbar {
display: none;
}
.popup-box .view .popup .item-box {
width: 100%;
height: 120px;
border-radius: 10px;
border: 1px solid #666;
}
</style>
</head>
<body>
<button class="nativeListBtn">大数据列表</button>
<button class="virtualListBtn">虚拟数据列表(定高)</button>
<div class="popup-box">
<div class="view"
<div class="popup"></div>
</div>
</div>
</body>
<script>
let allList = [];
;(() => {
// 获取按钮
const nativeBtn = document.querySelector('.nativeListBtn');
const virtualBtn = document.querySelector('.virtualListBtn');
// 获取容器
const popup = document.querySelector('.popup');
const popupBox = document.querySelector('.popup-box');
const view = document.querySelector('.view');
// 每条数据的高度
const itemHeight = 120;
// 可视区高度
const viewPointHeight = 640;
// 页面展示条数
const limitSize = Math.ceil(viewPointHeight / itemHeight);
// 可视区 头部+尾部索引
let startIndex = 0;
let endIndex = 0;
//头部+尾部 缓冲偏移
const startOffset = 3,
endOffset = 3;
// 原始渲染
nativeBtn.addEventListener('click', () => {
console.time('native');
popupBox.style.display = 'block';
let elementList = createList(allList);
popup.append(...elementList);
console.timeEnd('native');
});
// 虚拟列表渲染
virtualBtn.addEventListener('click', () => {
console.time('virtual');
popupBox.style.display = 'block';
// 初次渲染
endIndex = limitSize;
render(startIndex, limitSize, true);
console.timeEnd('virtual');
// 监听滚动
view.addEventListener('scroll', e => {
// 获取卷去了几个元素高度
let start = Math.floor(e.target.scrollTop / itemHeight);
if (start !== startIndex) {
startIndex = start;
// 生成虚拟列表
render(startIndex, startIndex + limitSize);
}
})
});
/*
* @description 用于创建元素
* @param {number} result 数据列表
* @returns {HTMLDivElementList[]} element[]
* */
function createList(result) {
return result.map(child => {
let div = document.createElement('div');
let p = document.createElement('p');
let span = document.createElement('span');
div.className = 'item-box';
p.className = 'item-msg';
span.className = 'item-time';
p.innerText = child.msg;
span.innerText = child.date + '/' + child.now;
div.append(p);
div.append(span);
return div;
})
}
/*
* @description 用于生成可视区 + 缓冲区 + 留白区内容
* @param {number} startIndex 可视区头部元素索引
* @param {number} endIndex 可视区尾部元素索引
* @returns {void}
* */
function render(startIndex, endIndex) {
// 可视区域元素渲染列表
let elementList = createList(allList.slice(startIndex, endIndex));
// 头部缓冲区域渲染列表
let startOffsetList = createList(allList.slice(startIndex - startOffset, startIndex));
// 尾部缓冲区域渲染列表
let endOffsetList = createList(allList.slice(endIndex, endIndex + endOffset));
// 设置头部空白占位
popup.style.paddingTop = (startIndex - startOffset > 0 ? startIndex - startOffset : 0) * itemHeight + 'px';
// 设置尾部空白占位
popup.style.paddingBottom = (allList.length - endIndex) * itemHeight + 'px';
// 清空缓冲 + 可视区内容
[...popup.children].forEach(child => {
popup.removeChild(child);
})
// 动态添加节点
popup.append(...startOffsetList, ...elementList, ...endOffsetList);
}
})();
</script>
<script>
;(() => {
const xhr = new XMLHttpRequest();
xhr.open('get', 'http://localhost:4800/api/list?count=8000');
xhr.setRequestHeader("Authorization", window.localStorage.getItem('TOKEN'));
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300 || xhr.status === 304) {
allList = JSON.parse(xhr.response).data;
}
});
xhr.send();
})();
</script>
</html>
express
router.get('/list', async (req, res, next) => {
try {
const {count} = req.query;
if (!count) {
res.status(400).json({
code: 400,
message: 'params error',
data: [],
});
return;
}
let i = 0;
const arr = [];
while (i < count) {
arr.push({
id: i,
msg: `这是数据${i + 1}`,
// eslint-disable-next-line max-len
date: `${(new Date()).getMonth()+1}月${(new Date()).getDate()}日${(new Date()).getHours()}时${(new Date()).getMinutes()}分`,
now: `${Date.now()}`,
});
i++;
}
res.status(200).json({
code: 200,
message: 'Ok',
data: arr,
});
} catch (error) {
next(error);
}
});