本文由 简悦 SimpRead 转码, 原文地址 mp.weixin.qq.com
引言
在前端开发中,性能优化是一个永恒的话题。我们经常需要处理和展示大量的数据,所以当面试官问到:“如何一次性渲染十万条数据而不影响用户体验?” 你会怎么回答?直接渲染十万条数据可能会导致页面卡顿、响应迟缓,甚至浏览器崩溃。本篇文章详细介绍时间分片和虚拟列表的解决方案,帮助你轻松拿下面试~
前置知识
js 是单线程的,会有一个同步和异步的概念,为了确保主线程不会被长时间阻塞,js 引擎就会依照**「事件循环机制」**来执行代码:
先执行同步代码(也属于是宏任务)
同步执行完毕后,检查是否有异步代码需要执行
执行所有的微任务
微任务执行完毕后,若有需要就会渲染页面
执行宏任务(也就是下一次事件循环开始)
微任务:Promise.then()、process.nextTick()、async/await、MutationObserver() 等
宏任务:script(开启一整份代码的执行)、setTimeout、setInterval、setImmediate、I/O 操作、UI-Rendering、同步代码等
时间分片
v8 引擎执行 js 代码速度很快,然而渲染页面时间相对来说要长很多。如果直接将十万条数据给到渲染引擎,很容易造成页面卡顿或白屏,所以一次性渲染十万条数据的关键在于——要让浏览器的渲染线程尽量均匀流畅地将数据渲染上去。
时间分片的核心思想是将一个大的任务分解成多个小的任务,使用 setTimeout或requestAnimationFrame 分批次地渲染一部分数据。
使用 setTimeout
「初始化」:定义总数据条数
total、每次渲染的数据条数once、需要渲染的总次数page和当前渲染的索引index。「递归渲染」:
loop函数通过递归调用来逐步渲染数据。每次 for 循环渲染once条数据,并使用setTimeout将渲染操作放入下一个事件循环中。「定时器」:
setTimeout确保每次渲染操作不会阻塞主线程,从而保持页面的流畅性和响应性。「结束条件」:当所有数据都渲染完毕后(即
curTotal - pageCount <= 0),递归调用停止。
<!---->
<body>
<ul id="container"></ul>
<script>
let ul = document.getElementById('container');
const total = 100000 // 总数据条数
let once = 20 // 每次渲染条数
let page = total / once // 需要渲染的总次数
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);
}
loop(curTotal - pageCount, curIndex + pageCount)
})
}
loop(total, index)
</script>
</body>不让 v8 一次事件循环就把 js 部分执行掉,浏览器一次性暴力渲染十万条,而是让 v8 执行五千次事件循环,浏览器每次只渲染二十条。这样 v8 分摊了浏览器渲染线程的压力,能减少页面加载时间
requestAnimationFrame
使用 setTimeout 将渲染操作放入下一个事件循环会有个小问题:假设浏览器页面刷新时间为 16.7ms,如果 v8 引擎的性能不够高,进行完一次事件循环的时间比 16.7ms 要长,那么浏览器在 16.7ms 内渲染完了 20 条数据,而 v8 引擎还没将下一个 20 条数据给出来,这样就很可能会造成页面的闪屏或卡顿。
要解决定时器带来的事件循环与屏幕刷新不同步的问题,我们可以用 requestAnimationFrame() 取代 setTimeout()。
在此基础上,我们需要尽量人为地减少回流重绘次数。如果每一次 for 循环都渲染一条数据,那这样高频地操作 DOM 会浪费开销影响性能。
所以,我们可以使用文档碎片 document.createDocumentFragment()——一个虚拟的 DOM, 来批量插入 li 元素。使得生成好一条数据后,先不要往 ul 里面添加,固定每 20 个 li 只回流一次
<body>
<ul id="container"></ul>
<script>
let ul = document.getElementById('container');
const total = 100000
let once = 20
let page = total / once
let index = 0
function loop(curTotal, curIndex) {
let pageCount = Math.min(once, curTotal)
requestAnimationFrame(() => {
// 创建一个文档碎片,是一个虚拟的DOM结构
let fragment = document.createDocumentFragment();
for (let i = 0; i < pageCount; i++) {
let li = document.createElement('li');
li.innerText = curIndex + i + ':' + ~~(Math.random() * total);
fragment.appendChild(li);
}
// 固定每20个li只回流一次
ul.appendChild(fragment);
loop(curTotal - pageCount, curIndex + pageCount)
})
}
loop(total, index)
</script>
</body>虚拟列表
虚拟列表技术通过**「只渲染当前可见区域的数据」**来提高性能,而不是一次性渲染所有数据。这样可以显著减少 DOM 元素的数量,从而提高页面的加载速度和滚动流畅性。
核心思想
「初始化容器和数据」:创建固定高度的容器,准备数据源。
「计算可视区域」:获取容器高度,计算每个项的高度和可视区域的数据条数。
「渲染初始可见区域」:计算起始和结束索引,渲染初始数据。
「监听滚动事件」:绑定滚动事件,计算新的起始和结束索引,更新渲染数据。
「调整样式」:计算偏移量,处理实际列表跟随父容器一起移动的情况
接下来用 vue 技术栈展示虚拟列表实现步骤:
根组件 App.vue 中:
定义数据源
data,里面存放一千个对象,每个对象包含id和value属性引入自定义的
virtualList组件,并通过:listData属性将data传递给它设置
.app类的样式,使其具有固定的宽度和高度,并添加边框以区分容器区域
<!---->
<template>
<div class="app">
<virtualList :listData="data" />
</div>
</template>
<script setup>
import { ref } from 'vue';
import virtualList from './components/virtualList.vue';
// 数据源
const data = ref([])
for(let i = 0; i < 1000; i++) {
data.value.push({id: i, value: i})
}
</script>
<style lang="css" scoped>
.app{
width: 300px;
height: 400px;
border: 1px solid #000;
}
</style>自定义 virtualList 组件中:
「模板部分:」
根元素
infinite-list-container:绑定了 ref 为 listRef,用于后续获取 DOM 元素,并且绑定滚动事件处理器 scrollEvent虚拟占位元素
infinite-list-phantom:用于撑开父容器的高度,确保可以滚动。其高度由 listHeight 计算得出实际列表元素
infinite-list:使用 transform 属性来控制列表的位置列表项元素
infinite-list-item:通过 v-for 循环渲染 visibleData 中的数据项。每个项的高度和行高由 itemSize 控制
<!---->
<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 { onMounted, reactive, ref, computed } from 'vue';
const props = defineProps({
listData: [],
// 每个item的高度
itemSize: {
type: Number,
default: 50
}
})
const state = reactive({
screenHeight: 0, // 可视区域的高度
startOffset: 0, // 偏移量
start: 0, // 起始数据下标
end: 0 // 结束数据下标
})
// 可视区域显示的数据条数
const visibleCount = computed(() => {
return state.screenHeight / props.itemSize
})
// 可视区域显示的真实数据
const visibleData = computed(() => {
return props.listData.slice(state.start, Math.min(props.listData.length, state.end))
})
// 当前列表总高度
const listHeight = computed(() => {
return props.listData.length * props.itemSize
})
// list跟着父容器移动了,现在列表要移回来
const getTransform = computed(() => {
return `translateY(${state.startOffset}px)`
})
// 获取可视区域的高度
const listRef = ref(null)
onMounted(() => {
state.screenHeight = listRef.value.clientHeight
state.end = state.start + visibleCount.value
})
const scrollEvent = () => {
let 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 lang="css" scoped>
.infinite-list-container {
height: 100%;
overflow: auto;
position: relative;
-webkit-overflow-scrolling: touch; /* 启用触摸滚动 */
}
.infinite-list-phantom{
position: absolute;
left: 0;
top: 0;
right: 0;
z-index: -1; /* 置于背景层 **/
}
.infinite-list{
position: absolute;
left: 0;
top: 0;
right: 0;
text-align: center;
}
.infinite-list-item {
border-bottom: 1px, solid, #000;
box-sizing: border-box;
}
</style>「效果:」
总结
「时间分片」:
根据事件循环机制,每一次事件循环都会先进行页面渲染,再执行宏任务。
所以可以使用
setTimeout或requestAnimationFrame定时器将生成数据的 js 线程操作和渲染数据的渲染线程操作隔离到两次事件循环中,这样浏览器就能分批次地渲染一部分数据。再配合文档碎片
document.createDocumentFragment()减少回流次数,提高性能「虚拟列表」:
拿到所有数据,计算出所有数据应有列表高度;
获取可视区域的高度,计算出可视区域中能渲染的数据条数
在实时滚动时计算要渲染的数据起始和截止下标,仅渲染那些在当前视口中的数据项
计算偏移量,调整样式
除了以上这两种方法,还可以采用懒加载、Web Workers 等方法对渲染大量数据的操作进行优化,希望对你有所帮助。
(都看到这了,点赞收藏一下再走吧~)