頁面需要渲染10萬條資料,應該怎麼實現?
關鍵點:不卡頓,互動流暢
一、最傳統、最簡單粗暴的方式
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>如何渲染10000條資料在dom節點上</title> </head> <body> <ul id="root"> </div> <script> function createOneHundredThousandData(){ let arr = []; for(let i=0;i<100000;i++){ arr.push({ imgUrl:'https://zos.alipayobjects.com/rmsportal/hfVtzEhPzTUewPm.png', key:i }) } return arr; } var beginTime = performance.now(); console.log('beginTime',beginTime); let h = []; let data = createOneHundredThousandData() // 寫法1 原生js 的 for迴圈 for(let i =0;i<data.length;i++){ h.push('<li>' + '<img src="'+ data[i].imgUrl +'" \/>'+ 'current index ' + data[i].key + '<\/li>'); } // 寫法2 陣列自帶的map方法 // h = data.map((item,index)=>'<li>' + '<img src="'+ item.imgUrl +'" \/>'+ 'current index ' + item.key + '<\/li>'); document.getElementById('root').innerHTML = h.join(''); document.addEventListener('DOMContentLoaded',function(){ var endTime = performance.now(); console.log('DOMContentLoaded endTime',endTime); var total = ((endTime - beginTime)/1000).toFixed(5); console.log('DOMContentLoaded render 100000 items takes ' + total + ' 秒'); }); window.onload = function(){ var endTime = performance.now(); console.log('window.onload endTime',endTime); var total = ((endTime - beginTime)/1000).toFixed(5); console.log('window.onload render 100000 items takes ' + total + ' 秒'); } </script> </body> </html>
chrome瀏覽器(版本 74.0.3729.169(正式版本) (64 位))控制檯執行結果如下
beginTime 398.8050000043586
DOMContentLoaded endTime 9032.814999984112
DOMContentLoaded render 100000 items takes 8.63401 秒
window.onload endTime 17766.104999987874
window.onload render 100000 items takes 17.36730 秒
也就是說,渲染包含十萬條記錄,每一條資料僅僅只有圖片和文字的簡單組合,就要花費將近17秒。頁面渲染完成之前,估計使用者早已不耐煩,關掉該頁面了。這還是版本較新的chrome瀏覽器。換做其他瀏覽器,可能效果更差。很顯然,傳統的方式肯定不合格。
關於上述demo,有幾個問題可以補充說明一下:
- 1、用innerHtml插入dom,而不是用document.createElement,document.appendChild,這兩者效能上來說,innerHtml優勢明顯
- 2、用陣列[] 來快取dom字串,先push進來,最後再直接jion(''),將數組裡面每一項串聯成字串,比一個一個字串拼接的效能要強很多
- 3、迴圈一個數組物件,可以用for迴圈,也可以用map,forEach等,資料量少的時候兩者差別不大,在此例中,可以看到map來迴圈十萬條資料時間效能稍遜於普通for迴圈。
- 4、插入dom節點,還可以使用克隆技術,文件斷片createDocumentFragment,其根本目的在於儘可能減少dom操作次數,從而使得重繪跟重排帶來的效能影響降到最低。若讀者有興趣深入研究,可以查閱《高效能JavaScript》(貓頭鷹頭像的封面)。
- 5、關於DOMContentLoaded事件和window.onload事件的對比,也是頁面渲染過程中比較關鍵的,需要重點搞清楚的地方。 簡單來說,DOMContentLoaded 表示 dom 家在完成,通俗來說,就是dom標籤堆砌完畢,至於dom標籤引用什麼資源,有沒有請求載入完畢,那就不管了。比如在此例子中,十萬條資料img標籤堆上去,不需要等到img src指向的資源全部載入完就可以觸發DOMContentLoaded;而window.onload事件則不一樣,要等到全部的src指向的資源全部載入完才會被觸發。
DOMContentLoaded 也多用於關鍵路徑優化中(首屏操作優化),因為頁面dom載入完了就得給使用者提供一些互動。不能出現讓使用者看到UI介面卻做不了任何互動操作的情況。
二、解決卡頓問題之setTimeout
卡頓,多半是優於使用者發起一個操作,到頁面響應這個操作,把UI結果反饋給使用者這個時間存在明顯的延遲。給人不流暢的使用者體驗。
從JavaScript這門語言來看,它是單執行緒的,註定了同一時間,該執行緒只能處理一個任務,該任何處理完畢後才能處理下一個任務,你可以理解為序列執行。(更詳細更嚴謹的,可以去深入地瞭解JavaScript Event Loop)。所以,當頁面在執行渲染,或者很耗時JavaScript操作,該操作還沒完成,而此時你在頁面發起互動,就得不到及時的響應。
回到此題,我們在渲染十萬掉資料的時候,要用到切片(有點類似react fiber的思想)。怎麼理解呢?就是把十萬掉資料分批次的渲染到頁面,這個批次任務必須放到非同步回撥(首批任務不用),這樣才能在後續的渲染中,把優先順序讓出給執行佇列執行緒,當執行佇列空閒時,再回過頭來繼續取出非同步回撥裡面的切片來執行
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>如何渲染10000條資料在dom節點上</title>
</head>
<body>
<ul id="root">
</div>
<script>
function createOneHundredThousandData(){
let arr = [];
for(let i=0;i<100000;i++){
arr.push({
imgUrl:'https://zos.alipayobjects.com/rmsportal/hfVtzEhPzTUewPm.png',
key:i
})
}
return arr;
}
var beginTime = performance.now();
console.log('beginTime',beginTime);
let h = [];
let data = createOneHundredThousandData();
// 先渲染100條資料
let firstScreenData = data.splice(0,100); // 用陣列的splice方法,擷取後並修改原陣列
for(let i=0;i<100;i++){
let li = document.createElement('li');
let img = document.createElement('img');
img.src = firstScreenData[i].imgUrl;
li.appendChild(img);
let text = document.createTextNode(firstScreenData[i].key);
// console.log('partialData[i].key',partialData[i].key);
li.appendChild(text);
document.getElementById('root').appendChild(li);
}
// setTimeout 中的回撥會在主執行緒空閒時被執行
setTimeout(()=>{
function renderHundred(n){
// console.log('n=',n);
// 每次渲染100條
let partialData = data.splice(0,100);
for(let i=0;i<100 && partialData.length>0;i++){
let li = document.createElement('li');
let img = document.createElement('img');
img.src = partialData[i].imgUrl;
li.appendChild(img);
let text = document.createTextNode(partialData[i].key);
// console.log('partialData[i].key',partialData[i].key);
li.appendChild(text);
document.getElementById('root').appendChild(li);
}
if(n){
setTimeout(()=>{
renderHundred(n-1);
},50)
}
}
renderHundred(999);// 渲染除了首屏資料外的資料
},1000);
document.addEventListener('DOMContentLoaded',function(){
var endTime = performance.now();
console.log('DOMContentLoaded endTime',endTime);
var total = ((endTime - beginTime)/1000).toFixed(5);
console.log('DOMContentLoaded render 100000 items takes ' + total + ' 秒');
});
window.onload = function(){
var endTime = performance.now();
console.log('window.onload endTime',endTime);
var total = ((endTime - beginTime)/1000).toFixed(5);
console.log('window.onload render 100000 items takes ' + total + ' 秒');
}
</script>
</body>
</html>
執行結果如下:
beginTime 139.08000002265908
DOMContentLoaded endTime 193.2200000155717
DOMContentLoaded render 100000 items takes 0.05414 秒
window.onload endTime 207.63000001898035
window.onload render 100000 items takes 0.06855 秒
這個資料體會不出來什麼資訊,可以理解為首個切片實行的耗時統計(後面還有999個切片沒有體現出來),但是在互動方面的體驗大大提升了。至於具體卡不卡頓的資料支撐,可以在chrome控制檯performance模組檢視。大家有興趣的話,拷貝這份程式碼嘗試一下。結論還是比較樂觀的。
三、除了setTimeout,還有其他的選擇嗎?
答案是,必須有。那就是requestAnimationFrame
window.requestAnimationFrame() 告訴瀏覽器——你希望執行一個動畫,並且要求瀏覽器在下次重繪之前呼叫指定的回撥函式更新動畫。該方法需要傳入一個回撥函式作為引數,該回調函式會在瀏覽器下一次重繪之前執行
注意:若你想在瀏覽器下次重繪之前繼續更新下一幀動畫,那麼回撥函式自身必須再次呼叫window.requestAnimationFrame()
從官方文件可以看到,這麼幾個關鍵字:“傳入一個回撥函式”。 那麼是不是可以用這個取代setTimeout?
以下擷取部分程式碼:
let data = createOneHundredThousandData();
let count = 0;
let totalLoop = 1000;// 渲染1000
function animatonCb(){
console.log(count);
let partialData = data.splice(0,100); // 用陣列的splice方法,擷取後並修改原陣列
for(let i=0;i<100 && partialData.length >=1;i++){
let li = document.createElement('li');
let img = document.createElement('img');
img.src = partialData[i].imgUrl;
li.appendChild(img);
let text = document.createTextNode(partialData[i].key);
// console.log('partialData[i].key',partialData[i].key);
li.appendChild(text);
document.getElementById('root').appendChild(li);
}
if(count < totalLoop){
count ++;
requestAnimationFrame(animatonCb)
}
}
requestAnimationFrame(animatonCb);
看下控制檯資料:
beginTime 249.32000000262633
0
DOMContentLoaded endTime 279.33499999926426
DOMContentLoaded render 100000 items takes 0.03001 秒
1
2
window.onload endTime 308.28500000643544
window.onload render 100000 items takes 0.05897 秒
我們假如了迴圈次數count的列印,發現這個穿插在了DOMContentLoaded 和 onload事件中間。有興趣的童鞋可以深入瞭解requestAnimationFrame。
總之,這個requestAnimationFrame 也能實現我們的需求。相比於setTimeout更好一點。
四、十萬條資料載入完成後呢?
上述兩個方案,也就是解決了如何渲染不卡頓的問題。本例中每條記錄dom結構不復雜,可能看起來效果還行。但實際業務場景肯定是比這個更復雜。每次修改dom都會引起10萬條資料但重回重排,這樣效能方面肯定也會有問題。
解決思路就是,監聽該元素是否在可視視窗IntersectionObserver
IntersectionObserver介面 (從屬於Intersection Observer API) 提供了一種非同步觀察目標元素與其祖先元素或頂級文件視窗(viewport)交叉狀態的方法。祖先元素與視窗(viewport)被稱為根(root)。 當一個IntersectionObserver物件被建立時,其被配置為監聽根中一段給定比例的可見區域。一旦IntersectionObserver被建立,則無法更改其配置,所以一個給定的觀察者物件只能用來監聽可見區域的特定變化值;然而,你可以在同一個觀察者物件中配置監聽多個目標元素。
大概思路如下:
- 設定總資料來源,頁面內容資料儲存容器
- 制定頁面內容資料儲存容器規則(假設儲存容器設定為200條,一屏最多展示20條。那麼儲存容器能展示10螢幕的資料。
- 當用戶滑到地6屏資料的時候,顯然前面5屏資料不在可視視窗,那你可以將儲存容器的前3屏資料刪除。同時,再從總資料來源取第11屏到第13屏資料。
後續有空了再詳細研究這塊。
結語
此類問題,也是bat這種大廠經常會問到的,知識點涵蓋也點廣,掌握好了後,對前端效能,卡頓這塊的理解會更透徹了。