前言
现在有一个需求,需要将十万条数据依次插入页面:
<ul id="container"></ul>
// 记录任务开始时间
let now = Date.now();
// 插入十万条数据
const total = 100000;
// 获取容器
let ul = document.getElementById('container');
// 将数据插入容器中
for (let i = 0; i < total; i++) {
let li = document.createElement('li');
li.innerText = i
ul.appendChild(li);
}
console.log('JS运行时间:',Date.now() - now);
setTimeout(()=>{
console.log('总运行时间:',Date.now() - now);
},0)
//JS运行时间: 157
//time1.html:25 总运行时间: 4892
浏览器有着肉眼可见的卡顿,频繁刷新甚至会卡死。
这里浏览器渲染的时间问题需要搞一下
对于一次性插入大量数据的情况,一般有两种做法,时间分片和虚拟列表。
时间分片
定时器
使用setTimeout来实现分批渲染
// 记录任务开始时间
let now = Date.now();
// 插入十万条数据
const total = 100000;
//需要插入的容器
let ul = document.getElementById('container');
// 一次插入 20 条
let once = 20;
//总页数
let page = total/once
//每条记录的索引
let index = 0;
//循环加载数据
function loop(curTotal,curIndex){
if(curTotal <= 0){
return false;
}
//每页多少条
let pageCount = Math.min(curTotal , once);
setTimeout(()=>{
for(let i = 0; i < pageCount; i++){
let li = document.createElement('li');
li.innerText = curIndex + i
ul.appendChild(li)
}
loop(curTotal - pageCount,curIndex + pageCount)
},0)
}
loop(total,index);
这里实际上就是一个分页渲染,首先渲染前面20条数据,随后重新计算剩余总条数,递归调用loop函数再依次渲染,直到剩余总条数小于等于0。这里不管再怎么刷新,浏览器会非常顺畅。
然而这种方法快速滚动时会出现白屏或闪屏,主要因为setTimeout的执行时间并不是确定的。在JS中,setTimeout任务被放进事件队列中,只有主线程执行完才会去检查事件队列中的任务是否需要执行,因此setTimeout的实际执行时间可能会比其设定的时间晚一些。
刷新频率受屏幕分辨率和屏幕尺寸的影响,因此不同设备的刷新频率可能会不同,而setTimeout只能设置一个固定时间间隔,这个时间不一定和屏幕的刷新时间相同。
在setTimeout中对dom进行操作,必须要等到屏幕下次绘制时才能更新到屏幕上,如果两者步调不一致,就可能导致中间某一帧的操作被跨越过去,而直接更新下一帧的元素,从而导致丢帧现象。
requestAnimationFrame
window.requestAnimationFrame()告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行
// 记录任务开始时间
let now = Date.now();
//需要插入的容器
let ul = document.getElementById('container');
// 插入十万条数据
let total = 100000;
// 一次插入 20 条
let once = 20;
//总页数
let page = total/once
//每条记录的索引
let index = 0;
//循环加载数据
function loop(curTotal,curIndex){
if(curTotal <= 0){
return false;
}
//每页多少条
let pageCount = Math.min(curTotal , once);
window.requestAnimationFrame(function(){
for(let i = 0; i < pageCount; i++){
let li = document.createElement('li');
li.innerText = curIndex + i
ul.appendChild(li)
}
loop(curTotal - pageCount,curIndex + pageCount)
})
}
loop(total,index);
这种快速滚动也不会出现白屏或者闪屏,因为requestAnimationFrame方法会在每次刷新页面时就会主动调用回调函数,就是我们的分页渲染。
DocumentFragment
DocumentFragment,文档片段接口,一个没有父对象的最小文档对象。它被作为一个轻量版的Document使用,就像标准的document一样,存储由节点(nodes)组成的文档结构。与document相比,最大的区别是DocumentFragment 不是真实 DOM 树的一部分,它的变化不会触发 DOM 树的重新渲染,且不会导致性能等问题。
//需要插入的容器
let ul = document.getElementById('container');
// 插入十万条数据
let total = 100000;
// 一次插入 20 条
let once = 20;
//总页数
let page = total/once
//每条记录的索引
let index = 0;
//循环加载数据
function loop(curTotal,curIndex){
if(curTotal <= 0){
return false;
}
//每页多少条
let pageCount = Math.min(curTotal , once);
window.requestAnimationFrame(function(){
let fragment = document.createDocumentFragment();
for(let i = 0; i < pageCount; i++){
let li = document.createElement('li');
li.innerText = curIndex + i + ' : ' + Number( curIndex + i)
fragment.appendChild(li)
}
ul.appendChild(fragment)
loop(curTotal - pageCount,curIndex + pageCount)
})
}
loop(total,index);
虚拟列表
虚拟列表就是按需显示,即只对可见区域进行渲染,对非可见区域中的数据不渲染或者部分渲染的技术,从而达到较高的渲染性能。
首先回一下dom中的各种位置:
偏移尺寸:包含元素在屏幕上所占用的所有视觉空间。元素在页面上的视觉空间由其高度和宽度决定,包括所有内边距、滚动条和边框
- offsetHeight,元素在垂直方向上占用的像素尺寸, 包括它的高度、水平滚动条高度(如果可 见)和上、下边框的高度
- offsetWidth,元素在水平方向上占用的像素尺寸,包括它的宽度、垂直滚动条宽度(如果可 见)和左、右边框的宽度
- offsetLeft,元素左边框外侧距离包含元素左边框内侧的像素数
- offsetTop,元素上边框外侧距离包含元素上边框内侧的像素数
客户端尺寸: 包含元素内容及其内边距所占用的空间。客户端尺寸只有两 个相关属性:clientWidth 和 clientHeight。其中,clientWidth 是内容区宽度加左、右内边距宽 度,clientHeight 是内容区高度加上、下内边距高度 。
客户端尺寸实际上就是元素内部的空间,因此不包含滚动条占用的空间。这两个属性最常用于确定 浏览器视口尺寸,即检测 document.documentElement 的 clientWidth 和 clientHeight。这两个 属性表示视口(或元素)的尺寸
滚动尺寸: 提供了元素内容滚动距离的信息。
- scrollHeight,没有滚动条出现时,元素内容的总高度。
- scrollWidth,没有滚动条出现时,元素内容的总宽度
- scrollLeft,内容区左侧隐藏的像素数,设置这个属性可以改变元素的滚动位置。
- scrollTop,内容区顶部隐藏的像素数,设置这个属性可以改变元素的滚动位置。
接下来封装一个vue虚拟滚动的组件,
<template>
<div ref="list" class="infinite-list-container" @scroll="scrollEvent($event)">
<div class="infinite-list-phantom" :style="{ height: listHeight + 'px' }"></div>
<div class="infinite-list" :style="{ transform: getTransform }">
<div ref="items"
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>
export default {
name:'VirtualList',
props: {
//所有列表数据
listData:{
type:Array,
default:()=>[]
},
//每项高度
itemSize: {
type: Number,
default:200
}
},
computed:{
//列表总高度
listHeight(){
return this.listData.length * this.itemSize;
},
//可显示的列表项数
visibleCount(){
return Math.ceil(this.screenHeight / this.itemSize)
},
//偏移量对应的style
getTransform(){
return `translate3d(0,${this.startOffset}px,0)`;
},
//获取真实显示列表数据
visibleData(){
return this.listData.slice(this.start, Math.min(this.end,this.listData.length));
}
},
mounted() {
this.screenHeight = this.$el.clientHeight;
this.start = 0;
this.end = this.start + this.visibleCount;
},
data() {
return {
//可视区域高度
screenHeight:0,
//偏移量
startOffset:0,
//起始索引
start:0,
//结束索引
end:null,
};
},
methods: {
scrollEvent() {
//当前滚动位置
let scrollTop = this.$refs.list.scrollTop;
//此时的开始索引
this.start = Math.floor(scrollTop / this.itemSize);
//此时的结束索引
this.end = this.start + this.visibleCount;
//此时的偏移量
this.startOffset = scrollTop - (scrollTop % this.itemSize);
}
}
};
</script>
<style 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 {
left: 0;
right: 0;
top: 0;
position: absolute;
text-align: center;
}
.infinite-list-item {
padding: 10px;
color: #555;
box-sizing: border-box;
border-bottom: 1px solid #999;
}
</style>
这里实现原理就是通过监听加载可视区内的需要的列表项。