原創: 寶丁 玄說前端
本文作者:字節跳動 - 寶丁
- 一、Preact 是什么
- 二、Preact 和 React 的區別有哪些?
- 三、Preact 是怎么工作的
- 四、結合實際組件了解整體渲染流程
- 五、Preact Hooks
- 結束語
- 2.1 事件系統
- 2.2 更符合 DOM 規范的描述
- 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 執行 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 一定是我們耳熟能詳的前端開發框架之一,它的出現可以說是帶給了我們全的 Web 開發體驗,其中也帶來了許多新的概念:JSX、virtual-dom、組件化、合成事件等。當我們想從源碼層面去研究它的原理時,由於 React 的源碼的龐大和晦澀難懂,這也會變得異常困難。但是在愛好“造輪子”的前端界,我們會發現一些和 React 有着近乎相同的框架,本文的主人公 Preact 也是其一,但是它相對簡練的代碼,使得我們更好地去學習和研究它的原理。本文將從以下幾個方面介紹:
-
Preact 是什么?
-
Preact 和 React 的區別有哪些?
-
Preact是怎么工作的
-
JSX
-
Virtual Dom
-
Preact 的 Virtual DOM Diff 算法
-
Preact Hooks 的實現
-
一個組件的生命周期
一、Preact 是什么
簡單而言,Preact 是 React 的 3KB 輕量級替代方案,它擁有着和 React 一樣的 API。有同學或許會問,Preact 中的 P 的含義是什么,根據 Preact 的作者表述的是 performance 的含義,這也是 Preact 框架的目標之一。
我們先來看用 Preact 編寫的幾個例子:
圖 1
圖 2
大家第一眼看上去,和 React 的寫法基本上一致的,如果仔細的看,大家可能會幾個疑問:
- h 進行了變量的聲明,但是沒有使用,這個有什么意義?可以去掉么?
- 表單里面使用的是 onInput 方法,而不是在 React 中寫的 onChange 方法,這是為什么?
在這里我先不直接告訴大家答案,這些疑問會在下面的內容中一一為大家解答。
二、Preact 和 React 的區別有哪些?
Preact 號稱打包后的體積只有 3KB,自然相比 React 而言,在某些方面進行了精簡,並且它本身的定位也不是准備從新實現一個 React,所以兩者之間肯定是存在一些區別。
我們在這里主要介紹兩者最主要的區別:
- 事件系統
- 更符合 Dom 規范的描述
2.1 事件系統
通過一個例子,大家或許就能知道兩者的區別。
圖 3
在 React 內部,其自身實現了一套事件合成系統,所以我們一般在 React 的表單組件中使用的都是 onChange 方法來進行組件值的更新,而在 Preact 內部,沒有事件合成系統,它直接使用的是由瀏覽器原生提供的事件系統,這也是為什么 Preact 在表單里面使用的是 onInput 方法,而不是在 React 中寫的 onChange 方法。這也是它體積更小的直接原因之一。
2.2 更符合 DOM 規范的描述
在 React 中我們想描述一個 DOM 的類名,必須要使用 className, 而在 Preact 中,不僅可以使用 className 來描述,也可以直接使用 class 來描述 DOM 的類名,這也使得 Preact 更接近原生 DOM 規范的描述。
當然除了這些,Preact 和 React 直接還有一些差別,由於它不是本文的重點,在這里我們就不一一展開介紹,大家可以直接通過 Preact 官網來進一步了解。
三、Preact 是怎么工作的
在本節,我們將開始介紹 Preact 的內部工作流程,希望閱讀本節過后,大家對 Preact 會有進一步的認識。
3.1 JSX
在介紹 JSX 之前,我們先想一下如何在 JS 中來描述 DOM 結構,很多同學可能會想,可以通過瀏覽器的操作 DOM 的 API 來完成,或者封裝成一個工廠函數來進行接收一定的輸入,輸出就是相應的 DOM。
圖 4
但是如果每次需要讓我們通過這么復雜的方式來進行 DOM 結構的描述,想必 React 的性能再優秀,也能進一步的進行推廣。
這個時候,如果換一種圖 5 這樣的的方式,是不是大家就很熟悉?
圖 5
沒錯,左側其實就是我們平時寫的 JSX 語法,經過 babel 或者其他的插件轉換之后變成我們上面所說的函數式的描述,然后再經過一系列的處理,變成我們所熟悉的原生 DOM 的結構,這也是 JSX 產生的本質原因。
綜合來看,其實 JSX 的本質就是 JS 的擴展,它允許你用類似 HTML/XML 的結構,進而編譯成類似圖 6 的一個函數調用。
圖 6
這個時候,我們就不得不提 babel 的強大之處了,原來從 JSX轉化到函數調用這個階段是由 React 團隊提供的,后面因為 babel 做的更好,更強大,就逐漸演變成了 @babel/plugin-transform-react-JSX 這個核心插件了,那么這個時候我們也可以揭開上文中提到的 h 函數的神秘面紗,正是因為在 Preact 中,JSX 的語法會通過 babel 這個插件轉換成一個名稱為 h 的工廠函數,類似於在 React 中的 React.createElement 的作用,所以我們才需要去聲明 h 函數,雖然我們在實際開發環境上用不到,但是它的作用是體現在 babel 轉換后的代碼中的,大家也可以通過這個鏈接來體驗 babel 的強大所在。
3.2 Virtual DOM
在本節當中,我們將會介紹 Preact 中的 Virtual DOM 是什么?那么它和我們前面說的 JSX 之間有什么關聯呢?
我們前面提到了 h 函數是一個工廠函數,輸入我們知道了,是一些描述 DOM 結構的基本信息,那么它的輸出是什么呢?我們可以通過下圖來揭曉謎底。
圖 7
從圖 7 我們可以看出,其實 h 函數的輸出是一個特殊類型的數據結構,而 Virtual DOM 本質上就是一種用來描述 DOM 結構的數據結構,所以 h 函數的輸出其實就是我們常說的 Virtual DOM。
不管在 React 中還是在 Preact 中,最核心的都是 Virtual DOM 的 Diff 算法,怎么把最新的數據所驅動的 DOM 結構表現在頁面當中,這個也是大家最關心的環節。
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 進行遍歷,如果發現新的 child 可以在老的 children 中找到相同的 key,那么會執行 diff <type>
這個階段,如果沒找到相同的 key,會去看是不是相同的類型,比如是不是相同的 DOM node 的類型,或者是相同的構造函數等,找到了的話 也會執行 diff <type>
這個階段,如果沒有找到,會把這個老的 child 放到一個數組當中。
當對新的 children 遍歷完畢之后,我們會執行下一個流程,也就是右側的流程圖,會進行遍歷沒有使用的 old child 數組,將它們一一unmout 掉,這個時候也會執行相應的生命周期。當這個 child 是一個父組件的話,會對它的 children 重復這個流程,直到全部 unmount。
在這個階段,我們也可以得到為什么寫 key 是一個非常小但是卻非常有用的性能優化手段,因為在一定的程度上它會有效地減少 Diff 過程中所帶來的性能損耗。
3.3.2 Diff
圖 9
Diff <type>
環節可以說是在整個 Diff 算法中最重要的一個環節,也是最復雜的一個環節。手首先我們會進行新的 vnode 判斷它所屬於的類型,目前來看,主要包括: Fragment、Component 和 DOM node,其中當判斷 vnode 的組件是一個空函數的時候表示的就是 Fragment,而為非空函數的就是 Component 類型。然后根據當前的 vnode 所屬的類型進行下一步的處理。
當 type 為 Fragment 的時候,就直接會將 Fragment 內部的 children 進入到上文中提到的 Diff children 階段。
當 type 為 component 時,我們會先判斷當前的 vnode 所代表的組件是否已經存在過,如果沒有存在則執行 create 操作,同時也會執行相對應的生命周期,如果已經存在對應的組件,那么則會執行 update 操作,並且執行相對應的生命周期函數,在這里我們可以強調一下 shouldComponentUpdate 生命周期函數,當它返回 false 的時候,那么我們就不會再去執行下一步要執行的 render 函數,只有當該生命周期函數不存在或者返回非 false 的時候,我們會繼續執行 render 函數,然后繼續走該 Diff <type>
階段。
當 type 為 DOM node 時,我們首先會判斷新老 vnode 是否為同一 node type,如果不同,則會創建新的 DOM 並且代替,如果相同,則會進行更新操作。
回過頭來看 Diff <type>
環節,並且結合我們平時寫組件的習慣,可以發現,最后我們寫的組件都是原生的 DOM 結構,所以最后都會進入到 Diff DOM node 這一流程中,也是在這一流程中,真正的去創建和更新 DOM。
3.3.3 Diff props
圖 10
我相信,大家可能會有點奇怪這一個階段是做什么的?在上文中我們提到了當兩個 DOM node 節點類型相同的時候,會執行更新操作,那么該環節主要是為這個更新操作而服務。
它的原理很簡單:先循環老的 DOM 的 props,如果它不在新的 DOM 上,那么就會將它設為空,然后循環新的 props,然后和老的 props 中相同的 prop 去做比較,然后設置最新的 prop 的值。
到這里,我們整個的 Virtual DOM 過程也就完成了,Preact 內部的工作原理也基本上介紹完了,但是大家可能還比較難和一個真實的組件來相關聯,接下來我們通過一個真實的組件,來將上面的過程進行串聯,加深大家對它的理解。
四、結合實際組件了解整體渲染流程
首先,我們先編寫一個如下圖的 Clock 組件:
圖 11
接下來我們會通過兩個階段來介紹:
- 初次渲染
- 執行 setState
為了方便介紹,我在畫了一個流程圖,大家可以搭配圖 12 的流程圖(點擊這里獲取高清大圖)和文字來看,方便大家更容易理解。
圖 12
4.1 初次渲染
- 入口函數為
render(<Clock />, document.body)
。 - 將 JSX 語法轉化成 h 函數的形式之后,也就是 createElement 函數來創建一個用來描述子組件為 Clock 組件的 vitrual node(下文簡稱為 vnode),類似於這種結構
{type: Fragment, children: [Clock], props: null }
。 - 將該 vnode,用數組包裹起來,然后送入到 Diff children 階段
- 當 Diff children 階段結束之后,會執行 commitRoot 方法來執行掛載組件的 componentDidMount 方法,內部主要是通過 promise 或者 setTimeout 來做有異步的處理。
- 接下來我們主要來進行描述 Diff children 的流程。
- 因為是第一次渲染,所以我們都沒有老的 vnode 也就沒有所謂的是否具有相同 key 或者相同 type 的新老 vnode。
- 直接進入到 diff(newChild, oldChild) 這一階段。
- 判斷我們的 vnode 的 type 是一個 component, 並且是一個新的組件,這個時候我們創建新組件,並且執行對應的生命周期,然后調用我們的 render 函數。
- 因為 render 函數的返回值其實依然是一個 vnode,所以會繼續流轉到 diff(newChild, oldChild) 這一個階段,直到判斷 type 是 DOM node 時,會執行 DOM 的操作變化。
4.2 執行 setState
- 我們可以從流程圖中看到,其實 setState 本質上的操作,會將它所在的 vnode 送入到 diff(newChild, oldChild) 中,而 newChild 和 oldChild 的主要區別其實就是 state 的變化。
- 因為 Clock 組件是一個 component 類型的 vnode,所以我們會繼續判斷它是不是新組件,很顯然已經不是了,於是會執行對應的生命周期,如果沒有 shouldComponentUpdate 生命周期函數或者返回了 true,那么我們會繼續執行 render 函數,不然我們會停止組件的渲染。
- 這個時候 render 函數中,已經有了我們最新的 state了,那么對應的接下來會繼續走 diff(newChild, oldChild) 流程,直到將更改的 state 值在真實的 DOM 結構中的 props 中體現出來。
在這里,整個 Clock 組件的渲染過程就介紹完了,也希望大家通過這個例子,能夠對 Preact 的底層工作原理有了更深的認識。
五、Preact Hooks
Hooks 是 React v16.8 版本中引入的新 API,Preact 作為 React 的可代替方案,自然也會跟上這個變化,在 Preact 中,Hooks 是作為一個單獨的包引入的,包括注釋總代碼僅 300 行。
在 Preact 中,Hooks 可以分為三類:
- MemoHook
- ReducerHook
- EffectHook
接下來我們將通過這三類來介紹。
5.1 MemoHook
MemoHook 的主要作用是用來做一些性能優化的 Hook 集合。並且在 MemoHook 內部,有一個通用的數據結構,用來表示該 Hook 內部的數據結構。
圖 13
5.1.1 useMemo
useMemo 的作用主要是:我們可以記住計算的結果,並且僅在其中一個依賴項發生更改時才重新計算它。
圖 14
當我們每次進行渲染的時候,都會去執行 expensive 這個非常耗費性能的計算,這樣下來,會造成一定的性能的損耗,那我們可以使用 useMemo 來進行優化。這樣如果 expensive 依賴的值沒有變化,就不需要執行這個函數,而是取它的緩存值。
圖 15
其實它的內部原理很簡單,我們可以通過下圖通過它的源碼進行分析。
圖 16
本質上就是進行前后比較它的依賴的數據是否發生了改變,如果發生了變化,則調用傳入的 callback 函數,否則就直接返回原來的內部的 state 的值。
5.1.2 useCallback
作用:它可用於確保只要沒有依賴項發生更改,返回的函數將始終保持引用相等。
圖 17
用上圖的例子來說明它的作用就是,當它的依賴項 a、b 未發生變化的時候,onClick 這個函數始終是相同的。
實際上 useCallback(fn, deps)
和 useMemo(() => fn, deps)
是等價的,因為 useCallback 就是用 useMemo 來實現的,只是它返回的是一個沒有進行調用的 callback,所以上圖的代碼可以等價於:
圖 18
即當 a、b 不發生變化的時候,() => console.log(a, b)
也就不會發生變化。
5.1.3 useRef
作用:獲得對功能組件內部的 DOM 節點的引用。 它的工作原理類似於 createRef。
圖 19
它的原理也是十分的簡單。
圖 20
本質上就是初始化的時候創建一個內部狀態為 {current:initialValue} 的組件,且不依賴任何數據,需要則通過手動賦值修改。
5.2 ReducerHook
ReducerHook 的主要作用是用來做一些性能優化的 Hook 集合。並且在 ReducerHook 內部,有一個通用的數據結構,用來表示該 Hook 內部的數據結構。
圖 21
5.2.1 useReducer
useReducer 的使用方式和 Redux 非常像。
圖 22
對於使用過 Redux 的同學來說,這樣的用法應該會很容易接受和熟悉。
我們可以通過源碼來進行分析它的實現原理。
圖 23
更新 state 就是調用 dispatch,也就是通過 reducer(preState, action) 計算出下次的 state 賦值給 _value。然后調用組件的 setState 方法進行組件的 Diff 和相應更新操作。
5.2.2 useState
useState 大概是平時在開發過程中最常使用的 Hook,它類似於 class 組件中的 state 狀態值。
圖 24
它的原理很簡單,就是利用 useReducer 來進行實現的,也就是 useState 其實只是傳特定 reducer 的 useReducer 一種實現。
圖 25
5.3 EffectHook
“副作用”一詞在很多參與過 React 相關的項目開發的同學來說,肯定不會陌生,無論是要從 API 獲取某些數據還是要對文檔觸發效果,基本上可以發現 EffectHook 幾乎可以滿足所有需求。 這也是 Hooks API 的主要優點之一,它使你的思維重塑了對效果的思考,而不是對組件生命周期的思考。
在整個 EffectHook 中,都貫穿了下面這樣的通用數據結構。
圖 26
5.3.1 useEffect 和 useLayoutEffect
這兩個 Hook 的用法完全一致,都是在 render 過程中執行一些副作用的操作,可來實現以往 class 組件中一些生命周期的操作。區別在於, useEffect 的 callback 執行是在本次渲染結束之后,下次渲染之前執行。useLayoutEffect 則是在本次會在瀏覽器 layout 之后,painting 之前執行,是同步的。
圖 27
使用的方式和前面的 Hook 的使用方式基本上一致,傳遞一個回調函數和一個依賴數組,數組的依賴參數變化時,重新執行回調。
圖 28
它們的實現機制,稍微有些復雜,我們先看源碼。
圖 28
從代碼上來看,它們的實現幾乎一樣,唯一的區別是進入的回調分別是 _renderCallbacks、_pendingEffects,從而達到了不同時機下進行渲染,這一塊的具體邏輯,大家可以參考這篇文章了解更多的細節。
整體來看,Preact 的 Hook 模塊的代碼實現雖然內不多,但是是卻體現出了它的精煉以及 Preact 優秀的架構。
結束語
最后希望大家能夠通過本文,對 Preact 的整體工作機制有了更加深入的理解,有時間的同學也可以自己嘗試閱讀 Preact 的源碼並結合本文,我相信閱讀之后一定能夠對 React 的理解更上一層樓。再次感謝大家!
歡迎關注“玄說-前端”微信公眾號
福利:
掃描下方二維碼,加”助理“好友,回復”加群“,進入“玄說-前端” 微信群,一起討論前端技術,更有大廠內推機會。