阿里三面:靈魂拷問——有react fiber,為什么不需要vue fiber?
提到react fiber,大部分人都知道這是一個(gè)react新特性,看過(guò)一些網(wǎng)上的文章,大概能說(shuō)出“纖程”“一種新的數(shù)據(jù)結(jié)構(gòu)”“更新時(shí)調(diào)度機(jī)制”等關(guān)鍵詞。
但如果被問:
有react fiber,為什么不需要 vue fiber呢; 之前遞歸遍歷虛擬dom樹被打斷就得從頭開始,為什么有了react fiber就能斷點(diǎn)恢復(fù)呢;
本文將從兩個(gè)框架的響應(yīng)式設(shè)計(jì)為切入口講清這兩個(gè)問題,不涉及晦澀源碼,不管有沒有使用過(guò)react,閱讀都不會(huì)有太大阻力。
什么是響應(yīng)式
無(wú)論你常用的是 react,還是 vue,“響應(yīng)式更新”這個(gè)詞肯定都不陌生。
響應(yīng)式,直觀來(lái)說(shuō)就是視圖會(huì)自動(dòng)更新。如果一開始接觸前端就直接上手框架,會(huì)覺得這是理所當(dāng)然的,但在“響應(yīng)式框架”出世之前,實(shí)現(xiàn)這一功能是很麻煩的。
下面我將做一個(gè)時(shí)間顯示器,用原生 js、react、vue 分別實(shí)現(xiàn):

原生js:
想讓屏幕上內(nèi)容變化,必須需要先找到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 = `現(xiàn)在是:${Util.getTime()}`
greetDom.innerText = Util.getGreet()
}, 1000);
</script>
有了響應(yīng)式框架,一切變得簡(jiǎn)單了
react:
對(duì)內(nèi)容做修改,只需要調(diào)用setState去修改數(shù)據(jù),之后頁(yè)面便會(huì)重新渲染。
<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>現(xiàn)在是:{time}</div>
</div>
)
}
ReactDOM.render(<Clock/>,document.getElementById('root'))
</script>
</body>
vue:
我們一樣不用關(guān)注dom,在修改數(shù)據(jù)時(shí),直接this.state=xxx修改,頁(yè)面就會(huì)展示最新的數(shù)據(jù)。
<body>
<div id="root">
<div>{{greet}}</div>
<div>現(xiàn)在是:{{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的響應(yīng)式原理
上文提到修改數(shù)據(jù)時(shí),react需要調(diào)用setState方法,而vue直接修改變量就行??雌饋?lái)只是兩個(gè)框架的用法不同罷了,但響應(yīng)式原理正在于此。
從底層實(shí)現(xiàn)來(lái)看修改數(shù)據(jù):在react中,組件的狀態(tài)是不能被修改的,setState沒有修改原來(lái)那塊內(nèi)存中的變量,而是去新開辟一塊內(nèi)存;而vue則是直接修改保存狀態(tài)的那塊原始內(nèi)存。
所以經(jīng)常能看到react相關(guān)的文章里經(jīng)常會(huì)出現(xiàn)一個(gè)詞"immutable",翻譯過(guò)來(lái)就是不可變的。
數(shù)據(jù)修改了,接下來(lái)要解決視圖的更新:react中,調(diào)用setState方法后,會(huì)自頂向下重新渲染組件,自頂向下的含義是,該組件以及它的子組件全部需要渲染;而vue使用Object.defineProperty(vue@3遷移到了Proxy)對(duì)數(shù)據(jù)的設(shè)置(setter)和獲?。?code style="font-size: 14px;overflow-wrap: break-word;padding: 2px 4px;border-radius: 4px;margin-right: 2px;margin-left: 2px;background-color: rgba(27, 31, 35, 0.05);font-family: "Operator Mono", Consolas, Monaco, Menlo, monospace;word-break: break-all;color: rgb(60, 112, 198);">getter)做了劫持,也就是說(shuō),vue能準(zhǔn)確知道視圖模版中哪一塊用到了這個(gè)數(shù)據(jù),并且在這個(gè)數(shù)據(jù)修改時(shí),告訴這個(gè)視圖,你需要重新渲染了。
所以當(dāng)一個(gè)數(shù)據(jù)改變,react的組件渲染是很消耗性能的——父組件的狀態(tài)更新了,所有的子組件得跟著一起渲染,它不能像vue一樣,精確到當(dāng)前組件的粒度。
為了佐證,我分別用react和vue寫了一個(gè)demo,功能很簡(jiǎn)單:父組件嵌套子組件,點(diǎn)擊父組件的按鈕會(huì)修改父組件的狀態(tài),點(diǎn)擊子組件的按鈕會(huì)修改子組件的狀態(tài)。
為了更好的對(duì)比,直觀展示渲染階段,沒用使用更流行的react函數(shù)式組件,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時(shí)的效果,修改父組件的狀態(tài),父子組件都會(huì)重新渲染:點(diǎn)擊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時(shí)的效果,無(wú)論是修改哪個(gè)狀態(tài),組件都只重新渲染最小顆粒:點(diǎn)擊change Father state,只打印Father:render,不會(huì)打印child:render。
后臺(tái)回復(fù)【父子組件demo】獲取上述兩個(gè)sandbox在線鏈接
不同響應(yīng)式原理的影響
首先需要強(qiáng)調(diào)的是,上文提到的“渲染”“render”“更新“都不是指瀏覽器真正渲染出視圖。而是框架在javascript層面上,調(diào)用自身實(shí)現(xiàn)的render方法,生成一個(gè)普通的對(duì)象,這個(gè)對(duì)象保存了真實(shí)dom的屬性,也就是常說(shuō)的虛擬dom。本文會(huì)用組件渲染和頁(yè)面渲染對(duì)兩者做區(qū)分。
每次的視圖更新流程是這樣的:
組件渲染生成一棵新的虛擬dom樹; 新舊虛擬dom樹對(duì)比,找出變動(dòng)的部分;(也就是常說(shuō)的diff算法) 為真正改變的部分創(chuàng)建真實(shí)dom,把他們掛載到文檔,實(shí)現(xiàn)頁(yè)面重渲染;
由于react和vue的響應(yīng)式實(shí)現(xiàn)原理不同,數(shù)據(jù)更新時(shí),第一步中react組件會(huì)渲染出一棵更大的虛擬dom樹。

fiber是什么
上面說(shuō)了這么多,都是為了方便講清楚為什么需要react fiber:在數(shù)據(jù)更新時(shí),react生成了一棵更大的虛擬dom樹,給第二步的diff帶來(lái)了很大壓力——我們想找到真正變化的部分,這需要花費(fèi)更長(zhǎng)的時(shí)間。js占據(jù)主線程去做比較,渲染線程便無(wú)法做其他工作,用戶的交互得不到響應(yīng),所以便出現(xiàn)了react fiber。
react fiber沒法讓比較的時(shí)間縮短,但它使得diff的過(guò)程被分成一小段一小段的,因?yàn)樗辛恕氨4婀ぷ鬟M(jìn)度”的能力。js會(huì)比較一部分虛擬dom,然后讓渡主線程,給瀏覽器去做其他工作,然后繼續(xù)比較,依次往復(fù),等到最后比較完成,一次性更新到視圖上。
fiber是一種新的數(shù)據(jù)結(jié)構(gòu)
上文提到了,react fiber使得diff階段有了被保存工作進(jìn)度的能力,這部分會(huì)講清楚為什么。
我們要找到前后狀態(tài)變化的部分,必須把所有節(jié)點(diǎn)遍歷。

在老的架構(gòu)中,節(jié)點(diǎn)以樹的形式被組織起來(lái):每個(gè)節(jié)點(diǎn)上有多個(gè)指針指向子節(jié)點(diǎn)。要找到兩棵樹的變化部分,最容易想到的辦法就是深度優(yōu)先遍歷,規(guī)則如下:
從根節(jié)點(diǎn)開始,依次遍歷該節(jié)點(diǎn)的所有子節(jié)點(diǎn); 當(dāng)一個(gè)節(jié)點(diǎn)的所有子節(jié)點(diǎn)遍歷完成,才認(rèn)為該節(jié)點(diǎn)遍歷完成;
如果你系統(tǒng)學(xué)習(xí)過(guò)數(shù)據(jù)結(jié)構(gòu),應(yīng)該很快就能反應(yīng)過(guò)來(lái),這不過(guò)是深度優(yōu)先遍歷的后續(xù)遍歷。根據(jù)這個(gè)規(guī)則,在圖中標(biāo)出了節(jié)點(diǎn)完成遍歷的順序。
這種遍歷有一個(gè)特點(diǎn),必須一次性完成。假設(shè)遍歷發(fā)生了中斷,雖然可以保留當(dāng)下進(jìn)行中節(jié)點(diǎn)的索引,下次繼續(xù)時(shí),我們的確可以繼續(xù)遍歷該節(jié)點(diǎn)下面的所有子節(jié)點(diǎn),但是沒有辦法找到其父節(jié)點(diǎn)——因?yàn)槊總€(gè)節(jié)點(diǎn)只有其子節(jié)點(diǎn)的指向。斷點(diǎn)沒有辦法恢復(fù),只能從頭再來(lái)一遍。
以該樹為例:

在遍歷到節(jié)點(diǎn)2時(shí)發(fā)生了中斷,我們保存對(duì)節(jié)點(diǎn)2的索引,下次恢復(fù)時(shí)可以把它下面的3、4節(jié)點(diǎn)遍歷到,但是卻無(wú)法找回5、6、7、8節(jié)點(diǎn)。

在新的架構(gòu)中,每個(gè)節(jié)點(diǎn)有三個(gè)指針:分別指向第一個(gè)子節(jié)點(diǎn)、下一個(gè)兄弟節(jié)點(diǎn)、父節(jié)點(diǎn)。這種數(shù)據(jù)結(jié)構(gòu)就是fiber,它的遍歷規(guī)則如下:
從根節(jié)點(diǎn)開始,依次遍歷該節(jié)點(diǎn)的子節(jié)點(diǎn)、兄弟節(jié)點(diǎn),如果兩者都遍歷了,則回到它的父節(jié)點(diǎn); 當(dāng)一個(gè)節(jié)點(diǎn)的所有子節(jié)點(diǎn)遍歷完成,才認(rèn)為該節(jié)點(diǎn)遍歷完成;
根據(jù)這個(gè)規(guī)則,同樣在圖中標(biāo)出了節(jié)點(diǎn)遍歷完成的順序。跟樹結(jié)構(gòu)對(duì)比會(huì)發(fā)現(xiàn),雖然數(shù)據(jù)結(jié)構(gòu)不同,但是節(jié)點(diǎn)的遍歷開始和完成順序一模一樣。不同的是,當(dāng)遍歷發(fā)生中斷時(shí),只要保留下當(dāng)前節(jié)點(diǎn)的索引,斷點(diǎn)是可以恢復(fù)的——因?yàn)槊總€(gè)節(jié)點(diǎn)都保持著對(duì)其父節(jié)點(diǎn)的索引。

同樣在遍歷到節(jié)點(diǎn)2時(shí)中斷,fiber結(jié)構(gòu)使得剩下的所有節(jié)點(diǎn)依舊能全部被走到。
這就是react fiber的渲染可以被中斷的原因。樹和fiber雖然看起來(lái)很像,但本質(zhì)上來(lái)說(shuō),一個(gè)是樹,一個(gè)是鏈表。
fiber是纖程
這種數(shù)據(jù)結(jié)構(gòu)之所以被叫做fiber,因?yàn)閒iber的翻譯是纖程,它被認(rèn)為是協(xié)程的一種實(shí)現(xiàn)形式。協(xié)程是比線程更小的調(diào)度單位:它的開啟、暫停可以被程序員所控制。具體來(lái)說(shuō),react fiber是通過(guò)requestIdleCallback這個(gè)api去控制的組件渲染的“進(jìn)度條”。
requesetIdleCallback是一個(gè)屬于宏任務(wù)的回調(diào),就像setTimeout一樣。不同的是,setTimeout的執(zhí)行時(shí)機(jī)由我們傳入的回調(diào)時(shí)間去控制,requesetIdleCallback是受屏幕的刷新率去控制。本文不對(duì)這部分做深入探討,只需要知道它每隔16ms會(huì)被調(diào)用一次,它的回調(diào)函數(shù)可以獲取本次可以執(zhí)行的時(shí)間,每一個(gè)16ms除了requesetIdleCallback的回調(diào)之外,還有其他工作,所以能使用的時(shí)間是不確定的,但只要時(shí)間到了,就會(huì)停下節(jié)點(diǎn)的遍歷。
使用方法如下:
const workLoop = (deadLine) => {
let shouldYield = false;// 是否該讓出線程
while(!shouldYield){
console.log('working')
// 遍歷節(jié)點(diǎn)等工作
shouldYield = deadLine.timeRemaining()<1;
}
requestIdleCallback(workLoop)
}
requestIdleCallback(workLoop);
requestIdleCallback的回調(diào)函數(shù)可以通過(guò)傳入的參數(shù)deadLine.timeRemaining()檢查當(dāng)下還有多少時(shí)間供自己使用。上面的demo也是react fiber工作的偽代碼。
但由于兼容性不好,加上該回調(diào)函數(shù)被調(diào)用的頻率太低,react實(shí)際使用的是一個(gè)polyfill(自己實(shí)現(xiàn)的api),而不是requestIdleCallback。
現(xiàn)在,可以總結(jié)一下了:React Fiber是React 16提出的一種更新機(jī)制,使用鏈表取代了樹,將虛擬dom連接,使得組件更新的流程可以被中斷恢復(fù);它把組件渲染的工作分片,到時(shí)會(huì)主動(dòng)讓出渲染主線程。
react fiber帶來(lái)的變化
首先放一張?jiān)谏鐓^(qū)廣為流傳的對(duì)比圖,分別是用react 15和16實(shí)現(xiàn)的。這是一個(gè)寬度變化的三角形,每個(gè)小圓形中間的數(shù)字會(huì)隨時(shí)間改變,除此之外,將鼠標(biāo)懸停,小圓點(diǎn)的顏色會(huì)發(fā)生變化。

后臺(tái)回復(fù)【三角形案例】獲取在線連接
實(shí)操一下,可以發(fā)現(xiàn)兩個(gè)特點(diǎn):
使用新架構(gòu)后,動(dòng)畫變得流暢,寬度的變化不會(huì)卡頓; 使用新架構(gòu)后,用戶響應(yīng)變快,鼠標(biāo)懸停時(shí)顏色變化更快;
看到到這里先稍微停一下,這兩點(diǎn)都是fiber帶給我們的嗎——用戶響應(yīng)變快是可以理解的,但使用react fiber能帶來(lái)渲染的加速嗎?
動(dòng)畫變流暢的根本原因,一定是一秒內(nèi)可以獲得更多動(dòng)畫幀。但是當(dāng)我們使用react fiber時(shí),并沒有減少更新所需要的總時(shí)間。
為了方便理解,我把刷新時(shí)的狀態(tài)做了一張圖:

上面是使用舊的react時(shí),獲得每一幀的時(shí)間點(diǎn),下面是使用fiber架構(gòu)時(shí),獲得每一幀的時(shí)間點(diǎn),因?yàn)榻M件渲染被分片,完成一幀更新的時(shí)間點(diǎn)反而被推后了,我們把一些時(shí)間片去處理用戶響應(yīng)了。
這里要注意,不會(huì)出現(xiàn)“一次組件渲染沒有完成,頁(yè)面部分渲染更新”的情況,react會(huì)保證每次更新都是完整的。
但頁(yè)面的動(dòng)畫確實(shí)變得流暢了,這是為什么呢?
我把該項(xiàng)目的代碼倉(cāng)庫(kù) down下來(lái),看了一下它的動(dòng)畫實(shí)現(xiàn):組件動(dòng)畫效果并不是直接修改width獲得的,而是使用的transform:scale屬性搭配3D變換。如果你聽說(shuō)過(guò)硬件加速,大概知道為什么了:這樣設(shè)置頁(yè)面的重新渲染不依賴上圖中的渲染主線程,而是在GPU中直接完成。也就是說(shuō),這個(gè)渲染主線程線程只用保證有一些時(shí)間片去響應(yīng)用戶交互就可以了。
-<SierpinskiTriangle x={0} y={0} s={1000}>
+<SierpinskiTriangle x={0} y={0} s={1000*t}>
{this.state.seconds}
</SierpinskiTriangle>
后臺(tái)回復(fù)【三角形倉(cāng)庫(kù)】獲取github連接
修改一下項(xiàng)目代碼中152行,把圖形的變化改為寬度width修改,會(huì)發(fā)現(xiàn)即使用react fiber,動(dòng)畫也會(huì)變得相當(dāng)卡頓,所以這里的流暢主要是CSS動(dòng)畫的功勞。(內(nèi)存不大的電腦謹(jǐn)慎嘗試,瀏覽器會(huì)卡死)
react不如vue?
我們現(xiàn)在已經(jīng)知道了react fiber是在彌補(bǔ)更新時(shí)“無(wú)腦”刷新,不夠精確帶來(lái)的缺陷。這是不是能說(shuō)明react性能更差呢?
并不是。孰優(yōu)孰劣是一個(gè)很有爭(zhēng)議的話題,在此不做評(píng)價(jià)。因?yàn)関ue實(shí)現(xiàn)精準(zhǔn)更新也是有代價(jià)的,一方面是需要給每一個(gè)組件配置一個(gè)“監(jiān)視器”,管理著視圖的依賴收集和數(shù)據(jù)更新時(shí)的發(fā)布通知,這對(duì)性能同樣是有消耗的;另一方面vue能實(shí)現(xiàn)依賴收集得益于它的模版語(yǔ)法,實(shí)現(xiàn)靜態(tài)編譯,這是使用更靈活的JSX語(yǔ)法的react做不到的。
在react fiber出現(xiàn)之前,react也提供了PureComponent、shouldComponentUpdate、useMemo,useCallback等方法給我們,來(lái)聲明哪些是不需要連帶更新子組件。
結(jié)語(yǔ)
回到開頭的幾個(gè)問題,答案不難在文中找到:
react因?yàn)橄忍斓牟蛔恪獰o(wú)法精確更新,所以需要react fiber把組件渲染工作切片;而vue基于數(shù)據(jù)劫持,更新粒度很小,沒有這個(gè)壓力; react fiber這種數(shù)據(jù)結(jié)構(gòu)使得節(jié)點(diǎn)可以回溯到其父節(jié)點(diǎn),只要保留下中斷的節(jié)點(diǎn)索引,就可以恢復(fù)之前的工作進(jìn)度;
如果這篇文章對(duì)你有幫助,給我點(diǎn)個(gè)贊唄~這對(duì)我很重要
(點(diǎn)個(gè)在看更好!>3<)
