# 前端如何处理10万条数据
# 业务场景介绍
当后端一次性返回10万条数据不做分页,需要前端直接做渲染展示。
# 解决方法
如果直接迭代所有数据进行展示,则会因数据量庞大导致页面性能降低,造成页面卡顿。本章主要使用三种方式来处理数据量庞大的渲染: 虚拟列表、懒加载、时间分片。(本文以vue3环境作示例。)
# 虚拟列表
# 定义
虚拟列表其实是按需显示的一种实现,即只对可见区域进行渲染,对非可见区域中的数据不渲染或部分渲染的技术,从而达到极高的渲染性能。
# 原理
根据容器可视区域的列表容积数量,监听用户滑动或滚动事件,动态截取长列表数据中的部分数据渲染到页面上,动态使用空白站位填充容器上下滚动区域内容,模拟实现原生滚动效果
# 代码演示
<script setup >
import { onMounted, ref, computed } from "vue";
// index.js
// 获取列表函数
const getList = (num) => {
let dataList = [];
for (let i = 0; i < num; i++) {
dataList.push({ id: i, name: generateRandomName(3) });
}
return dataList;
};
// 随机生成指定长度的名字
const generateRandomName = (namelen) => {
let words = "周杰伦王力宏林俊杰陶喆袁娅维古天乐谭维维王靖雯";
let res = "";
for (let j = 0; j < namelen; j++) {
res += words[Math.floor(Math.random() * words.length)];
}
return res;
};
const container = ref(); // container节点
const blank = ref(); // blank节点
const header = ref(); // header节点
const list = ref([]); // 列表
const page = ref(1); // 当前页数
const limit = 200; // 一页展示
// 最大页数 (向上取整)
const maxPage = computed(() => Math.ceil(list.value.length / limit));
// 真实展示的列表
const showList = computed(() =>
list.value.slice((page.value - 1) * limit, page.value * limit)
);
const handleScroll = () => {
// 当前页数与最大页数的比较
if (page.value > maxPage.value) return;
// 获取容器高度
const clientHeight = container.value?.clientHeight;
// 获取底部空标签当前顶部位置
const blankTop = ~~blank.value?.getBoundingClientRect().top;
// 获取顶部空标签当前顶部位置
const headerTop = ~~header.value?.getBoundingClientRect().top;
// 若滚动条已经拉到顶
if (headerTop == 0) {
// header出现在视图,则当前页数减1
page.value--;
}
// 若滚动条已经拉到底
if (clientHeight === blankTop) {
// blank出现在视图,则当前页数加1
page.value++;
}
};
// 防抖函数
const debounce = (fn, delay) => {
let timeout = null;
return function () {
clearTimeout(timeout);
timeout = setTimeout(() => {
fn.apply(this, [...arguments]);
}, delay);
};
};
// 简单防抖
const debounceHandleScroll = debounce(handleScroll, 200);
onMounted(() => {
const res = getList(100000);
list.value = res;
});
</script>
<template>
<div id="container" @scroll="debounceHandleScroll" ref="container">
<div ref="header"></div>
<div class="sunshine" v-for="item in showList" :key="item.id">
<span>{{ item.id }}__{{ item.name }}</span>
</div>
<div ref="blank"></div>
</div>
</template>
<style scoped>
#container {
height: 100vh;
overflow-y: auto;
}
</style>
# 懒加载
# 定义
懒加载是指数据量一开始只加载一部分,当需要加载更多数据时,触发回调后才会加载下一部分。
# 原理
在首屏加载的时候,只加载可视区域内需要的列表项,当滚动发生时,动态更新列表后续数据。
# 代码演示
<script setup >
import { onMounted, ref, computed } from "vue";
// index.js
// 获取列表函数
const getList = (num) => {
let dataList = [];
for (let i = 0; i < num; i++) {
dataList.push({ id: i, name: generateRandomName(3) });
}
return dataList;
};
// 随机生成指定长度的名字
const generateRandomName = (namelen) => {
let words = "周杰伦王力宏林俊杰陶喆袁娅维古天乐谭维维王靖雯";
let res = "";
for (let j = 0; j < namelen; j++) {
res += words[Math.floor(Math.random() * words.length)];
}
return res;
};
const container = ref(); // container节点
const blank = ref(); // blank节点
const list = ref([]); // 列表
const page = ref(1); // 当前页数
const limit = 200; // 一页展示
// 最大页数 (向上取整)
const maxPage = computed(() => Math.ceil(list.value.length / limit));
const showList = computed(()=> list.value.slice(0,page.value*limit))
const handleScroll = () => {
// 当前页数与最大页数的比较
if (page.value > maxPage.value) return;
// 获取容器高度
const clientHeight = container.value?.clientHeight;
// 获取底部空标签当前顶部位置
const blankTop = ~~blank.value?.getBoundingClientRect().top;
// 若滚动条已经拉到底
if (clientHeight === blankTop) {
// blank出现在视图,则当前页数加1
page.value++;
}
};
// 防抖函数
const debounce = (fn, delay) => {
let timeout = null;
return function () {
clearTimeout(timeout);
timeout = setTimeout(() => {
fn.apply(this, [...arguments]);
}, delay);
};
};
// 简单防抖
const debounceHandleScroll = debounce(handleScroll, 200);
onMounted(() => {
const res = getList(100000);
list.value = res;
});
</script>
<template>
<div id="container" @scroll="debounceHandleScroll" ref="container">
<div ref="header"></div>
<div class="sunshine" v-for="item in showList" :key="item.id">
<span>{{ item.id }}__{{ item.name }}</span>
</div>
<div ref="blank"></div>
</div>
</template>
<style scoped>
#container {
height: 100vh;
overflow-y: auto;
}
</style>
</script>
<template>
<div id="container" @scroll="handleScroll" ref="container">
<div class="sunshine" v-for="item in showList" :key="item.id">
<span>{{item.id}}__{{ item.name }}</span>
</div>
<div ref="blank"></div>
</div>
</template>
<style scoped>
#container{
height: 100vh;
overflow-y:auto;
}
</style>
# 时间分片
# 定义
如字面意思,随时间将数据分成多个片段加载渲染。那么使用setTimeout异步循环加载定量数据,直到定量数据加载至全部,便完成数据渲染。
# api选用
由于屏幕自身的刷新率和实际异步执行的间隔时间的不相同,导致页面会闪屏。我将使用requestAnimationFrame
来代替setTimeout
,配合DocumentFragments
去实现10万条数据渲染。
# requestAnimationFrame定义
requestAnimationFrame是浏览器用于定时循环操作的一个接口,类似于setTimeout,主要用途是按帧对网页进行重绘。
# DocumentFragment定义
DocumentFragment,文档片段接口,表示一个没有父级文件的最小文档对象。它被作为一个轻量版的Document使用,用于存储已排好版的或尚未打理好格式的XML片段。最大的区别是因为DocumentFragment不是真实DOM树的一部分,它的变化不会触发DOM树的(重新渲染) ,且不会导致性能等问题。
# 代码实现
<script>
import { reactive } from "vue";
export default {
setup() {
const dataList = reactive({ personalInfo: [] });
// 获取数据表容器dom,注意这里是类collection数组,需要实例化后取第一个元素。
const dataContainerDom = document.getElementsByClassName("data-container");
const once = 20; // 一次性添加 20 条
let total = 100000; // 添加10万条数据
let index = 0; // 每条记录的索引
// 递归循环,添加数据
function loop(curTotal, curIndex) {
// 总数为0时,结束循环
if (curTotal <= 0) return;
// 每页多少条(主要处理末次循环)
let pageCount = Math.min(curTotal, once);
// 类似于setTimeout,按帧重绘
window.requestAnimationFrame(function () {
// 生成片段,数据添加时将不会触发页面回流。
let fragment = document.createDocumentFragment();
for (let i = 0; i < pageCount; i++) {
let row = document.createElement("div");
row.classList.add("data-row", "fl", "jc-center", "ai-center");
row.innerText = `${dataList.personalInfo[curIndex+i].id}__${dataList.personalInfo[curIndex+i].name}`;
fragment.appendChild(row);
}
// 给容器添加数据条
dataContainerDom[0].appendChild(fragment);
// 数据量减少当前渲染条数,下标移动至当前渲染条数之后。
loop(curTotal - pageCount, curIndex + pageCount);
});
}
// 随机生成指定长度的名字
function generateRandomName(namelen) {
let words = "周杰伦王力宏林俊杰陶喆袁娅维古天乐谭维维王靖雯";
let res = "";
for (let j = 0; j < namelen; j++) {
res += words[Math.floor(Math.random() * words.length)];
}
return res;
}
return {
dataList,
dataContainerDom,
once,
total,
index,
loop,
generateRandomName,
}
},
created() {
// 在组件渲染完成之前,添加10万条数据
for (let i = 0; i <= this.total; i++) {
this.dataList.personalInfo.push({
id: i.toString(),
name: this.generateRandomName(3),
});
}
},
mounted() {
// 页面开始时,开始循环加载渲染。
this.loop(this.total, this.index);
},
};
</script>
<template>
<div class="million_page fl jc-center ai-center">
<div class="data-container">
</div>
</div>
</template>
<style lang="less">
.million_page {
height: 100vh;
.data-container {
overflow-y: auto;
width: 75vw;
height: 80vh;
border: 1px solid black;
.data-row {
padding: 0 50px;
height: 80px;
border: 1px solid rgb(153, 153, 255);
}
}
}
</style>
# 步骤概括
1、虚拟列表的步骤:
- 通过添加在容器顶部、底部添加空标签。
- 用
getBoundingClientRect
获取空标签高度。 - 在空标签顶部位置与容器高度一致时,加载后续一段列表数据,移除前一段列表数据。
2、懒加载的步骤与虚拟列表基本相同,区别在于懒加载是不断添加数据,前面的数据都有所保留。
3、数据列表时间分片的步骤:
- 获取容器的DOM
- 声明loop函数,用到requestAnimationFrame代替setTimeout,传入添加数据回调
- 创建createDocumentFragment文本对象fragment,该文本对象不会引起回流。数据appendChild到这fragment后,再向容器DOM上appendChild这个fragment上。
- 通过loop递归循环,不断地在容器DOM中添加定量数据,直到加载完毕。
# 参考文章
后端一次给你10万条数据,如何优雅展示,到底考察我什么? - 掘金 (juejin.cn) (opens new window)