有 react fiber,爲什麼不需要 vue fiber?

提到 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

 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】獲取上述兩個 sandbox 在線鏈接

不同響應式原理的影響

首先需要強調的是,上文提到的 “渲染”“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 實現的。這是一個寬度變化的三角形,每個小圓形中間的數字會隨時間改變,除此之外,將鼠標懸停,小圓點的顏色會發生變化。

後臺回覆【三角形案例】獲取在線連接

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

  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>

後臺回覆【三角形倉庫】獲取 github 連接

修改一下項目代碼中 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 這種數據結構使得節點可以回溯到其父節點,只要保留下中斷的節點索引,就可以恢復之前的工作進度;

前端私教年年 鵝廠前端,致力於分享說人話的技術文章。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/po503V7TTezSVFHqsgoZVw