Preact(React)核心原理詳解


原創: 寶丁 玄說前端

本文作者:字節跳動 - 寶丁

  • 一、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 編寫的幾個例子:

image

圖 1

image

圖 2
大家第一眼看上去,和 React 的寫法基本上一致的,如果仔細的看,大家可能會幾個疑問:

  1. h 進行了變量的聲明,但是沒有使用,這個有什么意義?可以去掉么?
  2. 表單里面使用的是 onInput 方法,而不是在 React 中寫的 onChange 方法,這是為什么?

在這里我先不直接告訴大家答案,這些疑問會在下面的內容中一一為大家解答。
二、Preact 和 React 的區別有哪些?
Preact 號稱打包后的體積只有 3KB,自然相比 React 而言,在某些方面進行了精簡,並且它本身的定位也不是准備從新實現一個 React,所以兩者之間肯定是存在一些區別。
我們在這里主要介紹兩者最主要的區別:

  • 事件系統
  • 更符合 Dom 規范的描述

2.1 事件系統
通過一個例子,大家或許就能知道兩者的區別。

image

圖 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。

image

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

image

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

image

圖 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 結構的基本信息,那么它的輸出是什么呢?我們可以通過下圖來揭曉謎底。

image

圖 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

image

圖 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

image

圖 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

image

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

image

圖 11
接下來我們會通過兩個階段來介紹:

  1. 初次渲染
  2. 執行 setState

為了方便介紹,我在畫了一個流程圖,大家可以搭配圖 12 的流程圖(點擊這里獲取高清大圖)和文字來看,方便大家更容易理解。

image

圖 12
4.1 初次渲染

  1. 入口函數為 render(<Clock />, document.body)
  2. 將 JSX 語法轉化成 h 函數的形式之后,也就是 createElement 函數來創建一個用來描述子組件為 Clock 組件的 vitrual node(下文簡稱為 vnode),類似於這種結構 {type: Fragment, children: [Clock], props: null }
  3. 將該 vnode,用數組包裹起來,然后送入到 Diff children 階段
  4. 當 Diff children 階段結束之后,會執行 commitRoot 方法來執行掛載組件的 componentDidMount 方法,內部主要是通過 promise 或者 setTimeout 來做有異步的處理。
  5. 接下來我們主要來進行描述 Diff children 的流程。
  6. 因為是第一次渲染,所以我們都沒有老的 vnode 也就沒有所謂的是否具有相同 key 或者相同 type 的新老 vnode。
  7. 直接進入到 diff(newChild, oldChild) 這一階段。
  8. 判斷我們的 vnode 的 type 是一個 component, 並且是一個新的組件,這個時候我們創建新組件,並且執行對應的生命周期,然后調用我們的 render 函數。
  9. 因為 render 函數的返回值其實依然是一個 vnode,所以會繼續流轉到 diff(newChild, oldChild) 這一個階段,直到判斷 type 是 DOM node 時,會執行 DOM 的操作變化。

4.2 執行 setState

  1. 我們可以從流程圖中看到,其實 setState 本質上的操作,會將它所在的 vnode 送入到 diff(newChild, oldChild) 中,而 newChild 和 oldChild 的主要區別其實就是 state 的變化。
  2. 因為 Clock 組件是一個 component 類型的 vnode,所以我們會繼續判斷它是不是新組件,很顯然已經不是了,於是會執行對應的生命周期,如果沒有 shouldComponentUpdate 生命周期函數或者返回了 true,那么我們會繼續執行 render 函數,不然我們會停止組件的渲染。
  3. 這個時候 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 內部的數據結構。

image

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

image

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

image

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

image

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

image

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

image

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

image

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

image

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

image

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

image

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

image

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

image

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

image

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

image

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

image

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

image

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

image

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

image

福利:

掃描下方二維碼,加”助理“好友,回復”加群“,進入“玄說-前端” 微信群,一起討論前端技術,更有大廠內推機會。

image


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM