問題背景
React16 更新了底層架構,新架構主要解決更新節點過多時,頁碼卡頓的問題。譬如如下程式碼,根據使用者輸入的文字生成10000行資料,使用者輸入框會出現卡頓現象。
class App extends React.Component {
constructor( props ) {
super( props );
this.state = {
rowData: []
}
}
handleUserInput = (e)=>{
let userInput = e.target.value;
let newRowData = [];
for( let i = 0; i < 10000; i++) {
newRowData.push( userInput );
}
this.setState( {
rowData: newRowData
} )
}
renderRows() {
return this.rowData.map( (s,index)=>{
return (
<tr key={index}>
<td>{s}</td>
</tr>
)
} )
}
render() {
return (
<div>
<div>
<input type="text" onChange={ this.handleUserInput }/>
</div>
<table>
<tbody>
{ this.renderRows() }
</tbody>
</table>
</div>
);
}
}
卡頓的原因
FPS
為了引出瀏覽器卡頓真正的原因,我們先簡單介紹一個概念:FPS(Frames Per Second) - 每秒傳輸幀數。舉個例子,一般來說動畫片是如何動起來的呢?是以極快的速度連續播放靜態的圖片,利用視網膜影象殘留效應,讓人產生動起來的錯覺。那麼這個播放要多塊呢?每秒最少要展示24張圖片,觀眾才勉強不會感受到畫面延時(即 FPS 達到24,不會讓人覺得卡頓)。
頁面繪製過程
瀏覽器其實也是類似的原理,每間隔一定的時間重新繪製一下當前頁面。一般來說這個頻率是每秒60次。也就是說每16毫秒( 1 / 60 ≈ 0.0167 )瀏覽器會有一個週期性地重繪行為,這每16毫秒我們稱為一幀。這一幀的時間裡面瀏覽器做些什麼事情呢:
- 執行JS。
- 計算Style。
- 構建佈局模型(Layout)。
- 繪製圖層樣式(Paint)。
- 組合計算渲染呈現結果(Composite)。
inter-frame idle period.jpg
這個過程是順序的,如果 JS 執行的時間過長,那麼後續的步驟也就會被相應的延後,導致的後果就是一幀的時間變長,FPS 變低。人直觀的感受就是頁面變卡頓。回到上面的例子,一下子更新10000條資料導致 React 執行了相當長的時間,讓瀏覽器這段時間內無法做其他事情,下一幀被延遲了。
有人會想到說,誒,一次執行時間太長會卡我能理解,但是為啥我以前用定時器做 JS 動畫有時也會卡呢?下面我們就分析下原因。
setTimeout/setInterval
我們把 setTimeout 和瀏覽器幀流兩條時間線放在一起看一下( 綠色是 paint,紫色是 render,黃色是執行 JS ):
- 第一種完美的情況,就是 setTimeout 執行的頻率和瀏覽器的幀率相同。
timeline-perfect-frequency.png
- 太頻繁,導致每一幀的元素變化過大(不是每次改變元素的效果都被顯示出來),表現為動畫不順滑。譬如,你期望元素每次移動10畫素,但是按之前的原理,使用者看到的是元素每次移動了40畫素。
timeline-too-frequent.png
- setTimeout 的頻率低於瀏覽器預設幀率,導致跳幀,表現也是不順滑。這個就不用說了,元素可能幾幀才動一次。
timeline-skip-frame.png
- setTimeout 某次或者每次執行的函式時間過長,導致瀏覽器的 FPS 降低,表現為動畫卡頓。這種別說動畫卡,頁面也卡了。
timeline-delay.png
想象一下,當你不知道瀏覽器頁面繪製原理的時候是不是全憑感覺來設定 setTimeout 的間隔?當然你也可以把 setTimeout 的間隔設定成16毫秒。不過如果對 event loop 機制瞭解的話,你會知道這個只能大致保證按這個時間間隔執行,並不會嚴格保證。setInterval 也是類似,但是比 setTimeout 更不可控。
解決方案
回過頭來我們仔細分解下每一幀瀏覽器要做些什麼(見下圖),先是響應各種事件,然後執行 event loop 中的任務,然後是一段 raf 時間,最後是計算排版(layout)和重新繪製(paint)。大致你可以認為是先執行程式,然後再根據 JS 執行的結果重繪頁面,當然如果 dom 元素沒有任何變化,那麼重繪這個步驟就省了。
life of a frame.png
如果我們能保證 JS 動畫的每次執行都在重繪前,那麼我們就能做到動畫的順滑,setTimeout 無法保證,但是瀏覽器提供了新的 API 來幫助我們了。
瀏覽器新API
requestAnimationFrame
這個函式的作用就是告訴瀏覽器你希望執行一段 JS,並且要求瀏覽器在下次重繪之前呼叫這段 JS 所在的回撥函式。
requestAnimationFrame( function(){
document.body.style.width = '100px';
} )
上述程式碼執行後,在瀏覽器繪製頁面的下一幀重繪前,會執行回撥函式,那麼就能保證修改的 dom 的效果能在下一幀被顯示出來。回看上面的幀的生命週期,raf 時間就是留給 requestAnimationFrame 所註冊的回撥函式執行用的。這樣我們把以前的 setTimeout 動畫就可以用 requestAnimationFrame 來改造。
// 舊版:讓元素右移500畫素
function moveToRight( div ) {
let left = parseInt( div.style.left );
if ( left < 500 ) {
div.style.left = (left+10+'px');
setTimeout( function(){
moveToRight( div );
}, 16 )
} else {
return;
}
}
moveToRight( div );
// 新版:讓元素右移500畫素
function moveToRight( div ) {
let left = parseInt( div.style.left );
if ( left < 500 ) {
div.style.left = (left+10+'px');
requestAnimationFrame( function(){
moveToRight( div );
} )
} else {
return;
}
}
requestAnimationFrame( function(){
moveToRight( div );
} )
特別注意:不是用了 requestAnimationFrame 後動畫就流暢了。如果你傳入 requestAnimationFrame 的回撥函式執行的 JS 耗時過長,一樣會導致後續步驟的延時,引起瀏覽器 FPS 的下降。所以這點在寫程式碼的時候要注意。
現在有一個問題,傳入 requestAnimationFrame 的回撥函式一定是會被被安排在下一次重繪前所呼叫的,但是如果 raf 時間之前就已經執行了長時間的 JS,那麼我再執行這個回撥豈不是雪上加霜?我能不能要求這種情況說,我的程式碼也不是很緊急,判斷下如果當前幀不“忙”,我就執行,如果幀“忙”,我可以等下一幀之類的呢?好!下一個 API 來了。
requestIdleCallback
這個函式告訴瀏覽器,在空閒時期依次執行註冊的回撥函式。什麼意思呢?上面我們說過瀏覽器在一幀的時間裡面要做這個事,那個事,但是並不是每時每刻這些事情都耗時的。譬如你開啟頁面後什麼都不做,那麼一幀16毫秒之內又沒有啥 JS 需要執行又沒有大量的重繪工作,產生了有很多空餘時間。看下圖,黃色部分就是一幀內的空餘時間,當瀏覽器發現一幀有空餘時間就會看下有沒有呼叫 requestIdleCallback 註冊的回撥函式,有的話就執行下。如果執行某個回撥前看到幀結束了,那麼就等下一次有空閒時間接著執行剩餘的回撥函式。
inter-frame idle period.jpg
有了 requestAnimationFrame 和 requestIdleCallback 我們就能比以前更細粒度的控制 JS 執行的時間了。接下來我們看下基於這個原理 React 如何優化它的更新 dom 的機制。
React排程演算法
React 程式碼中如果某處 setState 被呼叫引起了一系列更新,React 大致要做的是生成新的虛擬 dom 樹,然後和老的虛擬 dom 樹做比較,生成更新列表,最後根據這個列表更新真實的 dom。當然更新 dom 耗時在 JS 層面現階段是沒法優化了,而生成虛擬 dom,做新老虛擬 dom 比較過程的耗時,是可能隨著應用的複雜程度而增加的。React16 之前絕大多數情況是一次完成虛擬 dom 到真實 dom 更新的整個過程的。那麼這個過程如果在一幀裡面耗時過長,頁面就卡頓了。React16 的思路就是想利用 requestAnimationFrame 和 requestIdleCallback 兩個新 API,把一次耗時較長的更新任務分解到多個幀去執行。這樣給瀏覽器留出時間去響應頁面上的其他事件,解決卡頓的問題。接下來看下虛擬碼:
排程演算法虛擬碼
原來這段寫的匆忙且不好,重新更新了一篇講排程演算法的大概實現React16效能改善的原理(二)。
原更新步驟大致為
// 原更新步驟大致為:
setState( partialState ) {
var inst = this._instance;
var nextState = Object.assign( {}, inst.state, partialState );
// 根據新的 state 生成新的虛擬 dom
inst.state = nextState;
var nextRenderedElement = inst.render();
// 獲取上一次的虛擬 dom
var prevComponentInstance = this._renderedComponent; // render 中的根節點的渲染物件
var prevRenderedElement = prevComponentInstance._currentElement;
if( shouldUpdateReactComponent(prevRenderedElement, nextRenderedElement) ) {
// 更新 dom node
prevComponentInstance.receiveComponent( nextRenderedElement )
}
}
根據新的優化思路,React16新的更新過長大致為:
setState( partialState ) {
updateQueue.push( {
instance: this,
partialState: partialState
} );
requestIdleCallback( doDiff )
}
function doDiff( deadline ) {
let nextUpdate = updateQueue.shift();
let pendingCommit = [];
// 如果更新佇列裡面有更新,且時間富裕,則逐步計算出需要更新的內容
while( nextUpdate && deadline.timeRemaining()>ENOUGH_TIME ) {
// 生成 fiber 節點,對比新老節點,生成更新dom的任務
pendingCommit.push( calculateDomModification(nextUpdate) ); // 把更新 dom 的任務加入待更新佇列
nextUpdate = updateQueue.shift();
}
// 一次把當前時間片所有的 diff 出的更新任務都更新到 dom 上
if ( pendingCommit.lengt>0 ) {
commitAllWork( pendingCommit );
}
// 如果更新佇列還有更新,但是時間片耗盡了,那麼在下次空閒時間再更新
if ( nextUnitOfWork || updateQueue.length > 0 ) {
requestIdleCallback( doDiff );
}
}
實際程式碼當然要比這個複雜的多,React 對上述排程的實現基於現實的考慮進行了優化:考慮到 1.有的更新是比較緊急的不能等空閒去完成要用 requestAnimationFrame、2.有的是可以放到空閒時間去執行的、3.對於兩個新 API 的瀏覽器支援不是很好、4.瀏覽器預設重新整理頻率的的時間片太短。React 團隊實現了一個自己的排程函式 requestAnimationFrameWithTimeout。
其他關注點
後續還打算更新其他細節的內容,等研究好了再更新,譬如:1. 更新任務不是同步完成的,如果同一個節點在還沒有把更新真正反應到 dom 上的時候,有來了一次 setState 怎麼辦?
2. React fiber 為什麼是鏈式結構?