原文
https://medium.com/the-guild/under-the-hood-of-reacts-hooks-system-eb59638c9dba
前言
新的React Hook系統在社區中引起的反響很大。人們紛紛動手嘗試,並為之興奮不已。一想到 hooks 時它們似乎是某種魔法,React 以某種甚至不用暴露其實例(起碼沒有用到這個關鍵詞)的手段管理了你的組件。那么 React 究竟搗了什么鬼呢?
本文讓我們來深入 React 關於 hooks 的實現以更好地理解它。這個魔法特性的問題就在於一旦其發生了問題是難以調試的,因為它隱藏在了一個復雜的堆棧追蹤的背后。因此,深入理解 React 的 hooks 系統,我們就能在遭遇它們時相當快地解決問題,或至少能在早期階段避免它們。
我開始之前,我首先要聲明我並不是React的開發者/維護者,因此,大家不要太信任我的觀點。我確實非常深入地研究了React hooks的實現,但是無論如何我也不能保證這就是hooks的實際實現原理。話雖如此,我已經用React源碼來支持我的觀點,並嘗試着使我的論點盡可能的真實。
首先,讓我們進入需要確保hooks在React的作用域調用的機制,因為你現在可能知道如果在沒有正確的上下文調用鈎子是沒有意義的:
The dispatcher
dispatcher 是包含了hooks函數的共享對象。它將根據ReactDom的渲染階段來動態分配或者清除,並且確保用戶無法在 React 組件外訪問hooks。請參閱實現
我們可以在渲染根組件前通過簡單的切換來使用正確的dispatcher,用一個叫做enableHooks的標志來開啟/禁用;這意味這從技術上來說,我們可以在運行時開啟/禁用掛鈎。React 16.6.x就已經有了試驗性的實現,只不過它是被禁用的。請參閱實現
當我們執行完渲染工作時,我們將dispatcher 置空從而防止它在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 }
到此為止既然我們已經看過了這種簡單的封裝機制,我希望我們轉到本文的核心 - Hooks。我想向您介紹一個新概念:
The hooks queue
在使用場景之后,hooks表示為在調用順序下鏈接在一起的節點。它們被表示成這樣是因為hooks並不是簡單的創建然后又把它遺留下來。它們有一種可以讓他們變成它們自己的機制。一個Hook有幾個我希望你可以在深入研究實現之前記住的屬性:
- 它的初始狀態在首次渲染時被創建。
- 她的狀態可以即時更新。
- React會在之后的渲染中記住hook的狀態
- React會根據調用順序為您提供正確的狀態
- React會知道這個hook屬於哪個Fiber。
因此,我們需要重新思考我們查看組件狀態的方式。到目前為止,我們認為它就像是一個普通的對象:
{ foo: 'foo', bar: 'bar', baz: 'baz', }
但是在處理hook時,它應該被視為一個隊列,其中每個節點代表一個狀態的單個模型:
{ memoizedState: 'foo', next: { memoizedState: 'bar', next: { memoizedState: 'bar', next: null } } }
可以在實現中查看單個hook節點的模式。你會看到hook有一些額外的屬性,但是理解鈎子如何工作的關鍵在於memoizedState和next。其余屬性由useReducer()hook專門用於緩存已經調度的操作和基本狀態,因此在各種情況下,還原過程可以作為后備重復:
· baseState - 將給予reducer的狀態對象。
· baseUpdate- 最近的創建了最新baseState的調度操作。
· queue - 調度操作的隊列,等待進入reducer。
不幸的是,我沒有設法很好地掌握reducer hook,因為我沒有設法重現任何邊緣情況,所以我不覺得舒服去精心設計。我只能說,reducer 的實現是如此不一致,在代碼注釋中甚至指出,“不知道這些是否都是所需的語義”; 所以我該如何確定?!
所以回到hooks,在每個函數組件調用之前,將調用一個名為prepareHooks()的函數,其中當前fiber及其hooks隊列中的第一個hook節點將被存儲在全局變量中。這樣,只要我們調用一個hook函數(useXXX()),就會知道要在哪個上下文中運行。
let currentlyRenderingFiber let workInProgressQueue let currentHook // Source: https://github.com/facebook/react/tree/5f06576f51ece88d846d01abd2ddd575827c6127/react-reconciler/src/ReactFiberHooks.js:123 function prepareHooks(recentFiber) { currentlyRenderingFiber = workInProgressFiber currentHook = recentFiber.memoizedState } // Source: https://github.com/facebook/react/tree/5f06576f51ece88d846d01abd2ddd575827c6127/react-reconciler/src/ReactFiberHooks.js:148 function finishHooks() { currentlyRenderingFiber.memoizedState = workInProgressHook currentlyRenderingFiber = null workInProgressHook = null currentHook = null } // Source: 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") } // Source: 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() }
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狀態和一個action dispatcher。我希望你看一下state hook使用的reducer處理程序:
function basicStateReducer(state, action) { return typeof action === 'function' ? action(state) : action; }
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的屬性:
- 它們是在渲染時創建的,但它們在繪制后運行。
- 它們將在下一次繪制之前被銷毀。
- 它們按照已經被定義的順序執行。
請注意,我使用的是“繪制”術語,而不是“渲染”。這兩個是不同的東西,我看到最近React Conf中的許多發言者使用了錯誤的術語!即使在官方的React文檔中,他們也會說“在渲染屏幕之后”,在某種意義上應該更像“繪制”。render方法只創建fiber節點,但沒有繪制任何東西。
因此,應該有另一個額外的隊列保持這些effect,並應在繪制后處理。一般而言,fiber保持包含effect節點的隊列。每種effect都是不同的類型,應在適當的階段處理:
· 在變化之前調用實例的getSnapshotBeforeUpdate()方法請參閱實現。
·執行所有節點的插入,更新,刪除和ref卸載操作請參閱實現。
·執行所有生命周期和ref回調。生命周期作為單獨的過程發生,因此整個樹中的所有放置,更新和刪除都已經被調用。此過程還會觸發任何特定渲染的初始effects請參閱實現。
·由useEffect() hook 安排的effects - 基於實現也被稱為“passive effects” (也許我們應該在React社區中開始使用這個術語?!)。
當涉及到hook effects時,它們應該存儲在fiber的一個名為 updateQueue的屬性中,並且每個effect node應該具有以下模式請參閱實現:
· tag - 一個二進制數,它將決定effect的行為(我將盡快闡述)。
· create- 繪制后應該運行的回調。
· destroy- 從create()返回的回調應該在初始渲染之前運行。
· inputs - 一組值,用於確定是否應銷毀和重新創建effect。
· next - 函數組件中定義的下一個effect的引用。
除了tag屬性外,其他屬性都非常簡單易懂。如果你已經很好地研究了hooks,你就會知道React為你提供了幾個特殊的hooks:useMutationEffect()和useLayoutEffect()。這兩種效果在內部使用useEffect(),這實際上意味着它們創建了一個effect節點,但它們使用不同的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;
這些二進制值的最常見用例是使用管道(|)將這些位按原樣添加到單個值。然后我們可以使用&符號(&)檢查標簽是否實現某種行為。如果結果為非零,則表示tag實現了指定的行為。
以下是React支持的hook effect類型及其標簽請參閱實現:
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 }
因此,基於我們剛剛學到的關於effect hooks的內容,我們實際上可以在外部向某個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} /> )