- 作者:老汪软件技巧
- 发表时间:2024-09-17 11:03
- 浏览量:
一:问题分析
最近秋招开始面试了,在前端岗的面试中遇到这样的一个情景题,这题目考察的是对前端性能优化的理解以及处理大数据量时的技术方案。下面带友友们来剖一剖,首先我们先来一个小demo来看看一次性渲染十万条数据的效果是怎么样的,我们在一个HTML页面中创建一个包含10万个元素的
<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.innerHTML = curIndex + i + ':' + ~~(Math.random() * total)
ul.appendChild(li)
}
loop(curTotal - pageCount, curIndex + pageCount)
})
}
loop(total, index)
script>
浏览器的刷新频率通常是每秒60帧,即大约每16.7毫秒刷新一次,虽然使用setTimeout可以减轻浏览器的负担,但setTimeout默认延迟时间为0,这意味着它会在当前任务队列结束后执行,也就是说定时器生效时间并不是固定的。v8引擎的事件循环机制中,下一个事件不一定要等到16.7ms,但如果v8引擎没有跟上,在一个或者多个16.7ms后没有进入到下一个事件中,由于是非阻塞的,就可能造成它的执行时间与页面的刷新时间并不完全同步。这意味着浏览器在渲染时可能无法及时更新屏幕,特别是在大量DOM操作的情况下。这可能导致以下问题:
我们将setTimeout改为requestAnimationFrame,可以很好解决这个不同步的问题,requestAnimationFrame具有以下特性:
同步刷新频率:requestAnimationFrame 虽然是嵌入到事件循环机制中的,但它是在渲染阶段之前执行,而不是像 setTimeout 或 setInterval 那样在回调队列中排队执行,并且requestAnimationFrame会在浏览器准备绘制下一帧前调用提供的回调函数,这样可以确保动画与屏幕刷新频率同步。性能优化:如果浏览器处于后台或者标签页不可见状态,requestAnimationFrame 会自动暂停,从而节省CPU资源。
解决了以上不同步的问题,还有性能方面的细节我们也要注意。由于不知道优化队列具体能装多少条数据,并且每循环一次就要回流重绘一次,因此以上的分批渲染会引起多次回流重绘。为了避免上述问题,可以使用文档片段(Document Fragment)来构建DOM结构。
文档片段是一个没有标签的节点,可以在内存中构建完整的DOM结构,然后再一次性插入到文档中,这样可以显著减少页面的回流次数。
requestAnimationFrame(() => {
let fragment = document.createDocumentFragment()
for (let i = 0; i < pageCount; i++) {
let li = document.createElement("li")
li.innerHTML = curIndex + i + ':' + ~~(Math.random() * total)
fragment.appendChild(li)
}
ul.appendChild(fragment)
loop(curTotal - pageCount, curIndex + pageCount)
})
三:虚拟列表
虚拟列表通过只渲染当前可视区域的数据,而不是整个数据集,从而减少DOM操作和提高了应用性能,虚拟列表的关键在于动态计算和渲染当前可视区域内的数据,并在用户滚动时更新这些数据。
核心思路获取整个页面的真实数据的高度。计算可视区的高度以及其中可以放置的数据条数。在用户滚动页面时,实时计算出起始下标和结束下标。对样式进行偏移,避免屏幕错误的移动。
下面用vue项目进行展示,带友友们实现虚拟列表,主要涉及两个页面:App.vue和自定义组件virtualList.vue
3.1: 主组件App.vue
创建一个容器,用于展示虚拟列表组件, 将虚拟列表组件virtualList渲染到容器中,并传递listData属性。
<div class="app">
<virtualList :listData="data" />
div>
导入virtualList组件。 初始化data数组,包含 10 万个对象,每个对象都有id和value属性。
3.2: 自定义组件virtualList.vue1: 模板部分
"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>
占位符:
class="infinite-list-phantom" :style="{ height: listHeight + 'px' }">
实际列表:
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>
2: 脚本部分
初始化容器和数据:
计算可视区数据:
动态更新列表:
样式优化:
import { computed, nextTick, onMounted, reactive, ref } from 'vue';
const props = defineProps({
listData: [],
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(state.end, props.listData.length))
})
// 当前列表总高度
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)
}
计算属性:
const visibleCount = computed(() => {
return state.screenHeight / props.itemSize
3})
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)`
})
引用和生命周期钩子:
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)
}
四:总结
在前端面试中探讨一次性渲染十万条数据的问题时,面试官主要想考察的是,是否理解性能优化的重要性,比如通过分页或无限滚动来减少单次加载的数据量,是否掌握虚拟滚动技术,仅渲染当前可视区域的内容,以及是否了解如何利用虚拟DOM或Web Workers等技术来提升应用性能,确保良好的用户体验。
本文带友友们实现了前两种,至于Web Workers之后会单开一篇仔细讲讲。此外,在面试中遇到这样的问题,友友们要有 性能意识,最好可以掌握 分页技术,懒加载(lazy loading), 无限滚动(infinite scrolling), 虚拟滚动(virtual scrolling)。当然,像数据压缩,服务器端渲染在某些场景下的优势(如SEO),或者利用流式数据处理技术来逐步加载和渲染数据,也可以对性能进行优化。