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