前端如何一次性渲染十万条数据?
前言
当面试官问:“给你十万条数据,你会怎么办?”这时我们该如何应对呢?
在实际的 Web 开发中,有时需要展示大量数据,比如用户评论、商品列表等。如果一次性渲染太多的数据(如100,000条),会导致浏览器卡顿,用户体验变差。下面从一个简单的例子开始,逐步改进代码,最终使用现代框架的虚拟滚动技术来解决这一问题。
正文
最直接的方法
下面是最直接的方法,一次性创建所有的列表项并添加到 DOM 树中。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<ul id="container"></ul>
<script>
let ul = document.getElementById('container');
const total = 100000;
let now = Date.now();
for (let i = 0; i < total; i++) {
let li = document.createElement('li');
li.innerText = ~~(Math.random() * total);
ul.appendChild(li);
}
console.log('js运行耗时', Date.now() - now);
setTimeout(() => {
console.log('运行耗时', Date.now() - now);
});
</script>
</body>
</html>
代码解释:
该方法简单直接,但由于一次性将所有数据渲染到 DOM 中,导致浏览器需要较长时间渲染页面,出现页面卡顿或白屏的情况。
setTimeout 分批渲染
为了避免一次性操作引起的卡顿,可以使用 setTimeout
将创建和添加操作分散到多个时间点,每次渲染一部分数据。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<ul id="container"></ul>
<script>
let ul = document.getElementById('container');
const total = 100000;
let once = 20;
let index = 0;
function loop(curTotal, curIndex) {
let pageCount = Math.min(once, curTotal);
setTimeout(() => {
for (let i = 0; i < pageCount; i++) {
let li = document.createElement('li');
li.innerText = curIndex + i + ':' + ~~(Math.random() * total);
ul.appendChild(li);
}
if (curTotal > pageCount) {
loop(curTotal - pageCount, curIndex + pageCount);
}
});
}
loop(total, index);
</script>
</body>
</html>
代码解释:
通过将渲染任务分批执行,减少一次性渲染的压力,避免页面卡顿或白屏的问题。
使用 requestAnimationFrame
相比 setTimeout
,requestAnimationFrame
是更优秀的解决方案,它能保证操作在浏览器每一帧刷新时进行。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<ul id="container"></ul>
<script>
let ul = document.getElementById('container');
const total = 100000;
let once = 20;
let index = 0;
function loop(curTotal, curIndex) {
let pageCount = Math.min(once, curTotal);
requestAnimationFrame(() => {
for (let i = 0; i < pageCount; i++) {
let li = document.createElement('li');
li.innerText = curIndex + i + ':' + ~~(Math.random() * total);
ul.appendChild(li);
}
if (curTotal > pageCount) {
loop(curTotal - pageCount, curIndex + pageCount);
}
});
}
loop(total, index);
</script>
</body>
</html>
代码解释:
requestAnimationFrame
可以让渲染更加流畅,避免了 setTimeout
可能出现的卡顿问题。
使用文档碎片(requestAnimationFrame
+ DocumentFragment
)
使用 DocumentFragment
可以将所有节点暂时存放在内存中,减少 DOM 操作次数,进一步提升性能。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<ul id="container"></ul>
<script>
let ul = document.getElementById('container');
const total = 100000;
let once = 20;
let index = 0;
function loop(curTotal, curIndex) {
let fragment = document.createDocumentFragment();
let pageCount = Math.min(once, curTotal);
requestAnimationFrame(() => {
for (let i = 0; i < pageCount; i++) {
let li = document.createElement('li');
li.innerText = curIndex + i + ':' + ~~(Math.random() * total);
fragment.appendChild(li);
}
ul.appendChild(fragment);
if (curTotal > pageCount) {
loop(curTotal - pageCount, curIndex + pageCount);
}
});
}
loop(total, index);
</script>
</body>
</html>
代码解释:
DocumentFragment
将多个 DOM 操作合并为一次提交,减少回流与重绘,进一步提高渲染性能。
使用虚拟滚动(Virtual Scrolling)
虚拟滚动只渲染当前可视区域内的数据,当用户滚动时,动态替换数据。这是处理大数据集的最佳实践。
<template>
<div class="app">
<virtualList :listData="data"></virtualList>
</div>
</template>
<script setup>
import virtualList from './components/virtualList.vue'
const data = [];
for (let i = 0; i < 100000; i++) {
data.push({ id: i, value: i });
}
</script>
<style scoped>
.app {
height: 400px;
width: 300px;
border: 1px solid #000;
}
</style>
<template>
<div ref="listRef" class="infinite-list-container" @scroll="scrollEvent">
<div class="infinite-list-phantom" :style="{ height: listHeight + 'px' }"></div>
<div class="infinite-list" :style="{ transform: getTransform }">
<div
class="infinite-list-item"
v-for="item in visibleData"
:key="item.id"
:style="{ height: itemSize + 'px', lineHeight: itemSize + 'px' }"
>
{{ item.value }}
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref, reactive, onMounted } from 'vue';
const props = defineProps({
listData: Array,
itemSize: { type: Number, default: 50 }
});
const state = reactive({
screenHeight: 0,
startOffset: 0,
start: 0,
end: 0
});
const visibleCount = computed(() => Math.ceil(state.screenHeight / props.itemSize));
const visibleData = computed(() => props.listData.slice(state.start, Math.min(state.end, props.listData.length)));
const listHeight = computed(() => props.listData.length * props.itemSize);
const getTransform = computed(() => `translateY(${state.startOffset}px)`);
const listRef = ref(null);
onMounted(() => {
state.screenHeight = listRef.value.clientHeight;
state.end = state.start + visibleCount.value;
});
const scrollEvent = () => {
const scrollTop = listRef.value.scrollTop;
state.start = Math.floor(scrollTop / props.itemSize);
state.end = state.start + visibleCount.value;
state.startOffset = scrollTop - (scrollTop % props.itemSize);
};
</script>
<style scoped>
.infinite-list-container {
height: 100%;
overflow: auto;
position: relative;
}
.infinite-list-phantom {
position: absolute;
left: 0;
right: 0;
top: 0;
}
.infinite-list {
position: absolute;
left: 0;
right: 0;
top: 0;
text-align: center;
}
.infinite-list-item {
border-bottom: 1px solid #eee;
box-sizing: border-box;
}
</style>
代码解释:
虚拟滚动的核心思想是只渲染当前可视区域的数据,大大减少渲染
的 DOM 数量,提升性能。
总结
通过上述方法,从最简单的 DOM 操作到使用虚拟滚动技术,我们逐步提升了渲染大数据量时的性能。使用虚拟滚动是最佳实践,尤其是在处理非常大规模的数据时。