前言 当面试官问:给你十万条数据,你会怎么办?这时我们该如何应对呢?
在实际的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 >
image.png 代码解释:
我们获取了一个<ul>
元素,并定义了一个总数total
为1000,使用for
循环来创建<li>
元素,并给每个元素设置一个文本值,~~
为向下取整, 每个新创建的<li>
都被添加到<ul>
元素中。 我们记录了整个过程的耗时,可以看到js引擎
在编译完代码只花了92ms
还是非常快的。 而定时器耗时了3038ms
,我们知道js引擎
是单线程工作的,首先它会执行同步代码,然后再执行微任务,接着再在浏览器上渲染,最后执行宏任务,setTimeout
这里我们人为的写一个宏任务,这个打印的出来时间可以看成开始运行代码再到浏览器把数据渲染所花的时间对吧,可以看到还是要一会的对吧。 结论: 这种方法虽然实现起来简单直接,但由于它在一个循环中创建并添加了所有列表项至DOM树
,因此在执行过程中,浏览器需要等待JavaScript
完全执行完毕才能开始渲染页面。当数据量非常大(例如本例中的100,000个列表项)时,这种大量的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 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 > </html >
代码解释:
这里我们将所有数据分批渲染,每批次添加20个元素,因为到最后可能会不足20个所有我们用Math.min(once,curTotal)
取两者小的那个,如果还有剩余的元素需要添加,则递归调用loop
函数继续处理,每次递归减去相应数量。 首先上来执行一遍,同步,异步,然后渲染,啥也没有渲染对吧,然后执行setTimeout
也就是宏任务,然后再向刚刚一样同步,异步,然后渲染,这时候可以渲染20条数据,接着再这样一直递归到数据加载完毕。 结论:
这里就是把浏览器渲染时的压力分摊给了js引擎
,js引擎
是单线程工作的,先执行同步,异步,然后浏览器渲染,再宏任务,这里就很好的利用了这一点,把渲染的任务分批执行,减轻了浏览器一次要渲染大量数据造成的渲染“阻塞”,也很好的解决了数据过多
时可能造成页面卡顿或白屏的问题, 但是有点小问题,我们现在用的电脑屏幕刷新率基本上都是60Hz
,意味着它每秒钟可以刷新显示60
次新的画面。如果我们以此为例计算,那么两次刷新之间的时间间隔大约是16.67
毫秒,如果说当执行本次宏任务里的同步,异步,然后渲染这个时间点是在16.67ms
以后也就是屏幕画面刚刷新完以后,是不是得等到下一次的16.67ms
屏幕画面刷新才能有数据看到,所有当用户往下翻的时候有可能那一瞬间看不到东西,但是很快马上就有了,这个问题不是你迅速往下拉数据没加载那个,这个问题现在是不法完成避免的。 使用requestAnimationFrame requestAnimationFrame
是一个比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 page=total/once 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) } loop(curTotal-pageCount,curIndex+pageCount) }) } loop(total,index) </script > </body > </html >
代码解释:
和使用setTimeout
类似,这里我们也使用分批处理。 不同之处在于使用了requestAnimationFrame
代替setTimeout
,这使得操作更加流畅,就是在屏幕画面刷新的时候渲染,就避免了上面的问题。 结论: 通过requestAnimationFrame
代替setTimeout
,在屏幕画面刷新的时候渲染,就避免了上面setTimeout
可能出现的问题。
使用文档碎片(requsetAnimationFrame+DocuemntFragment ) 文档碎片是一种可以暂时存放DOM节点的“容器”,它不会出现在文档流中。当所有节点都准备好之后,再一次性添加到DOM中,可以减少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 page=total/once 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) loop(curTotal-pageCount,curIndex+pageCount) }) } loop(total,index) </script > </body > </html >
代码解释:
创建一个DocumentFragment
实例fragment
来暂存<li>
元素,在循环内部,将生成的<li>
元素添加到fragment
中,你可以理解为一个虚假的标签,把<li>
挂在这个标签上,只不过这个标签不会出现在DOM中。 循环结束后,一次性将fragment
添加到<ul>
元素中,这样就减少了DOM操作次数,提高了性能。 结论: 通过使用 DocumentFragment
,可以在内存中暂存一组 DOM 节点,直到这些节点被一次性添加到 DOM 树中。这样做可以减少 DOM 的重排和重绘次数,从而提高性能这对于提高页面性能是非常重要的,尤其是在进行大量的DOM更新时。
用虚拟滚动(Virtual Scrolling) 对于非常大的数据集,最佳实践是使用虚拟滚动技术,现在很多公司都是用的这种方法。虚拟滚动只渲染当前可视区域内的数据,当用户滚动时,动态替换这些数据。
这里使用vue实现一个简单的虚拟滚动列表。
image.png 就两个文件
App.vue <template > <div class ="app" > <virtualList :listData ="data" ></virtualList > </div > </template > <script setup > import virtualList from './components/virtualList.vue' // 创建一个包含10万条数据的大数组 const data = [] for (let i = 0 ; i < 100000 ; i++) { data.push({ id : i, value : i }) } </script > <style lang ="css" scoped > .app { height : 400px ; /* 设置可视区域的高度 */ width : 300px ; /* 设置可视区域的宽度 */ border : 1px solid #000 ; /* 边框,便于看到边界 */ } </style >
virtualList.vue <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, nextTick, onMounted, ref } 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(() => { return Math .ceil(state.screenHeight / props.itemSize); // 可视区域内能显示的项目数量 }); const visibleData = computed(() => { return props.listData.slice(state.start, Math .min(state.end, props.listData.length)); // 当前可视数据 }); const listHeight = computed(() => { return props.listData.length * props.itemSize; // 列表总高度 }); const getTransform = computed(() => { return `translateY(${state.startOffset} px)` ; // 计算transform值 }); // 引用元素 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 lang ="css" scoped > .infinite-list-container { height : 100% ; /* 占满整个父容器高度 */ overflow : auto; /* 允许滚动 */ position : relative; /* 使内部元素可以相对于它定位 */ } .infinite-list-phantom { position : absolute; /* 绝对定位 */ left : 0 ; right : 0 ; /* 宽度充满整个容器 */ top : 0 ; /* 顶部对齐 */ z-index : -1 ; /* 放在底层 */ } .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 > **代码解释:** `可视区域` <div ref ="listRef" class ="infinite-list-container" @scroll ="scrollEvent()" ></div > ![image.png](https://p0-xtjj-private.juejin.cn/tos-cn-i-73owjymdk6/db74bf871da94fb3b45d8e91cdb1e782~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgc29ycnloYw==:q75.awebp?policy=eyJ2bSI6MywidWlkIjoiMzA2MTQ3NjEzMDA0NDQ4NyJ9\&rk3s=e9ecf3d6\&x-orig-authkey=f32326d3454f2ac7e96d3d06cdbb035152127018\&x-orig-expires=1724928998\&x-orig-sign=p2QyI1b1YnbRxmYCIkATvAiwBuc%3D) 这个是可视的区域,就好比电脑和手机能看到东西的窗口大小,这是用户实际可以看到的区域,它有一个固定的大小,并且允许滚动 `虚拟高度占位符` ```html<div class ="infinite-list-phantom" :style ="{height: listHeight + 'px'}" ></div >
这个占位符的作用是模拟整个数据集的高度,即使实际上并没有渲染所有的数据项。它是一个不可见的元素,高度等于所有数据项的高度之和。
动态渲染数据的区域
<div class ="infinite-list" :style ="{transform: getTransform}" ></div >
image.png 这部分负责实际显示数据项,和可视化的区域一样大,它通过 transform
属性调整位置,确保只显示当前可视区域内的数据项。
核心实现原理: 先拿到所有数据的占的区域,当往下滚动的时候,整个所有区域的数据会往上走(也就是这个div class="infinite-list-phantom"
),而我们现在这个区域(div class="infinite-list"
)就是跟用户看到的数据区域一样大的区域也会往上滚,可以保证给的数据是正确的数据,当往上滚时,用户看到数据会更新并且会往上移动,变得越来越少,我们通过 transform
属性调整位置把它移动到我们固定的可视化的区域(div ref="listRef" class="infinite-list-container"
),给用户看的数据就是完整的数据了。也就相当于我们这个有全部的虚假数据大小,我们只截取用户能看到的真实的部分数据给他们看。
结论: 虚拟滚动的核心思想是只渲染当前可视区域的数据,而不是一次性渲染整个数据集。这在处理大数据量时尤为重要,因为它可以显著提高应用的性能和响应速度。
总结 通过上述五个方法,我们从最基本的DOM操作的方法到使用现代前端技术使用的方法,本文到此就结束了,希望对你有所帮助!