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

React Hook 系統的原理

咱們將會一塊兒查看它的實現方法,由內而外地學習 React Hook。html

咱們都已經據說過了:React 16.7 的新特性,hook 系統,並已在社區中激起了熱議。咱們都試用過、測試過,對它自己和它的潛力都感到很是興奮。你必定認爲 hook 如魔法般神奇,React 居然能夠在不暴露實例的狀況下(不須要使用 this 關鍵字),幫助你管理組件。那麼 React 到底是怎麼作到的呢?前端

那麼今天,讓咱們一塊兒深刻探究 React Hook 的實現方法,以便更好的理解它。可是,它的各類神奇特性的不足是,一旦出現問題,調試很是困難,這是因爲它的背後是由複雜的堆棧追蹤(stack trace)支持的。所以,經過深刻學習 React 的新特性:hook 系統,咱們就能比較快地解決遇到的問題,甚至能夠直接杜絕問題的發生。react

在開始講解以前,我先聲明我不是 React 的開發者或者維護者,因此個人理解可能也並非徹底正確。我確實很是深刻地研究過了 React 的 hook 系統的實現,可是不管如何我仍沒法保證這就是 React 實際的工做方式。話雖如此,我仍是會用 React 源代碼中的證據和引用來支持個人文章,使個人論點儘量堅實。android

React hook 系統概要示意圖ios


咱們先來了解 hook 的運行機制,並要確保它必定在 React 的做用域內使用,由於若是 hook 不在正確的上下文中被調用,它就是毫無心義的,這一點你或許已經知道了。git

Dispatcher

dispatcher 是一個包含了 hook 函數的共享對象。基於 ReactDOM 的渲染狀態,它將會被動態的分配或者清理,而且它可以確保用戶不可在 React 組件以外獲取 hook(詳見源碼)。github

在切換到正確的 Dispatcher 以渲染根組件以前,咱們經過一個名爲 enableHooks 的標誌來啓用/禁用 hook。在技術上來講,這就意味着咱們能夠在運行時開啓或關閉 hook。React 16.6.X 版本中也有對此的實驗性實現,但它實際上處於禁用狀態(詳見源碼json

當咱們完成渲染工做後,咱們將 dispatcher 置空並禁止用戶在 ReactDOM 的渲染週期以外使用 hook。這個機制可以保證用戶不會作什麼蠢事(詳見源碼)。後端

dispatcher 在每次 hook 的調用中都會被函數 resolveDispatcher() 解析。正如我以前所說,在 React 的渲染週期以外,這些都無心義了,React 將會打印出警告信息:「hook 只能在函數組件內部調用」(詳見源碼)。設計模式

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 實現方式概覽。


如今咱們簡單瞭解了 dispatcher 的封裝機制,下面繼續回到本文的核心 —— hook。下面我想先給你介紹一個新的概念:

hook 隊列

在 React 後臺,hook 被表示爲以調用順序鏈接起來的節點。這樣作緣由是 hook 並不能簡單的被建立而後丟棄。它們有一套特有的機制,也正是這些機制讓它們成爲 hook。一個 hook 會有數個屬性,在繼續學習以前,我但願你能牢記於心:

  • 它的初始狀態會在初次渲染的時候被建立。
  • 它的狀態能夠在運行時更新。
  • React 能夠在後續渲染中記住 hook 的狀態。
  • React 能根據調用順序提供給你正確的狀態。
  • React 知道當前 hook 屬於哪一個 fiber。

另外,咱們也須要從新思考看待組件狀態的方式。目前,咱們只把它看做一個簡單的對象:

{
  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()](https://github.com/facebook/react/tree/5f06576f51ece88d846d01abd2ddd575827c6127/react-reconciler/src/ReactFiberHooks.js:123) 的函數將先被調用,在這個函數中,當前 fiber 和 fiber 的 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()](https://github.com/facebook/react/tree/5f06576f51ece88d846d01abd2ddd575827c6127/react-reconciler/src/ReactFiberHooks.js:148) 的函數將會被調用,在這個函數中,hook 隊列中第一個節點的引用將會被保存在已渲染 fiber 的 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 文檔中,也有寫「在渲染生效於屏幕以後」,其實這個過程更像是「繪製」。渲染函數只是建立了 fiber 節點,可是並無繪製任何內容。

因而就應該有另外一個隊列來保存這些 effect hook,而且還要可以在繪製後被定位到。一般來講,應該是 fiber 保存包含了 effect 節點的隊列。每一個 effect 節點都是一個不一樣的類型,並能在適當的狀態下被定位到:

  • 在修改以前調用 getSnapshotBeforeUpdate() 實例(詳見源碼)。

  • 運行全部插入、更新、刪除和 ref 的卸載(詳見源碼)。

  • 運行全部生命週期函數和 ref 回調函數。生命週期函數會在一個獨立的通道中運行,因此整個組件樹中全部的替換、更新、刪除都會被調用。這個過程還會觸發任何特定於渲染器的初始 effect hook(詳見源碼)。

  • useEffect() hook 調度的 effect —— 也被稱爲「被動 effect」,它基於這部分代碼(也許咱們要開始在 React 社區內使用這個術語了?!)。

hook effect 將會被保存在 fiber 一個稱爲 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 的知識,咱們能夠實際操做,從外部向 fiber 插入一些 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 的示例


這就是 hooks 了!閱讀本文你最大的收穫是什麼?你將如何把新學到的知識應用於 React 應用中?但願看到你留下有趣的評論!

若是發現譯文存在錯誤或其餘須要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可得到相應獎勵積分。文章開頭的 本文永久連接 即爲本文在 GitHub 上的 MarkDown 連接。


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

相關文章
相關標籤/搜索