性能優化是一個系統性的工程,若是隻看到局部,引入算法,固然是越快越好; 但從總體來看,在關鍵點引入緩存,能夠秒殺N多算法,或另闢蹊徑,探索事件的本質,可能用戶要的並非快……
React16啓用了全新的架構,叫作Fiber,其最大的使命是解決大型React項目的性能問題,再順手解決以前的一些痛點。css
主要有以下幾個:前端
咱們能夠經過如下實驗來窺探React16的優化思想。java
function randomHexColor(){
return "#" + ("0000"+ (Math.random() * 0x1000000 << 0).toString(16)).substr(-6);
}
setTimeout(function() {
var k = 0;
var root = document.getElementById("root");
for(var i = 0; i < 10000; i++){
k += new Date - 0 ;
var el = document.createElement("div");
el.innerHTML = k;
root.appendChild(el);
el.style.cssText = `background:${randomHexColor()};height:40px`;
}
}, 1000);
複製代碼
這是一個擁有10000個節點的插入操做,包含了innerHTML與樣式設置,花掉1000ms。node
咱們再改進一下,分派次插入節點,每次只操做100個節點,共100次,發現性能異常的好!react
function randomHexColor() {
return "#" + ("0000" + (Math.random() * 0x1000000 << 0).toString(16)).substr(-6);
}
var root = document.getElementById("root");
setTimeout(function () {
function loop(n) {
var k = 0;
console.log(n);
for (var i = 0; i < 100; i++) {
k += new Date - 0;
var el = document.createElement("div");
el.innerHTML = k;
root.appendChild(el);
el.style.cssText = `background:${randomHexColor()};height:40px`;
}
if (n) {
setTimeout(function () {
loop(n - 1);
}, 40);
}
}
loop(100);
}, 1000);
複製代碼
究其緣由是由於瀏覽器是單線程,它將GUI描繪,時間器處理,事件處理,JS執行,遠程資源加載通通放在一塊兒。當作某件事,只有將它作完才能作下一件事。若是有足夠的時間,瀏覽器是會對咱們的代碼進行編譯優化(JIT)及進行熱代碼優化,一些DOM操做,內部也會對reflow進行處理。reflow是一個性能黑洞,極可能讓頁面的大多數元素進行從新佈局。git
瀏覽器的運做流程github
渲染 -> tasks -> 渲染 -> tasks -> 渲染 -> tasks -> ....
這些tasks中有些咱們可控,有些不可控,好比setTimeout何時執行很差說,它老是不許時; 資源加載時間不可控。但一些JS咱們能夠控制,讓它們分派執行,tasks的時長不宜過長,這樣瀏覽器就有時間優化JS代碼與修正reflow!下圖是咱們理想中的渲染過程web
總結一句,就是讓瀏覽器休息好,瀏覽器就能跑得更快。算法
JSX是一個快樂出奇蛋,一會兒知足你兩個願望:組件化與標籤化。而且JSX成爲組件化的標準化語言。redux
<div>
<Foo>
<Bar />
</Foo>
</div>
複製代碼
但標籤化是自然套嵌的結構,意味着它會最終編譯成遞歸執行的代碼。所以React團隊稱React16以前的調度器爲棧調度器,棧沒有什麼很差,棧顯淺易懂,代碼量少,但它的壞處不能隨意break掉,continue掉。根據咱們上面的實驗,break後咱們還要從新執行,咱們須要一種鏈表的結構。
鏈表是對異步友好的。鏈表在循環時不用每次都進入遞歸函數,從新生成什麼執行上下文,變量對象,激活對象,性能固然比遞歸好。
所以Reat16設法將組件的遞歸更新,改爲鏈表的依次執行。若是頁面有多個虛擬DOM樹,那麼就將它們的根保存到一個數組中。
ReactDOM.render(<A />, node1)
ReactDOM.render(<B />, node2)
//node1與node2不存在包含關係,那麼這頁面就有兩棵虛擬DOM樹
複製代碼
若是仔細閱讀源碼,React這個純視圖庫其實也是三層架構。在React15有虛擬DOM層
,它只負責描述結構與邏輯;內部組件層
,它們負責組件的更新, ReactDOM.render、 setState、 forceUpdate都是與它們打交道,能讓你屢次setState,只執行一次真實的渲染, 在適合的時機執行你的組件實例的生命週期鉤子; 底層渲染層
, 不一樣的顯示介質有不一樣的渲染方法,好比說瀏覽器端,它使用元素節點,文本節點,在Native端,會調用oc, java的GUI, 在canvas中,有專門的API方法。。。
虛擬DOM是由JSX轉譯過來的,JSX的入口函數是React.createElement, 可操做空間不大, 第三大的底層API也很是穩定,所以咱們只能改變第二層。
React16將內部組件層改爲Fiber這種數據結構,所以它的架構名也改叫Fiber架構。Fiber節點擁有return, child, sibling三個屬性,分別對應父節點, 第一個孩子, 它右邊的兄弟, 有了它們就足夠將一棵樹變成一個鏈表, 實現深度優化遍歷。
在React15中,每次更新時,都是從根組件或setState後的組件開始,更新整個子樹,咱們惟一能作的是,在某個節點中使用SCU斷開某一部分的更新,或者是優化SCU的比較效率。
React16則是須要將虛擬DOM轉換爲Fiber節點,首先它規定一個時間段內,而後在這個時間段能轉換多少個FiberNode,就更新多少個。
所以咱們須要將咱們的更新邏輯分紅兩個階段,第一個階段是將虛擬DOM轉換成Fiber, Fiber轉換成組件實例或真實DOM(不插入DOM樹,插入DOM樹會reflow)。Fiber轉換成後二者明顯會耗時,須要計算還剩下多少時間。而且轉換實例須要調用一些鉤子,如componentWillMount, 若是是重複利用已有的實例,這時就是調用componentWillReceiveProps, shouldComponentUpdate, componentWillUpdate,這時也會耗時。
爲了讓讀者能直觀瞭解React Fiber的運做過程,咱們簡單實現一下ReactDOM.render, 但不保證會跑起來。
首先是一些簡單的方法:
var queue = []
ReacDOM.render = function (root, container) {
queue.push(root)
updateFiberAndView()
}
function getVdomFormQueue() {
return queue.shift()
}
function Fiber(vnode){
for(var i in vnode){
this[i] = vnode[i]
}
this.uuid = Math.random()
}
//咱們簡單的Fiber目前來看,只比vdom多了一個uuid屬性
function toFiber(vnode){
if(!vnode.uuid){
return new Fiber(vnode)
}
return vnode
}
複製代碼
updateFiberAndView要實現React的時間分片,咱們先用setTimeout模擬。咱們暫時不用理會updateView怎麼實現,可能它就是updateComponentOrElement中將它們放到又一個列隊,需再出來執行insertBefore, componentDidMount操做呢!
function updateFiberAndView() {
var now = new Date - 0;
var deadline = new Date + 100;
updateView() //更新視圖,這會耗時,所以須要check時間
if (new Date < deadline) {
var vdom = getVdomFormQueue()
var fiber = vdom, firstFiber
var hasVisited = {}
do {//深度優先遍歷
var fiber = toFiber(fiber);//A處
if(!firstFiber){
fibstFiber = fiber
}
if (!hasVisited[fiber.uuid]) {
hasVisited[fiber.uuid] = 1
//根據fiber.type實例化組件或者建立真實DOM
//這會耗時,所以須要check時間
updateComponentOrElement(fiber);
if (fiber.child) {
//向下轉換
if (newDate - 0 > deadline) {
queue.push(fiber.child)//時間不夠,放入棧
break
}
fiber = fiber.child;
continue //讓邏輯跑回A處,不斷轉換child, child.child, child.child.child
}
}
//若是組件沒有children,那麼就向右找
if (fiber.sibling) {
fiber = fiber.sibling;
continue //讓邏輯跑回A處
}
// 向上找
fiber = fiber.return
if(fiber === fibstFiber || !fiber){
break
}
} while (1)
}
if (queue.length) {
setTimeout(updateFiberAndView, 40)
}
}
複製代碼
裏面有一個do while循環,每一次都是當心翼翼進行計時,時間不夠就未來不及處理的節點放進列隊。
updateComponentOrElement無非是這樣:
function updateComponentOrElement(fiber){
var {type, stateNode, props} = fiber
if(!stateNode){
if(typeof type === "string"){
fiber.stateNode = document.createElement(type)
}else{
var context = {}//暫時免去這個獲取細節
fiber.stateNode = new type(props, context)
}
}
if(stateNode.render){
//執行componentWillMount等鉤子
children = stateNode.render()
}else{
children = fiber.childen
}
var prev = null;
//這裏只是mount的實現,update時還須要一個oldChildren, 進行key匹配,重複利用已有節點
for(var i = 0, n = children.length; i < n; i++){
var child = children[i];
child.return = fiber;
if(!prev){
fiber.child = child
}else{
prev.sibling = child
}
prev = child;
}
}
複製代碼
所以這樣Fiber的return, child, sibling就有了,能夠happy地進行深度優先遍歷了。
剛纔的updateFiberAndView其實有一個問題,咱們安排了100ms來更新視圖與虛擬DOM,而後再安排40ms來給瀏覽器來作其餘事。若是咱們的虛擬DOM樹很小,其實不須要100ms; 若是咱們的代碼以後, 瀏覽器有更多其餘事要幹, 40ms可能不夠。IE10出現了setImmediate,requestAnimationFrame這些新定時器,讓咱們這些前端,其實瀏覽器有能力讓頁面更流暢地運行起來。
瀏覽器自己也不斷進化中,隨着頁面由簡單的展現轉向WebAPP,它須要一些新能力來承載更多節點的展現與更新。
下面是一些自救措施:
咱們依次稱爲瀏覽器層面的幀數控制調用,閒時調用,多線程調用, 進入可視區調用。
requestAnimationFrame在作動畫時常常用到,jQuery新版本都使用它。web worker在angular2開始就釋出一些包,實驗性地用它進行diff數據。IntersectionObserver能夠用到ListView中。而requestIdleCallback是一個生臉孔,而React官方偏偏看上它。
剛纔說updateFiberAndView有出兩個時間段,一個給本身的,一個給瀏覽器的。requestAnimationFrame能幫咱們解決第二個時間段,從而確保總體都是60幀或75幀(這個幀數能夠在操做系統的顯示器刷新頻率中設置)流暢運行。
咱們看requestIdleCallback是怎麼解決這問題的
它的第一個參數是一個回調,回調有一個參數對象,對象有一個timeRemaining方法,就至關於new Date - deadline
,而且它是一個高精度數據, 比毫秒更準確, 至少瀏覽器到底安排了多少時間給更新DOM與虛擬DOM,咱們不用管。第二個時間段也不用管,不過瀏覽器可能1,2秒才執行這個回調,所以爲了保險起見,咱們能夠設置第二個參數,讓它在回調結束後300ms才執行。要相信瀏覽器,由於都是大牛們寫的,時間的調度比你安排更有效率。
因而咱們的updateFiberAndView能夠改爲這樣:
function updateFiberAndView(dl) {
updateView() //更新視圖,這會耗時,所以須要check時間
if (dl.timeRemaining() > 1) {
var vdom = getVdomFormQueue()
var fiber = vdom, firstFiber
var hasVisited = {}
do {//深度優先遍歷
var fiber = toFiber(fiber);//A處
if(!firstFiber){
fibstFiber = fiber
}
if (!hasVisited[fiber.uuid]) {
hasVisited[fiber.uuid] = 1
//根據fiber.type實例化組件或者建立真實DOM
//這會耗時,所以須要check時間
updateComponentOrElement(fiber);
if (fiber.child) {
//向下轉換
if (dl.timeRemaining() > 1) {
queue.push(fiber.child)//時間不夠,放入棧
break
}
fiber = fiber.child;
continue //讓邏輯跑回A處,不斷轉換child, child.child, child.child.child
}
}
//....略
} while (1)
}
if (queue.length) {
requetIdleCallback(updateFiberAndView, {
timeout:new Date + 100
}
)
}
}
複製代碼
到這裏,ReactFiber基於時間分片的限量更新講完了。實際上React爲了照顧絕大多數的瀏覽器,本身實現了requestIdleCallback。
但React團隊以爲還不夠,須要更強大的東西。由於有的業務對視圖的實時同步需求並不強烈,但願將全部邏輯都跑完才更新視圖,因而有了batchedUpdates,目前它還不是一個穩定的API,所以你們使用它時要這樣用ReactDOM.unstable_batchedUpdates。
這個東西怎麼實現呢?就是搞一個全局的開關,若是打開了,就讓updateView不起做用。
var isBatching = false
function batchedUpdates(callback, event) {
let keepbook = isBatching;
isBatching = true;
try {
return callback(event);
} finally {
isBatching = keepbook;
if (!isBatching) {
requetIdleCallback(updateFiberAndView, {
timeout:new Date + 1
}
}
}
};
function updateView(){
if(isBatching){
return
}
//更新視圖
}
複製代碼
事實上,固然沒有這麼簡單,考慮到你們看不懂React的源碼,你們能夠看一下anujs是怎麼實現的:
React內部也大量使用batchedUpdates來優化用戶代碼,好比說在事件回調中setState,在commit階段的鉤子(componentDidXXX)中setState 。
能夠說,setState是對單個組件的合併渲染,batchedUpdates是對多個組件的合併渲染
。合併渲染是React最主要的優化手段。
React經過Fiber將樹的遍歷變成了鏈表的遍歷,但遍歷手段有這麼多種,爲何恰恰使用DFS?!
這涉及一個很經典的消息通訊問題。若是是父子通訊,咱們能夠經過props進行通訊,子組件能夠保存父的引用,能夠隨時call父組件。若是是多級組件間的通訊,或不存在包含關係的組件通訊就麻煩了,因而React發明了上下文對象(context)。
context一開始是一個空對象,爲了方便起見,咱們稱之爲unmaskedContext。
當它遇到一個有getChildContext方法的組件時,那個方法會產生一個新context,與上面的合併,而後將新context做爲unmaskedContext往下傳。
當它遇到一個有contextTypes的組件,context就抽取一部份內容給這個組件進行實例化。這個只有部份內容的context,咱們稱之爲maskedContext。
組件老是從unmaskedContext中割一塊肉下來做爲本身的context。可憐!
若是子組件沒有contextTypes,那麼它就沒有任何屬性。
在React15中,爲了傳遞unmaskedContext,因而大部分方法與鉤子都留了一個參數給它。但這麼大架子的context居然在文檔中沒有什麼地位。那時React團隊尚未想好如何處理組件通訊,所以社區一直用舶來品Redux來救命。這狀況一直到Redux的做者入主React團隊。
還有一個隱患,它可能被SCU比較時是用maskedContext,而不是unmaskedContext。
基於這些問題,終於new Context API出來了。首先, unmaskedContext 再也不像之前那樣各個方法中來往穿梭了,有一個獨立的contextStack。開始時就push進一個空對象,到達某個組件須要實例化時,就取它第一個。當再次訪問這個組件時, 就像它從棧中彈出。所以咱們須要深度優先遍歷,保證每點節點都訪問兩次。
相同的狀況還有container,container是咱們某個元素虛擬DOM須要用到的真實父節點。在React15中,它會裝在一個containerInfo對象也層層傳送。
咱們知道,虛擬DOM分紅兩大類,一種是組件虛擬DOM,type爲函數或類,它自己不產生節點,而是生成組件實例,而經過render方法,產生下一級的虛擬DOM。一種是元素虛擬DOM,type爲標籤名,會產生DOM節點。上面的元素虛擬DOM的stateNode(DOM節點),就是下方的元素虛擬DOM的contaner。
這種獨立的棧機制有效地解決了內部方法的參數冗餘問題。
但有一個問題,當第一次渲染完畢後,contextStack置爲空了。而後咱們位於虛擬DOM樹的某個組件setState,這時它的context應該如何獲取呢?React的解決方式是,每次都是從根開始渲染,經過updateQueue加速跳過沒有更新的 節點——每一個組件在setState或forceUpdate時,都會建立一個updateQueue屬性在它的上面。anujs則是保存它以前的unmaskedContext到實例上,unmaskedContext能夠看做是上面全部context的並集,而且一個能夠當多個使用。
當咱們批量更新時,可能有多少不連續的子組件被更新了,其中兩個組件之間的某個組件使用了SCU return false,這個SCU應該要被忽視。 所以咱們引用一些變量讓它透明化。就像forceUpdate能讓組件無視SCU同樣。
React將虛擬DOM的更新過程劃分兩個階段,reconciler階段與commit階段。reconciler階段對應早期版本的diff過程,commit階段對應早期版本的patch過程。
一些迷你React,如preact會將它們混合在一塊兒,一邊diff一邊patch(幸虧它使用了Promise.then來優化,確保每次只更新一個組件) 。
有些迷你React則是經過減小移動進行優化,因而絞盡腦汁,用上各類算法,最短編輯距離,最長公共子序列,最長上升子序列。。。
其實基於算法的優化是一種絕望的優化,就相似瑪雅文明由於找不到銅礦一直停留於石器時代,誕生了偉大的工匠精神把石器打磨得美倫美奐。
之因此這麼說,由於diff算法都用於組件的新舊children比較,children通常不會出現過長的狀況,有點大炮打蚊子。何況當咱們的應用變得很是龐大,頁面有上萬個組件,要diff這麼多組件,再卓絕的算法也不能保證瀏覽器不會累趴。由於他們沒想到瀏覽器也會累趴,也沒有想到這是一個長跑的問題。若是是100米短跑,或者1000米競賽,固然越快越好。若是是馬拉松,就須要考慮到保存體力了,須要注意休息了。性能是一個系統性的工程。
在咱們的代碼裏面,休息
就是檢測時間而後斷開Fiber鏈。
updateFiberAndView裏面先進行updateView,因爲節點的更新是不可控,所以所有更新完,才檢測時間。而且咱們徹底不用擔憂updateView會出問題,由於updateView實質上是在batchedUpdates中,裏面有try catch。而接下來咱們基於DFS更新節點,每一個節點都要check時間,這個過程其實很懼怕出錯的, 由於組件在掛載過程當中會調三次鉤子/方法(constructor, componentWillMount, render), 組件在更新過程當中會調4次鉤子 (componentWillReceiveProps, shouldUpdate, componentWillUpdate), 總不能每一個方法都用try catch包起來,這樣會性能不好。而constructor, render是不可避免的,因而對三個willXXX動刀了。
在早期版本中,componentWillMount與componentWillReceiveProps會作內部優化,執行屢次setState都會延後到render時進行合併處理。所以用戶就肆意setState了。這些willXXX還可讓用戶任意操做DOM。 操做DOM會可能reflow,這是官方不肯意看到的。因而官方推出了getDerivedStateFromProps,讓你在render設置新state,你主要返回一個新對象,它就主動幫你setState。因爲這是一個靜態方法,你不能操做instance,這就阻止了你屢次操做setState。因爲沒有instance,也就沒有instance.refs.xxx,你也沒有機會操做DOM了。這樣一來,getDerivedStateFromProps的邏輯應該會很簡單,這樣就不會出錯,不會出錯,就不會打斷DFS過程。
getDerivedStateFromProps取代了原來的componentWillMount與componentWillReceiveProps方法,而componentWillUpdate原本就是無關緊要,之前徹底是爲了對稱好看。
在即便到來的異步更新中,reconciler階段可能執行屢次,才執行一次commit,這樣也會致使willXXX鉤子執行屢次,違反它們的語義,它們的廢棄是不可逆轉的。
在進入commi階段時,組件多了一個新鉤子叫getSnapshotBeforeUpdate,它與commit階段的鉤子同樣只執行一次。
若是出錯呢,在componentDidMount/Update後,咱們可使用componentDidCatch方法。因而整個流程變成這樣:
reconciler階段的鉤子都不該該操做DOM,最好也不要setState,咱們稱之爲輕量鉤子*。commit階段的鉤子則對應稱之爲重量鉤子**。
updateFiberAndView是位於一個requestIdleCallback中,所以它的時間頗有限,分給DFS部分的時間也更少,所以它們不能作太多事情。這怎麼辦呢,標記一下,留給commit階段作。因而產生了一個任務系統。
每一個Fiber分配到新的任務時,就經過位操做,累加一個sideEffect。sideEffect字面上是反作用的意思,很是重FP流的味道,但咱們理解爲任務更方便咱們的理解。
每一個Fiber可能有多個任務,好比它要插入DOM或移動,就須要加上Replacement,須要設置樣式,須要加上Update。
怎麼添加任務呢?
fiber.effectTag |= Update
複製代碼
怎麼保證不會重複添加相同的任務?
fiber.effectTag &= ~DidCapture;
複製代碼
在commit階段,怎麼知道它包含了某項任務?
if(fiber.effectTag & Update){ /*操做屬性*/}
複製代碼
React內置這麼多任務,從DOM操做到Ref處理到回調喚起。。。
順便說一下anu的任務名,是基於素數進行乘除。
不管是位操做仍是素數,咱們只要保證某個Fiber的相同性質任務只執行一次就好了。
此外,任務系統還有另外一個存在乎義,保證一些任務優先執行,某些任務是在另外一些任務以前。咱們稱之爲任務分揀。這就像快遞的倉庫管理同樣,有了歸類纔好進行優化。好比說,元素虛擬DOM的插入移動操做必須在全部任務以前執行,移除操做必須在componentWillUnmount後執行。這些任務之因此是這個順序,由於這樣作才合理,都通過高手們的嚴密推敲,通過React15時代的大衆驗證。
連體嬰是一個可怕的名詞,想一想就不舒服,由於事實上Fiber就是一個不尋常的結構,直到如今個人anujs尚未很好實現這結構。Fiber有一個叫alternate的屬性,大家稱之爲備胎,替死鬼,替身演員。你也能夠視它爲git的開發分支,穩定沒錯的那個則是master。每次 setState時,組件實例stateNode上有一個_reactInternalFiber的對象,就是master分支,而後當即複製一個如出一轍的專門用來踩雷的alternate對象。
alternate對象會接受上方傳遞下來的新props,而後從getDerivedStateFromProps獲得新state,因而render不同的子組件,子組件再render,漸漸的,master與alternate的差別愈來愈大,當某一個子組件出錯,因而咱們又回滾到該邊界組件的master分支。
能夠說,React16經過Fiber這種數據結構模擬了git的三種重要操做, git add, git commit, git revert。
有關連體嬰結構的思考,能夠參看我另外一篇文章《從錯誤邊界到回滾到MWI》,這裏就再也不展開。
提及中間件系統,你們可能對koa與redux裏面的洋蔥模型比較熟悉。
早在React15時代,已經有一個叫Transaction的東西,與洋蔥模型如出一轍。在 Transaction 的源碼中有一幅特別的 ASCII 圖,形象的解釋了 Transaction 的做用。
簡單地說,一個Transaction 就是將須要執行的 method 使用 wrapper 封裝起來,再經過 Transaction 提供的 perform 方法執行。而在 perform 以前,先執行全部 wrapper 中的 initialize 方法;perform 完成以後(即 method 執行後)再執行全部的 close 方法。一組 initialize 及 close 方法稱爲一個 wrapper,從上面的示例圖中能夠看出 Transaction 支持多個 wrapper 疊加。
這個東西有什麼用呢? 最少有兩個用處,在更新DOM時,收集當前獲取焦點的元素與選區,更新結束後,還原焦點與選區(由於插入新節點會引發焦點丟失,document.activeElement變成body,或者是autoFocus,讓焦點變成其餘input,致使咱們正在輸入的input的光標不見了,沒法正常輸入)。在更新時,咱們須要保存一些非受控組件,在更新後,對非受控組件進行還原(非受控組件是一個隱澀的知識點,目的是讓那些沒有設置onChange的表單元素沒法手動改變它的值)。固然了,contextStack, containerStack的初次入棧與清空也能夠作成中間件。中間件就是分佈在batchedUpdates的兩側,一種很是易於擴展的設計,爲何很少用用呢!
React Fiber是對React來講是一次革命,解決了React項目嚴重依賴於手工優化的痛點,經過系統級別的時間調度,實現劃時代的性能優化。鬼才般的Fiber結構,爲異常邊界提供了退路,也爲限量更新提供了下一個起點。React團隊的人才輩出,創造力非凡,別出心裁,從更高的層次處理問題,這是其餘開源團隊不可多見。這也是我一直選擇與學習React的緣由所在。