[譯] 深入 React Hook 系統的原理

Yuqi發表於2019-03-26

首先我們看看它是如何實現的,然後再從裡到外地瞭解它。

[譯] 深入 React Hook 系統的原理

我們都聽說過了它了。React 16.7 中的新特性:hook 系統,它已經在社群中激起了熱議。我們都試用過並測試過它,對於它和它的潛能感到非常興奮。你認為 hook 很神奇,React 竟然可以在不暴露例項的情況下(不需要使用 this 關鍵字),幫助你管理元件。那麼 React 究竟是怎麼做到的呢?

今天,我就要深入探究 React 是如何實現 hook 的,這樣我們就能更好地理解它。像這樣神奇特性存在的不足就是:當出現問題的時候,除錯就非常困難,這是因為它是由複雜的棧蹤跡(stack trace)支援的。因此,通過深入學習 React 的新 hook 系統,我們就能在遇到問題以後比較快地解決它,甚至可以直接杜絕問題的發生。

在開始之前,我想先宣告我不是 React 的開發者或者維護者,所以我的話可能也並不是完全正確。我確實非常深入地研究過了 React 的 hook 系統,但是無論如何我仍無法保證這就是 React 實際的工作方式。話雖如此,我還是會用 React 原始碼中的證據和引用來支援我這篇文章,使我的論點儘可能堅實。

[譯] 深入 React Hook 系統的原理

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 執行的關鍵程式碼在於 memoizedStatenext。其他的屬性會被 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 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章