前言 當(dāng)面試官問:給你十萬條數(shù)據(jù),你會(huì)怎么辦?這時(shí)我們?cè)撊绾螒?yīng)對(duì)呢?
在實(shí)際的Web開發(fā)中,有時(shí)我們需要在頁面上展示大量的數(shù)據(jù),比如用戶評(píng)論、商品列表等。如果一次性渲染太多的數(shù)據(jù)(如100,000條數(shù)據(jù) ),直接將所有數(shù)據(jù)一次性渲染到頁面上會(huì)導(dǎo)致瀏覽器卡頓,用戶體驗(yàn)變差。下面我們從一個(gè)簡(jiǎn)單的例子開始,逐步改進(jìn)代碼,直到使用現(xiàn)代框架的虛擬滾動(dòng)技術(shù) 來解決這個(gè)問題,看完本文后,你就可以跟面試官侃侃而談了。
正文 最直接的方法 下面是最直接的方法,一次性創(chuàng)建所有的列表項(xiàng)并添加到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運(yùn)行耗時(shí)' ,Date .now()-now) setTimeout(() => { console .log('運(yùn)行耗時(shí)' ,Date .now()-now) }) </script > </body > </html >
image.png 代碼解釋:
我們獲取了一個(gè)<ul>
元素,并定義了一個(gè)總數(shù)total
為1000,使用for
循環(huán)來創(chuàng)建<li>
元素,并給每個(gè)元素設(shè)置一個(gè)文本值,~~
為向下取整, 每個(gè)新創(chuàng)建的<li>
都被添加到<ul>
元素中。 我們記錄了整個(gè)過程的耗時(shí),可以看到js引擎
在編譯完代碼只花了92ms
還是非常快的。 而定時(shí)器耗時(shí)了3038ms
,我們知道js引擎
是單線程工作的,首先它會(huì)執(zhí)行同步代碼,然后再執(zhí)行微任務(wù),接著再在瀏覽器上渲染,最后執(zhí)行宏任務(wù),setTimeout
這里我們?nèi)藶榈膶懸粋€(gè)宏任務(wù),這個(gè)打印的出來時(shí)間可以看成開始運(yùn)行代碼再到瀏覽器把數(shù)據(jù)渲染所花的時(shí)間對(duì)吧,可以看到還是要一會(huì)的對(duì)吧。 結(jié)論: 這種方法雖然實(shí)現(xiàn)起來簡(jiǎn)單直接,但由于它在一個(gè)循環(huán)中創(chuàng)建并添加了所有列表項(xiàng)至DOM樹
,因此在執(zhí)行過程中,瀏覽器需要等待JavaScript
完全執(zhí)行完畢才能開始渲染頁面。當(dāng)數(shù)據(jù)量非常大(例如本例中的100,000個(gè)列表項(xiàng))時(shí),這種大量的DOM操作
會(huì)導(dǎo)致瀏覽器的渲染隊(duì)列積壓大量工作,從而引發(fā)頁面的回流與重繪,瀏覽器無法進(jìn)行任何渲染操作,導(dǎo)致了所謂的“阻塞”渲染。
setTimeout分批渲染 為了避免一次性操作引起瀏覽器卡頓,我們可以使用setTimeout
將創(chuàng)建和添加操作分散到多個(gè)時(shí)間點(diǎn),每次只渲染一部分 數(shù)據(jù)。
<!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 >
代碼解釋:
這里我們將所有數(shù)據(jù)分批渲染,每批次添加20個(gè)元素,因?yàn)榈阶詈罂赡軙?huì)不足20個(gè)所有我們用Math.min(once,curTotal)
取兩者小的那個(gè),如果還有剩余的元素需要添加,則遞歸調(diào)用loop
函數(shù)繼續(xù)處理,每次遞歸減去相應(yīng)數(shù)量。 首先上來執(zhí)行一遍,同步,異步,然后渲染,啥也沒有渲染對(duì)吧,然后執(zhí)行setTimeout
也就是宏任務(wù),然后再向剛剛一樣同步,異步,然后渲染,這時(shí)候可以渲染20條數(shù)據(jù),接著再這樣一直遞歸到數(shù)據(jù)加載完畢。 結(jié)論:
這里就是把瀏覽器渲染時(shí)的壓力分?jǐn)偨o了js引擎
,js引擎
是單線程工作的,先執(zhí)行同步,異步,然后瀏覽器渲染,再宏任務(wù),這里就很好的利用了這一點(diǎn),把渲染的任務(wù)分批執(zhí)行,減輕了瀏覽器一次要渲染大量數(shù)據(jù)造成的渲染“阻塞”,也很好的解決了數(shù)據(jù)過多
時(shí)可能造成頁面卡頓或白屏的問題, 但是有點(diǎn)小問題,我們現(xiàn)在用的電腦屏幕刷新率基本上都是60Hz
,意味著它每秒鐘可以刷新顯示60
次新的畫面。如果我們以此為例計(jì)算,那么兩次刷新之間的時(shí)間間隔大約是16.67
毫秒,如果說當(dāng)執(zhí)行本次宏任務(wù)里的同步,異步,然后渲染這個(gè)時(shí)間點(diǎn)是在16.67ms
以后也就是屏幕畫面剛刷新完以后,是不是得等到下一次的16.67ms
屏幕畫面刷新才能有數(shù)據(jù)看到,所有當(dāng)用戶往下翻的時(shí)候有可能那一瞬間看不到東西,但是很快馬上就有了,這個(gè)問題不是你迅速往下拉數(shù)據(jù)沒加載那個(gè),這個(gè)問題現(xiàn)在是不法完成避免的。 使用requestAnimationFrame requestAnimationFrame
是一個(gè)比setTimeout
更優(yōu)秀的解決方案,因?yàn)樗褪瞧聊凰⑿侣实臅r(shí)間。
<!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
,這使得操作更加流暢,就是在屏幕畫面刷新的時(shí)候渲染,就避免了上面的問題。 結(jié)論: 通過requestAnimationFrame
代替setTimeout
,在屏幕畫面刷新的時(shí)候渲染,就避免了上面setTimeout
可能出現(xiàn)的問題。
使用文檔碎片(requsetAnimationFrame+DocuemntFragment ) 文檔碎片是一種可以暫時(shí)存放DOM節(jié)點(diǎn)的“容器”,它不會(huì)出現(xiàn)在文檔流中。當(dāng)所有節(jié)點(diǎn)都準(zhǔn)備好之后,再一次性添加到DOM中,可以減少DOM操作次數(shù)。
<!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(); //創(chuàng)建文檔碎片 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 >
代碼解釋:
創(chuàng)建一個(gè)DocumentFragment
實(shí)例fragment
來暫存<li>
元素,在循環(huán)內(nèi)部,將生成的<li>
元素添加到fragment
中,你可以理解為一個(gè)虛假的標(biāo)簽,把<li>
掛在這個(gè)標(biāo)簽上,只不過這個(gè)標(biāo)簽不會(huì)出現(xiàn)在DOM中。 循環(huán)結(jié)束后,一次性將fragment
添加到<ul>
元素中,這樣就減少了DOM操作次數(shù),提高了性能。 結(jié)論: 通過使用 DocumentFragment
,可以在內(nèi)存中暫存一組 DOM 節(jié)點(diǎn),直到這些節(jié)點(diǎn)被一次性添加到 DOM 樹中。這樣做可以減少 DOM 的重排和重繪次數(shù),從而提高性能這對(duì)于提高頁面性能是非常重要的,尤其是在進(jìn)行大量的DOM更新時(shí)。
用虛擬滾動(dòng)(Virtual Scrolling) 對(duì)于非常大的數(shù)據(jù)集,最佳實(shí)踐是使用虛擬滾動(dòng)技術(shù),現(xiàn)在很多公司都是用的這種方法。虛擬滾動(dòng)只渲染當(dāng)前可視區(qū)域內(nèi)的數(shù)據(jù),當(dāng)用戶滾動(dòng)時(shí),動(dòng)態(tài)替換這些數(shù)據(jù)。
這里使用vue實(shí)現(xiàn)一個(gè)簡(jiǎn)單的虛擬滾動(dòng)列表。
image.png 就兩個(gè)文件
App.vue <template > <div class ="app" > <virtualList :listData ="data" ></virtualList > </div > </template > <script setup > import virtualList from './components/virtualList.vue' // 創(chuàng)建一個(gè)包含10萬條數(shù)據(jù)的大數(shù)組 const data = [] for (let i = 0 ; i < 100000 ; i++) { data.push({ id : i, value : i }) } </script > <style lang ="css" scoped > .app { height : 400px ; /* 設(shè)置可視區(qū)域的高度 */ width : 300px ; /* 設(shè)置可視區(qū)域的寬度 */ border : 1px solid #000 ; /* 邊框,便于看到邊界 */ } </style >
virtualList.vue <template > <!-- 可視區(qū)域 --> <div ref ="listRef" class ="infinite-list-container" @scroll ="scrollEvent()" > <!-- 虛擬高度占位符 --> <div class ="infinite-list-phantom" :style ="{ height: listHeight + 'px' }" ></div > <!-- 動(dòng)態(tài)渲染數(shù)據(jù)的區(qū)域 --> <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 } }); // 反應(yīng)式狀態(tài) const state = reactive({ screenHeight : 0 , // 可視區(qū)域高度 startOffset : 0 , // 當(dāng)前偏移量 start : 0 , // 開始索引 end : 0 // 結(jié)束索引 }); // 計(jì)算屬性 const visibleCount = computed(() => { return Math .ceil(state.screenHeight / props.itemSize); // 可視區(qū)域內(nèi)能顯示的項(xiàng)目數(shù)量 }); const visibleData = computed(() => { return props.listData.slice(state.start, Math .min(state.end, props.listData.length)); // 當(dāng)前可視數(shù)據(jù) }); const listHeight = computed(() => { return props.listData.length * props.itemSize; // 列表總高度 }); const getTransform = computed(() => { return `translateY(${state.startOffset} px)` ; // 計(jì)算transform值 }); // 引用元素 const listRef = ref(null ); // 生命周期鉤子 onMounted(() => { state.screenHeight = listRef.value.clientHeight; // 初始化可視區(qū)域高度 state.end = state.start + visibleCount.value; // 初始化結(jié)束索引 }); // 滾動(dòng)事件處理 const scrollEvent = () => { const scrollTop = listRef.value.scrollTop; // 當(dāng)前滾動(dòng)距離 state.start = Math .floor(scrollTop / props.itemSize); // 計(jì)算開始索引 state.end = state.start + visibleCount.value; // 更新結(jié)束索引 state.startOffset = scrollTop - (scrollTop % props.itemSize); // 更新偏移量 }; </script > <style lang ="css" scoped > .infinite-list-container { height : 100% ; /* 占滿整個(gè)父容器高度 */ overflow : auto; /* 允許滾動(dòng) */ position : relative; /* 使內(nèi)部元素可以相對(duì)于它定位 */ } .infinite-list-phantom { position : absolute; /* 絕對(duì)定位 */ left : 0 ; right : 0 ; /* 寬度充滿整個(gè)容器 */ top : 0 ; /* 頂部對(duì)齊 */ z-index : -1 ; /* 放在底層 */ } .infinite-list { position : absolute; /* 絕對(duì)定位 */ left : 0 ; right : 0 ; /* 寬度充滿整個(gè)容器 */ top : 0 ; /* 頂部對(duì)齊 */ text-align : center; /* 文本居中 */ } .infinite-list-item { border-bottom : 1px solid #eee ; /* 分隔線 */ box-sizing : border-box; /* 包含邊框和內(nèi)邊距 */ } </style > **代碼解釋:** `可視區(qū)域` <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) 這個(gè)是可視的區(qū)域,就好比電腦和手機(jī)能看到東西的窗口大小,這是用戶實(shí)際可以看到的區(qū)域,它有一個(gè)固定的大小,并且允許滾動(dòng) `虛擬高度占位符` ```html<div class ="infinite-list-phantom" :style ="{height: listHeight + 'px'}" ></div >
這個(gè)占位符的作用是模擬整個(gè)數(shù)據(jù)集的高度,即使實(shí)際上并沒有渲染所有的數(shù)據(jù)項(xiàng)。它是一個(gè)不可見的元素,高度等于所有數(shù)據(jù)項(xiàng)的高度之和。
動(dòng)態(tài)渲染數(shù)據(jù)的區(qū)域
<div class ="infinite-list" :style ="{transform: getTransform}" ></div >
image.png 這部分負(fù)責(zé)實(shí)際顯示數(shù)據(jù)項(xiàng),和可視化的區(qū)域一樣大,它通過 transform
屬性調(diào)整位置,確保只顯示當(dāng)前可視區(qū)域內(nèi)的數(shù)據(jù)項(xiàng)。
核心實(shí)現(xiàn)原理: 先拿到所有數(shù)據(jù)的占的區(qū)域,當(dāng)往下滾動(dòng)的時(shí)候,整個(gè)所有區(qū)域的數(shù)據(jù)會(huì)往上走(也就是這個(gè)div class="infinite-list-phantom"
),而我們現(xiàn)在這個(gè)區(qū)域(div class="infinite-list"
)就是跟用戶看到的數(shù)據(jù)區(qū)域一樣大的區(qū)域也會(huì)往上滾,可以保證給的數(shù)據(jù)是正確的數(shù)據(jù),當(dāng)往上滾時(shí),用戶看到數(shù)據(jù)會(huì)更新并且會(huì)往上移動(dòng),變得越來越少,我們通過 transform
屬性調(diào)整位置把它移動(dòng)到我們固定的可視化的區(qū)域(div ref="listRef" class="infinite-list-container"
),給用戶看的數(shù)據(jù)就是完整的數(shù)據(jù)了。也就相當(dāng)于我們這個(gè)有全部的虛假數(shù)據(jù)大小,我們只截取用戶能看到的真實(shí)的部分?jǐn)?shù)據(jù)給他們看。
結(jié)論: 虛擬滾動(dòng)的核心思想是只渲染當(dāng)前可視區(qū)域的數(shù)據(jù),而不是一次性渲染整個(gè)數(shù)據(jù)集。這在處理大數(shù)據(jù)量時(shí)尤為重要,因?yàn)樗梢燥@著提高應(yīng)用的性能和響應(yīng)速度。
總結(jié) 通過上述五個(gè)方法,我們從最基本的DOM操作的方法到使用現(xiàn)代前端技術(shù)使用的方法,本文到此就結(jié)束了,希望對(duì)你有所幫助!