阿里三面:靈魂一擊——有react fiber,為什麼不需要vue fiber呢?

前端私教年年發表於2022-03-23

提到react fiber,大部分人都知道這是一個react新特性,看過一些網上的文章,大概能說出“纖程”“一種新的資料結構”“更新時排程機制”等關鍵詞。

但如果被問:

  1. 有react fiber,為什麼不需要 vue fiber呢;
  2. 之前遞迴遍歷虛擬dom樹被打斷就得從頭開始,為什麼有了react fiber就能斷點恢復呢;

本文將從兩個框架的響應式設計為切入口講清這兩個問題,不涉及晦澀原始碼,不管有沒有使用過react,閱讀都不會有太大阻力。

什麼是響應式

無論你常用的是 react,還是 vue,“響應式更新”這個詞肯定都不陌生。

響應式,直觀來說就是檢視會自動更新。如果一開始接觸前端就直接上手框架,會覺得這是理所當然的,但在“響應式框架”出世之前,實現這一功能是很麻煩的。

下面我將做一個時間顯示器,用原生 js、react、vue 分別實現:

  1. 原生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>

有了響應式框架,一切變得簡單了

  1. 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>
  1. 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(vue@3遷移到了Proxy)對資料的設定(setter)和獲取(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
(戳這裡試試線上demo)

 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

(戳這裡試試線上demo)

不同響應式原理的影響

首先需要強調的是,上文提到的“渲染”“render”“更新“都不是指瀏覽器真正渲染出檢視。而是框架在javascript層面上,呼叫自身實現的render方法,生成一個普通的物件,這個物件儲存了真實dom的屬性,也就是常說的虛擬dom。本文會用元件渲染和頁面渲染對兩者做區分。

每次的檢視更新流程是這樣的:

  1. 元件渲染生成一棵新的虛擬dom樹;
  2. 新舊虛擬dom樹對比,找出變動的部分;(也就是常說的diff演算法)
  3. 為真正改變的部分建立真實dom,把他們掛載到文件,實現頁面重渲染;

由於react和vue的響應式實現原理不同,資料更新時,第一步中react元件會渲染出一棵更大的虛擬dom樹。

fiber是什麼

上面說了這麼多,都是為了方便講清楚為什麼需要react fiber:在資料更新時,react生成了一棵更大的虛擬dom樹,給第二步的diff帶來了很大壓力——我們想找到真正變化的部分,這需要花費更長的時間。js佔據主執行緒去做比較,渲染執行緒便無法做其他工作,使用者的互動得不到響應,所以便出現了react fiber。

react fiber沒法讓比較的時間縮短,但它使得diff的過程被分成一小段一小段的,因為它有了“儲存工作進度”的能力。js會比較一部分虛擬dom,然後讓渡主執行緒,給瀏覽器去做其他工作,然後繼續比較,依次往復,等到最後比較完成,一次性更新到檢視上。

fiber是一種新的資料結構

上文提到了,react fiber使得diff階段有了被儲存工作進度的能力,這部分會講清楚為什麼。

我們要找到前後狀態變化的部分,必須把所有節點遍歷。

在老的架構中,節點以樹的形式被組織起來:每個節點上有多個指標指向子節點。要找到兩棵樹的變化部分,最容易想到的辦法就是深度優先遍歷,規則如下:

  1. 從根節點開始,依次遍歷該節點的所有子節點;
  2. 當一個節點的所有子節點遍歷完成,才認為該節點遍歷完成;

如果你係統學習過資料結構,應該很快就能反應過來,這不過是深度優先遍歷的後續遍歷。根據這個規則,在圖中標出了節點完成遍歷的順序。

這種遍歷有一個特點,必須一次性完成。假設遍歷發生了中斷,雖然可以保留當下進行中節點的索引,下次繼續時,我們的確可以繼續遍歷該節點下面的所有子節點,但是沒有辦法找到其父節點——因為每個節點只有其子節點的指向。斷點沒有辦法恢復,只能從頭再來一遍。

以該樹為例:

在遍歷到節點2時發生了中斷,我們儲存對節點2的索引,下次恢復時可以把它下面的3、4節點遍歷到,但是卻無法找回5、6、7、8節點。

在新的架構中,每個節點有三個指標:分別指向第一個子節點、下一個兄弟節點、父節點。這種資料結構就是fiber,它的遍歷規則如下:

  1. 從根節點開始,依次遍歷該節點的子節點、兄弟節點,如果兩者都遍歷了,則回到它的父節點;
  2. 當一個節點的所有子節點遍歷完成,才認為該節點遍歷完成;

根據這個規則,同樣在圖中標出了節點遍歷完成的順序。跟樹結構對比會發現,雖然資料結構不同,但是節點的遍歷開始和完成順序一模一樣。不同的是,當遍歷發生中斷時,只要保留下當前節點的索引,斷點是可以恢復的——因為每個節點都保持著對其父節點的索引。

同樣在遍歷到節點2時中斷,fiber結構使得剩下的所有節點依舊能全部被走到。

這就是react fiber的渲染可以被中斷的原因。樹和fiber雖然看起來很像,但本質上來說,一個是樹,一個是連結串列。

fiber是纖程

這種資料結構之所以被叫做fiber,因為fiber的翻譯是纖程,它被認為是協程的一種實現形式。協程是比執行緒更小的排程單位:它的開啟、暫停可以被程式設計師所控制。具體來說,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實現的。這是一個寬度變化的三角形,每個小圓形中間的數字會隨時間改變,除此之外,將滑鼠懸停,小圓點的顏色會發生變化。

(戳這裡是react15-stack線上地址|這裡是react16-fiber

實操一下,可以發現兩個特點:

  1. 使用新架構後,動畫變得流暢,寬度的變化不會卡頓;
  2. 使用新架構後,使用者響應變快,滑鼠懸停時顏色變化更快;

看到到這裡先稍微停一下,這兩點都是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等方法給我們,來宣告哪些是不需要連帶更新子元件。

結語

回到開頭的幾個問題,答案不難在文中找到:

  1. react因為先天的不足——無法精確更新,所以需要react fiber把元件渲染工作切片;而vue基於資料劫持,更新粒度很小,沒有這個壓力;
  2. react fiber這種資料結構使得節點可以回溯到其父節點,只要保留下中斷的節點索引,就可以恢復之前的工作進度;

如果這篇文章對你有幫助,給我點個讚唄~這對我很重要


(點個在看更好!>3<)

相關文章