- 原文地址:Under the hood of React’s hooks system
- 原文作者:Eytan Manor
- 譯文出自:掘金翻譯計劃
- 本文永久連結:github.com/xitu/gold-m…
- 譯者:EmilyQiRabbit
- 校對者:sunui,hanxiansen
首先我們看看它是如何實現的,然後再從裡到外地瞭解它。
我們都聽說過了它了。React 16.7 中的新特性:hook 系統,它已經在社群中激起了熱議。我們都試用過並測試過它,對於它和它的潛能感到非常興奮。你認為 hook 很神奇,React 竟然可以在不暴露例項的情況下(不需要使用 this
關鍵字),幫助你管理元件。那麼 React 究竟是怎麼做到的呢?
今天,我就要深入探究 React 是如何實現 hook 的,這樣我們就能更好地理解它。像這樣神奇特性存在的不足就是:當出現問題的時候,除錯就非常困難,這是因為它是由複雜的棧蹤跡(stack trace)支援的。因此,通過深入學習 React 的新 hook 系統,我們就能在遇到問題以後比較快地解決它,甚至可以直接杜絕問題的發生。
在開始之前,我想先宣告我不是 React 的開發者或者維護者,所以我的話可能也並不是完全正確。我確實非常深入地研究過了 React 的 hook 系統,但是無論如何我仍無法保證這就是 React 實際的工作方式。話雖如此,我還是會用 React 原始碼中的證據和引用來支援我這篇文章,使我的論點儘可能堅實。
React hook 系統的簡單示意圖
首先,我們簡單瞭解它的執行機制,確保 hook 在 React 的作用域內使用,因為也許你已經知道,如果 hook 不在正確的上下文中被呼叫,它就是無意義的:
Dispatcher
Dispatcher 是一個包含了 hook 函式的共享物件。基於 ReactDOM 的渲染狀態,它將會被動態的分配或者清理,並且它將會確保使用者不能在 React 元件之外獲取到 hook(詳見原始碼)。
在切換到正確的 Dispatcher 來呈現根元件之前,我們通過一個名為 enableHooks
的標誌來啟用/禁用 hook;在技術上來說,這就意味著我們可以在執行時開啟或關閉 hook。React 16.6.X 版本的實驗性功能中也加入了它,但它預設處於禁用狀態(詳見原始碼)。
當我們完成渲染工作後,我們會廢棄 dispatcher 並禁止 hook,來防止在 ReactDOM 的渲染週期之外不小心使用了它。這個機制能夠保證使用者不會做傻事(詳見原始碼)。
Dispatcher 在每次 hook 的呼叫中都會被函式 resolveDispatcher()
解析。正如我之前所說,在 React 的渲染週期之外,這就是無意義的了,React 將會列印出警告資訊:“Hooks 只能在函式元件內部呼叫”(詳見原始碼)。
let currentDispatcher
const dispatcherWithoutHooks = { /* ... */ }
const dispatcherWithHooks = { /* ... */ }
function resolveDispatcher() {
if (currentDispatcher) return currentDispatcher
throw Error("Hooks can't be called")
}
function useXXX(...args) {
const dispatcher = resolveDispatcher()
return dispatcher.useXXX(...args)
}
function renderRoot() {
currentDispatcher = enableHooks ? dispatcherWithHooks : dispatcherWithoutHooks
performWork()
currentDispatcher = null
}
複製程式碼
Dispatcher 的簡單實現方式。
現在我們瞭解了簡單的封裝機制,我們繼續學習本文的核心 —— hook。接下來,我想給你介紹一個新的概念:
Hook 佇列
在 React 後臺,hook 會被表示為節點,並以呼叫順序連線起來。這樣表示的原因是 hook 並不是被簡單的建立然後丟棄,它們有一套獨有的機制。一個 hook 會有數個屬性,我希望在繼續學習之前,你能記住它們:
- 在初次渲染的時候,它的初始狀態會被建立
- 它的狀態可以在執行時更新
- React 可以在後續渲染中記住 hook 的狀態
- React 能根據呼叫順序提供給你正確的狀態
- React 知道當前 hook 屬於哪個部分
另外,我們需要重新思考我們看待元件狀態的方式。目前,我們只把它看作一個簡單的物件:
{
foo: 'foo',
bar: 'bar',
baz: 'baz',
}
複製程式碼
React 狀態 —— 舊視角
但是當處理 hook 的時候,狀態需要被看作是一個佇列,每個節點都表示了物件的一個模組:
{
memoizedState: 'foo',
next: {
memoizedState: 'bar',
next: {
memoizedState: 'bar',
next: null
}
}
}
複製程式碼
React 狀態 —— 新的視角
單個 hook 節點的結構可以在原始碼中檢視。你將會發現,hook 還有一些附加的屬性,但是弄明白 hook 執行的關鍵程式碼在於 memoizedState
和 next
。其他的屬性會被 useReducer()
hook 使用,來快取傳送過的 action 以及基本的狀態,這樣在一些情況下,縮減(reduction)過程還可以作為後備被重複一次:
baseState
—— 傳給 reducer 的狀態物件。baseUpdate
—— 最近一次建立baseState
的已傳送的 action。queue
—— 已傳送 action 組成的佇列,等待傳入 reducer。
不幸的是,我還沒有完全掌握 reducer 的 hook,因為我沒辦法復現它任何的邊緣情況,所以講述這部分就很困難。我就只簡單的說一下,reducer 的實現顯得很不一致,甚至它自己原始碼中的評論都宣告“不確定這些是否是所需要的語義”;所以我怎麼可能確定呢?!
所以我們還是回到對 hook 的討論,在每個函式元件呼叫前,一個名為 prepareHooks()
的函式將先被呼叫,在這個函式中,當前結構和 hook 佇列中的第一個 hook 節點將被儲存在全域性變數中。這樣,我們無論何時呼叫 hook 函式(useXXX()
),它都能知道執行上下文。
let currentlyRenderingFiber
let workInProgressQueue
let currentHook
// 原始碼:https://github.com/facebook/react/tree/5f06576f51ece88d846d01abd2ddd575827c6127/react-reconciler/src/ReactFiberHooks.js:123
function prepareHooks(recentFiber) {
currentlyRenderingFiber = workInProgressFiber
currentHook = recentFiber.memoizedState
}
// 原始碼:https://github.com/facebook/react/tree/5f06576f51ece88d846d01abd2ddd575827c6127/react-reconciler/src/ReactFiberHooks.js:148
function finishHooks() {
currentlyRenderingFiber.memoizedState = workInProgressHook
currentlyRenderingFiber = null
workInProgressHook = null
currentHook = null
}
// 原始碼:https://github.com/facebook/react/tree/5f06576f51ece88d846d01abd2ddd575827c6127/react-reconciler/src/ReactFiberHooks.js:115
function resolveCurrentlyRenderingFiber() {
if (currentlyRenderingFiber) return currentlyRenderingFiber
throw Error("Hooks can't be called")
}
// 原始碼:https://github.com/facebook/react/tree/5f06576f51ece88d846d01abd2ddd575827c6127/react-reconciler/src/ReactFiberHooks.js:267
function createWorkInProgressHook() {
workInProgressHook = currentHook ? cloneHook(currentHook) : createNewHook()
currentHook = currentHook.next
workInProgressHook
}
function useXXX() {
const fiber = resolveCurrentlyRenderingFiber()
const hook = createWorkInProgressHook()
// ...
}
function updateFunctionComponent(recentFiber, workInProgressFiber, Component, props) {
prepareHooks(recentFiber, workInProgressFiber)
Component(props)
finishHooks()
}
複製程式碼
Hook 佇列的簡單實現。
一旦更新完成,一個名為 finishHooks()
的函式將會被呼叫,在這個函式中,hook 佇列的第一個節點的引用將會被儲存在渲染了的結構的 memoizedState
屬性中。這就意味著,hook 佇列和它的狀態可以在外部定位到。
const ChildComponent = () => {
useState('foo')
useState('bar')
useState('baz')
return null
}
const ParentComponent = () => {
const childFiberRef = useRef()
useEffect(() => {
let hookNode = childFiberRef.current.memoizedState
assert(hookNode.memoizedState, 'foo')
hookNode = hooksNode.next
assert(hookNode.memoizedState, 'bar')
hookNode = hooksNode.next
assert(hookNode.memoizedState, 'baz')
})
return (
<ChildComponent ref={childFiberRef} />
)
}
複製程式碼
從外部讀取某一元件記憶的狀態
下面我們來更加專門的討論某一類 hook,首先從使用最廣泛的內容開始 —— state hook:
State hook
你一定會很吃驚,但是 useState
這個 hook 在後臺使用了 useReducer
,並且它將 useReducer
作為預定義的 reducer(詳見原始碼)。這意味著,useState
返回的結果實際上已經是 reducer 的狀態,同時也是 action dispatcher。請你看如下的 state hook 使用的 reducer 處理器:
function basicStateReducer(state, action) {
return typeof action === 'function' ? action(state) : action;
}
複製程式碼
State hook 的 reducer,又名基礎狀態 reducer。
所以正如你期望的那樣,我們可以直接將 action dispatcher 和新的狀態傳入;但是你看到了嗎?!我們也可以傳入帶 action 函式的 dispatcher,這個 action 函式可以接收舊的狀態並返回新的。(在本篇文章寫就時,這種方法並沒有記錄在 React 官方文件中,很遺憾的是,它其實非常有用!)這意味著,當你向元件樹傳送狀態設定器的時候,你可以修改父級元件修改狀態,同時不用將它作為另一個屬性傳入,例如:
const ParentComponent = () => {
const [name, setName] = useState()
return (
<ChildComponent toUpperCase={setName} />
)
}
const ChildComponent = (props) => {
useEffect(() => {
props.toUpperCase((state) => state.toUpperCase())
}, [true])
return null
}
複製程式碼
根據舊狀態返回新狀態。
最後,effect hook —— 它對於元件的生命週期影響很大,以及它是如何工作的:
Effect hook
Effect hook 和其他 hook 的行為有一些區別,並且它有一個附加的邏輯層,這點我在後文將會解釋。在我分析原始碼之前,我要重申一次,提到 effect hook 的屬性的內容可能並不完全正確,大家要抱著質疑的態度。
- 它們在渲染時被建立,但是在瀏覽器繪製後執行。
- 如果給出了銷燬指令,它們將在下一次繪製前被銷燬。
- 它們會按照定義的順序被執行。
注意,我使用了“繪製”而不是“渲染”。它們是不同的事情,在最近的 React 會議中,我看到很多發言者錯誤的使用了這兩個詞!甚至在官方 React 文件中,也有寫“在渲染生效於螢幕之後”,其實這個過程更像是“繪製”。渲染函式只是建立了元件節點,但是並沒有繪製任何內容。
因此,就應該有另一個佇列,來儲存這些 effect hook,並且在繪製後能夠被定位到。通常來說,應該是元件儲存包含了 effect 節點的佇列。每個 effect 節點都是一個不同的型別,並能在適當的時候被定位到:
-
在修改之前呼叫
getSnapshotBeforeUpdate()
例項(詳見原始碼)。 -
執行所有插入,更新,刪除和 ref 的解除安裝(詳見原始碼)。
-
執行所有生命週期函式和 ref 回撥函式。生命週期函式會在一個獨立的通道中執行,所以整個元件樹中所有的替換、更新、刪除都會被呼叫。這個過程還會觸發任何特定於渲染器的初始 effect hook(詳見原始碼)。
-
useEffect()
hook 排程的 effect —— 也被稱為“被動 effect”,它基於這部分程式碼(也許我們要開始在 React 社群內使用這個術語了?!)。
Hook effect 將會被儲存在元件一個稱為 updateQueue
的屬性上,每個 effect 節點都有如下的結構(詳見原始碼):
tag
—— 一個二進位制數字,它控制了 effect 節點的行為(後文我將詳細說明)。create
—— 繪製之後執行的回撥函式。destroy
—— 它是create()
返回的回撥函式,將會在初始渲染前執行。inputs
—— 一個集合,該集合中的值將會決定一個 effect 節點是否應該被銷燬或者重新建立。next
—— 它指向下一個定義在函式元件中的 effect 節點。
除了 tag
屬性,其他的屬性都很簡明易懂。如果你對 hook 很瞭解,你應該知道,React 提供了一些特殊的 effect hook:比如 useMutationEffect()
和 useLayoutEffect()
。這兩個 effect hook 內部使用了 useEffect()
,實際上這就意味著它們能建立 effect hook,但是卻使用了不同的 tag 屬性值。
這個 tag 屬性值是由二進位制的值組合而成(詳見原始碼):
const NoEffect = /* */ 0b00000000;
const UnmountSnapshot = /* */ 0b00000010;
const UnmountMutation = /* */ 0b00000100;
const MountMutation = /* */ 0b00001000;
const UnmountLayout = /* */ 0b00010000;
const MountLayout = /* */ 0b00100000;
const MountPassive = /* */ 0b01000000;
const UnmountPassive = /* */ 0b10000000;
複製程式碼
React 支援的 hook effect 型別
這些二進位制值中最常用的情景是使用管道符號(|
)連線,將位元相加到單個某值上。然後我們就可以使用符號(&
)檢查某個 tag 屬性是否能觸發一個特定的動作。如果結果是非零的,就表示能觸發。
const effectTag = MountPassive | UnmountPassive
assert(effectTag, 0b11000000)
assert(effectTag & MountPassive, 0b10000000)
複製程式碼
如何使用 React 的二進位制設計模式的示例
這裡是 React 支援的 hook effect,以及它們的 tag 屬性(詳見原始碼):
- Default effect —
UnmountPassive | MountPassive
. - Mutation effect —
UnmountSnapshot | MountMutation
. - Layout effect —
UnmountMutation | MountLayout
.
以及這裡是 React 如何檢查動作觸發的(詳見原始碼):
if ((effect.tag & unmountTag) !== NoHookEffect) {
// Unmount
}
if ((effect.tag & mountTag) !== NoHookEffect) {
// Mount
}
複製程式碼
React 原始碼節選
所以,基於我們剛才學習的關於 effect hook 的知識,我們可以實際操作,從外部向元件插入一些 effect:
function injectEffect(fiber) {
const lastEffect = fiber.updateQueue.lastEffect
const destroyEffect = () => {
console.log('on destroy')
}
const createEffect = () => {
console.log('on create')
return destroy
}
const injectedEffect = {
tag: 0b11000000,
next: lastEffect.next,
create: createEffect,
destroy: destroyEffect,
inputs: [createEffect],
}
lastEffect.next = injectedEffect
}
const ParentComponent = (
<ChildComponent ref={injectEffect} />
)
複製程式碼
插入 effect 的示例
這就是 hook 了!閱讀本文你最大的收穫是什麼?你將如何把新學到的知識應用於 React 應用中?希望看到你留下有趣的評論!
如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。
掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。