有react fiber,為什么不需要vue fiber?
提到react fiber,大部分人都知道這是一個react新特性,看過一些網上的文章,大概能說出“纖程”“一種新的數據結構”“更新時調度機制”等關鍵詞。
但如果被問:
- 有react fiber,為什么不需要 vue fiber呢;
- 之前遞歸遍歷虛擬dom樹被打斷就得從頭開始,為什么有了react fiber就能斷點恢復呢;
本文將從兩個框架的響應式設計為切入口講清這兩個問題,不涉及晦澀源碼,不管有沒有使用過react,閱讀都不會有太大阻力。
什么是響應式
無論你常用的是 react,還是 vue,“響應式更新”這個詞肯定都不陌生。
響應式,直觀來說就是視圖會自動更新。如果一開始接觸前端就直接上手框架,會覺得這是理所當然的,但在“響應式框架”出世之前,實現這一功能是很麻煩的。
下面我將做一個時間顯示器,用原生 js、react、vue 分別實現:
原生js:
想讓屏幕上內容變化,必須需要先找到dom(document.getElementById
),然后再修改dom(clockDom.innerText
)。
<div id="root"> <div id="greet"></div> <div id="clock"></div> </div> <script> const clockDom = document.getElementById('clock'); const greetDom = document.getElementById('greet'); setInterval(() => { clockDom.innerText = `現在是:${Util.getTime()}` greetDom.innerText = Util.getGreet() }, 1000); </script>
有了響應式框架,一切變得簡單了。
react:
對內容做修改,只需要調用setState
去修改數據,之后頁面便會重新渲染。
<body> <div id="root"></div> <script type="text/babel"> function Clock() { const [time, setTime] = React.useState() const [greet, setGreet] = React.useState() setInterval(() => { setTime(Util.getTime()) setGreet(Util.getGreet()) }, 1000); return ( <div> <div>{greet}</div> <div>現在是:{time}</div> </div> ) } ReactDOM.render(<Clock/>,document.getElementById('root')) </script> </body>
vue:
我們一樣不用關注dom,在修改數據時,直接this.state=xxx
修改,頁面就會展示最新的數據。
<body> <div id="root"> <div>{{greet}}</div> <div>現在是:{{time}}</div> </div> <script> const Clock = Vue.createApp({ data(){ return{ time:'', greet:'' } }, mounted(){ setInterval(() => { this.time = Util.getTime(); this.greet = Util.getGreet(); }, 1000); } }) Clock.mount('#root') </script> </body>
react、vue的響應式原理
上文提到修改數據時,react需要調用setState
方法,而vue直接修改變量就行??雌饋碇皇莾蓚€框架的用法不同罷了,但響應式原理正在于此。
從底層實現來看修改數據:在react中,組件的狀態是不能被修改的,setState
沒有修改原來那塊內存中的變量,而是去新開辟一塊內存;而vue則是直接修改保存狀態的那塊原始內存。
所以經常能看到react相關的文章里經常會出現一個詞”immutable”,翻譯過來就是不可變的。
數據修改了,接下來要解決視圖的更新:react中,調用setState
方法后,會自頂向下重新渲染組件,自頂向下的含義是,該組件以及它的子組件全部需要渲染;而vue使用Object.defineProperty
([email protected])對數據的設置(setter
)和獲?。?code>getter)做了劫持,也就是說,vue能準確知道視圖模版中哪一塊用到了這個數據,并且在這個數據修改時,告訴這個視圖,你需要重新渲染了。
所以當一個數據改變,react的組件渲染是很消耗性能的——父組件的狀態更新了,所有的子組件得跟著一起渲染,它不能像vue一樣,精確到當前組件的粒度。
為了佐證,我分別用react和vue寫了一個demo,功能很簡單:父組件嵌套子組件,點擊父組件的按鈕會修改父組件的狀態,點擊子組件的按鈕會修改子組件的狀態。
為了更好的對比,直觀展示渲染階段,沒用使用更流行的react函數式組件,vue也用的是不常見的render方法:
class Father extends React.Component{ state = { fatherState:'Father-original state' } changeState = () => { console.log('-----change Father state-----') this.setState({fatherState:'Father-new state'}) } render(){ console.log('Father:render') return ( <div> <h2>{this.state.fatherState}</h2> <button onClick={this.changeState}>change Father state</button> <hr/> <Child/> </div> ) } } class Child extends React.Component{ state = { childState:'Child-original state' } changeState = () => { console.log('-----change Child state-----') this.setState({childState:'Child-new state'}) } render(){ console.log('child:render') return ( <div> <h3>{this.state.childState}</h3> <button onClick={this.changeState}>change Child state</button> </div> ) } } ReactDOM.render(<Father/>,document.getElementById('root'))
效果如下:
上面是使用react時的效果,修改父組件的狀態,父子組件都會重新渲染:點擊change Father state
,不僅打印了Father:render
,還打印了child:render
。
const Father = Vue.createApp({ data() { return { fatherState:'Father-original state', } }, methods:{ changeState:function(){ console.log('-----change Father state-----') this.fatherState = 'Father-new state' } }, render(){ console.log('Father:render') return Vue.h('div',{},[ Vue.h('h2',this.fatherState), Vue.h('button',{onClick:this.changeState},'change Father state'), Vue.h('hr'), Vue.h(Vue.resolveComponent('child')) ]) } }) Father.component('child',{ data() { return { childState:'Child-original state' } }, methods:{ changeState:function(){ console.log('-----change Child state-----') this.childState = 'Child-new state' } }, render(){ console.log('child:render') return Vue.h('div',{},[ Vue.h('h3',this.childState), Vue.h('button',{onClick:this.changeState},'change Child state'), ]) } }) Father.mount('#root')
效果如下:
上面使用vue時的效果,無論是修改哪個狀態,組件都只重新渲染最小顆粒:點擊change Father state
,只打印Father:render
,不會打印child:render
。
不同響應式原理的影響
首先需要強調的是,上文提到的“渲染”“render”“更新“都不是指瀏覽器真正渲染出視圖。而是框架在javascript層面上,調用自身實現的render方法,生成一個普通的對象,這個對象保存了真實dom的屬性,也就是常說的虛擬dom。本文會用組件渲染和頁面渲染對兩者做區分。
每次的視圖更新流程是這樣的:
- 組件渲染生成一棵新的虛擬dom樹;
- 新舊虛擬dom樹對比,找出變動的部分;(也就是常說的diff算法)
- 為真正改變的部分創建真實dom,把他們掛載到文檔,實現頁面重渲染;
由于react和vue的響應式實現原理不同,數據更新時,第一步中react組件會渲染出一棵更大的虛擬dom樹。
fiber是什么
上面說了這么多,都是為了方便講清楚為什么需要react fiber:在數據更新時,react生成了一棵更大的虛擬dom樹,給第二步的diff帶來了很大壓力——我們想找到真正變化的部分,這需要花費更長的時間。js占據主線程去做比較,渲染線程便無法做其他工作,用戶的交互得不到響應,所以便出現了react fiber。
react fiber沒法讓比較的時間縮短,但它使得diff的過程被分成一小段一小段的,因為它有了“保存工作進度”的能力。js會比較一部分虛擬dom,然后讓渡主線程,給瀏覽器去做其他工作,然后繼續比較,依次往復,等到最后比較完成,一次性更新到視圖上。
fiber是一種新的數據結構
上文提到了,react fiber使得diff階段有了被保存工作進度的能力,這部分會講清楚為什么。
我們要找到前后狀態變化的部分,必須把所有節點遍歷。
在老的架構中,節點以樹的形式被組織起來:每個節點上有多個指針指向子節點。要找到兩棵樹的變化部分,最容易想到的辦法就是深度優先遍歷,規則如下:
- 從根節點開始,依次遍歷該節點的所有子節點;
- 當一個節點的所有子節點遍歷完成,才認為該節點遍歷完成;
如果你系統學習過數據結構,應該很快就能反應過來,這不過是深度優先遍歷的后續遍歷。根據這個規則,在圖中標出了節點完成遍歷的順序。
這種遍歷有一個特點,必須一次性完成。假設遍歷發生了中斷,雖然可以保留當下進行中節點的索引,下次繼續時,我們的確可以繼續遍歷該節點下面的所有子節點,但是沒有辦法找到其父節點——因為每個節點只有其子節點的指向。斷點沒有辦法恢復,只能從頭再來一遍。
以該樹為例:
在遍歷到節點2時發生了中斷,我們保存對節點2的索引,下次恢復時可以把它下面的3、4節點遍歷到,但是卻無法找回5、6、7、8節點。
在新的架構中,每個節點有三個指針:分別指向第一個子節點、下一個兄弟節點、父節點。這種數據結構就是fiber,它的遍歷規則如下:
- 從根節點開始,依次遍歷該節點的子節點、兄弟節點,如果兩者都遍歷了,則回到它的父節點;
- 當一個節點的所有子節點遍歷完成,才認為該節點遍歷完成;
根據這個規則,同樣在圖中標出了節點遍歷完成的順序。跟樹結構對比會發現,雖然數據結構不同,但是節點的遍歷開始和完成順序一模一樣。不同的是,當遍歷發生中斷時,只要保留下當前節點的索引,斷點是可以恢復的——因為每個節點都保持著對其父節點的索引。
同樣在遍歷到節點2時中斷,fiber結構使得剩下的所有節點依舊能全部被走到。
這就是react fiber的渲染可以被中斷的原因。樹和fiber雖然看起來很像,但本質上來說,一個是樹,一個是鏈表。
fiber是纖程
這種數據結構之所以被叫做fiber,因為fiber的翻譯是纖程,它被認為是協程的一種實現形式。協程是比線程更小的調度單位:它的開啟、暫??梢员怀绦騿T所控制。具體來說,react fiber是通過requestIdleCallback
這個api去控制的組件渲染的“進度條”。
requesetIdleCallback
是一個屬于宏任務的回調,就像setTimeout一樣。不同的是,setTimeout的執行時機由我們傳入的回調時間去控制,requesetIdleCallback是受屏幕的刷新率去控制。本文不對這部分做深入探討,只需要知道它每隔16ms會被調用一次,它的回調函數可以獲取本次可以執行的時間,每一個16ms除了requesetIdleCallback
的回調之外,還有其他工作,所以能使用的時間是不確定的,但只要時間到了,就會停下節點的遍歷。
使用方法如下:
const workLoop = (deadLine) => { let shouldYield = false;// 是否該讓出線程 while(!shouldYield){ console.log('working') // 遍歷節點等工作 shouldYield = deadLine.timeRemaining()<1; } requestIdleCallback(workLoop) } requestIdleCallback(workLoop);
requestIdleCallback的回調函數可以通過傳入的參數deadLine.timeRemaining()
檢查當下還有多少時間供自己使用。上面的demo也是react fiber工作的偽代碼。
但由于兼容性不好,加上該回調函數被調用的頻率太低,react實際使用的是一個polyfill(自己實現的api),而不是requestIdleCallback。
現在,可以總結一下了:React Fiber是React 16提出的一種更新機制,使用鏈表取代了樹,將虛擬dom連接,使得組件更新的流程可以被中斷恢復;它把組件渲染的工作分片,到時會主動讓出渲染主線程。
react fiber帶來的變化
首先放一張在社區廣為流傳的對比圖,分別是用react 15和16實現的。這是一個寬度變化的三角形,每個小圓形中間的數字會隨時間改變,除此之外,將鼠標懸停,小圓點的顏色會發生變化。
實操一下,可以發現兩個特點:
- 使用新架構后,動畫變得流暢,寬度的變化不會卡頓;
- 使用新架構后,用戶響應變快,鼠標懸停時顏色變化更快;
看到到這里先稍微停一下,這兩點都是fiber帶給我們的嗎——用戶響應變快是可以理解的,但使用react fiber能帶來渲染的加速嗎?
動畫變流暢的根本原因,一定是一秒內可以獲得更多動畫幀。但是當我們使用react fiber時,并沒有減少更新所需要的總時間。
為了方便理解,我把刷新時的狀態做了一張圖:
上面是使用舊的react時,獲得每一幀的時間點,下面是使用fiber架構時,獲得每一幀的時間點,因為組件渲染被分片,完成一幀更新的時間點反而被推后了,我們把一些時間片去處理用戶響應了。
這里要注意,不會出現“一次組件渲染沒有完成,頁面部分渲染更新”的情況,react會保證每次更新都是完整的。
但頁面的動畫確實變得流暢了,這是為什么呢?
我把該項目的代碼倉庫 down下來,看了一下它的動畫實現:組件動畫效果并不是直接修改width
獲得的,而是使用的transform:scale
屬性搭配3D變換。如果你聽說過硬件加速,大概知道為什么了:這樣設置頁面的重新渲染不依賴上圖中的渲染主線程,而是在GPU中直接完成。也就是說,這個渲染主線程線程只用保證有一些時間片去響應用戶交互就可以了。
-<SierpinskiTriangle x={0} y={0} s={1000}> +<SierpinskiTriangle x={0} y={0} s={1000*t}> {this.state.seconds} </SierpinskiTriangle>
修改一下項目代碼中152行,把圖形的變化改為寬度width
修改,會發現即使用react fiber,動畫也會變得相當卡頓,所以這里的流暢主要是CSS動畫的功勞。(內存不大的電腦謹慎嘗試,瀏覽器會卡死)
react不如vue?
我們現在已經知道了react fiber是在彌補更新時“無腦”刷新,不夠精確帶來的缺陷。這是不是能說明react性能更差呢?
并不是。孰優孰劣是一個很有爭議的話題,在此不做評價。因為vue實現精準更新也是有代價的,一方面是需要給每一個組件配置一個“監視器”,管理著視圖的依賴收集和數據更新時的發布通知,這對性能同樣是有消耗的;另一方面vue能實現依賴收集得益于它的模版語法,實現靜態編譯,這是使用更靈活的JSX語法的react做不到的。
在react fiber出現之前,react也提供了PureComponent、shouldComponentUpdate、useMemo,useCallback等方法給我們,來聲明哪些是不需要連帶更新子組件。
結語
回到開頭的幾個問題,答案不難在文中找到:
- react因為先天的不足——無法精確更新,所以需要react fiber把組件渲染工作切片;而vue基于數據劫持,更新粒度很小,沒有這個壓力;
- react fiber這種數據結構使得節點可以回溯到其父節點,只要保留下中斷的節點索引,就可以恢復之前的工作進度;
文章來源:前端私教年年
1. 本站所有文章教程及資源素材均來源于網絡與用戶分享或為本站原創,僅限用于學習和研究。
2. 如果內容損害你的權益請聯系客服QQ:1642748312給予處理。
碼云筆記 » 有react fiber,為什么不需要vue fiber?