React 入門兒

誰都沒有看見過風,更不用說你和我了。但是當紙幣在飄的時候,我們知道那是風在數錢。

React 影響着我們工作的方方面面,我們每天都在使用它,只窺其表卻難以窺其裏。正所謂看不如寫,本篇文章的目的就是從原理層面探究 React 是如何工作的。

工具

在寫文章之前,爲了方便理解,我準備了一個懶人調試倉庫 simple_react ,這個倉庫將 benchmark 用例(只有兩個 ^ ^)和 React 源碼共同放在 src 文件夾中,通過 snowpack 進行熱更新,可以直接在源碼中加入 log 和 debuger 進行調試。當然這裏的“源碼”並不是真的源碼,因爲 React 源碼中充斥着巨量的 dev 代碼和不明確的功能函數,所以我對源碼進行了整理,用 typescript 對類型進行了規範,刪除了大量和核心流程無關的代碼(當然也誤刪了一些有關的 ^ ^)。

如果你只是希望瞭解 React 的運行流程而不是寫一個可以用的框架的話,那麼這個倉庫完全可以滿足你學習的需要。當然,這個倉庫基於 React16.8 ,雖然這個版本並不包括當前的航道模型 Lane 等新特性,但是是我個人認爲比較穩定且更適合閱讀的一個版本。

(如果希望調試完整的源碼,也可以參考 拉取源碼 通過 yarn link 來進行 debug)

文章結構

  1. fiber 架構設計及首次渲染流程
  2. 事件委託機制
  3. 狀態的更新
  4. 時間片

在瞭解 React 是如何工作之前,我們應該確保瞭解幾點有關 React 的基礎知識。

Why Framework

首先,我們需要知道使用框架對於開發的意義是什麼。如果我們還處於遠古時期使用純 JS 的階段,每次數據的改變都會引發組件的展示狀態改變,因此我們需要去手動的操作 DOM 。如果在某一秒內,數據異步的連續改變了幾十次,根據展示邏輯我們也需要連續對 DOM 進行幾十次修改。頻繁的 DOM 操作對網頁性能的影響是很大的,當然,創建 DOM 元素和修改 DOM 元素的屬性都不過分消耗性能,主要在於每次將新的 DOM 插入 document 都會導致瀏覽器重新計算佈局屬性,以及各個視圖層、合併、渲染。所以,這樣的代碼性能是十分低下的。

可以試想這樣一個場景。對於一個前端列表組件而言,當存在 3 條數據的時候展示 3 條,當存在 5 條數據的時候展示 5 條。也就是說 UI 的呈現在某種程度上必然會和數據存在某種邏輯關係。如果 JS 能夠感知到關鍵數據的改變,使用一種高效的方式將 DOM 改寫成與數據相對應的狀態。那麼於開發者而言,就可以專注於業務邏輯和數據的改變,工作效率也會大幅提高。

所以, 框架 最核心的功能之一就是 高效地 達成 UI層和數據層的統一。

React 哲學

React 本身並不是框架, React 只是一個 JavaScript 庫,他的作用是通過組件構建用戶界面,屬於 MVC 應用中的 View 視圖層。 React 通過 props 和 state 來簡化關鍵數據的存儲,對於一個 react 組件函數而言,在 1 秒內可能被執行很多次。而每一次被執行,數據被注入 JSX , JSX 並不是真實的 DOM ,在 React 中會被轉換成 React.createElement(type, props, children) 函數,執行的結果就是 ReactElement 元素 ,也即是 虛擬DOM ,用來描述在瀏覽器的某一幀中,組件應該被呈現爲什麼樣子。

Virtual Dom

VirtualDom 並非 React 專屬,就像 redux 也可以在非 React 環境下使用一樣,它們只是一種設計的思路。

事實上, React 在使用 fiber 架構之前的 Virtual Dom 和 diff 過程要相對直觀一些。但是在引入了 fiber 架構之後整個流程變得冗長,如果單純想了解 VirtualDom 和 diff 過程的原理也可以通過 simple-virtual-dom 這個倉庫來學習。

VirtualDom 的本質是利用 JS變量 對真實 DOM 進行抽象,既然每一次操作 DOM 都可能觸發瀏覽器的重排消耗性能,那麼就可以使用 VirtualDom 來緩存當前組件狀態,對用戶交互和數據的變動進行批次處理,直接計算出每一幀頁面應該呈現的最終狀態,而這個狀態是以 JS變量 的形式存在於內存中的。所以通過 VirtualDom 既能夠保證用戶看到的每一幀都響應了數據的變化,又能節約性能保證瀏覽器不出現卡頓。

第一次渲染 First Render

首先我們應該注意到 React(瀏覽器環境) 代碼的入口 render 函數

1
ReactDOM.render(<App />, domContainer)

這個 render 過程中, React 需要做到的是根據用戶創造的 JSX 語法,構建出一個虛擬的樹結構(也就是 ReactElement 和 Fiber )來表示用戶 期望中 頁面中的元素結構。當然對於這個過程相對並不複雜(誤),因爲此時的 document 內還是一片虛無。就思路上而言,只需要根據虛擬 DOM 節點生成真實的 DOM 元素然後插入 document ,第一次渲染就算圓滿完成。

createReactElement

通常我們會通過 Babel 將 JSX 轉換爲一個 JS 執行函數。例如我們在 React 環境下用 JSX 中寫了一個標題組件

1
2
3


<h1 className='title'>
<div>Class Component</div>
</h1>


那麼這個組件被 Babel 轉換之後將會是

1
2
3


React.createElement('h1', { className: 'title' }, [
React.createElement('div', null, [ 'Class Component' ]
])


傳統編譯講究一個 JSON 化,當然 JSX 和 React 也沒有什麼關係, JSX 只是 React 推薦的一種拓展語法。當然你也可以不用 JSX 直接使用 React.createElement 函數,但是對比上面的兩種寫法你就也能知道,使用純 JS 的心智成本會比簡明可見的 JSX 高多少。我們可以看出, React.createElement 需要接收 3 個參數,分別是 DOM 元素的標籤名,屬性對象以及一個子元素數組,返回值則是一個 ReactElement 對象。

事實上, JSX 編譯後的 json 結構本身就是一個對象,即使不執行 React.createElement 函數也已經初步可以使用了。那麼在這個函數中我們做了什麼呢。

一個 ReactElement 元素主要有 5 個關鍵屬性,我們都知道要構建成一個頁面需要通過 html 描述元素的類型和結構,通過 style 和 class 去描述元素的樣式呈現,通過 js 和綁定事件來觸發交互事件和頁面更新。

所以最重要的是第一個屬性,元素類型 type 。如果這個元素是一個純 html 標籤元素,例如 div ,那麼 type 將會是字符串 div ,如果是一個 React 組件,例如

1
2
3
4
5




function App() {
return (
<div>Hello, World!</div>
)
}




那麼 type 的值將會指向 App 函數,當然 Class組件 也一樣(衆所周知 ES6 的 Class 語法本身就是函數以及原型鏈構成的語法糖)

第二個屬性是 props ,我們在 html 標籤中寫入的大部分屬性都會被收集在 props 中,例如 id 、 className 、 style 、 children 、點擊事件等等。

第三個第四個屬性分別是 keyref ,其中 key 在數組的處理和 diff 過程中有重要作用,而 ref 則是引用標識,在這裏就先不做過多介紹。

最後一個屬性是 $$typeof ,這個屬性會指向 Symbol(React.element) 。作爲 React 元素的唯一標識的同時,這個標籤也承擔了安全方面的功能。我們已經知道了所謂的 ReactElement 其實就是一個 JS 對象。那麼如果有用戶惡意的向服務端數據庫中存入了某個有侵入性功能的 僞React 對象,在實際渲染過程中被當做頁面元素渲染,那麼將有可能威脅到用戶的安全。而 Symbol 是無法在數據庫中被存儲的,換句話說, React 所渲染的所有元素,都必須是由 JSX 編譯的擁有 Symbol 標識的元素。(如果在低版本不支持 Symbol 的瀏覽器中,將會使用字符串替代,也就沒有這層安排保護了)

ok,接下來回到 render 函數。在這個函數中到底發生了什麼呢,簡單來說就是創建 Root 結構。

https://img11.360buyimg.com/ling/jfs/t1/136683/17/16826/28884/5fb8b62cE2f9f4cba/bf1649d3a0238025.png

enqueueUpdate

從設計者的角度,根據 單一職責原則開閉口原則 需要有與函數體解耦的數據結構來告訴 React 應該怎麼操作 fiber 。而不是初次渲染寫一套邏輯,第二次渲染寫一套邏輯。因此, fiber 上有了更新隊列 UpdateQueue 和 更新鏈表 Update 結構

如果查看一下相關的定義就會發現,更新隊列 updateQueue 是多個更新組成的鏈表結構,而 update 的更新也是一個鏈表,至於爲什麼是這樣設計,試想在一個 Class Component 的更新函數中連續執行了 3 次 setState ,與其將其作爲 3 個更新掛載到組件上,不如提供一種更小粒度的控制方式。一句話概括就是, setState 級別的小更新合併成一個狀態更新,組件中的多個狀態更新在組件的更新隊列中合併,就能夠計算出組件的新狀態 newState

對於初次渲染而言,只需要在第一個 fiber 上,掛載一個 update 標識這是一個初次渲染的 fiber 即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

























// 更新根節點
export function ScheduleRootUpdate (
current: Fiber,
element: ReactElement,
expirationTime: number,
suspenseConfig: SuspenseConfig | null,
callback?: Function
) {
// 創建一個update實例
const update = createUpdate(expirationTime, suspenseConfig)
// 對於作用在根節點上的 react element
update.payload = {
element
}

// 將 update 掛載到根 fiber 的 updateQueue 屬性上
enqueueUpdate(
current,
update
)

ScheduleWork(
current,
expirationTime
)
}

























Fiber

作爲整個 Fiber架構 中最核心的設計, Fiber 被設計成了鏈表結構。

  • child 指向當前節點的第一個子元素
  • return 指向當前節點的父元素
  • sibling 指向同級的下一個兄弟節點

如果是 React16 之前的樹狀結構,就需要通過 DFS 深度遍歷來查找每一個節點。而現在只需要將指針按照 child → sibling → return 的優先級移動,就可以處理所有的節點

https://img11.360buyimg.com/ling/jfs/t1/123616/3/19615/109246/5fb8c0cdE2b1a2848/0a47db7bef39c043.png

這樣設計還有一個好處就是在 React 工作的時候只需要使用一個全局變量作爲指針在鏈表中不斷移動,如果出現用戶輸入或其他優先級更高的任務就可以 暫停 當前工作,其他任務結束後只需要根據指針的位置繼續向下移動就可以繼續之前的工作。指針移動的規律可以歸納爲 自頂向下,從左到右 。

康康 fiber 的基本結構

https://img14.360buyimg.com/ling/jfs/t1/134461/22/16700/98512/5fb8ce73Ea86427a2/55e9ab20d4dd1731.png

其中

  • tag fiber 的類型 ,例如函數組件,類組件,原生組件, Portal 等。
  • type React元素 類型 詳見上方 createElement。
  • alternate 代表雙向緩衝對象(看後面)。
  • effectTag 代表這個 fiber 在下一次渲染中將會被如何處理。例如只需要插入,那麼這個值中會包含 Placement ,如果需要被刪除,那麼將會包含 Deletion 。
  • expirationTime 過期時間,過期時間越靠前,就代表這個 fiber 的優先級越高。
  • firstEffectlastEffect 的類型都和 fiber 一樣,同樣是鏈表結構,通過 nextEffect 來連接。代表着即將更新的 fiber 狀態
  • memorizeStatememorizeProps 代表在上次渲染中組件的 props 和 state 。如果成功更新,那麼新的 pendingProps 和 newState 將會替代這兩個變量的值
  • ref 引用標識
  • stateNode 代表這個 fiber 節點對應的真實狀態
    • 對於原生組件,這個值指向一個 dom 節點(雖然已經被創建了,但不代表就被插入了 document )
    • 對於類組件,這個值指向對應的類實例
    • 對於函數組件,這個值指向 Null
    • 對於 RootFiber,這個值指向 FiberRoot (如圖)

接下來是初次渲染的幾個核心步驟,因爲是初次渲染,核心任務就是將首屏元素渲染到頁面上,所以這個過程將會是同步的。

PrepareFreshStack

因爲筆者是土貨沒學過英語,百度了下發現是 準備乾淨的棧 的意思。結合了下流程,可以看出這一步的作用是在真正工作之前做一些準備,例如初始化一些變量,放棄之前未完成的工作,以及最重要的—— 創建雙向緩衝變量 WorkInProgress

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28



























let workInProgress: Fiber | null = null
...
export function prepareFreshStack (
root: FiberRoot,
expirationTime: number
) {
// 重置根節點的finishWork
root.finishedWork = null
root.finishedExpirationTime = ExpirationTime.NoWork

...

if (workInProgress !== null) {
// 如果已經存在了WIP,說明存在未完成的任務
// 向上找到它的root fiber
let interruptedWork = workInProgress.return
while (interruptedWork !== null) {
// unwindInterruptedWork // 抹去未完成的任務
unwindInterruptedWork(interruptedWork)
interruptedWork = interruptedWork.return
}
}
workInProgressRoot = root
// 創建雙向緩衝對象
workInProgress = createWorkInProgress(root.current, null, expirationTime)
renderExpirationTime = expirationTime
workInProgressRootExitStatus = RootExitStatus.RootImcomplete
}



























雙向緩衝變量 WorkInProgress

這裏簡稱 WIP 好了,與之對應的是 current , current 代表的是當前頁面上呈現的組件對應的 fiber 節點,你可以將其類比爲 git 中的 master 分支,它代表的是已經對外的狀態。而 WIP 則代表了一個 pending 的狀態,也就是下一幀屏幕將要呈現的狀態,就像是從 master 拉出來的一個 feature 分支,我們可以在這個分支上做任意的更改。最終協調完畢,將 WIP 的結果渲染到了頁面上,按照頁面內容對應 current 的原則, current 將會指向 WIP ,也就是說, WIP 取代了之前的 current ( git 的 master 分支)。

在這之前 current 和 WIP 的 alternate 字段分別指向彼此。

https://img20.360buyimg.com/ling/jfs/t1/122851/26/20021/10658/5fbbbdebE314a4349/5e082a5b3ded4328.png

那麼 WIP 是如何被創造出來的呢:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37




































// 根據已有 fiber 生成一個 workInProgress 節點
export function createWorkInProgress (
current: Fiber,
pendingProps: any,
expirationTime
): Fiber {
let workInProgress = current.alternate
if (workInProgress === null) {
// 如果當前fiber沒有alternate
// tip: 這裏使用的是“雙緩衝池技術”,因爲我們最多需要一棵樹的兩個實例。
// tip: 我們可以自由的複用未使用的節點
// tip: 這是異步創建的,避免使用額外的對象
// tip: 這同樣支持我們釋放額外的內存(如果需要的話
workInProgress = createFiber(
current.tag,
pendingProps,
current.key,
current.mode
)
workInProgress.elementType = current.elementType
workInProgress.type = current.type
workInProgress.stateNode = current.stateNode

workInProgress.alternate = current
current.alternate = workInProgress
} else {
// 我們已經有了一個 WIP
workInProgress.pendingProps = pendingProps

// 重置 effectTag
workInProgress.effectTag = EffectTag.NoEffect

// 重置 effect 鏈表
workInProgress.nextEffect = null
workInProgress.firstEffect = null
workInProgress.lastEffect = null
}




































可以看出 WIP 其實就是繼承了 current 的核心屬性,但是去除了一些副作用和工作記錄的 乾淨 的 fiber。

工作循環 WorkLoop

在工作循環中,將會執行一個 while 語句,每執行一次循環,都會完成對一個 fiber 節點的處理。在 workLoop 模塊中有一個指針 workInProgress 指向當前正在處理的 fiber ,它會不斷向鏈表的尾部移動,直到指向的值爲 null ,就停止這部分工作, workLoop 的部分也就結束了。

每處理一個 fiber 節點都是一個工作單元,結束了一個工作單元后 React 會進行一次判斷,是否需要暫停工作檢查有沒有更高優先級的用戶交互進來。

1
2
3
4
5
6





function workLoopConcurrent() {
// 執行工作直到 Scheduler 要求我們 yield
while (workInProgress !== null && !shouldYield()) {
workInProgress = performUnitOfWork(workInProgress);
}
}





跳出條件只有:

  1. 所有 fiber 都已經被遍歷結束了
  2. 當前線程的使用權移交給了外部任務隊列

但是我們現在討論的是第一次渲染,觸屏渲染的優先級高於一切,所以並不存在第二個限制條件。

1
2
3
4
5
6





function workLoopSync () {
// 只要沒有完成reconcile就一直執行
while(workInProgress !== null) {
workInProgress = performUnitOfWork(workInProgress as Fiber)
}
}





PerformUnitOfWork & beginWork

單元工作 performUnitOfWork 的主要工作是通過 beginWork 來完成。 beginWork 的核心工作是通過判斷 fiber.tag 判斷當前的 fiber 代表的是一個類組件、函數組件還是原生組件,並且針對它們做一些特殊處理。這一切都是爲了最終步驟:操作真實 DOM 做準備,即通過改變 fiber.effectTag 和 pendingProps 告訴後面的 commitRoot 函數應該對真實 DOM 進行怎樣的改寫。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

















switch (workInProgress.tag) {
// RootFiber
case WorkTag.HostRoot:
return updateHostRoot(current as Fiber, workInProgress, renderExpirationTime)
// class 組件
case WorkTag.ClassComponent: {
const Component = workInProgress.type
const resolvedProps = workInProgress.pendingProps
return updateClassComponent(
current,
workInProgress,
Component,
resolvedProps,
renderExpirationTime
)
}
...
}

















此處就以 Class 組件爲例,查看一下具體是如何構建的。

之前有提過,對於類組件而言, fiber.stateNode 會指向這個類之前構造過的實例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29




























// 更新Class組件
function updateClassComponent (
current: Fiber | null,
workInProgress: Fiber,
Component: any,
nextProps,
renderExpiration: number
) {
// 如果這個 class 組件被渲染過,stateNode 會指向類實例
// 否則 stateNode 指向 null
const instance = workInProgress.stateNode
if (instance === null) {
// 如果沒有構造過類實例
...
} else {
// 如果構造過類實例
...
}

// 完成 render 的構建,將得到的 react 元素和已有元素進行調和
const nextUnitOfWork = finishClassComponent(
current,
workInProgress,
Component,
shouldUpdate,
false,
renderExpiration
)
return nextUnitOfWork




























如果這個 fiber 並沒有構建過類實例的話,就會調用它的構建函數,並且將更新器 updater 掛載到這個類實例上。(處理 setState 邏輯用的,事實上所有的類組件實例上的更新器都是同一個對象,後面會提到)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

























if (instance === null) {
// 這個 class 第一次渲染
if (current !== null) {
// 刪除 current 和 WIP 之間的指針
current.alternate = null
workInProgress.alternate = null
// 插入操作
workInProgress.effectTag |= EffectTag.Placement
}
// 調用構造函數,創造新的類實例
// 給予類實例的某個指針指向更新器 updater
constructClassInstance(
workInProgress,
Component,
nextProps,
renderExpiration
)

// 將屬性掛載到類實例上,並且觸發多個生命週期
mountClassInstance(
workInProgress,
Component,
nextProps,
renderExpiration
)
}

























如果實例已經存在,就需要對比新舊 props 和 state ,判斷是否需要更新組件(萬一寫了 shouldComponentUpdate 呢)。並且觸發一些更新時的生命週期鉤子,例如 getDerivedStateFromProps 等等。

1
2
3
4
5
6
7
8
9
10









else {
// 已經 render 過了,更新
shouldUpdate = updateClassInstance(
current,
workInProgress,
Component,
nextProps,
renderExpiration
)
}









屬性計算完畢後,調用類的 render 函數獲取最終的 ReactElement ,打上 Performed 標記,代表這個類在本次渲染中已經執行過了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44











































// 完成Class組件的構建
function finishClassComponent (
current: Fiber | null,
workInProgress: Fiber,
Component: any,
shouldUpdate: boolean,
hasContext: boolean,
renderExpiration: number
) {

// 錯誤 邊界捕獲
const didCaptureError = false

if (!shouldUpdate && !didCaptureError) {
if (hasContext) {
// 拋出問題
return bailoutOnAlreadyFinishedWork(
current,
workInProgress,
renderExpiration
)
}
}

// 實例
const instance = workInProgress.stateNode

let nextChildren

nextChildren = instance.render()

// 標記爲已完成
workInProgress.effectTag |= EffectTag.PerformedWork

// 開始調和 reconcile
reconcileChildren(
current,
workInProgress,
nextChildren,
renderExpiration
)

return workInProgress.child
}











































調和過程

如果還記得之前的內容的話,我們在一切工作開始之前只是構建了第一個根節點 fiberRoot 和第一個無意義的空 root ,而在單個元素的調和過程 reconcileSingleElement 中會根據之前 render 得到的 ReactElement 元素構建出對應的 fiber 並且插入到整個 fiber 鏈表中去。

並且通過 placeSingleChild 給這個 fiber 的 effectTag 打上 Placement 的標籤,擁有 Placement 標記後這裏的工作就完成了,可以將 fiber 指針移動到下一個節點了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45












































// 處理對象類型(單個節點)
const isObjectType = isObject(newChild) && !isNull(newChild)
// 對象
if (isObjectType) {
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE: {
// 在遞歸調和結束,向上回溯的過程中
// 給這個 fiber 節點打上 Placement 的 Tag
return placeSingleChild(
reconcileSingleElement(
returnFiber,
currentFirstChild,
newChild,
expirationTime
)
)
}
// 還有 Fragment 等類型
}
}

// 如果這時子元素是字符串或者數字,按照文字節點來處理
// 值得一提的是,如果元素的子元素是純文字節點
// 那麼這些文字不會被轉換成 fiber
// 而是作爲父元素的 prop 來處理
if (isString(newChild) || isNumber(newChild)) {
return placeSingleChild(
reconcileSingleTextNode(
returnFiber,
currentFirstChild,
'' + newChild,
expirationTime
)
)
}

// 數組
if (isArray(newChild)) {
return reconcileChildrenArray(
returnFiber,
currentFirstChild,
newChild,
expirationTime
)
}












































文章篇幅有限,對於函數組件和原生組件這裏就不做過多介紹。假設我們已經完成了對於所有 WIP 的構建和調和過程,對於第一次構建而言,我們需要插入大量的 DOM 結構,但是到現在我們得到的仍然是一些虛擬的 fiber 節點。

所以,在最後一次單元工作 performUnitOfWork 中將會執行 completeWork ,在此之前,我們的單元工作是一步步向尾部的 fiber 節點移動。而在 completeWork 中,我們的工作將是自底向上,根據 fiber 生成真實的 dom 結構,並且在向上的過程中將這些結構拼接成一棵 dom 樹。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62





























































export function completeWork (
current: Fiber | null,
workInProgress: Fiber,
renderExpirationTime: number
): Fiber | null {
// 最新的 props
const newProps = workInProgress.pendingProps

switch (workInProgress.tag) {
...
case WorkTag.HostComponent: {
// pop 該 fiber 對應的上下文
popHostContext(workInProgress)
// 獲取 stack 中的當前 dom
const rootContainerInstance = getRootHostContainer()

// 原生組件類型
const type = workInProgress.type

if (current !== null && workInProgress.stateNode !== null) {
// 如果不是初次渲染了,可以嘗試對已有的 dom 節點進行更新複用
updateHostComponent(
current,
workInProgress,
type as string,
newProps,
rootContainerInstance
)
} else {
if (!newProps) {
throw new Error('如果沒有newProps,是不合法的')
}
const currentHostContext = getHostContext()

// 創建原生組件
let instance = createInstance(
type as string,
newProps,
rootContainerInstance,
currentHostContext,
workInProgress
)

// 將之前所有已經生成的子 dom 元素裝載到 instance 實例中
// 逐步拼接成一顆 dom 樹
appendAllChildren(instance, workInProgress, false, false)

// fiber 的 stateNode 指向這個 dom 結構
workInProgress.stateNode = instance

// feat: 這個函數真的藏得很隱蔽,我不知道這些人是怎麼能註釋都不提一句的呢→_→
// finalizeInitialChildren 作用是將props中的屬性掛載到真實的dom元素中去,結果作爲一個判斷條件被調用
// 返回一個bool值,代表是否需要auto focus(input, textarea...)
if (finalizeInitialChildren(instance, type as string, newProps, rootContainerInstance, currentHostContext)) {
markUpdate(workInProgress)
}
}
}
}

return null
}





























































構建完畢後,我們得到了形如下圖,虛擬 dom 和 真實 dom,父元素和子元素之間的關係結構

https://img12.360buyimg.com/ling/jfs/t1/128064/14/19510/42749/5fba15e8E1c00dca2/f1bd2f6b8ce53ca8.png

截止到當前,調和 reconcile 工作已經完成,我們已經進入了準備提交到文檔 ready to commit 的狀態。其實從進入 completeUnitOfWork 構建開始,後面的過程就已經和時間片,任務調度系統沒有關係了,此時一切事件、交互、異步任務都將屏氣凝神,聆聽接下來 dom 的改變。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22





















// 提交根實例(dom)到瀏覽器真實容器root中
function commitRootImpl (
root: FiberRoot,
renderPriorityLevel: ReactPriorityLevel
) {
...
// 因爲這次是整個組件樹被掛載,所以根 fiber 節點將會作爲 fiberRoot 的 finishedWork
const finishedWork = root.finishedWork
...
// effect 鏈表,即那些將要被插入的原生組件 fiber
let firstEffect = finishedWork.firstEffect
...
let nextEffect = firstEffect

while (nextEffect !== null) {
try {
commitMutationEffects(root, renderPriorityLevel)
} catch(err) {
throw new Error(err)
}
}
}





















在 commitMutationEffects 函數之前其實對 effect 鏈表還進行了另外兩次遍歷,分別是一些生命週期的處理,例如 getSnapshotBeforeUpdate ,以及一些變量的準備。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35


































// 真正改寫文檔中dom的函數
// 提交fiber effect
function commitMutationEffects (
root: FiberRoot,
renderPriorityLevel: number
) {
// @question 這個 while 語句似乎是多餘的 = =
while (nextEffect !== null) {
// 當前fiber的tag
const effectTag = nextEffect.effectTag

// 下方的switch語句只處理 Placement,Deletion 和 Update
const primaryEffectTag = effectTag & (
EffectTag.Placement |
EffectTag.Update |
EffectTag.Deletion |
EffectTag.Hydrating
)
switch (primaryEffectTag) {
case EffectTag.Placement: {
// 執行插入
commitPlacement(nextEffect)
// effectTag 完成實名制後,要將對應的 effect 去除
nextEffect.effectTag &= ~EffectTag.Placement
}
case EffectTag.Update: {
// 更新現有的 dom 組件
const current = nextEffect.alternate
commitWork(current, nextEffect)
}
}

nextEffect = nextEffect.nextEffect
}
}


































截至此刻,第一次渲染的內容已經在屏幕上出現。也就是說,真實 DOM 中的內容不再對應此時的 current fiber ,而是對應着我們操作的 workInProgress fiber ,即函數中的 finishedWork 變量。

1
2
3
4



// 在 commit Mutation 階段之後,workInProgress tree 已經是真實 Dom 對應的樹了
// 所以之前的 tree 仍然是 componentWillUnmount 階段的狀態
// 所以此時, workInProgress 代替了 current 成爲了新的 current
root.current = finishedWork



一次點擊事件

如果你是一個經常使用 React 的打工人,就會發現 React 中的 event 是“閱後即焚的”。假設這樣一段代碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14













import React, { MouseEvent } from 'react'

function TestPersist () {

const handleClick = (
event: MouseEvent<HTMLElement, globalThis.MouseEvent>
) => {
setTimeout(() => console.log('event', event))
}

return (
<div onClick={handleClick}>O2</div>
)
}













如果我們需要異步的獲取這次點擊事件在屏幕中的位置並且做出相應處理,那麼在 setTimeout 中能否達到目的呢。

答案是否定的,因爲 React 使用了 事件委託 機制,我們拿到的 event 對象並不是原生的 nativeEvent ,而是被 React 挾持處理過的合成事件 SyntheticEvent ,這一點從 ts 類型中也可以看出, 我們使用的 MouseEvent 是從 React 包中引入的而不是全局的默認事件類型。在 handleClick 函數同步執行完畢的一瞬間,這個 event 就已經在 React 事件池中被銷燬了,我們可以跑這個組件康一康。

https://img30.360buyimg.com/ling/jfs/t1/151336/9/7644/85935/5fba22f6E6d4a0a30/9d15606d04b891e6.png

當然 React 也提供了使用異步事件對象的解決方案,它提供了一個 persist 函數,可以讓事件不再進入事件池。(在 React17 中爲了解決某些 issue ,已經重寫了合成事件機制,事件不再由 document 來代理,官網的說法是合成事件不再由事件池管理,也沒有了 persist 函數)

那,爲什麼要用事件委託呢。還是回到那個經典的命題,渲染 2 個 div 當然橫着寫豎着寫都沒關係,如果是 1000 個組件 2000 個點擊事件呢。事件委託的收益就是:

  1. 簡化了事件註冊的流程,優化性能。
  2. dom 元素不斷在更新,你無法保證下一幀的 div 和上一幀中的 div 在內存中的地址是同一個。既然不是同一個,事件又要全部重新綁定,煩死了(指瀏覽器)。

ok,言歸正傳。我們點擊事件到底發生了什麼呢。首先是在 React 的 render 函數執行之前,在 JS 腳本中就已經自動執行了事件的注入。

事件注入

事件注入的過程稍微有一點複雜,不光模塊之間有順序,數據也做了不少處理,這裏不 po 太詳細的代碼。可能有人會問爲啥不直接寫死呢,瀏覽器的事件不也就那麼億點點。就像 Redux 不是專門爲 React 服務的一樣, React 也不是專門爲瀏覽器服務的。文章開頭也說了 React 只是一個 javascipt 庫,它也可以服務 native 端、桌面端甚至各種終端。所以根據底層環境的不同動態的注入事件集也是非常合理的做法。

當然注入過程並不重要,我們需要知道的就是 React 安排了每種事件在 JSX 中的寫法和原生事件的對應關係(例如 onClick 和 onclick ),以及事件的優先級。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20



















/* ReactDOM環境 */

// DOM 環境的事件 plugin
const DOMEventPluginOrder = [
'ResponderEventPlugin',
'SimpleEventPlugin',
'EnterLeaveEventPlugin',
'ChangeEventPlugin',
'SelectEventPlugin',
'BeforeInputEventPlugin',
];

// 這個文件被引入的時候自動執行 injectEventPluginOrder
// 確定 plugin 被註冊的順序,並不是真正引入
EventPluginHub.injectEventPluginOrder(DOMEventPluginOrder)

// 真正的注入事件內容
EventPluginHub.injectEventPluginByName({
SimpleEventPlugin: SimpleEventPlugin
})



















這裏以 SimpleEventPlugin 爲例,點擊事件等我們平時常用的事件都屬於這個 plugin。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
























// 事件元組類型
type EventTuple = [
DOMTopLevelEventType, // React 中的事件類型
string, // 瀏覽器中的事件名稱
EventPriority // 事件優先級
]

const eventTuples: EventTuple[] = [
// 離散的事件
// 離散事件一般指的是在瀏覽器中連續兩次觸發間隔最少 33ms 的事件(沒有依據,我猜的)
// 例如你以光速敲打鍵盤兩次,這兩個事件的實際觸發時間戳仍然會有間隔
[ DOMTopLevelEventTypes.TOP_BLUR, 'blur', DiscreteEvent ],
[ DOMTopLevelEventTypes.TOP_CANCEL, 'cancel', DiscreteEvent ],
[ DOMTopLevelEventTypes.TOP_CHANGE, 'change', DiscreteEvent ],
[ DOMTopLevelEventTypes.TOP_CLICK, 'click', DiscreteEvent ],
[ DOMTopLevelEventTypes.TOP_CLOSE, 'close', DiscreteEvent ],
[ DOMTopLevelEventTypes.TOP_CONTEXT_MENU, 'contextMenu', DiscreteEvent ],
[ DOMTopLevelEventTypes.TOP_COPY, 'copy', DiscreteEvent ],
[ DOMTopLevelEventTypes.TOP_CUT, 'cut', DiscreteEvent ],
[ DOMTopLevelEventTypes.TOP_DOUBLE_CLICK, 'doubleClick', DiscreteEvent ],
[ DOMTopLevelEventTypes.TOP_AUX_CLICK, 'auxClick', DiscreteEvent ],
[ DOMTopLevelEventTypes.TOP_FOCUS, 'focus', DiscreteEvent ],
[ DOMTopLevelEventTypes.TOP_INPUT, 'input', DiscreteEvent ],
...
]
























那麼,這些事件的監聽事件是如何被註冊的呢。還記得在調和 Class 組件的時候會計算要向瀏覽器插入什麼樣的 dom 元素或是要如何更新 dom 元素。在這個過程中會通過 diffProperty 函數對元素的屬性進行 diff 對比,其中通過 ListenTo 來添加監聽函數

大家都知道,最終被綁定的監聽事件一定是被 React 魔改過,然後綁定在 document 上的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42









































function trapEventForPluginEventSystem (
element: Document | Element | Node,
topLevelType: DOMTopLevelEventType,
capture: boolean
): void {
// 生成一個 listener 監聽函數
let listener
switch (getEventPriority(topLevelType)) {
case DiscreteEvent: {
listener = dispatchDiscreteEvent.bind(
null,
topLevelType,
EventSystemFlags.PLUGIN_EVENT_SYSTEM
)
break
}
...
default: {
listener = dispatchEvent.bind(
null,
topLevelType,
EventSystemFlags.PLUGIN_EVENT_SYSTEM
)
}
}
// @todo 這裏用一個getRawEventName轉換了一下
// 這個函數就是 →_→
// const getRawEventName = a => a
// 雖然這個函數什麼都沒有做
// 但是它的名字語義化的說明了這一步
// 目的是得到瀏覽器環境下addEventListener第一個參數的合法名稱
const rawEventName = topLevelType
// 將捕獲事件listener掛載到根節點
// 這兩個部分都是爲了爲了兼容 IE 封裝過的 addEventListener
if (capture) {
// 註冊捕獲事件
addEventCaptureListener(element, rawEventName, listener)
} else {
// 註冊冒泡事件
addEventBubbleListener(element, rawEventName, listener)
}
}









































大家應該都知道 addEventListener 的第三個參數是控制監聽捕獲過程 or 冒泡過程的吧

https://img14.360buyimg.com/ling/jfs/t1/146083/13/15257/92531/5fba3093E68ec25bc/b189ae2c2eb7fe3e.jpg

ok,right now,鼠標點了下頁面,頁面調用了這個函數。開局就一個 nativeEvent 對象,這個函數要做的第一件事就是知道真正被點的那個組件是誰,其實看了一些源碼就知道, React 但凡有什麼事兒第一個步驟總是找到需要負責的那個 fiber 。

首先,通過 nativeEvent 獲取目標 dom 元素也就是 dom.target

1
const nativeEventTarget = getEventTarget(nativeEvent)
1
2
3
4
5
6
7
8
9








export default function getEventTarget(nativeEvent) {
// 兼容寫法
let target = nativeEvent.target || nativeEvent.srcElement || window

// Normalize SVG
// @todo

return target.nodeType === HtmlNodeType.TEXT_NODE ? target.parentNode : target
}








那麼如何通過 dom 拿到這個 dom 對應的 fiber 呢,事實上, React 會給這個 dom 元素添加一個屬性指向它對應的 fiber 。對於這個做法我是有疑問的,這樣的映射關係也可以通過維護一個 WeekMap 對象來實現,操作一個 WeakMap 的性能或許會優於操作一個 DOM 的屬性,且後者似乎不太優雅,如果你有更好的想法也歡迎在評論區指出。

每當 completeWork 中爲 fiber 構造了新的 dom,都會給這個 dom 一個指針來指向它的 fiber

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

































// 隨機Key
const randomKey = Math.random().toString(36).slice(2)

// 隨機Key對應的當前實例的Key
const internalInstanceKey = '__reactInternalInstance$' + randomKey
// Key 對應 render 之後的 props
const internalEventHandlersKey = '__reactEventHandlers$' + randomKey
// 對應實例
const internalContianerInstanceKey = '__reactContainer$' + randomKey

// 綁定操作
export function precacheFiberNode (
hostInst: object,
node: Document | Element | Node
): void {
node[internalInstanceKey] = hostInst
}

// 讀取操作
export function getClosestInstanceFromNode (targetNode) {
let targetInst = targetNode[internalInstanceKey]
// 如果此時沒有Key,直接返回null
if (targetInst) {
return targetInst
}

// 省略了一部分代碼
// 如果這個 dom 上面找不到 internalInstanceKey 這個屬性
// 就會向上尋找父節點,直到找到一個擁有 internalInstanceKey 屬性的 dom 元素
// 這也是爲什麼這個函數名要叫做 從 node 獲取最近的 (fiber) 實例
...

return null
}

































此時我們已經擁有了原生事件的對象,以及觸發了事件的 dom 以及對應的 fiber ,就可以從 fiber.memorizedProps 中取到我們綁定的 onClick 事件。這些信息已經足夠生成一個 React 合成事件 ReactSyntheticEvent 的實例了。

React 聲明瞭一個全局變量 事件隊列 eventQueue ,這個隊列用來存儲某次更新中所有被觸發的事件,我們需要讓這個點擊事件入隊。然後觸發。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27


























// 事件隊列
let eventQueue: ReactSyntheticEvent[] | ReactSyntheticEvent | null = null

export function runEventsInBatch (
events: ReactSyntheticEvent[] | ReactSyntheticEvent | null
) {
if (events !== null) {
// 存在 events 的話,加入事件隊列

// react 自己寫的合併數組函數 accumulateInto
// 或許是 ES3 時期寫的吧
eventQueue = accumulateInto<ReactSyntheticEvent>(eventQueue, events)
}

const processingEventQueue = eventQueue

// 執行完畢之後要清空隊列
// 雖然已經這些 event 已經被釋放了,但還是會被遍歷
eventQueue = null

if (!processingEventQueue) return

// 將這些事件逐個觸發

// forEachAccumulated 是 React 自己實現的 foreach
forEachAccumulated(processingEventQueue, executeDispatchesAndReleaseTopLevel)
}


























1
2
3
4
5
6
7
8
9
10
11
12











// 觸發一個事件並且立刻將事件釋放到事件池中,除非執行了presistent
const executeDispatchesAndRelease = function (event: ReactSyntheticEvent) {
if (event) {
// 按照次序依次觸發和該事件類型綁定的所有 listener
executeDispatchesInOrder(event)
}

// 如果沒有執行 persist 持久化 , 立即銷燬事件
if (!event.isPersistent()) {
(event.constructor as any).release(event)
}
}











可以看到合成事件的構造函數實例上掛載了一個函數 release ,用來釋放事件。我們看一看 SyntheticEvent 的代碼,可以發現這裏使用了一個事件池的概念 eventPool 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
































































Object.assign(SyntheticEvent.prototype, {

// 模擬原生的 preventDefault 函數
preventDefault: function() {
this.defaultPrevented = true;
const event = this.nativeEvent;
if (!event) {
return;
}

if (event.preventDefault) {
event.preventDefault();
} else {
event.returnValue = false;
}
this.isDefaultPrevented = functionThatReturnsTrue;
},

// 模擬原生的 stopPropagation
stopPropagation: function() {
const event = this.nativeEvent;
if (!event) {
return;
}

if (event.stopPropagation) {
event.stopPropagation();
} else {
event.cancelBubble = true;
}

this.isPropagationStopped = functionThatReturnsTrue;
},

/**
* 在每次事件循環之後,所有被 dispatch 過的合成事件都會被釋放
* 這個函數能夠允許一個引用使用事件不會被 GC 回收
*/
persist: function() {
this.isPersistent = functionThatReturnsTrue;
},

/**
* 這個 event 是否會被 GC 回收
*/
isPersistent: functionThatReturnsFalse,

/**
* 銷燬實例
* 就是將所有的字段都設置爲 null
*/
destructor: function() {
const Interface = this.constructor.Interface;
for (const propName in Interface) {
this[propName] = null;
}
this.dispatchConfig = null;
this._targetInst = null;
this.nativeEvent = null;
this.isDefaultPrevented = functionThatReturnsFalse;
this.isPropagationStopped = functionThatReturnsFalse;
this._dispatchListeners = null;
this._dispatchInstances = null;
},
});
































































React 在構造函數上直接添加了一個事件池屬性,其實就是一個數組,這個數組將被全局共用。每當事件被釋放的時候,如果線程池的長度還沒有超過規定的大小(默認是 10 ),那麼這個被銷燬後的事件就會被放進事件池

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
















// 爲合成事件構造函數添加靜態屬性
// 事件池爲所有實例所共用
function addEventPoolingTo (EventConstructor) {
EventConstructor.eventPool = []
EventConstructor.getPooled = getPooledEvent
EventConstructor.release = releasePooledEvent
}

// 將事件釋放
// 事件池有容量的話,放進事件池
function releasePooledEvent (event) {
const EventConstructor = this
event.destructor()
if (EventConstructor.eventPool.length < EVENT_POOL_SIZE) {
EventConstructor.eventPool.push(event)
}
}
















我們都知道單例模式,就是對於一個類在全局最多隻會有一個實例。而這種事件池的設計相當於是 n 例模式,每次事件觸發完畢之後,實例都要還給構造函數放進事件池,後面的每次觸發都將複用這些乾淨的實例,從而減少內存方面的開銷。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22





















// 需要事件實例的時候直接從事件池中取出
function getPooledEvent(dispatchConfig, targetInst, nativeEvent, nativeInst) {
const EventConstructor = this
if (EventConstructor.eventPool.length) {
// 從事件池中取出最後一個
const instance = EventConstructor.eventPool.pop()
EventConstructor.call(
instance,
dispatchConfig,
targetInst,
nativeEvent,
nativeInst
)
return instance
}
return new EventConstructor (
dispatchConfig,
targetInst,
nativeEvent,
nativeInst
)
}





















如果在短時間內瀏覽器事件被頻繁觸發,那麼將出現的現象是,之前事件池中的實例都被取出複用,而後續的合成事件對象就只能被老老實實重新創建,結束的時候通過放棄引用來被 V8 引擎的 GC 回收。

回到之前的事件觸發,如果不特地將屬性名寫成 onClickCapture 的話,那麼默認將被觸發的就會是冒泡過程。這個過程也是 React 模擬的,就是通過 fiber 逐層向上觸發的方式,捕獲過程也是同理。

我們都知道正常的事件觸發流程是:

  1. 事件捕獲
  2. 處於事件
  3. 事件冒泡

處於事件 階段是一個 try-catch 語句,這樣即使發生錯誤也會處於 React 的錯誤捕獲機制當中。我們真正想要執行的函數實體就是在此被觸發:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

























export default function invodeGuardedCallbackImpl<
A,
B,
C,
D,
E,
F,
Context
>(
name: string | null,
func: (a: A, b: B, c: C, d: D, e: E, f: F) => void,
context?: Context,
a?: A,
b?: B,
c?: C,
d?: D,
e?: E,
f?: F,
): void {
const funcArgs = Array.prototype.slice.call(arguments, 3)
try {
func.apply(context, funcArgs)
} catch (error) {
this.onError(error)
}
}

























類與函數

當我們使用類組件或是函數組件的時候,最終目的都是爲了得到一份 JSX 來描述我們的頁面。那麼其中就存在着一個問題—— React 是如何分辨函數組件和類組件的。

雖然在 ES6 中,我們可以輕易的看出 Class 和 函數的區別,但是別忘了,我們實際使用的往往是 babel 編譯後的代碼,而類就是函數和原型鏈構成的語法糖。可能大部分人最直接的想法就是,既然類組件繼承了 React.Component ,那麼應該可以直接使用類類型判斷就就行:

1
App instanceof React.Component

當然, React 採用的做法是在原型鏈上添加一個標識

1
Component.prototype.isReactComponent = {}

源碼中需要判斷是否是類組件的時候,就可以直接讀取函數的 isReactComponent 屬性時,因爲在函數(也是對象)自身找不到時,就會向上遊原型鏈逐級查找,直到到達 Object.prototype 對象爲止。

爲什麼 isReactComponent 是一個對象而不是布爾以及爲什麼不能用 instanceOf

狀態的更新

之前我們已經看懂了 React 的事件委託機制,那麼不如在一次點擊事件中嘗試修改組件的狀態來更新我們的頁面。

首先康康 setState 是如何工作的,我們知道 this.setState 是 React.Component 類中的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16















/**
* @description 更新組件state
* @param { object | Function } partialState 下個階段的狀態
* @param { ?Function } callback 更新完畢之後的回調
*/
Component.prototype.setState = function (partialState, callback) {
if (!(
isObject(partialState) ||
isFunction(partialState) ||
isNull
)) {
console.warn('setState的第一個參數應爲對象、函數或null')
return
}
this.updater.enqueueSetState(this, partialState, callback, 'setState')
}















看起來核心步驟就是觸發掛載在實例上的一個 updater 對象。默認的, updater 會是一個展位的空對象,雖然實現了 enqueueSetState 等方法,但是這些方法內部都是空的。

1
2

// 我們初始化這個默認的update,真正的updater會被renderer注入
this.updater = updater || ReactNoopUpdateQueue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40







































export const ReactNoopUpdateQueue = {
/**
* 檢查組件是否已經掛載
*/
isMounted: function (publishInstance) {
// 初始化ing的組件就別掛載不掛載了
return false
},

/**
* 強制更新
*/
enqueueForceUpdate: function (publishInstance, callback, callerName) {
console.warn('enqueueForceUpdate', publishInstance)
},

/**
* 直接替換整個state,通常用這個或者setState來更新狀態
*/
enqueueReplaceState: function (
publishInstance,
completeState,
callback,
callerName
) {
console.warn('enqueueReplaceState', publishInstance)
},

/**
* 修改部分state
*/
enqueueSetState: function (
publishInstance,
partialState,
callback,
callerName
) {
console.warn('enqueueSetState', publishInstance)
}
}







































還記得我們在 render 的過程中,是通過執行 Component.render() 來獲得一個類組件的實例,當 React 得到了這個實例之後,就會將實例的 updater 替換成真正的 classComponentUpdater :

1
2
3
4
5
6
7






function adoptClassInstance (
workInProgress: Fiber,
instance: any
): void {
instance.updater = classComponentUpdate
...
}






剛剛我們觸發了這個對象中的 enqueueSetState 函數,那麼可以看看實現:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36



































const classComponentUpdate = {
isMounted,
/**
* 觸發組件狀態的更新
* @param inst ReactElement
* @param payload any
* @param callback 更新結束之後的回調
*/
enqueueSetState(
inst: ReactElement,
payload: any,
callback?: Function
) {
// ReactElement -> fiber
const fiber = getInstance(inst)
// 當前時間
const currentTime = requestCurrentTime()
// 獲取當前 suspense config
const suspenseConfig = requestCurrentSuspenseConfig()
// 計算當前 fiber 節點的任務過期時間
const expirationTime = computeExpirationForFiber(
currentTime,
fiber,
suspenseConfig
)

// 創建一個 update 實例
const update = createUpdate(expirationTime, suspenseConfig)
update.payload = payload
// 將 update 裝載到 fiber 的 queue 中
enqueueUpdate(fiber, update)
// 安排任務
ScheduleWork(fiber, expirationTime)
},
...
}



































顯然,這個函數的作用就是獲得類組件對應的 fiber ,更新它在任務調度器中的過期時間(領導給了新工作,自然要定新的 Deadline ),然後就是創建一個新的 update 任務裝載到 fiber 的任務隊列中。最後通過 ScheduleWork (告訴任務調度器來任務了,趕緊幹活) 要求從這個 fiber 開始調和,至於調和和更新的步驟我們在第一次渲染中已經有了大致的瞭解。

順帶提一提 Hooks 中的 useState 。網絡上有挺多講解 hook 實現的文章已經講得很全面了,我們只需要搞清楚以下幾點問題。

Q1. 函數組件不像類組件一樣擁有實例,數據存儲在哪裏

A1. 任何以 ReactElement 爲粒度的組件都需要圍繞 fiber ,數據存儲在 fiber.memorizedState 上

Q2. useState 的實現

A2. 如果你聽過了 useState 那麼你就應該聽過 useReducer ,如果聽過 reducer 就應該知道 redux。首先,useState 的本質就是 useReducer 的語法糖。我們都知道構建一個狀態庫需要一個 reducer ,useState 就是當 reducer 函數爲 a => a 時的特殊情況。

1
2
3
4
5
6
7
8
9








function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {
return typeof action === 'function' ? action(state) : action
}

function updateState<S>(
initialState: (() => S) | S
): [ S, Dispatch<BasicStateAction<S>> ] {
return updateReducer<S, (() => S) | S, any>(basicStateReducer, initialState)
}








Q3. 爲什麼 Hooks 的順序和個數不允許改變

A3. 每次執行 Hooks 函數需要取出上一次渲染時數據的最終狀態,因爲結構是鏈表而不是一個 Map,所以這些最終狀態也會是有序的,所以如果個數和次序改變會導致數據的錯亂。

時間調度機制

雖然今年過期時間 expirationTime 機制已經被淘汰了,但是不管是航道模型還是過期時間,本質上都是任務優先級的不同體現形式。

在探究運行機制之前我們需要知道一個問題就是,爲什麼時間片的性能會優於同步計算的性能。此處借用司徒正美老師文章中的例子。

實驗1,通過 for 循環一次性向 document 中插入1000 個節點

1
2
3
4
5
6
7
8
9
10
11
12
13
14













function randomHexColor(){
return "#" + ("0000"+ (Math.random() * 0x1000000 << 0).toString(16)).substr(-6);
}
setTimeout(function() {
var k = 0;
var root = document.getElementById("root");
for(var i = 0; i < 10000; i++){
k += new Date - 0 ;
var el = document.createElement("div");
el.innerHTML = k;
root.appendChild(el);
el.style.cssText = background:${randomHexColor()};height:40px ;
}
}, 1000);













實驗2,進行 10 次 setTimeout 分批次操作,每次插入 100 個節點

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23






















function randomHexColor() {
return "#" + ("0000" + (Math.random() * 0x1000000 << 0).toString(16)).substr(-6);
}
var root = document.getElementById("root");
setTimeout(function () {
function loop(n) {
var k = 0;
console.log(n);
for (var i = 0; i < 100; i++) {
k += new Date - 0;
var el = document.createElement("div");
el.innerHTML = k;
root.appendChild(el);
el.style.cssText = background:${randomHexColor()};height:40px ;
}
if (n) {
setTimeout(function () {
loop(n - 1);
}, 40);
}
}
loop(100);
}, 1000);






















相同的結果,第一個實驗花費了 1000 ms,而第二個實驗僅僅花費了 31.5 ms。

這和 V8 引擎的底層原理有關,我們都知道瀏覽器是單線程,一次性需要做到 GUI 描繪,事件處理,JS 執行等多個操作時,V8 引擎會優先對代碼進行執行,而不會對執行速度進行優化。如果我們稍微給瀏覽器一些時間,瀏覽器就能夠進行 JIT ,也叫熱代碼優化。

簡單來說, JS 是一種解釋型語言,每次執行都需要被編譯成字節碼才能被運行。但是如果某個函數被多次執行,且參數類型和參數個數始終保持不變。那麼這段代碼會被識別爲 熱代碼 ,遵循着“萬物皆可空間換時間”的原則,這段代碼的字節碼會被緩存,下次再次運行的時候就會直接被運行而不需要進行耗時的解釋操作。也就是 解釋器 + 編譯器 的模式。

做個比喻來說,我們工作不能一直蠻幹,必須要給自己一些時間進行反思和總結,否則工作速度和效率始終是線性的,人也不會有進步。

還記得在 WorkLoop 函數中,每次處理完一個 fiber 都會跳出循環執行一次 shouldYield 函數進行判斷,是否應該將執行權交還給瀏覽器處理用戶時間或是渲染。看看這個 shouldYield 函數的代碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
























// 當前是否應該阻塞 react 的工作
function shouldYield (): boolean {
// 獲取當前的時間點
const currentTime = getCurrentTime()

// 檢查任務隊列中是否有任務需要執行
advanceTimers(currentTime)

// 取出任務隊列中任務優先級最高的任務
const firstTask = peek(taskQueue)

// 以下兩種情況需要yield
// 1. 當前任務隊列中存在任務,且第一個任務的開始時間還沒到,且過期時間小於當前任務
// 2. 處於固定的瀏覽器渲染時間區間
return (
(
currentTask !== null &&
firstTask !== null &&
(firstTask as any).startTime <= currentTime &&
(firstTask as any).expirationTime < currentTask.expirationTime
)
// 當前處於時間片的阻塞區間
|| shouldYieldToHost()
)
}
























決定一個任務當前是否應該被執行有兩個因素。

  1. 這個任務是否非執行不可,正所謂一切的不論是不是先問爲什麼都是耍流氓。如果到期時間還沒到,爲什麼不先把線程空出來留給可能的高優先級任務呢。
  2. 如果多個任務都非執行不可,那麼任務的優先級是否是當前隊列中最高的。

如果一個任務的過期時間已經到了必須執行,那麼這個任務就應該處於 待執行隊列 taskQueue 中。相反這個任務的過期時間還沒到,就可以先放在 延遲列表 中。每一幀結束的時候都會執行 advanceTimer 函數,將一些延遲列表中到期的任務取出,插入待執行隊列。

可能是出於最佳實踐考慮,待執行隊列是一個小根堆結構,而延遲隊列是一個有序鏈表。

回想一下 React 的任務調度要求,當一個新的優先級更高的任務產生,需要能夠打斷之前的工作並插隊。也就是說,React 需要維持一個始終有序的數組數據結構。因此,React 自實現了一個小根堆,但是這個小根堆無需像堆排序的結果一樣整體有序,只需要保證每次進行 push 和 pop 操作之後,優先級最高的任務能夠到達堆頂。

所以 shouldYield 返回 true 的一個關鍵條件就是,當前 taskQueue 堆中的堆頂任務的過期時間已經到了,那麼就應該暫停工作交出線程使用權。

那麼待執行的任務是如何被執行的呢。這裏我們需要先了解 MessageChannel 的概念。Message

Channel 的實例會擁有兩個端口,其中第一個端口爲發送信息的端口,第二個端口爲接收信息的端口。當接收到信息就可以執行指定的回調函數。

1
2
3
4
5




const channel = new MessageChannel()
// 發送端
const port = channel.port2
// 接收端
channel.port1.onmessage = performWorkUntilDeadline // 在一定時間內儘可能的處理任務




每當待執行任務隊列中有任務的時候,就會通過 Channel 的發送端發送一個空的 message ,當接收端異步地接收到這個信號的時候,就會在一個時間片內儘可能地執行任務。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46













































// 記錄任一時間片的結束時刻
let deadline = 0

// 單位時間切片長度
let yieldInterval = 5

// 執行任務直到用盡當前時間片空閒時間
function performWorkUntilDeadline () {
if (scheduledHostCallback !== null) {
// 如果有計劃任務,那麼需要執行

// 當前時間
const currentTime = getCurrentTime()

// 在每個時間片之後阻塞(5ms)
// deadline 爲這一次時間片的結束時間
deadline = currentTime + yieldInterval

// 既然能執行這個函數,就代表着還有時間剩餘
const hasTimeRemaining = true

try {
// 將當前阻塞的任務計劃執行
const hasMoreWork = scheduledHostCallback(
hasTimeRemaining,
currentTime
)

if (!hasMoreWork) {
// 如果沒有任務了, 清空數據
isMessageLoopRunning = false
scheduledHostCallback = null
} else {
// 如果還有任務,在當前時間片的結尾發送一個 message event
// 接收端接收到的時候就將進入下一個時間片
port.postMessage(null)
}
} catch (error) {
port.postMessage(null)
throw(error)
}
} else {
// 壓根沒有任務,不執行
isMessageLoopRunning = false
}
}













































我們在之前說過,阻塞 WorkLoop 的條件有兩個,第一個是任務隊列的第一個任務還沒到時間,第二個條件就是 shouldYieldToHost 返回 true,也就是處於時間片期間。

1
2
3
4



// 此時是否是【時間片阻塞】區間
export function shouldYieldToHost () {
return getCurrentTime() >= deadline
}



總結一下,時間調度機制其實就是 fiber 遍歷任務 WorkLoop 和調度器中的任務隊列爭奪線程使用權的過程。不過區別是前者完全是同步的過程,只會在每個 while 的間隙去詢問 調度器 :我是否可以繼續執行下去。而在調度器拿到線程使用權的每個時間片中,都會盡可能的處理任務隊列中的任務。

傳統武術講究點到爲止,以上內容,就是這次 React 原理的全部。在文章中我並沒有放出大量的代碼,只是放出了一些片段用來佐證我對於源碼的一些看法和觀點,文中的流程只是一個循序思考的過程,如果需要查看更多細節還是應該從源碼入手。

當然文中的很多觀點帶有主觀色彩,並不一定就正確,同時我也不認爲網絡上的其他文章的說法就和 React 被設計時的初衷完全一致,甚至 React 源碼中的很多寫法也未必完美。不管閱讀什麼代碼,我們都不要神話它,而是應該辯證的去看待它。總的來說,功過91開。

前端世界並不需要第二個 React ,我們學習的意義並不是爲了證明我們對這個框架有多麼瞭解。而是通過窺探這些頂級工程師的實現思路,去完善我們自己的邏輯體系,從而成爲一個更加嚴謹的人。

經驗分享
感謝您的閱讀,本文由 凹凸實驗室 版權所有。如若轉載,請註明出處:凹凸實驗室( https://aotu.io/notes/2020/11/12/react-indoor/
上次更新:2020-11-25 15:10:47
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章