從原理層面探究 React 是如何工作的
共計 10137 字,閱讀時長約 25 分鐘
誰都沒有看見過風,更不用說你和我了。但是當紙幣在飄的時候,我們知道那是風在數(shù)錢。
React 影響著我們工作的方方面面,我們每天都在使用它,只窺其表卻難以窺其里。正所謂看不如寫,本篇文章的目的就是從原理層面探究 React 是如何工作的。
工具—
在寫文章之前,為了方便理解,我準備了一個懶人調(diào)試倉庫 simple_react[1] ,這個倉庫將 benchmark 用例(只有兩個 ^ ^)和 React 源碼共同放在 src 文件夾中,通過 snowpack 進行熱更新,可以直接在源碼中加入 log 和 debuger 進行調(diào)試。當然這里的“源碼”并不是真的源碼,因為 React 源碼中充斥著巨量的 dev 代碼和不明確的功能函數(shù),所以我對源碼進行了整理,用 typescript 對類型進行了規(guī)范,刪除了大量和核心流程無關的代碼(當然也誤刪了一些有關的 ^ ^)。
如果你只是希望了解 React 的運行流程而不是寫一個可以用的框架的話,那么這個倉庫完全可以滿足你學習的需要。當然,這個倉庫基于 React16.8 ,雖然這個版本并不包括當前的航道模型 Lane 等新特性,但是是我個人認為比較穩(wěn)定且更適合閱讀的一個版本。
(如果希望調(diào)試完整的源碼,也可以參考 拉取源碼[2] 通過 yarn link 來進行 debug)
文章結(jié)構(gòu)—
fiber 架構(gòu)設計及首次渲染流程 事件委托機制 狀態(tài)的更新 時間片
在了解 React 是如何工作之前,我們應該確保了解幾點有關 React 的基礎知識。
Why Framework—
首先,我們需要知道使用框架對于開發(fā)的意義是什么。如果我們還處于遠古時期使用純 JS 的階段,每次數(shù)據(jù)的改變都會引發(fā)組件的展示狀態(tài)改變,因此我們需要去手動的操作 DOM 。如果在某一秒內(nèi),數(shù)據(jù)異步的連續(xù)改變了幾十次,根據(jù)展示邏輯我們也需要連續(xù)對 DOM 進行幾十次修改。頻繁的 DOM 操作對網(wǎng)頁性能的影響是很大的,當然,創(chuàng)建 DOM 元素和修改 DOM 元素的屬性都不過分消耗性能,主要在于每次將新的 DOM 插入 document 都會導致瀏覽器重新計算布局屬性,以及各個視圖層、合并、渲染。所以,這樣的代碼性能是十分低下的。
可以試想這樣一個場景。對于一個前端列表組件而言,當存在 3 條數(shù)據(jù)的時候展示 3 條,當存在 5 條數(shù)據(jù)的時候展示 5 條。也就是說 UI 的呈現(xiàn)在某種程度上必然會和數(shù)據(jù)存在某種邏輯關系。如果 JS 能夠感知到關鍵數(shù)據(jù)的改變,使用一種高效的方式將 DOM 改寫成與數(shù)據(jù)相對應的狀態(tài)。那么于開發(fā)者而言,就可以專注于業(yè)務邏輯和數(shù)據(jù)的改變,工作效率也會大幅提高。
所以, 框架 最核心的功能之一就是 高效地 達成 UI 層和數(shù)據(jù)層的統(tǒng)一。
React 哲學—
React 本身并不是框架, React 只是一個 JavaScript 庫,他的作用是通過組件構(gòu)建用戶界面,屬于 MVC 應用中的 View 視圖層。React 通過 props 和 state 來簡化關鍵數(shù)據(jù)的存儲,對于一個 react 組件函數(shù)而言,在 1 秒內(nèi)可能被執(zhí)行很多次。而每一次被執(zhí)行,數(shù)據(jù)被注入 JSX , JSX 并不是真實的 DOM ,在 React 中會被轉(zhuǎn)換成 React.createElement(type, props, children) 函數(shù),執(zhí)行的結(jié)果就是 ReactElement 元素 ,也即是 虛擬 DOM ,用來描述在瀏覽器的某一幀中,組件應該被呈現(xiàn)為什么樣子。
Virtual Dom—
VirtualDom 并非 React 專屬,就像 redux 也可以在非 React 環(huán)境下使用一樣,它們只是一種設計的思路。
事實上, React 在使用 fiber 架構(gòu)之前的 Virtual Dom 和 diff 過程要相對直觀一些。但是在引入了 fiber 架構(gòu)之后整個流程變得冗長,如果單純想了解 VirtualDom 和 diff 過程的原理也可以通過 simple-virtual-dom[3] 這個倉庫來學習。
VirtualDom 的本質(zhì)是利用 JS 變量 對真實 DOM 進行抽象,既然每一次操作 DOM 都可能觸發(fā)瀏覽器的重排消耗性能,那么就可以使用 VirtualDom 來緩存當前組件狀態(tài),對用戶交互和數(shù)據(jù)的變動進行批次處理,直接計算出每一幀頁面應該呈現(xiàn)的最終狀態(tài),而這個狀態(tài)是以 JS 變量 的形式存在于內(nèi)存中的。所以通過 VirtualDom 既能夠保證用戶看到的每一幀都響應了數(shù)據(jù)的變化,又能節(jié)約性能保證瀏覽器不出現(xiàn)卡頓。
第一次渲染 First Render
首先我們應該注意到 React(瀏覽器環(huán)境) 代碼的入口 render 函數(shù)
ReactDOM.render(<App />, domContainer)
這個 render 過程中, React 需要做到的是根據(jù)用戶創(chuàng)造的 JSX 語法,構(gòu)建出一個虛擬的樹結(jié)構(gòu)(也就是 ReactElement 和 Fiber )來表示用戶 期望中 頁面中的元素結(jié)構(gòu)。當然對于這個過程相對并不復雜(誤),因為此時的 document 內(nèi)還是一片虛無。就思路上而言,只需要根據(jù)虛擬 DOM 節(jié)點生成真實的 DOM 元素然后插入 document ,第一次渲染就算圓滿完成。
createReactElement—
通常我們會通過 Babel 將 JSX 轉(zhuǎn)換為一個 JS 執(zhí)行函數(shù)。例如我們在 React 環(huán)境下用 JSX 中寫了一個標題組件
Class Component
那么這個組件被 Babel 轉(zhuǎn)換之后將會是
React.createElement('h1', { className: 'title' }, [
React.createElement('div', null, [ 'Class Component' ]
])
傳統(tǒng)編譯講究一個 JSON 化,當然 JSX 和 React 也沒有什么關系, JSX 只是 React 推薦的一種拓展語法。當然你也可以不用 JSX 直接使用 React.createElement 函數(shù),但是對比上面的兩種寫法你就也能知道,使用純 JS 的心智成本會比簡明可見的 JSX 高多少。我們可以看出, React.createElement 需要接收 3 個參數(shù),分別是 DOM 元素的標簽名,屬性對象以及一個子元素數(shù)組,返回值則是一個 ReactElement 對象。
事實上, JSX 編譯后的 json 結(jié)構(gòu)本身就是一個對象,即使不執(zhí)行 React.createElement 函數(shù)也已經(jīng)初步可以使用了。那么在這個函數(shù)中我們做了什么呢。
一個 ReactElement 元素主要有 5 個關鍵屬性,我們都知道要構(gòu)建成一個頁面需要通過 html 描述元素的類型和結(jié)構(gòu),通過 style 和 class 去描述元素的樣式呈現(xiàn),通過 js 和綁定事件來觸發(fā)交互事件和頁面更新。
所以最重要的是第一個屬性,元素類型 type 。如果這個元素是一個純 html 標簽元素,例如 div ,那么 type 將會是字符串 div ,如果是一個 React 組件,例如
function App() {
return (
<div>Hello, World!div>
)
}
那么 type 的值將會指向 App 函數(shù),當然 Class 組件 也一樣(眾所周知 ES6 的 Class 語法本身就是函數(shù)以及原型鏈構(gòu)成的語法糖)
第二個屬性是 props ,我們在 html 標簽中寫入的大部分屬性都會被收集在 props 中,例如 id 、 className 、 style 、 children 、點擊事件等等。
第三個第四個屬性分別是 key 和 ref ,其中 key 在數(shù)組的處理和 diff 過程中有重要作用,而 ref 則是引用標識,在這里就先不做過多介紹。
最后一個屬性是 $$typeof ,這個屬性會指向 Symbol(React.element) 。作為 React 元素的唯一標識的同時,這個標簽也承擔了安全方面的功能。我們已經(jīng)知道了所謂的 ReactElement 其實就是一個 JS 對象。那么如果有用戶惡意的向服務端數(shù)據(jù)庫中存入了某個有侵入性功能的 偽 React 對象,在實際渲染過程中被當做頁面元素渲染,那么將有可能威脅到用戶的安全。而 Symbol 是無法在數(shù)據(jù)庫中被存儲的,換句話說, React 所渲染的所有元素,都必須是由 JSX 編譯的擁有 Symbol 標識的元素。(如果在低版本不支持 Symbol 的瀏覽器中,將會使用字符串替代,也就沒有這層安排保護了)
ok,接下來回到 render 函數(shù)。在這個函數(shù)中到底發(fā)生了什么呢,簡單來說就是創(chuàng)建 Root 結(jié)構(gòu)。

enqueueUpdate
從設計者的角度,根據(jù) 單一職責原則 和 開閉口原則 需要有與函數(shù)體解耦的數(shù)據(jù)結(jié)構(gòu)來告訴 React 應該怎么操作 fiber 。而不是初次渲染寫一套邏輯,第二次渲染寫一套邏輯。因此, fiber 上有了更新隊列 UpdateQueue 和 更新鏈表 Update 結(jié)構(gòu)
如果查看一下相關的定義就會發(fā)現(xiàn),更新隊列 updateQueue 是多個更新組成的鏈表結(jié)構(gòu),而 update 的更新也是一個鏈表,至于為什么是這樣設計,試想在一個 Class Component 的更新函數(shù)中連續(xù)執(zhí)行了 3 次 setState ,與其將其作為 3 個更新掛載到組件上,不如提供一種更小粒度的控制方式。一句話概括就是, setState 級別的小更新合并成一個狀態(tài)更新,組件中的多個狀態(tài)更新在組件的更新隊列中合并,就能夠計算出組件的新狀態(tài) newState。
對于初次渲染而言,只需要在第一個 fiber 上,掛載一個 update 標識這是一個初次渲染的 fiber 即可。
// 更新根節(jié)點
export function ScheduleRootUpdate (
current: Fiber,
element: ReactElement,
expirationTime: number,
suspenseConfig: SuspenseConfig | null,
callback?: Function
) {
// 創(chuàng)建一個update實例
const update = createUpdate(expirationTime, suspenseConfig)
// 對于作用在根節(jié)點上的 react element
update.payload = {
element
}
// 將 update 掛載到根 fiber 的 updateQueue 屬性上
enqueueUpdate(
current,
update
)
ScheduleWork(
current,
expirationTime
)
}
Fiber—
作為整個 Fiber 架構(gòu) 中最核心的設計, Fiber 被設計成了鏈表結(jié)構(gòu)。
child 指向當前節(jié)點的第一個子元素 return 指向當前節(jié)點的父元素 sibling 指向同級的下一個兄弟節(jié)點
如果是 React16 之前的樹狀結(jié)構(gòu),就需要通過 DFS 深度遍歷來查找每一個節(jié)點。而現(xiàn)在只需要將指針按照 child → sibling → return 的優(yōu)先級移動,就可以處理所有的節(jié)點

這樣設計還有一個好處就是在 React 工作的時候只需要使用一個全局變量作為指針在鏈表中不斷移動,如果出現(xiàn)用戶輸入或其他優(yōu)先級更高的任務就可以 暫停 當前工作,其他任務結(jié)束后只需要根據(jù)指針的位置繼續(xù)向下移動就可以繼續(xù)之前的工作。指針移動的規(guī)律可以歸納為 自頂向下,從左到右 。
康康 fiber 的基本結(jié)構(gòu)

其中
tag fiber 的類型 ,例如函數(shù)組件,類組件,原生組件, Portal 等。 type React 元素 類型 詳見上方 createElement。 alternate 代表雙向緩沖對象(看后面)。 effectTag 代表這個 fiber 在下一次渲染中將會被如何處理。例如只需要插入,那么這個值中會包含 Placement ,如果需要被刪除,那么將會包含 Deletion 。 expirationTime 過期時間,過期時間越靠前,就代表這個 fiber 的優(yōu)先級越高。 firstEffect 和 lastEffect 的類型都和 fiber 一樣,同樣是鏈表結(jié)構(gòu),通過 nextEffect 來連接。代表著即將更新的 fiber 狀態(tài) memorizeState 和 memorizeProps 代表在上次渲染中組件的 props 和 state 。如果成功更新,那么新的 pendingProps 和 newState 將會替代這兩個變量的值 ref 引用標識 stateNode 代表這個 fiber 節(jié)點對應的真實狀態(tài) 對于原生組件,這個值指向一個 dom 節(jié)點(雖然已經(jīng)被創(chuàng)建了,但不代表就被插入了 document ) 對于類組件,這個值指向?qū)念悓嵗?/section> 對于函數(shù)組件,這個值指向 Null 對于 RootFiber,這個值指向 FiberRoot (如圖)
接下來是初次渲染的幾個核心步驟,因為是初次渲染,核心任務就是將首屏元素渲染到頁面上,所以這個過程將會是同步的。
PrepareFreshStack—
因為筆者是土貨沒學過英語,百度了下發(fā)現(xiàn)是 準備干凈的棧 的意思。結(jié)合了下流程,可以看出這一步的作用是在真正工作之前做一些準備,例如初始化一些變量,放棄之前未完成的工作,以及最重要的—— 創(chuàng)建雙向緩沖變量 WorkInProgress
let workInProgress: Fiber | null = null
...
export function prepareFreshStack (
root: FiberRoot,
expirationTime: number
) {
// 重置根節(jié)點的finishWork
root.finishedWork = null
root.finishedExpirationTime = ExpirationTime.NoWork
...
if (workInProgress !== null) {
// 如果已經(jīng)存在了WIP,說明存在未完成的任務
// 向上找到它的root fiber
let interruptedWork = workInProgress.return
while (interruptedWork !== null) {
// unwindInterruptedWork // 抹去未完成的任務
unwindInterruptedWork(interruptedWork)
interruptedWork = interruptedWork.return
}
}
workInProgressRoot = root
// 創(chuàng)建雙向緩沖對象
workInProgress = createWorkInProgress(root.current, null, expirationTime)
renderExpirationTime = expirationTime
workInProgressRootExitStatus = RootExitStatus.RootImcomplete
}
雙向緩沖變量 WorkInProgress
這里簡稱 WIP 好了,與之對應的是 current , current 代表的是當前頁面上呈現(xiàn)的組件對應的 fiber 節(jié)點,你可以將其類比為 git 中的 master 分支,它代表的是已經(jīng)對外的狀態(tài)。而 WIP 則代表了一個 pending 的狀態(tài),也就是下一幀屏幕將要呈現(xiàn)的狀態(tài),就像是從 master 拉出來的一個 feature 分支,我們可以在這個分支上做任意的更改。最終協(xié)調(diào)完畢,將 WIP 的結(jié)果渲染到了頁面上,按照頁面內(nèi)容對應 current 的原則, current 將會指向 WIP ,也就是說, WIP 取代了之前的 current ( git 的 master 分支)。
在這之前 current 和 WIP 的 alternate 字段分別指向彼此。

那么 WIP 是如何被創(chuàng)造出來的呢:
// 根據(jù)已有 fiber 生成一個 workInProgress 節(jié)點
export function createWorkInProgress (
current: Fiber,
pendingProps: any,
expirationTime
): Fiber {
let workInProgress = current.alternate
if (workInProgress === null) {
// 如果當前fiber沒有alternate
// tip: 這里使用的是“雙緩沖池技術(shù)”,因為我們最多需要一棵樹的兩個實例。
// tip: 我們可以自由的復用未使用的節(jié)點
// tip: 這是異步創(chuàng)建的,避免使用額外的對象
// tip: 這同樣支持我們釋放額外的內(nèi)存(如果需要的話
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 {
// 我們已經(jīng)有了一個 WIP
workInProgress.pendingProps = pendingProps
// 重置 effectTag
workInProgress.effectTag = EffectTag.NoEffect
// 重置 effect 鏈表
workInProgress.nextEffect = null
workInProgress.firstEffect = null
workInProgress.lastEffect = null
}
可以看出 WIP 其實就是繼承了 current 的核心屬性,但是去除了一些副作用和工作記錄的 干凈 的 fiber。
工作循環(huán) WorkLoop
在工作循環(huán)中,將會執(zhí)行一個 while 語句,每執(zhí)行一次循環(huán),都會完成對一個 fiber 節(jié)點的處理。在 workLoop 模塊中有一個指針 workInProgress 指向當前正在處理的 fiber ,它會不斷向鏈表的尾部移動,直到指向的值為 null ,就停止這部分工作, workLoop 的部分也就結(jié)束了。
每處理一個 fiber 節(jié)點都是一個工作單元,結(jié)束了一個工作單元后 React 會進行一次判斷,是否需要暫停工作檢查有沒有更高優(yōu)先級的用戶交互進來。
function workLoopConcurrent() {
// 執(zhí)行工作直到 Scheduler 要求我們 yield
while (workInProgress !== null && !shouldYield()) {
workInProgress = performUnitOfWork(workInProgress);
}
}
跳出條件只有:
所有 fiber 都已經(jīng)被遍歷結(jié)束了 當前線程的使用權(quán)移交給了外部任務隊列
但是我們現(xiàn)在討論的是第一次渲染,觸屏渲染的優(yōu)先級高于一切,所以并不存在第二個限制條件。
function workLoopSync () {
// 只要沒有完成reconcile就一直執(zhí)行
while(workInProgress !== null) {
workInProgress = performUnitOfWork(workInProgress as Fiber)
}
}
PerformUnitOfWork & beginWork—
單元工作 performUnitOfWork 的主要工作是通過 beginWork 來完成。beginWork 的核心工作是通過判斷 fiber.tag 判斷當前的 fiber 代表的是一個類組件、函數(shù)組件還是原生組件,并且針對它們做一些特殊處理。這一切都是為了最終步驟:操作真實 DOM 做準備,即通過改變 fiber.effectTag 和 pendingProps 告訴后面的 commitRoot 函數(shù)應該對真實 DOM 進行怎樣的改寫。
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 組件為例,查看一下具體是如何構(gòu)建的。
之前有提過,對于類組件而言, fiber.stateNode 會指向這個類之前構(gòu)造過的實例。
// 更新Class組件
function updateClassComponent (
current: Fiber | null,
workInProgress: Fiber,
Component: any,
nextProps,
renderExpiration: number
) {
// 如果這個 class 組件被渲染過,stateNode 會指向類實例
// 否則 stateNode 指向 null
const instance = workInProgress.stateNode
if (instance === null) {
// 如果沒有構(gòu)造過類實例
...
} else {
// 如果構(gòu)造過類實例
...
}
// 完成 render 的構(gòu)建,將得到的 react 元素和已有元素進行調(diào)和
const nextUnitOfWork = finishClassComponent(
current,
workInProgress,
Component,
shouldUpdate,
false,
renderExpiration
)
return nextUnitOfWork
如果這個 fiber 并沒有構(gòu)建過類實例的話,就會調(diào)用它的構(gòu)建函數(shù),并且將更新器 updater 掛載到這個類實例上。(處理 setState 邏輯用的,事實上所有的類組件實例上的更新器都是同一個對象,后面會提到)
if (instance === null) {
// 這個 class 第一次渲染
if (current !== null) {
// 刪除 current 和 WIP 之間的指針
current.alternate = null
workInProgress.alternate = null
// 插入操作
workInProgress.effectTag |= EffectTag.Placement
}
// 調(diào)用構(gòu)造函數(shù),創(chuàng)造新的類實例
// 給予類實例的某個指針指向更新器 updater
constructClassInstance(
workInProgress,
Component,
nextProps,
renderExpiration
)
// 將屬性掛載到類實例上,并且觸發(fā)多個生命周期
mountClassInstance(
workInProgress,
Component,
nextProps,
renderExpiration
)
}
如果實例已經(jīng)存在,就需要對比新舊 props 和 state ,判斷是否需要更新組件(萬一寫了 shouldComponentUpdate 呢)。并且觸發(fā)一些更新時的生命周期鉤子,例如 getDerivedStateFromProps 等等。
else {
// 已經(jīng) render 過了,更新
shouldUpdate = updateClassInstance(
current,
workInProgress,
Component,
nextProps,
renderExpiration
)
}
屬性計算完畢后,調(diào)用類的 render 函數(shù)獲取最終的 ReactElement ,打上 Performed 標記,代表這個類在本次渲染中已經(jīng)執(zhí)行過了。
// 完成Class組件的構(gòu)建
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
// 開始調(diào)和 reconcile
reconcileChildren(
current,
workInProgress,
nextChildren,
renderExpiration
)
return workInProgress.child
}
調(diào)和過程
如果還記得之前的內(nèi)容的話,我們在一切工作開始之前只是構(gòu)建了第一個根節(jié)點 fiberRoot 和第一個無意義的空 root ,而在單個元素的調(diào)和過程 reconcileSingleElement 中會根據(jù)之前 render 得到的 ReactElement 元素構(gòu)建出對應的 fiber 并且插入到整個 fiber 鏈表中去。
并且通過 placeSingleChild 給這個 fiber 的 effectTag 打上 Placement 的標簽,擁有 Placement 標記后這里的工作就完成了,可以將 fiber 指針移動到下一個節(jié)點了。
// 處理對象類型(單個節(jié)點)
const isObjectType = isObject(newChild) && !isNull(newChild)
// 對象
if (isObjectType) {
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE: {
// 在遞歸調(diào)和結(jié)束,向上回溯的過程中
// 給這個 fiber 節(jié)點打上 Placement 的 Tag
return placeSingleChild(
reconcileSingleElement(
returnFiber,
currentFirstChild,
newChild,
expirationTime
)
)
}
// 還有 Fragment 等類型
}
}
// 如果這時子元素是字符串或者數(shù)字,按照文字節(jié)點來處理
// 值得一提的是,如果元素的子元素是純文字節(jié)點
// 那么這些文字不會被轉(zhuǎn)換成 fiber
// 而是作為父元素的 prop 來處理
if (isString(newChild) || isNumber(newChild)) {
return placeSingleChild(
reconcileSingleTextNode(
returnFiber,
currentFirstChild,
'' + newChild,
expirationTime
)
)
}
// 數(shù)組
if (isArray(newChild)) {
return reconcileChildrenArray(
returnFiber,
currentFirstChild,
newChild,
expirationTime
)
}
文章篇幅有限,對于函數(shù)組件和原生組件這里就不做過多介紹。假設我們已經(jīng)完成了對于所有 WIP 的構(gòu)建和調(diào)和過程,對于第一次構(gòu)建而言,我們需要插入大量的 DOM 結(jié)構(gòu),但是到現(xiàn)在我們得到的仍然是一些虛擬的 fiber 節(jié)點。
所以,在最后一次單元工作 performUnitOfWork 中將會執(zhí)行 completeWork ,在此之前,我們的單元工作是一步步向尾部的 fiber 節(jié)點移動。而在 completeWork 中,我們的工作將是自底向上,根據(jù) fiber 生成真實的 dom 結(jié)構(gòu),并且在向上的過程中將這些結(jié)構(gòu)拼接成一棵 dom 樹。
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 節(jié)點進行更新復用
updateHostComponent(
current,
workInProgress,
type as string,
newProps,
rootContainerInstance
)
} else {
if (!newProps) {
throw new Error('如果沒有newProps,是不合法的')
}
const currentHostContext = getHostContext()
// 創(chuàng)建原生組件
let instance = createInstance(
type as string,
newProps,
rootContainerInstance,
currentHostContext,
workInProgress
)
// 將之前所有已經(jīng)生成的子 dom 元素裝載到 instance 實例中
// 逐步拼接成一顆 dom 樹
appendAllChildren(instance, workInProgress, false, false)
// fiber 的 stateNode 指向這個 dom 結(jié)構(gòu)
workInProgress.stateNode = instance
// feat: 這個函數(shù)真的藏得很隱蔽,我不知道這些人是怎么能注釋都不提一句的呢→_→
// finalizeInitialChildren 作用是將props中的屬性掛載到真實的dom元素中去,結(jié)果作為一個判斷條件被調(diào)用
// 返回一個bool值,代表是否需要auto focus(input, textarea...)
if (finalizeInitialChildren(instance, type as string, newProps, rootContainerInstance, currentHostContext)) {
markUpdate(workInProgress)
}
}
}
}
return null
}
構(gòu)建完畢后,我們得到了形如下圖,虛擬 dom 和 真實 dom,父元素和子元素之間的關系結(jié)構(gòu)

截止到當前,調(diào)和 reconcile 工作已經(jīng)完成,我們已經(jīng)進入了準備提交到文檔 ready to commit 的狀態(tài)。其實從進入 completeUnitOfWork 構(gòu)建開始,后面的過程就已經(jīng)和時間片,任務調(diào)度系統(tǒng)沒有關系了,此時一切事件、交互、異步任務都將屏氣凝神,聆聽接下來 dom 的改變。
// 提交根實例(dom)到瀏覽器真實容器root中
function commitRootImpl (
root: FiberRoot,
renderPriorityLevel: ReactPriorityLevel
) {
...
// 因為這次是整個組件樹被掛載,所以根 fiber 節(jié)點將會作為 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 函數(shù)之前其實對 effect 鏈表還進行了另外兩次遍歷,分別是一些生命周期的處理,例如 getSnapshotBeforeUpdate ,以及一些變量的準備。
// 真正改寫文檔中dom的函數(shù)
// 提交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: {
// 執(zhí)行插入
commitPlacement(nextEffect)
// effectTag 完成實名制后,要將對應的 effect 去除
nextEffect.effectTag &= ~EffectTag.Placement
}
case EffectTag.Update: {
// 更新現(xiàn)有的 dom 組件
const current = nextEffect.alternate
commitWork(current, nextEffect)
}
}
nextEffect = nextEffect.nextEffect
}
}
截至此刻,第一次渲染的內(nèi)容已經(jīng)在屏幕上出現(xiàn)。也就是說,真實 DOM 中的內(nèi)容不再對應此時的 current fiber ,而是對應著我們操作的 workInProgress fiber ,即函數(shù)中的 finishedWork 變量。
// 在 commit Mutation 階段之后,workInProgress tree 已經(jīng)是真實 Dom 對應的樹了
// 所以之前的 tree 仍然是 componentWillUnmount 階段的狀態(tài)
// 所以此時, workInProgress 代替了 current 成為了新的 current
root.current = finishedWork
一次點擊事件
如果你是一個經(jīng)常使用 React 的打工人,就會發(fā)現(xiàn) React 中的 event 是“閱后即焚的”。假設這樣一段代碼:
import React, { MouseEvent } from 'react'
function TestPersist () {
const handleClick = (
event: MouseEvent
) => {
setTimeout(() => console.log('event', event))
}
return (
<div onClick={handleClick}>O2div>
)
}
如果我們需要異步的獲取這次點擊事件在屏幕中的位置并且做出相應處理,那么在 setTimeout 中能否達到目的呢。
答案是否定的,因為 React 使用了 事件委托 機制,我們拿到的 event 對象并不是原生的 nativeEvent ,而是被 React 挾持處理過的合成事件 SyntheticEvent ,這一點從 ts 類型中也可以看出, 我們使用的 MouseEvent 是從 React 包中引入的而不是全局的默認事件類型。在 handleClick 函數(shù)同步執(zhí)行完畢的一瞬間,這個 event 就已經(jīng)在 React 事件池中被銷毀了,我們可以跑這個組件康一康。

當然 React 也提供了使用異步事件對象的解決方案,它提供了一個 persist 函數(shù),可以讓事件不再進入事件池。(在 React17 中為了解決某些 issue ,已經(jīng)重寫了合成事件機制,事件不再由 document 來代理,官網(wǎng)的說法是合成事件[4]不再由事件池管理,也沒有了 persist 函數(shù))
那,為什么要用事件委托呢。還是回到那個經(jīng)典的命題,渲染 2 個 div 當然橫著寫豎著寫都沒關系,如果是 1000 個組件 2000 個點擊事件呢。事件委托的收益就是:
簡化了事件注冊的流程,優(yōu)化性能。 dom 元素不斷在更新,你無法保證下一幀的 div 和上一幀中的 div 在內(nèi)存中的地址是同一個。既然不是同一個,事件又要全部重新綁定,煩死了(指瀏覽器)。
ok,言歸正傳。我們點擊事件到底發(fā)生了什么呢。首先是在 React 的 render 函數(shù)執(zhí)行之前,在 JS 腳本中就已經(jīng)自動執(zhí)行了事件的注入。
事件注入—
事件注入的過程稍微有一點復雜,不光模塊之間有順序,數(shù)據(jù)也做了不少處理,這里不 po 太詳細的代碼。可能有人會問為啥不直接寫死呢,瀏覽器的事件不也就那么億點點。就像 Redux 不是專門為 React 服務的一樣, React 也不是專門為瀏覽器服務的。文章開頭也說了 React 只是一個 javascipt 庫,它也可以服務 native 端、桌面端甚至各種終端。所以根據(jù)底層環(huán)境的不同動態(tài)的注入事件集也是非常合理的做法。
當然注入過程并不重要,我們需要知道的就是 React 安排了每種事件在 JSX 中的寫法和原生事件的對應關系(例如 onClick 和 onclick ),以及事件的優(yōu)先級。
/* ReactDOM環(huán)境 */
// DOM 環(huán)境的事件 plugin
const DOMEventPluginOrder = [
'ResponderEventPlugin',
'SimpleEventPlugin',
'EnterLeaveEventPlugin',
'ChangeEventPlugin',
'SelectEventPlugin',
'BeforeInputEventPlugin',
];
// 這個文件被引入的時候自動執(zhí)行 injectEventPluginOrder
// 確定 plugin 被注冊的順序,并不是真正引入
EventPluginHub.injectEventPluginOrder(DOMEventPluginOrder)
// 真正的注入事件內(nèi)容
EventPluginHub.injectEventPluginByName({
SimpleEventPlugin: SimpleEventPlugin
})
這里以 SimpleEventPlugin 為例,點擊事件等我們平時常用的事件都屬于這個 plugin。
// 事件元組類型
type EventTuple = [
DOMTopLevelEventType, // React 中的事件類型
string, // 瀏覽器中的事件名稱
EventPriority // 事件優(yōu)先級
]
const eventTuples: EventTuple[] = [
// 離散的事件
// 離散事件一般指的是在瀏覽器中連續(xù)兩次觸發(fā)間隔最少 33ms 的事件(沒有依據(jù),我猜的)
// 例如你以光速敲打鍵盤兩次,這兩個事件的實際觸發(fā)時間戳仍然會有間隔
[ 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 ],
...
]
那么,這些事件的監(jiān)聽事件是如何被注冊的呢。還記得在調(diào)和 Class 組件的時候會計算要向瀏覽器插入什么樣的 dom 元素或是要如何更新 dom 元素。在這個過程中會通過 diffProperty 函數(shù)對元素的屬性進行 diff 對比,其中通過 ListenTo 來添加監(jiān)聽函數(shù)
大家都知道,最終被綁定的監(jiān)聽事件一定是被 React 魔改過,然后綁定在 document 上的。
function trapEventForPluginEventSystem (
element: Document | Element | Node,
topLevelType: DOMTopLevelEventType,
capture: boolean
): void {
// 生成一個 listener 監(jiān)聽函數(shù)
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轉(zhuǎn)換了一下
// 這個函數(shù)就是 →_→
// const getRawEventName = a => a
// 雖然這個函數(shù)什么都沒有做
// 但是它的名字語義化的說明了這一步
// 目的是得到瀏覽器環(huán)境下addEventListener第一個參數(shù)的合法名稱
const rawEventName = topLevelType
// 將捕獲事件listener掛載到根節(jié)點
// 這兩個部分都是為了為了兼容 IE 封裝過的 addEventListener
if (capture) {
// 注冊捕獲事件
addEventCaptureListener(element, rawEventName, listener)
} else {
// 注冊冒泡事件
addEventBubbleListener(element, rawEventName, listener)
}
}
大家應該都知道 addEventListener 的第三個參數(shù)是控制監(jiān)聽捕獲過程 or 冒泡過程的吧

ok,right now,鼠標點了下頁面,頁面調(diào)用了這個函數(shù)。開局就一個 nativeEvent 對象,這個函數(shù)要做的第一件事就是知道真正被點的那個組件是誰,其實看了一些源碼就知道, React 但凡有什么事兒第一個步驟總是找到需要負責的那個 fiber 。
首先,通過 nativeEvent 獲取目標 dom 元素也就是 dom.target
const nativeEventTarget = getEventTarget(nativeEvent)
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 對象來實現(xiàn),操作一個 WeakMap 的性能或許會優(yōu)于操作一個 DOM 的屬性,且后者似乎不太優(yōu)雅,如果你有更好的想法也歡迎在評論區(qū)指出。
每當 completeWork 中為 fiber 構(gòu)造了新的 dom,都會給這個 dom 一個指針來指向它的 fiber
// 隨機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 這個屬性
// 就會向上尋找父節(jié)點,直到找到一個擁有 internalInstanceKey 屬性的 dom 元素
// 這也是為什么這個函數(shù)名要叫做 從 node 獲取最近的 (fiber) 實例
...
return null
}
此時我們已經(jīng)擁有了原生事件的對象,以及觸發(fā)了事件的 dom 以及對應的 fiber ,就可以從 fiber.memorizedProps 中取到我們綁定的 onClick 事件。這些信息已經(jīng)足夠生成一個 React 合成事件 ReactSyntheticEvent 的實例了。
React 聲明了一個全局變量 事件隊列 eventQueue ,這個隊列用來存儲某次更新中所有被觸發(fā)的事件,我們需要讓這個點擊事件入隊。然后觸發(fā)。
// 事件隊列
let eventQueue: ReactSyntheticEvent[] | ReactSyntheticEvent | null = null
export function runEventsInBatch (
events: ReactSyntheticEvent[] | ReactSyntheticEvent | null
) {
if (events !== null) {
// 存在 events 的話,加入事件隊列
// react 自己寫的合并數(shù)組函數(shù) accumulateInto
// 或許是 ES3 時期寫的吧
eventQueue = accumulateInto(eventQueue, events)
}
const processingEventQueue = eventQueue
// 執(zhí)行完畢之后要清空隊列
// 雖然已經(jīng)這些 event 已經(jīng)被釋放了,但還是會被遍歷
eventQueue = null
if (!processingEventQueue) return
// 將這些事件逐個觸發(fā)
// forEachAccumulated 是 React 自己實現(xiàn)的 foreach
forEachAccumulated(processingEventQueue, executeDispatchesAndReleaseTopLevel)
}
// 觸發(fā)一個事件并且立刻將事件釋放到事件池中,除非執(zhí)行了presistent
const executeDispatchesAndRelease = function (event: ReactSyntheticEvent) {
if (event) {
// 按照次序依次觸發(fā)和該事件類型綁定的所有 listener
executeDispatchesInOrder(event)
}
// 如果沒有執(zhí)行 persist 持久化 , 立即銷毀事件
if (!event.isPersistent()) {
(event.constructor as any).release(event)
}
}
可以看到合成事件的構(gòu)造函數(shù)實例上掛載了一個函數(shù) release ,用來釋放事件。我們看一看 SyntheticEvent 的代碼,可以發(fā)現(xiàn)這里使用了一個事件池的概念 eventPool 。
Object.assign(SyntheticEvent.prototype, {
// 模擬原生的 preventDefault 函數(shù)
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;
},
/**
* 在每次事件循環(huán)之后,所有被 dispatch 過的合成事件都會被釋放
* 這個函數(shù)能夠允許一個引用使用事件不會被 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 在構(gòu)造函數(shù)上直接添加了一個事件池屬性,其實就是一個數(shù)組,這個數(shù)組將被全局共用。每當事件被釋放的時候,如果線程池的長度還沒有超過規(guī)定的大小(默認是 10 ),那么這個被銷毀后的事件就會被放進事件池
// 為合成事件構(gòu)造函數(shù)添加靜態(tài)屬性
// 事件池為所有實例所共用
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 例模式,每次事件觸發(fā)完畢之后,實例都要還給構(gòu)造函數(shù)放進事件池,后面的每次觸發(fā)都將復用這些干凈的實例,從而減少內(nèi)存方面的開銷。
// 需要事件實例的時候直接從事件池中取出
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
)
}
如果在短時間內(nèi)瀏覽器事件被頻繁觸發(fā),那么將出現(xiàn)的現(xiàn)象是,之前事件池中的實例都被取出復用,而后續(xù)的合成事件對象就只能被老老實實重新創(chuàng)建,結(jié)束的時候通過放棄引用來被 V8 引擎的 GC 回收。
回到之前的事件觸發(fā),如果不特地將屬性名寫成 onClickCapture 的話,那么默認將被觸發(fā)的就會是冒泡過程。這個過程也是 React 模擬的,就是通過 fiber 逐層向上觸發(fā)的方式,捕獲過程也是同理。
我們都知道正常的事件觸發(fā)流程是:
事件捕獲 處于事件 事件冒泡
處于事件 階段是一個 try-catch 語句,這樣即使發(fā)生錯誤也會處于 React 的錯誤捕獲機制當中。我們真正想要執(zhí)行的函數(shù)實體就是在此被觸發(fā):
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)
}
}
類與函數(shù)
當我們使用類組件或是函數(shù)組件的時候,最終目的都是為了得到一份 JSX 來描述我們的頁面。那么其中就存在著一個問題—— React 是如何分辨函數(shù)組件和類組件的。
雖然在 ES6 中,我們可以輕易的看出 Class 和 函數(shù)的區(qū)別,但是別忘了,我們實際使用的往往是 babel 編譯后的代碼,而類就是函數(shù)和原型鏈構(gòu)成的語法糖。可能大部分人最直接的想法就是,既然類組件繼承了 React.Component ,那么應該可以直接使用類類型判斷就就行:
App instanceof React.Component
當然, React 采用的做法是在原型鏈上添加一個標識
Component.prototype.isReactComponent = {}
源碼中需要判斷是否是類組件的時候,就可以直接讀取函數(shù)的 isReactComponent 屬性時,因為在函數(shù)(也是對象)自身找不到時,就會向上游原型鏈逐級查找,直到到達 Object.prototype 對象為止。
為什么 isReactComponent 是一個對象而不是布爾以及為什么不能用 instanceOf [5]
狀態(tài)的更新
之前我們已經(jīng)看懂了 React 的事件委托機制,那么不如在一次點擊事件中嘗試修改組件的狀態(tài)來更新我們的頁面。
首先康康 setState 是如何工作的,我們知道 this.setState 是 React.Component 類中的方法:
/**
* @description 更新組件state
* @param { object | Function } partialState 下個階段的狀態(tài)
* @param { ?Function } callback 更新完畢之后的回調(diào)
*/
Component.prototype.setState = function (partialState, callback) {
if (!(
isObject(partialState) ||
isFunction(partialState) ||
isNull
)) {
console.warn('setState的第一個參數(shù)應為對象、函數(shù)或null')
return
}
this.updater.enqueueSetState(this, partialState, callback, 'setState')
}
看起來核心步驟就是觸發(fā)掛載在實例上的一個 updater 對象。默認的, updater 會是一個展位的空對象,雖然實現(xiàn)了 enqueueSetState 等方法,但是這些方法內(nèi)部都是空的。
// 我們初始化這個默認的update,真正的updater會被renderer注入
this.updater = updater || ReactNoopUpdateQueue
export const ReactNoopUpdateQueue = {
/**
* 檢查組件是否已經(jīng)掛載
*/
isMounted: function (publishInstance) {
// 初始化ing的組件就別掛載不掛載了
return false
},
/**
* 強制更新
*/
enqueueForceUpdate: function (publishInstance, callback, callerName) {
console.warn('enqueueForceUpdate', publishInstance)
},
/**
* 直接替換整個state,通常用這個或者setState來更新狀態(tài)
*/
enqueueReplaceState: function (
publishInstance,
completeState,
callback,
callerName
) {
console.warn('enqueueReplaceState', publishInstance)
},
/**
* 修改部分state
*/
enqueueSetState: function (
publishInstance,
partialState,
callback,
callerName
) {
console.warn('enqueueSetState', publishInstance)
}
}
還記得我們在 render 的過程中,是通過執(zhí)行 Component.render() 來獲得一個類組件的實例,當 React 得到了這個實例之后,就會將實例的 updater 替換成真正的 classComponentUpdater :
function adoptClassInstance (
workInProgress: Fiber,
instance: any
): void {
instance.updater = classComponentUpdate
...
}
剛剛我們觸發(fā)了這個對象中的 enqueueSetState 函數(shù),那么可以看看實現(xiàn):
const classComponentUpdate = {
isMounted,
/**
* 觸發(fā)組件狀態(tài)的更新
* @param inst ReactElement
* @param payload any
* @param callback 更新結(jié)束之后的回調(diào)
*/
enqueueSetState(
inst: ReactElement,
payload: any,
callback?: Function
) {
// ReactElement -> fiber
const fiber = getInstance(inst)
// 當前時間
const currentTime = requestCurrentTime()
// 獲取當前 suspense config
const suspenseConfig = requestCurrentSuspenseConfig()
// 計算當前 fiber 節(jié)點的任務過期時間
const expirationTime = computeExpirationForFiber(
currentTime,
fiber,
suspenseConfig
)
// 創(chuàng)建一個 update 實例
const update = createUpdate(expirationTime, suspenseConfig)
update.payload = payload
// 將 update 裝載到 fiber 的 queue 中
enqueueUpdate(fiber, update)
// 安排任務
ScheduleWork(fiber, expirationTime)
},
...
}
顯然,這個函數(shù)的作用就是獲得類組件對應的 fiber ,更新它在任務調(diào)度器中的過期時間(領導給了新工作,自然要定新的 Deadline ),然后就是創(chuàng)建一個新的 update 任務裝載到 fiber 的任務隊列中。最后通過 ScheduleWork (告訴任務調(diào)度器來任務了,趕緊干活) 要求從這個 fiber 開始調(diào)和,至于調(diào)和和更新的步驟我們在第一次渲染中已經(jīng)有了大致的了解。
順帶提一提 Hooks 中的 useState 。網(wǎng)絡上有挺多講解 hook 實現(xiàn)的文章已經(jīng)講得很全面了,我們只需要搞清楚以下幾點問題。
Q1. 函數(shù)組件不像類組件一樣擁有實例,數(shù)據(jù)存儲在哪里
A1. 任何以 ReactElement 為粒度的組件都需要圍繞 fiber ,數(shù)據(jù)存儲在 fiber.memorizedState 上
Q2. useState 的實現(xiàn)
A2. 如果你聽過了 useState 那么你就應該聽過 useReducer ,如果聽過 reducer 就應該知道 redux。首先,useState 的本質(zhì)就是 useReducer 的語法糖。我們都知道構(gòu)建一個狀態(tài)庫需要一個 reducer ,useState 就是當 reducer 函數(shù)為 a => a 時的特殊情況。
function basicStateReducer<S>(state: S, action: BasicStateAction): S {
return typeof action === 'function' ? action(state) : action
}
function updateState<S>(
initialState: (() => S) | S
): [ S, Dispatch<BasicStateAction<S>> ] {
return updateReducer() => S) | S, any>(basicStateReducer, initialState)
}
Q3. 為什么 Hooks 的順序和個數(shù)不允許改變
A3. 每次執(zhí)行 Hooks 函數(shù)需要取出上一次渲染時數(shù)據(jù)的最終狀態(tài),因為結(jié)構(gòu)是鏈表而不是一個 Map,所以這些最終狀態(tài)也會是有序的,所以如果個數(shù)和次序改變會導致數(shù)據(jù)的錯亂。
時間調(diào)度機制—
雖然今年過期時間 expirationTime 機制已經(jīng)被淘汰了,但是不管是航道模型還是過期時間,本質(zhì)上都是任務優(yōu)先級的不同體現(xiàn)形式。
在探究運行機制之前我們需要知道一個問題就是,為什么時間片的性能會優(yōu)于同步計算的性能。此處借用司徒正美老師文章[6]中的例子。
實驗 1,通過 for 循環(huán)一次性向 document 中插入 1000 個節(jié)點
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 個節(jié)點
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);
相同的結(jié)果,第一個實驗花費了 1000 ms,而第二個實驗僅僅花費了 31.5 ms。
這和 V8 引擎的底層原理有關,我們都知道瀏覽器是單線程,一次性需要做到 GUI 描繪,事件處理,JS 執(zhí)行等多個操作時,V8 引擎會優(yōu)先對代碼進行執(zhí)行,而不會對執(zhí)行速度進行優(yōu)化。如果我們稍微給瀏覽器一些時間,瀏覽器就能夠進行 JIT ,也叫熱代碼優(yōu)化。
簡單來說, JS 是一種解釋型語言,每次執(zhí)行都需要被編譯成字節(jié)碼才能被運行。但是如果某個函數(shù)被多次執(zhí)行,且參數(shù)類型和參數(shù)個數(shù)始終保持不變。那么這段代碼會被識別為 熱代碼 ,遵循著“萬物皆可空間換時間”的原則,這段代碼的字節(jié)碼會被緩存,下次再次運行的時候就會直接被運行而不需要進行耗時的解釋操作。也就是 解釋器 + 編譯器 的模式。
做個比喻來說,我們工作不能一直蠻干,必須要給自己一些時間進行反思和總結(jié),否則工作速度和效率始終是線性的,人也不會有進步。
還記得在 WorkLoop 函數(shù)中,每次處理完一個 fiber 都會跳出循環(huán)執(zhí)行一次 shouldYield 函數(shù)進行判斷,是否應該將執(zhí)行權(quán)交還給瀏覽器處理用戶時間或是渲染。看看這個 shouldYield 函數(shù)的代碼:
// 當前是否應該阻塞 react 的工作
function shouldYield (): boolean {
// 獲取當前的時間點
const currentTime = getCurrentTime()
// 檢查任務隊列中是否有任務需要執(zhí)行
advanceTimers(currentTime)
// 取出任務隊列中任務優(yōu)先級最高的任務
const firstTask = peek(taskQueue)
// 以下兩種情況需要yield
// 1. 當前任務隊列中存在任務,且第一個任務的開始時間還沒到,且過期時間小于當前任務
// 2. 處于固定的瀏覽器渲染時間區(qū)間
return (
(
currentTask !== null &&
firstTask !== null &&
(firstTask as any).startTime <= currentTime &&
(firstTask as any).expirationTime < currentTask.expirationTime
)
// 當前處于時間片的阻塞區(qū)間
|| shouldYieldToHost()
)
}
決定一個任務當前是否應該被執(zhí)行有兩個因素。
這個任務是否非執(zhí)行不可,正所謂一切的不論是不是先問為什么都是耍流氓。如果到期時間還沒到,為什么不先把線程空出來留給可能的高優(yōu)先級任務呢。 如果多個任務都非執(zhí)行不可,那么任務的優(yōu)先級是否是當前隊列中最高的。
如果一個任務的過期時間已經(jīng)到了必須執(zhí)行,那么這個任務就應該處于 待執(zhí)行隊列 taskQueue 中。相反這個任務的過期時間還沒到,就可以先放在 延遲列表 中。每一幀結(jié)束的時候都會執(zhí)行 advanceTimer 函數(shù),將一些延遲列表中到期的任務取出,插入待執(zhí)行隊列。
可能是出于最佳實踐考慮,待執(zhí)行隊列是一個小根堆結(jié)構(gòu),而延遲隊列是一個有序鏈表。
回想一下 React 的任務調(diào)度要求,當一個新的優(yōu)先級更高的任務產(chǎn)生,需要能夠打斷之前的工作并插隊。也就是說,React 需要維持一個始終有序的數(shù)組數(shù)據(jù)結(jié)構(gòu)。因此,React 自實現(xiàn)了一個小根堆,但是這個小根堆無需像堆排序的結(jié)果一樣整體有序,只需要保證每次進行 push 和 pop 操作之后,優(yōu)先級最高的任務能夠到達堆頂。
所以 shouldYield 返回 true 的一個關鍵條件就是,當前 taskQueue 堆中的堆頂任務的過期時間已經(jīng)到了,那么就應該暫停工作交出線程使用權(quán)。
那么待執(zhí)行的任務是如何被執(zhí)行的呢。這里我們需要先了解 MessageChannel[7] 的概念。Message
Channel 的實例會擁有兩個端口,其中第一個端口為發(fā)送信息的端口,第二個端口為接收信息的端口。當接收到信息就可以執(zhí)行指定的回調(diào)函數(shù)。
const channel = new MessageChannel()
// 發(fā)送端
const port = channel.port2
// 接收端
channel.port1.onmessage = performWorkUntilDeadline // 在一定時間內(nèi)盡可能的處理任務
每當待執(zhí)行任務隊列中有任務的時候,就會通過 Channel 的發(fā)送端發(fā)送一個空的 message ,當接收端異步地接收到這個信號的時候,就會在一個時間片內(nèi)盡可能地執(zhí)行任務。
// 記錄任一時間片的結(jié)束時刻
let deadline = 0
// 單位時間切片長度
let yieldInterval = 5
// 執(zhí)行任務直到用盡當前時間片空閑時間
function performWorkUntilDeadline () {
if (scheduledHostCallback !== null) {
// 如果有計劃任務,那么需要執(zhí)行
// 當前時間
const currentTime = getCurrentTime()
// 在每個時間片之后阻塞(5ms)
// deadline 為這一次時間片的結(jié)束時間
deadline = currentTime + yieldInterval
// 既然能執(zhí)行這個函數(shù),就代表著還有時間剩余
const hasTimeRemaining = true
try {
// 將當前阻塞的任務計劃執(zhí)行
const hasMoreWork = scheduledHostCallback(
hasTimeRemaining,
currentTime
)
if (!hasMoreWork) {
// 如果沒有任務了, 清空數(shù)據(jù)
isMessageLoopRunning = false
scheduledHostCallback = null
} else {
// 如果還有任務,在當前時間片的結(jié)尾發(fā)送一個 message event
// 接收端接收到的時候就將進入下一個時間片
port.postMessage(null)
}
} catch (error) {
port.postMessage(null)
throw(error)
}
} else {
// 壓根沒有任務,不執(zhí)行
isMessageLoopRunning = false
}
}
我們在之前說過,阻塞 WorkLoop 的條件有兩個,第一個是任務隊列的第一個任務還沒到時間,第二個條件就是 shouldYieldToHost 返回 true,也就是處于時間片期間。
// 此時是否是【時間片阻塞】區(qū)間
export function shouldYieldToHost () {
return getCurrentTime() >= deadline
}
總結(jié)一下,時間調(diào)度機制其實就是 fiber 遍歷任務 WorkLoop 和調(diào)度器中的任務隊列爭奪線程使用權(quán)的過程。不過區(qū)別是前者完全是同步的過程,只會在每個 while 的間隙去詢問 調(diào)度器 :我是否可以繼續(xù)執(zhí)行下去。而在調(diào)度器拿到線程使用權(quán)的每個時間片中,都會盡可能的處理任務隊列中的任務。
傳統(tǒng)武術(shù)講究點到為止,以上內(nèi)容,就是這次 React 原理的全部。在文章中我并沒有放出大量的代碼,只是放出了一些片段用來佐證我對于源碼的一些看法和觀點,文中的流程只是一個循序思考的過程,如果需要查看更多細節(jié)還是應該從源碼入手。
當然文中的很多觀點帶有主觀色彩,并不一定就正確,同時我也不認為網(wǎng)絡上的其他文章的說法就和 React 被設計時的初衷完全一致,甚至 React 源碼中的很多寫法也未必完美。不管閱讀什么代碼,我們都不要神話它,而是應該辯證的去看待它。總的來說,功過 91 開。
前端世界并不需要第二個 React ,我們學習的意義并不是為了證明我們對這個框架有多么了解。而是通過窺探這些頂級工程師的實現(xiàn)思路,去完善我們自己的邏輯體系,從而成為一個更加嚴謹?shù)娜恕?/p>
參考資料
simple_react: https://github.com/XHFkindergarten/simple_react
[2]拉取源碼: https://react.iamkasong.com/preparation/source.html#%E6%8B%89%E5%8F%96%E6%BA%90%E7%A0%81
[3]simple-virtual-dom: https://github.com/livoras/simple-virtual-dom
[4]合成事件: https://zh-hans.reactjs.org/docs/legacy-event-pooling.html
[5]為什么 isReactComponent 是一個對象而不是布爾以及為什么不能用 instanceOf : https://github.com/facebook/react/pull/4663
[6]文章: https://zhuanlan.zhihu.com/p/37095662
[7]MessageChannel: https://developer.mozilla.org/zh-CN/docs/Web/API/MessageChannel
