一:問題分析
最近秋招開始面試了,在前端崗的面試中遇到這樣的一個(gè)情景題,這題目考察的是對前端性能優(yōu)化的理解以及處理大數(shù)據(jù)量時(shí)的技術(shù)方案。下面帶友友們來剖一剖,首先我們先來一個(gè)小demo來看看一次性渲染十萬條數(shù)據(jù)的效果是怎么樣的,我們在一個(gè)HTML頁面中創(chuàng)建一個(gè)包含10萬個(gè)<li>
元素的<ul>
列表,并記錄整個(gè)過程的時(shí)間開銷
<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.innerHTML = ~~(Math.random() * total)
ul.appendChild(li)
}
console.log('js運(yùn)行耗時(shí):', Date.now() - now);
setTimeout(() => {
console.log('頁面加載總時(shí)長:', Date.now() - now);
})
</script>
</body>
?
可以看到,導(dǎo)致頁面加載緩慢的是頁面渲染速度過慢,js的運(yùn)行速度還算可以的。以上demo中是暴力渲染,接下來帶友友們了解兩個(gè)方法來加快渲染速度,提升用戶體驗(yàn)感以及頁面渲染效率
二:分批渲染
遞歸渲染函數(shù):
loop
函數(shù)接收兩個(gè)參數(shù):當(dāng)前還需要渲染的總數(shù)(curTotal
)和當(dāng)前已渲染的數(shù)量(curIndex
),計(jì)算本次需要渲染的數(shù)量(pageCount
),并且不超過剩余的數(shù)量。- 使用
setTimeout
來異步執(zhí)行DOM操作,這允許瀏覽器有時(shí)間去處理其他任務(wù)(如事件處理、繪制等)。 - 在
setTimeout
的回調(diào)函數(shù)中,使用for
循環(huán)創(chuàng)建<li>
元素,并將其追加到<ul>
容器中。 - 如果還有剩余數(shù)據(jù)需要渲染,則繼續(xù)遞歸調(diào)用
loop
函數(shù)。
<body>
<ul id="container"></ul>
<script>
let ul = document.getElementById("container");
const total = 100000;
let once = 20 //單次渲染數(shù)
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>
</body>
瀏覽器的刷新頻率通常是每秒60幀,即大約每16.7毫秒刷新一次,雖然使用setTimeout
可以減輕瀏覽器的負(fù)擔(dān),但setTimeout
默認(rèn)延遲時(shí)間為0,這意味著它會(huì)在當(dāng)前任務(wù)隊(duì)列結(jié)束后執(zhí)行,也就是說定時(shí)器生效時(shí)間并不是固定的。v8引擎的事件循環(huán)機(jī)制中,下一個(gè)事件不一定要等到16.7ms,但如果v8引擎沒有跟上,在一個(gè)或者多個(gè)16.7ms后沒有進(jìn)入到下一個(gè)事件中,由于是非阻塞的,就可能造成它的執(zhí)行時(shí)間與頁面的刷新時(shí)間并不完全同步。這意味著瀏覽器在渲染時(shí)可能無法及時(shí)更新屏幕,特別是在大量DOM操作的情況下。這可能導(dǎo)致以下問題:
- 閃屏:當(dāng)瀏覽器試圖渲染大量的DOM元素時(shí),如果DOM操作過于密集,瀏覽器可能無法及時(shí)完成渲染,導(dǎo)致用戶看到部分渲染的內(nèi)容,造成屏幕閃爍。
- 白屏:在極端情況下,如果DOM操作過于復(fù)雜或耗時(shí),瀏覽器可能無法在短時(shí)間內(nèi)完成渲染,導(dǎo)致屏幕呈現(xiàn)為空白狀態(tài),直到渲染完成。
我們將setTimeout
改為requestAnimationFrame
,可以很好解決這個(gè)不同步的問題,requestAnimationFrame
具有以下特性:
- 同步刷新頻率:
requestAnimationFrame
雖然是嵌入到事件循環(huán)機(jī)制中的,但它是在渲染階段之前執(zhí)行,而不是像 setTimeout
或 setInterval
那樣在回調(diào)隊(duì)列中排隊(duì)執(zhí)行,并且requestAnimationFrame
會(huì)在瀏覽器準(zhǔn)備繪制下一幀前調(diào)用提供的回調(diào)函數(shù),這樣可以確保動(dòng)畫與屏幕刷新頻率同步。 - 性能優(yōu)化:如果瀏覽器處于后臺(tái)或者標(biāo)簽頁不可見狀態(tài),
requestAnimationFrame
會(huì)自動(dòng)暫停,從而節(jié)省CPU資源。
解決了以上不同步的問題,還有性能方面的細(xì)節(jié)我們也要注意。由于不知道優(yōu)化隊(duì)列具體能裝多少條數(shù)據(jù),并且每循環(huán)一次就要回流重繪一次,因此以上的分批渲染會(huì)引起多次回流重繪。為了避免上述問題,可以使用文檔片段(Document Fragment
)來構(gòu)建DOM結(jié)構(gòu)。
文檔片段是一個(gè)沒有標(biāo)簽的節(jié)點(diǎn),可以在內(nèi)存中構(gòu)建完整的DOM結(jié)構(gòu),然后再一次性插入到文檔中,這樣可以顯著減少頁面的回流次數(shù)。
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);
})
三:虛擬列表
虛擬列表通過只渲染當(dāng)前可視區(qū)域的數(shù)據(jù),而不是整個(gè)數(shù)據(jù)集,從而減少DOM操作和提高了應(yīng)用性能,虛擬列表的關(guān)鍵在于動(dòng)態(tài)計(jì)算和渲染當(dāng)前可視區(qū)域內(nèi)的數(shù)據(jù),并在用戶滾動(dòng)時(shí)更新這些數(shù)據(jù)。
核心思路
- 獲取整個(gè)頁面的真實(shí)數(shù)據(jù)的高度。
- 計(jì)算可視區(qū)的高度以及其中可以放置的數(shù)據(jù)條數(shù)。
- 在用戶滾動(dòng)頁面時(shí),實(shí)時(shí)計(jì)算出起始下標(biāo)和結(jié)束下標(biāo)。
- 對樣式進(jìn)行偏移,避免屏幕錯(cuò)誤的移動(dòng)。
下面用vue項(xiàng)目進(jìn)行展示,帶友友們實(shí)現(xiàn)虛擬列表,主要涉及兩個(gè)頁面:App.vue
和自定義組件virtualList.vue
3.1: 主組件App.vue
創(chuàng)建一個(gè)容器,用于展示虛擬列表組件, 將虛擬列表組件 virtualList
渲染到容器中,并傳遞 listData
屬性。
<template>
<div class="app">
<virtualList :listData="data" />
</div>
</template>
導(dǎo)入 virtualList
組件。初始化 data
數(shù)組,包含 10 萬個(gè)對象,每個(gè)對象都有 id
和 value
屬性。
<script setup>
import virtualList from './components/virtualList.vue';
const data = []
for (let i = 0; i < 100000; i++) {
data.push({id: i, value: i})
}
</script>
3.2: 自定義組件virtualList.vue
1: 模板部分<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>
<div ref="listRef" class="infinite-list-container" @scroll="scrollEvent()">
- 創(chuàng)建一個(gè)名為
.infinite-list-container
的 <div>
容器。 - 添加
@scroll
事件監(jiān)聽器,當(dāng)容器滾動(dòng)時(shí)觸發(fā) scrollEvent
函數(shù)。
<div class="infinite-list-phantom" :style="{ height: listHeight + 'px' }"></div>
- 創(chuàng)建一個(gè)名為
.infinite-list-phantom
的 <div>
占位符。 - 設(shè)置占位符的高度為
listHeight
。
<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>
- 創(chuàng)建一個(gè)名為
.infinite-list
的 <div>
實(shí)際列表。 - 使用
:style
綁定屬性 transform
為 getTransform
的值。 - 使用
v-for
循環(huán)遍歷 visibleData
數(shù)組,并渲染每個(gè) item
。 - 設(shè)置每個(gè)列表項(xiàng)的高度和行高。
2: 腳本部分
- 初始化狀態(tài)對象
state
,包括可視區(qū)高度、偏移量、起始索引和結(jié)束索引。
計(jì)算可視區(qū)數(shù)據(jù):
- 計(jì)算可視區(qū)高度和可顯示的數(shù)據(jù)條數(shù)。
- 通過
slice
方法截取當(dāng)前可視區(qū)域內(nèi)的數(shù)據(jù)片段。
- 在滾動(dòng)事件中實(shí)時(shí)更新起始索引和結(jié)束索引,從而更新當(dāng)前可視區(qū)域的數(shù)據(jù)。
- 使用
transform
屬性對列表進(jìn)行偏移,確保列表隨用戶的滾動(dòng)而平滑移動(dòng)。
- 使用絕對定位和變換來控制列表的位置,減少DOM重排和重繪。
- 通過占位符(phantom)來模擬整個(gè)列表的高度,確保滾動(dòng)流暢。?
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
})
// 可視區(qū)顯示的數(shù)據(jù)條數(shù)
const visibleCount = computed(() => {
return state.screenHeight / props.itemSize
})
// 可視區(qū)域顯示的真實(shí)數(shù)據(jù)
const visibleData = computed(() => {
return props.listData.slice(state.start, Math.min(state.end, props.listData.length))
})
// 當(dāng)前列表總高度
const listHeight = computed(() => {
return props.listData.length * props.itemSize
})
// list跟著父容器移動(dòng)了,現(xiàn)在列表要移動(dòng)回來
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)
}
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
})
- 導(dǎo)入必要的Vue Composition API函數(shù)。
- 定義
props
屬性,包含 listData
和 itemSize
。 - 使用
reactive
創(chuàng)建響應(yīng)式狀態(tài)對象 state
。
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)`
})
visibleCount
計(jì)算可視區(qū)可以顯示的數(shù)據(jù)條數(shù)。visibleData
計(jì)算當(dāng)前可視區(qū)域的實(shí)際數(shù)據(jù)。listHeight
計(jì)算整個(gè)列表的高度。getTransform
計(jì)算列表的偏移量。
const listRef = ref(null)
onMounted(() => {
state.screenHeight = listRef.value.clientHeight
state.end = state.start + visibleCount.value
})
- 在
onMounted
生命周期鉤子中初始化 screenHeight
和 end
。
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)
}
scrollEvent
函數(shù)在滾動(dòng)時(shí)更新 start
、end
和 startOffset
。
四:總結(jié)
在前端面試中探討一次性渲染十萬條數(shù)據(jù)的問題時(shí),面試官主要想考察的是,是否理解性能優(yōu)化的重要性,比如通過分頁或無限滾動(dòng)來減少單次加載的數(shù)據(jù)量,是否掌握虛擬滾動(dòng)技術(shù),僅渲染當(dāng)前可視區(qū)域的內(nèi)容,以及是否了解如何利用虛擬DOM
或Web Workers
等技術(shù)來提升應(yīng)用性能,確保良好的用戶體驗(yàn)。
本文帶友友們實(shí)現(xiàn)了前兩種,至于Web Workers
之后會(huì)單開一篇仔細(xì)講講。此外,在面試中遇到這樣的問題,友友們要有 性能意識,最好可以掌握 分頁技術(shù),懶加載(lazy loading) , 無限滾動(dòng)(infinite scrolling) , 虛擬滾動(dòng)(virtual scrolling) 。當(dāng)然,像數(shù)據(jù)壓縮,服務(wù)器端渲染在某些場景下的優(yōu)勢(如SEO),或者利用流式數(shù)據(jù)處理技術(shù)來逐步加載和渲染數(shù)據(jù),也可以對性能進(jìn)行優(yōu)化。
本文來源于稀土掘金技術(shù)社區(qū),作者:midsummer18原文鏈接:https://juejin.cn/post/7414732910240874531