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