[譯] React Hooks 底層解析

原文:medium.com/the-guild/u…javascript

對於 React 16.7 中新的 hooks 系統在社區中引發的騷動,咱們都有所耳聞了。人們紛紛動手嘗試,併爲之興奮不已。一想到 hooks 時它們彷佛是某種魔法,React 以某種甚至不用暴露其實例(起碼沒有用到這個關鍵詞)的手段管理了你的組件。那麼 React 究竟搗了什麼鬼呢?java

今天讓咱們來深刻 React 關於 hooks 的實現以更好地理解它。這個魔法特性的問題就在於一旦其發生了問題是難以調試的,由於它隱藏在了一個複雜的堆棧追蹤的背後。所以,深刻理解 React 的 hooks 系統,咱們就能在遭遇它們時至關快地解決問題,或至少能在早期階段避免它們。node

醜話說在前面,我並非一名 React 的開發者/維護者,以及個人言論不須要太過當真。我很是深刻的研究了 React 的 hooks 系統的實現,但無論怎麼說我也不能保證這就是 React 如何工做的真諦。也就是說,個人言論基於 React 的源碼,並儘量地讓個人論據可靠。react

A rough schematic representation of React’s hooks system

首先,讓咱們瞭解一遍確保 hooks 在 React 的做用域內被調用的機制,由於你大概已經知道若是不在正確的上下文中調用,hooks 是沒有意義的:git

Dispatcher

dispatcher 是一個包含了 hooks 函數的共享對象。它將基於 ReactDOM 的渲染階段被動態地分配或清理,而且它將確保用戶不會超出一個 React 組件去訪問 hooks (github.com/facebook/re…)。github

hooks 被一個叫作 enableHooks 的標誌位變量啓用或禁用,在咱們剛剛渲染根組件時,判斷該標誌位並簡單的切換到合適的 dispatcher 上;這意味着從技術上來講咱們能在運行時啓用或禁用 hooks。React 16.6.X 也試驗性的實現了該特性, 但其實是被禁用的 (github.com/facebook/re…).數組

當咱們完成了渲染工做,咱們將 dispatcher 做廢,這預防了 hooks 被意外地從 ReactDOM 的渲染循環以外訪問。該機制將確保用戶不出昏招 (github.com/facebook/re…)。緩存

在全部 hook 的每一次調用時,都會用 resolveDispatcher() 得到 dispatcher 的引用。正如我以前所說,在 React 渲染循環以外的訪問應該是沒有意義的,這種狀況下 React 應該打印警告信息:「Hooks can only be called inside the body of a function component」 (github.com/facebook/re…)。ide

//react-hooks-dispatcher.js

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
}
複製代碼

咱們瞭解了這個簡單的封裝機制,讓咱們移向本文的核心 -- hooks。立刻爲你介紹一個新概念:函數

hooks 隊列

在帷幕以後,hooks 表現爲以其調用順序被連接在一塊兒的節點(nodes)。它們之因此表現成這樣是由於 hooks 並不是被簡單的建立後就獨自行事了。有一個容許它們按身份行事的機制。我想請你在深刻其實現以前記住一個 hook 的若干屬性:

  • 其初始狀態是在初次渲染中被建立的
  • 其狀態能夠被動態更新
  • React 會在以後的渲染中記住 hook 的狀態
  • React 會按照調用順序提供給你正確的狀態
  • React 知道該 hook 是屬於哪一個 fiber 的

相應的,咱們須要從新思考咱們看待一個組件的狀態的方式了。至今爲止咱們是將其看成一個 plain object 的:

//react-state-old.js

{
  foo: 'foo',
  bar: 'bar',
  baz: 'baz',
}
複製代碼

但當咱們處理 hooks 時應將其視做一個隊列,其每一個節點都表現爲一個單個的狀態模型:

//react-state-new.js

{
  memoizedState: 'foo',
  next: {
    memoizedState: 'bar',
    next: {
      memoizedState: 'baz',
      next: null
    }
  }
}
複製代碼

單個 hook 節點的模式能夠在實現中看到。你將發現 hook 有一些附加的屬性,但理解 hooks 如何工做的關鍵就潛藏在 memoizedState 和 next 中。其他的屬性被 useReducer() hook 特別的用來緩存已分發過的 actions 和基礎狀態,這樣在 useReducer 的遍歷過程當中相關邏輯就能夠在各類狀況下做爲一個 fallback 被重複執行:

  • baseState: 會被傳給 reducer 的狀態對象
  • baseUpdate: 最近一次 dispatch 過的用來建立 baseState 的 action
  • queue: 一個 dispatch 過的 actions 列表,等待遍歷 reducer

糟糕的是我沒法全面領悟 reducer hook,由於我沒能設法復現幾乎任何一個它的邊緣狀況,因此也就不展開細說了。我只能說 reducer 的實現是如此的先後矛盾以致於其本身的 一處註釋(github.com/facebook/re…) 中甚至說 「TODO: 不肯定這是否是預期的語義...我不記得是爲何了」;因此我又能如何肯定呢?!

回到 hooks,在每一個函數組件調用以前,一個叫作 prepareHooks() 的函數先被調用,當前 fiber 和其位於 hooks 隊列中的首個 hook 會被存儲在全局變量中。經過這種方式,每次咱們調用一個 hook 函數(useXXX())時,它都知道在哪一個上下文中運行了。

//react-hooks-queue.js

let currentlyRenderingFiber
let workInProgressQueue
let currentHook

// Source: https://github.com/facebook/react/tree/5f06576f51ece88d846d01abd2ddd575827c6127/react-reconciler/src/ReactFiberHooks.js#L123
function prepareHooks(recentFiber) {
  currentlyRenderingFiber = workInProgressFiber
  currentHook = recentFiber.memoizedState
}

// Source: https://github.com/facebook/react/tree/5f06576f51ece88d846d01abd2ddd575827c6127/react-reconciler/src/ReactFiberHooks.js#L148
function finishHooks() {
  currentlyRenderingFiber.memoizedState = workInProgressHook
  currentlyRenderingFiber = null
  workInProgressHook = null
  currentHook = null
}

// Source: https://github.com/facebook/react/tree/5f06576f51ece88d846d01abd2ddd575827c6127/react-reconciler/src/ReactFiberHooks.js#L115
function resolveCurrentlyRenderingFiber() {
  if (currentlyRenderingFiber) return currentlyRenderingFiber
  throw Error("Hooks can't be called")
}
// Source: https://github.com/facebook/react/tree/5f06576f51ece88d846d01abd2ddd575827c6127/react-reconciler/src/ReactFiberHooks.js#L267
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()
}
複製代碼

一旦一次更新完成,一個叫作 finishHooks() 的函數就會被調用,一個對 hooks 隊列中首個節點的引用將被存儲在已渲染的 fiber 的 memoizedState 屬性中。這意味着 hooks 隊列和它們的狀態可被從外部處理:

//react-state-external.js 

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} /> ) } 複製代碼

讓咱們看看更多的細節並談談個別 hooks,從最經常使用的 state hook 開始:

State hooks

你知道了可能會驚訝,但 useState hook 在幕後使用了 useReducer 並簡單地提供給後者一個預約義的 reducer 處理函數。這意味着從 useState 返回的結果其實是一個 reducer state 以及一個 action dispatcher。我想讓你看看 state hook 使用的 reducer 處理函數:

//react-basic-state-reducer.js

function basicStateReducer(state, action) {
  return typeof action === 'function' ? action(state) : action;
}
複製代碼

因此按照預期,咱們能夠向 action dispatcher 直接傳入新的 state;但你看到了什麼?!咱們也能傳入一個 action 函數,用以處理舊 state 並返回一個新的。這在官方文檔中從未說起(在本文成文之際)而且這有點遺憾由於這特別有用!這意味着當你已經把 state setter 發往組件樹後仍可改變父組件的當前狀態,而不用向其傳入一個不一樣的 prop。好比:

//react-state-dispatcher.js

const ParentComponent = () => {
  const [name, setName] = useState()
  
  return (
    <ChildComponent toUpperCase={setName} /> ) } const ChildComponent = (props) => { useEffect(() => { props.toUpperCase((state) => state.toUpperCase()) }, [true]) return null } 複製代碼

最後來看看在一個組件的生命週期上施展魔法效果的  —  effect hooks,以及它是如何工做的:

Effect hooks

Effect hooks 表現得稍有不一樣,我也想說說其額外的一個邏輯層。再說一次,在我深刻解釋實現以前,但願你記住關於 effect hooks 屬性的一些事情:

  • 它們在渲染時被建立,但在繪製(painting)以後才運行
  • 若是存在,它們會在下次繪製以前才被銷燬
  • 按定義的順序被調用

注意我使用了術語 「painting」 而不是 「rendering」。這二者大相徑庭,而我注意到最近許多演說者最近在 React Conf (conf.reactjs.org/) 上使用了錯誤的詞語!甚至在官方 React 文檔中他們也說 「after the render is committed to the screen」,其實應該是相似 「painting」 的。render() 方法只是建立 fiber 節點但並不繪製任何東西。

相應地,也應該有另外一個額外的隊列來保存這些 effects 並能在繪製後被處理。通常來講,一個 fiber 持有一個包含了 effect 節點的隊列。每一個 effect 都屬於一個不一樣的類型並應該在其相應的階段被處理:

  • 在突變前調用 getSnapshotBeforeUpdate() 的實例
  • 執行宿主上的全部插入、更新、刪除和 ref 卸載
  • 執行全部生命週期和 ref 回調。生命週期做爲一個獨立發生的階段,整個樹中的全部置入、更新和刪除也都會被調用。該階段也會觸發任何特定於渲染器的初始化 effects
  • useEffect() hook 調度的 effects -- 從源碼中可知其稱呼爲 「passive effects(消極影響)」 (咱們或許應該在 React 社區中開始用這個術語了?!)

hook effects 應該被存儲在 fiber 的 updateQueue 屬性上,而且每一個 effect 節點應該有以下結構:

  • tag:一個二進制數字,表示該 effect 的行爲(稍後我會詳述)
  • create:繪製以後應該運行的回調
  • destroy:由 create() 回調返回,應該早於初次渲染運行
  • inputs: 一個值的集合,用來決定 effect 是否應該被銷燬或重建
  • next:一個對定義在函數組件中的下一個 effect 的引用

除了 tag 屬性,其餘屬性都很易於理解。若是你熟悉 hooks,應該知道 React 提供了一對特殊的 effect hooks:useMutationEffect()useLayoutEffect()。二者內部都用了 useEffect(),意味着本質上它們都建立了一個 effect 節點,但它們用了不一樣的 tag 值。

tag 由一組二進制值構成:

//react-hook-effects-types.js

const NoEffect = /* */ 0b00000000;
const UnmountSnapshot = /* */ 0b00000010;
const UnmountMutation = /* */ 0b00000100;
const MountMutation = /* */ 0b00001000;
const UnmountLayout = /* */ 0b00010000;
const MountLayout = /* */ 0b00100000;
const MountPassive = /* */ 0b01000000;
const UnmountPassive = /* */ 0b10000000;
複製代碼

對於這些二進制值最多見的用例會是使用一個通道操做(|)並像單獨的值同樣增長二進制位。然後咱們就可使用一個 & 符號檢查一個 tag 是否實現了一個特定的行爲。若是結果非零,就意味着 tag 的實現達到了預期。

//react-bin-design-pattern-test.js

const effectTag = MountPassive | UnmountPassive
assert(effectTag, 0b11000000)
assert(effectTag & MountPassive, 0b10000000)
複製代碼

這是被 React 支持的 hook effect 類型,以及其 tags:

  • Default effect — UnmountPassive | MountPassive.
  • Mutation effect — UnmountSnapshot | MountMutation.
  • Layout effect — UnmountMutation | MountLayout.

而且 React 是這樣檢查行爲實現的:

//react-effect-hooks-real-usage.js

if ((effect.tag & unmountTag) !== NoHookEffect) {
  // Unmount
}
if ((effect.tag & mountTag) !== NoHookEffect) {
  // Mount
}
複製代碼

因此,基於咱們以及學過的涉及 effect hooks 的知識,實際上能夠從外部向一個特定 fiber 注入一個 effect:

//react-hook-effect-injection.js 

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} /> ) 複製代碼

大功告成!



--End--

搜索 fewelife 關注公衆號

轉載請註明出處

相關文章
相關標籤/搜索