转载:React Fiber架构(浅显易懂)
React16啟用了全新的架構,叫做Fiber,其最大的使命是解決大型React項目的性能問題,再順手解決之前的一些痛點。
痛點
主要有如下幾個:
- 組件不能返回數組,最見的場合是UL元素下只能使用LI,TR元素下只能使用TD或TH,這時這里有一個組件循環生成LI或TD列表時,我們并不想再放一個DIV,這會破壞HTML的語義。
- 彈窗問題,之前一直使用不穩定的unstable_renderSubtreeIntoContainer。彈窗是依賴原來DOM樹的上下文,因此這個API第一個參數是組件實例,通過它得到對應虛擬DOM,然后一級級往上找,得到上下文。它的其他參數也很好用,但這個方法一直沒有轉正。。。
- 異常處理,我們想知道哪個組件出錯,雖然有了React DevTool,但是太深的組件樹查找起來還是很吃力。希望有個方法告訴我出錯位置,并且出錯時能讓我有機會進行一些修復工作
- HOC的流行帶來兩個問題,畢竟是社區興起的方案,沒有考慮到ref與context的向下傳遞。
- 組件的性能優化全憑人肉,并且主要集中在SCU,希望框架能干些事情,即使不用SCU,性能也能上去。
解決進度
- 16.0 讓組件支持返回任何數組類型,從而解決數組問題; 推出createPortal API ,解決彈窗問題; 推出componentDidCatch新鉤子, 劃分出錯誤組件與邊界組件, 每個邊界組件能修復下方組件錯誤一次, 再次出錯,轉交更上層的邊界組件來處理,解決異常處理問題。
- 16.2 推出Fragment組件,可以看作是數組的一種語法糖。
- 16.3 推出createRef與forwardRef解決Ref在HOC中的傳遞問題,推出new Context API,解決HOC的context傳遞問題(主要是SCU作崇)
- 而性能問題,從16.0開始一直由一些內部機制來保證,涉及到批量更新及基于時間分片的限量更新。
一個小實驗
我們可以通過以下實驗來窺探React16的優化思想。
這是一個擁有10000個節點的插入操作,包含了innerHTML與樣式設置,花掉1000ms。
我們再改進一下,分派次插入節點,每次只操作100個節點,共100次,發現性能異常的好!
究其原因是因為瀏覽器是單線程,它將GUI描繪,時間器處理,事件處理,JS執行,遠程資源加載統統放在一起。當做某件事,只有將它做完才能做下一件事。如果有足夠的時間,瀏覽器是會對我們的代碼進行編譯優化(JIT)及進行熱代碼優化,一些DOM操作,內部也會對reflow進行處理。reflow是一個性能黑洞,很可能讓頁面的大多數元素進行重新布局。
瀏覽器的運作流程
這些tasks中有些我們可控,有些不可控,比如setTimeout什么時候執行不好說,它總是不準時; 資源加載時間不可控。但一些JS我們可以控制,讓它們分派執行,tasks的時長不宜過長,這樣瀏覽器就有時間優化JS代碼與修正reflow!下圖是我們理想中的渲染過程
總結一句,就是讓瀏覽器休息好,瀏覽器就能跑得更快。
如何讓代碼斷開重連
JSX是一個快樂出奇蛋,一下子滿足你兩個愿望:組件化與標簽化。并且JSX成為組件化的標準化語言。
但標簽化是天然套嵌的結構,意味著它會最終編譯成遞歸執行的代碼。因此React團隊稱React16之前的調度器為棧調度器,棧沒有什么不好,棧顯淺易懂,代碼量少,但它的壞處不能隨意break掉,continue掉。根據我們上面的實驗,break后我們還要重新執行,我們需要一種鏈表的結構。
鏈表是對異步友好的。鏈表在循環時不用每次都進入遞歸函數,重新生成什么執行上下文,變量對象,激活對象,性能當然比遞歸好。
因此Reat16設法將組件的遞歸更新,改成鏈表的依次執行。如果頁面有多個虛擬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, 但不保證會跑起來。
首先是一些簡單的方法:
updateFiberAndView要實現React的時間分片,我們先用setTimeout模擬。我們暫時不用理會updateView怎么實現,可能它就是updateComponentOrElement中將它們放到又一個列隊,需再出來執行insertBefore, componentDidMount操作呢!
里面有一個do while循環,每一次都是小心翼翼進行計時,時間不夠就將來不及處理的節點放進列隊。
updateComponentOrElement無非是這樣:
function updateComponentOrElement(fiber){var {type, stateNode, props} = fiberif(!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
- requestIdleCallback
- web worker
- IntersectionObserver
我們依次稱為瀏覽器層面的幀數控制調用,閑時調用,多線程調用, 進入可視區調用。
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可以改成這樣:
到這里,ReactFiber基于時間分片的限量更新講完了。實際上React為了照顧絕大多數的瀏覽器,自己實現了requestIdleCallback。
批量更新
但React團隊覺得還不夠,需要更強大的東西。因為有的業務對視圖的實時同步需求并不強烈,希望將所有邏輯都跑完才更新視圖,于是有了batchedUpdates,目前它還不是一個穩定的API,因此大家使用它時要這樣用ReactDOM.unstable_batchedUpdates。
這個東西怎么實現呢?就是搞一個全局的開關,如果打開了,就讓updateView不起作用。
事實上,當然沒有這么簡單,考慮到大家看不懂React的源碼,大家可以看一下anujs是怎么實現的:
github.com/RubyLouvre/…
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的任務名,是基于素數進行乘除。
github.com/RubyLouvre/…
無論是位操作還是素數,我們只要保證某個Fiber的相同性質任務只執行一次就行了。
此外,任務系統還有另一個存在意義,保證一些任務優先執行,某些任務是在另一些任務之前。我們稱之為任務分揀。這就像快遞的倉庫管理一樣,有了歸類才好進行優化。比如說,元素虛擬DOM的插入移動操作必須在所有任務之前執行,移除操作必須在componentWillUnmount后執行。這些任務之所以是這個順序,因為這樣做才合理,都經過高手們的嚴密推敲,經過React15時代的大眾驗證。
Fiber的連體嬰結構
連體嬰是一個可怕的名詞,想想就不舒服,因為事實上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的原因所在。
總結
以上是生活随笔為你收集整理的转载:React Fiber架构(浅显易懂)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 和平精英怎么改名字
- 下一篇: RAKsmart美国服务器CentOS