Preact(React)核心原理詳解
原創(chuàng): 寶丁 玄說前端
本文作者:字節(jié)跳動 - 寶丁
- 一、Preact 是什么
- 二、Preact 和 React 的區(qū)別有哪些?
- 三、Preact 是怎么工作的
- 四、結合實際組件了解整體渲染流程
- 五、Preact Hooks
- 結束語
- 2.1 事件系統(tǒng)
- 2.2 更符合 DOM 規(guī)范的描述
- 3.3.1 Diff children
- 3.3.2 Diff
- 3.3.3 Diff props
- 3.1 JSX
- 3.2 Virtual DOM
- 3.3 Preact 的 Virtual DOM 的 Diff 算法
- 4.1 初次渲染
- 4.2 執(zhí)行 setState
- 5.3.1 useEffect 和 useLayoutEffect
- 5.2.1 useReducer
- 5.2.2 useState
- 5.1.1 useMemo
- 5.1.2 useCallback
- 5.1.3 useRef
- 5.1 MemoHook
- 5.2 ReducerHook
- 5.3 EffectHook
在前端界,React 一定是我們耳熟能詳?shù)那岸碎_發(fā)框架之一,它的出現(xiàn)可以說是帶給了我們?nèi)?Web 開發(fā)體驗,其中也帶來了許多新的概念:JSX、virtual-dom、組件化、合成事件等。當我們想從源碼層面去研究它的原理時,由于 React 的源碼的龐大和晦澀難懂,這也會變得異常困難。但是在愛好“造輪子”的前端界,我們會發(fā)現(xiàn)一些和 React 有著近乎相同的框架,本文的主人公 Preact 也是其一,但是它相對簡練的代碼,使得我們更好地去學習和研究它的原理。本文將從以下幾個方面介紹:
Preact 是什么?
Preact 和 React 的區(qū)別有哪些?
Preact是怎么工作的
JSX
Virtual Dom
Preact 的 Virtual DOM Diff 算法
Preact Hooks 的實現(xiàn)
一個組件的生命周期
一、Preact 是什么
簡單而言,Preact 是 React 的 3KB 輕量級替代方案,它擁有著和 React 一樣的 API。有同學或許會問,Preact 中的 P 的含義是什么,根據(jù) Preact 的作者表述的是 performance 的含義,這也是 Preact 框架的目標之一。
我們先來看用 Preact 編寫的幾個例子:

圖 1

圖 2
大家第一眼看上去,和 React 的寫法基本上一致的,如果仔細的看,大家可能會幾個疑問:
- h 進行了變量的聲明,但是沒有使用,這個有什么意義?可以去掉么?
- 表單里面使用的是 onInput 方法,而不是在 React 中寫的 onChange 方法,這是為什么?
在這里我先不直接告訴大家答案,這些疑問會在下面的內(nèi)容中一一為大家解答。
二、Preact 和 React 的區(qū)別有哪些?
Preact 號稱打包后的體積只有 3KB,自然相比 React 而言,在某些方面進行了精簡,并且它本身的定位也不是準備從新實現(xiàn)一個 React,所以兩者之間肯定是存在一些區(qū)別。
我們在這里主要介紹兩者最主要的區(qū)別:
- 事件系統(tǒng)
- 更符合 Dom 規(guī)范的描述
2.1 事件系統(tǒng)
通過一個例子,大家或許就能知道兩者的區(qū)別。

圖 3
在 React 內(nèi)部,其自身實現(xiàn)了一套事件合成系統(tǒng),所以我們一般在 React 的表單組件中使用的都是 onChange 方法來進行組件值的更新,而在 Preact 內(nèi)部,沒有事件合成系統(tǒng),它直接使用的是由瀏覽器原生提供的事件系統(tǒng),這也是為什么 Preact 在表單里面使用的是 onInput 方法,而不是在 React 中寫的 onChange 方法。這也是它體積更小的直接原因之一。
2.2 更符合 DOM 規(guī)范的描述
在 React 中我們想描述一個 DOM 的類名,必須要使用 className, 而在 Preact 中,不僅可以使用 className 來描述,也可以直接使用 class 來描述 DOM 的類名,這也使得 Preact 更接近原生 DOM 規(guī)范的描述。
當然除了這些,Preact 和 React 直接還有一些差別,由于它不是本文的重點,在這里我們就不一一展開介紹,大家可以直接通過 Preact 官網(wǎng)來進一步了解。
三、Preact 是怎么工作的
在本節(jié),我們將開始介紹 Preact 的內(nèi)部工作流程,希望閱讀本節(jié)過后,大家對 Preact 會有進一步的認識。
3.1 JSX
在介紹 JSX 之前,我們先想一下如何在 JS 中來描述 DOM 結構,很多同學可能會想,可以通過瀏覽器的操作 DOM 的 API 來完成,或者封裝成一個工廠函數(shù)來進行接收一定的輸入,輸出就是相應的 DOM。

圖 4
但是如果每次需要讓我們通過這么復雜的方式來進行 DOM 結構的描述,想必 React 的性能再優(yōu)秀,也能進一步的進行推廣。
這個時候,如果換一種圖 5 這樣的的方式,是不是大家就很熟悉?

圖 5
沒錯,左側其實就是我們平時寫的 JSX 語法,經(jīng)過 babel 或者其他的插件轉換之后變成我們上面所說的函數(shù)式的描述,然后再經(jīng)過一系列的處理,變成我們所熟悉的原生 DOM 的結構,這也是 JSX 產(chǎn)生的本質原因。
綜合來看,其實 JSX 的本質就是 JS 的擴展,它允許你用類似 HTML/XML 的結構,進而編譯成類似圖 6 的一個函數(shù)調用。

圖 6
這個時候,我們就不得不提 babel 的強大之處了,原來從 JSX轉化到函數(shù)調用這個階段是由 React 團隊提供的,后面因為 babel 做的更好,更強大,就逐漸演變成了 @babel/plugin-transform-react-JSX 這個核心插件了,那么這個時候我們也可以揭開上文中提到的 h 函數(shù)的神秘面紗,正是因為在 Preact 中,JSX 的語法會通過 babel 這個插件轉換成一個名稱為 h 的工廠函數(shù),類似于在 React 中的 React.createElement 的作用,所以我們才需要去聲明 h 函數(shù),雖然我們在實際開發(fā)環(huán)境上用不到,但是它的作用是體現(xiàn)在 babel 轉換后的代碼中的,大家也可以通過這個鏈接來體驗 babel 的強大所在。
3.2 Virtual DOM
在本節(jié)當中,我們將會介紹 Preact 中的 Virtual DOM 是什么?那么它和我們前面說的 JSX 之間有什么關聯(lián)呢?
我們前面提到了 h 函數(shù)是一個工廠函數(shù),輸入我們知道了,是一些描述 DOM 結構的基本信息,那么它的輸出是什么呢?我們可以通過下圖來揭曉謎底。

圖 7
從圖 7 我們可以看出,其實 h 函數(shù)的輸出是一個特殊類型的數(shù)據(jù)結構,而 Virtual DOM 本質上就是一種用來描述 DOM 結構的數(shù)據(jù)結構,所以 h 函數(shù)的輸出其實就是我們常說的 Virtual DOM。
不管在 React 中還是在 Preact 中,最核心的都是 Virtual DOM 的 Diff 算法,怎么把最新的數(shù)據(jù)所驅動的 DOM 結構表現(xiàn)在頁面當中,這個也是大家最關心的環(huán)節(jié)。
3.3 Preact 的 Virtual DOM 的 Diff 算法
在 Preact 中,Virtual DOM 的 Diff 算法可以拆解為三大塊。
- Diff children
- Diff 這里的 type 指的是組件的類型,主要分成 component、Fragment 和 DOM node 三種。
- Diff props
接下來我們會分別仔細的介紹這三塊。
3.3.1 Diff children

圖 8
在對 children 主要會有兩個流程,首先我們先看左側的流程圖,在這個 Diff 階段,我們會先對新的 children 進行遍歷,如果發(fā)現(xiàn)新的 child 可以在老的 children 中找到相同的 key,那么會執(zhí)行 diff <type> 這個階段,如果沒找到相同的 key,會去看是不是相同的類型,比如是不是相同的 DOM node 的類型,或者是相同的構造函數(shù)等,找到了的話 也會執(zhí)行 diff <type> 這個階段,如果沒有找到,會把這個老的 child 放到一個數(shù)組當中。
當對新的 children 遍歷完畢之后,我們會執(zhí)行下一個流程,也就是右側的流程圖,會進行遍歷沒有使用的 old child 數(shù)組,將它們一一unmout 掉,這個時候也會執(zhí)行相應的生命周期。當這個 child 是一個父組件的話,會對它的 children 重復這個流程,直到全部 unmount。
在這個階段,我們也可以得到為什么寫 key 是一個非常小但是卻非常有用的性能優(yōu)化手段,因為在一定的程度上它會有效地減少 Diff 過程中所帶來的性能損耗。
3.3.2 Diff

圖 9
Diff <type> 環(huán)節(jié)可以說是在整個 Diff 算法中最重要的一個環(huán)節(jié),也是最復雜的一個環(huán)節(jié)。手首先我們會進行新的 vnode 判斷它所屬于的類型,目前來看,主要包括: Fragment、Component 和 DOM node,其中當判斷 vnode 的組件是一個空函數(shù)的時候表示的就是 Fragment,而為非空函數(shù)的就是 Component 類型。然后根據(jù)當前的 vnode 所屬的類型進行下一步的處理。
當 type 為 Fragment 的時候,就直接會將 Fragment 內(nèi)部的 children 進入到上文中提到的 Diff children 階段。
當 type 為 component 時,我們會先判斷當前的 vnode 所代表的組件是否已經(jīng)存在過,如果沒有存在則執(zhí)行 create 操作,同時也會執(zhí)行相對應的生命周期,如果已經(jīng)存在對應的組件,那么則會執(zhí)行 update 操作,并且執(zhí)行相對應的生命周期函數(shù),在這里我們可以強調一下 shouldComponentUpdate 生命周期函數(shù),當它返回 false 的時候,那么我們就不會再去執(zhí)行下一步要執(zhí)行的 render 函數(shù),只有當該生命周期函數(shù)不存在或者返回非 false 的時候,我們會繼續(xù)執(zhí)行 render 函數(shù),然后繼續(xù)走該 Diff <type> 階段。
當 type 為 DOM node 時,我們首先會判斷新老 vnode 是否為同一 node type,如果不同,則會創(chuàng)建新的 DOM 并且代替,如果相同,則會進行更新操作。
回過頭來看 Diff <type> 環(huán)節(jié),并且結合我們平時寫組件的習慣,可以發(fā)現(xiàn),最后我們寫的組件都是原生的 DOM 結構,所以最后都會進入到 Diff DOM node 這一流程中,也是在這一流程中,真正的去創(chuàng)建和更新 DOM。
3.3.3 Diff props

圖 10
我相信,大家可能會有點奇怪這一個階段是做什么的?在上文中我們提到了當兩個 DOM node 節(jié)點類型相同的時候,會執(zhí)行更新操作,那么該環(huán)節(jié)主要是為這個更新操作而服務。
它的原理很簡單:先循環(huán)老的 DOM 的 props,如果它不在新的 DOM 上,那么就會將它設為空,然后循環(huán)新的 props,然后和老的 props 中相同的 prop 去做比較,然后設置最新的 prop 的值。
到這里,我們整個的 Virtual DOM 過程也就完成了,Preact 內(nèi)部的工作原理也基本上介紹完了,但是大家可能還比較難和一個真實的組件來相關聯(lián),接下來我們通過一個真實的組件,來將上面的過程進行串聯(lián),加深大家對它的理解。
四、結合實際組件了解整體渲染流程
首先,我們先編寫一個如下圖的 Clock 組件:

圖 11
接下來我們會通過兩個階段來介紹:
- 初次渲染
- 執(zhí)行 setState
為了方便介紹,我在畫了一個流程圖,大家可以搭配圖 12 的流程圖(點擊這里獲取高清大圖)和文字來看,方便大家更容易理解。

圖 12
4.1 初次渲染
- 入口函數(shù)為
render(<Clock />, document.body)。 - 將 JSX 語法轉化成 h 函數(shù)的形式之后,也就是 createElement 函數(shù)來創(chuàng)建一個用來描述子組件為 Clock 組件的 vitrual node(下文簡稱為 vnode),類似于這種結構
{type: Fragment, children: [Clock], props: null }。 - 將該 vnode,用數(shù)組包裹起來,然后送入到 Diff children 階段
- 當 Diff children 階段結束之后,會執(zhí)行 commitRoot 方法來執(zhí)行掛載組件的 componentDidMount 方法,內(nèi)部主要是通過 promise 或者 setTimeout 來做有異步的處理。
- 接下來我們主要來進行描述 Diff children 的流程。
- 因為是第一次渲染,所以我們都沒有老的 vnode 也就沒有所謂的是否具有相同 key 或者相同 type 的新老 vnode。
- 直接進入到 diff(newChild, oldChild) 這一階段。
- 判斷我們的 vnode 的 type 是一個 component, 并且是一個新的組件,這個時候我們創(chuàng)建新組件,并且執(zhí)行對應的生命周期,然后調用我們的 render 函數(shù)。
- 因為 render 函數(shù)的返回值其實依然是一個 vnode,所以會繼續(xù)流轉到 diff(newChild, oldChild) 這一個階段,直到判斷 type 是 DOM node 時,會執(zhí)行 DOM 的操作變化。
4.2 執(zhí)行 setState
- 我們可以從流程圖中看到,其實 setState 本質上的操作,會將它所在的 vnode 送入到 diff(newChild, oldChild) 中,而 newChild 和 oldChild 的主要區(qū)別其實就是 state 的變化。
- 因為 Clock 組件是一個 component 類型的 vnode,所以我們會繼續(xù)判斷它是不是新組件,很顯然已經(jīng)不是了,于是會執(zhí)行對應的生命周期,如果沒有 shouldComponentUpdate 生命周期函數(shù)或者返回了 true,那么我們會繼續(xù)執(zhí)行 render 函數(shù),不然我們會停止組件的渲染。
- 這個時候 render 函數(shù)中,已經(jīng)有了我們最新的 state了,那么對應的接下來會繼續(xù)走 diff(newChild, oldChild) 流程,直到將更改的 state 值在真實的 DOM 結構中的 props 中體現(xiàn)出來。
在這里,整個 Clock 組件的渲染過程就介紹完了,也希望大家通過這個例子,能夠對 Preact 的底層工作原理有了更深的認識。
五、Preact Hooks
Hooks 是 React v16.8 版本中引入的新 API,Preact 作為 React 的可代替方案,自然也會跟上這個變化,在 Preact 中,Hooks 是作為一個單獨的包引入的,包括注釋總代碼僅 300 行。
在 Preact 中,Hooks 可以分為三類:
- MemoHook
- ReducerHook
- EffectHook
接下來我們將通過這三類來介紹。
5.1 MemoHook
MemoHook 的主要作用是用來做一些性能優(yōu)化的 Hook 集合。并且在 MemoHook 內(nèi)部,有一個通用的數(shù)據(jù)結構,用來表示該 Hook 內(nèi)部的數(shù)據(jù)結構。

圖 13
5.1.1 useMemo
useMemo 的作用主要是:我們可以記住計算的結果,并且僅在其中一個依賴項發(fā)生更改時才重新計算它。

圖 14
當我們每次進行渲染的時候,都會去執(zhí)行 expensive 這個非常耗費性能的計算,這樣下來,會造成一定的性能的損耗,那我們可以使用 useMemo 來進行優(yōu)化。這樣如果 expensive 依賴的值沒有變化,就不需要執(zhí)行這個函數(shù),而是取它的緩存值。

圖 15
其實它的內(nèi)部原理很簡單,我們可以通過下圖通過它的源碼進行分析。

圖 16
本質上就是進行前后比較它的依賴的數(shù)據(jù)是否發(fā)生了改變,如果發(fā)生了變化,則調用傳入的 callback 函數(shù),否則就直接返回原來的內(nèi)部的 state 的值。
5.1.2 useCallback
作用:它可用于確保只要沒有依賴項發(fā)生更改,返回的函數(shù)將始終保持引用相等。

圖 17
用上圖的例子來說明它的作用就是,當它的依賴項 a、b 未發(fā)生變化的時候,onClick 這個函數(shù)始終是相同的。
實際上 useCallback(fn, deps) 和 useMemo(() => fn, deps) 是等價的,因為 useCallback 就是用 useMemo 來實現(xiàn)的,只是它返回的是一個沒有進行調用的 callback,所以上圖的代碼可以等價于:

圖 18
即當 a、b 不發(fā)生變化的時候,() => console.log(a, b) 也就不會發(fā)生變化。
5.1.3 useRef
作用:獲得對功能組件內(nèi)部的 DOM 節(jié)點的引用。 它的工作原理類似于 createRef。

圖 19
它的原理也是十分的簡單。

圖 20
本質上就是初始化的時候創(chuàng)建一個內(nèi)部狀態(tài)為 {current:initialValue} 的組件,且不依賴任何數(shù)據(jù),需要則通過手動賦值修改。
5.2 ReducerHook
ReducerHook 的主要作用是用來做一些性能優(yōu)化的 Hook 集合。并且在 ReducerHook 內(nèi)部,有一個通用的數(shù)據(jù)結構,用來表示該 Hook 內(nèi)部的數(shù)據(jù)結構。

圖 21
5.2.1 useReducer
useReducer 的使用方式和 Redux 非常像。

圖 22
對于使用過 Redux 的同學來說,這樣的用法應該會很容易接受和熟悉。
我們可以通過源碼來進行分析它的實現(xiàn)原理。

圖 23
更新 state 就是調用 dispatch,也就是通過 reducer(preState, action) 計算出下次的 state 賦值給 _value。然后調用組件的 setState 方法進行組件的 Diff 和相應更新操作。
5.2.2 useState
useState 大概是平時在開發(fā)過程中最常使用的 Hook,它類似于 class 組件中的 state 狀態(tài)值。

圖 24
它的原理很簡單,就是利用 useReducer 來進行實現(xiàn)的,也就是 useState 其實只是傳特定 reducer 的 useReducer 一種實現(xiàn)。

圖 25
5.3 EffectHook
“副作用”一詞在很多參與過 React 相關的項目開發(fā)的同學來說,肯定不會陌生,無論是要從 API 獲取某些數(shù)據(jù)還是要對文檔觸發(fā)效果,基本上可以發(fā)現(xiàn) EffectHook 幾乎可以滿足所有需求。 這也是 Hooks API 的主要優(yōu)點之一,它使你的思維重塑了對效果的思考,而不是對組件生命周期的思考。
在整個 EffectHook 中,都貫穿了下面這樣的通用數(shù)據(jù)結構。

圖 26
5.3.1 useEffect 和 useLayoutEffect
這兩個 Hook 的用法完全一致,都是在 render 過程中執(zhí)行一些副作用的操作,可來實現(xiàn)以往 class 組件中一些生命周期的操作。區(qū)別在于, useEffect 的 callback 執(zhí)行是在本次渲染結束之后,下次渲染之前執(zhí)行。useLayoutEffect 則是在本次會在瀏覽器 layout 之后,painting 之前執(zhí)行,是同步的。

圖 27
使用的方式和前面的 Hook 的使用方式基本上一致,傳遞一個回調函數(shù)和一個依賴數(shù)組,數(shù)組的依賴參數(shù)變化時,重新執(zhí)行回調。

圖 28
它們的實現(xiàn)機制,稍微有些復雜,我們先看源碼。

圖 28
從代碼上來看,它們的實現(xiàn)幾乎一樣,唯一的區(qū)別是進入的回調分別是 _renderCallbacks、_pendingEffects,從而達到了不同時機下進行渲染,這一塊的具體邏輯,大家可以參考這篇文章了解更多的細節(jié)。
整體來看,Preact 的 Hook 模塊的代碼實現(xiàn)雖然內(nèi)不多,但是是卻體現(xiàn)出了它的精煉以及 Preact 優(yōu)秀的架構。
結束語
最后希望大家能夠通過本文,對 Preact 的整體工作機制有了更加深入的理解,有時間的同學也可以自己嘗試閱讀 Preact 的源碼并結合本文,我相信閱讀之后一定能夠對 React 的理解更上一層樓。再次感謝大家!