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