React技術揭祕 - 關於 Hooks 限制規則背後的祕密

寫在前頭

本文但願經過揭開一些 React 隱藏的技術細節, 來輔助對官方文檔中某些概念的理解前端

讀者能夠將本文看作對官方文檔的補充數組

行文方式我採用的是提問-解答的方式, 即先根據官方文檔給出的使用規則, 提出問題, Why ? 而後咱們根據實際的調試再來解答這個 Why, 最後系統的整理這些 Why 變成 How, 若是大家有更好的行文方式, 也歡迎留言討論數據結構

另外爲了閱讀體驗, 我不會粘貼過多的源碼, 避免打斷各位讀者的思路.架構

正文

從 Hooks 一些使用限制來看背後隱藏的細節

一. Hooks 爲何只能寫在 FCComponent 內 ? React 怎麼知道的 ?

其實沒有什麼黑魔法, React 在初始化的過程當中會構建一個 ReactCurrentDispatcher 的全局變量用於跟蹤當前的 dispatcher函數

dispatcher 能夠理解成一個 Hooks 的代理人post

因爲你在 FCC 外部執行 Hooks, 這時候要麼 React 沒有初始化, 要麼就是 Hooks 沒法關聯到 ReactCurrentDispatcher, 大部分場景都是由於生命週期的錯配而報錯, 因此 React 也並不能百分百知道你的 Hooks 執行時機是否正確測試

二. React useState如何在沒有 Key 的狀況下只經過初始值來判斷讀寫的是哪一個 State ?

官方文檔在關於 Hooks 執行順序和 State 讀寫之間的關聯說明上語焉不詳優化

"那麼 React 怎麼知道哪一個 state 對應哪一個 useState?答案是 React 靠的是 Hook 調用的順序。"ui

不得不說這個重要的細節, 官方卻給了個模棱兩可的答案.spa

看過其餘相關介紹的讀者應該知道 React 在 State 讀寫上借鑑了一個相似狀態單元格的概念, 經過將 State 和 setState 分離到兩個數組中, 而後根據數組下標就能肯定要讀寫的是哪一個 State, 但對於 React 來講基於 Fiber 的架構天然不可能這麼簡單

要解開這個謎題, 首先咱們得知道兩點, 即 React 如何存儲 State, Hook 到底是什麼

React 對 State 的處理並不複雜, 相似下面的鏈表結構來存儲一個 FCC 內部的, 經過 useState 聲明的 State

{   
    memoizedState:1
    next: {
        memoizedState:"ff"
        next: null
    }
}
複製代碼

經過 next 指針, React 能夠按照順序來讀寫 State, 這很方便

再次推薦前端開發同窗掌握基本的數據結構, 這樣有助於你更好的理解代碼

那 Hook 呢 ? 究竟什麼是 Hook, React 如何存儲 Hook ?

Hook 是一個對象, 固然 JS 裏一切都是對象, React 將 hook 聲明爲這樣一個結構

var hook = {
      memoizedState: null,
      baseState: null,
      baseQueue: null,
      queue: null,
      next: null
    };
複製代碼

跟 State 同樣 Hook 也是一個單向鏈表結構, 這裏的 memoizedState 和上面的那個是一致的, 嗯若是你有遵規則的話, 那就是一致的......

官網其實沒有明確給 Hook 作出定義, 相比 State, Hook 主要多了一個 queue 屬性, 那麼這是什麼呢?

var queue = {
      pending: null,
      dispatch: null,
      lastRenderedReducer: basicStateReducer,
      lastRenderedState: initialState
    };
複製代碼

這是 React 對 queue 的結構聲明, 在不深刻 Fiber 關於如何使用 queue 的細節下, 咱們姑且作個猜想, queue 是隊列的意思, pending 可能意指某個執行中的 dispatch, lastRenderedReducer, 這裏是一個默認函數, 在更新階段保存的是上一次使用的用來更新 State 的 Reducer 函數, 至於 lastRenderedState, 天然是前一個 State.

結合 queue 的結構, 咱們能夠試着給 Hook 一個定義 Hook 是一個對 State 邏輯處理函數進行管理的管理者, 它經過隊列的方式有效管理這些邏輯處理函數

考慮到 Hook 並不止 useState useEffect, React 的源碼也在不停的變動, 因此這裏的定義或許並不嚴謹, 不過本系列的文章並非一篇一次性的文章, 後續隨着細節的深刻和討論, 我會更新相關的一些定義和內容來修訂原有的版本, 以力求嚴謹和一致性

這裏的概念很接近 Redux, 不過在深刻這些細節以前, 本文仍是先聚焦 Hooks 的規則, 關於 React 內部的這種 State 更新管理機制以及它和 Fiber 的關係, 我會在後續文章中討論, 在這裏先有個概念吧.

瞭解了 React 如何存儲 State 和 Hook, 同時對 Hook 有了明確的結構定義後, 再補充一個 Fiber 的渲染邏輯, 即在 commit 階段, 渲染一旦發生就要所有完成, 不存在局部渲染, 每一次都是完整的"全部的節點"

這裏全部的節點打了個引號, 對於 Fiber 使用鏈表實現的樹所有遍歷一次的開銷依然巨大, 因此 React 作了優化, 關於這部分可查看這篇文章寫得仍是很通俗易懂的

在這種狀況下 FCC 的 ReRender 會致使內部的 Hooks 所有都執行一遍, 咱們把官網的那個例子稍微改改而後再作說明

"use strict";
function Counter({ initialCount }) {
    const [count, setCount] = React.useState(1);

    if (count === 1) {
        const [count2, setCount2] = React.useState(2);
    } 

    const [surname, setSurname] = React.useState('Poppins');

    return /*#__PURE__*/React.createElement(React.Fragment, null, "Count: ", count, /*#__PURE__*/React.createElement("button", {
        onClick: () => setCount(initialCount)
    }, "Reset"), /*#__PURE__*/React.createElement("button", {
        onClick: () => setCount(prevCount => prevCount - 1)
    }, "-"), /*#__PURE__*/React.createElement("button", {
        onClick: () => setCount(prevCount => prevCount + 1)
    }, "+"));
}


ReactDOM.render(React.createElement(Counter, { initialCount: 1 }, null),
    document.getElementById('root')
);
複製代碼

爲了便於調試, 我只使用了 React 必須的兩個庫, 例子中的代碼也沒有使用 JSX

在說明具體的例子前, 將上面的和一些背景知識作個整理

在瞭解 FCC ReRender 致使全部 Hooks 從新執行的基礎上, 咱們再加一條, 即對於 State 而言存在兩個階段即 "mount" 和 "update", 兩個階段都有不一樣的 dispatcher 來觸發, 也會分別調用 mountState 和 updateState 這樣的函數來處理, 路徑的分叉是在 Hooks 被執行前, React 稱爲 renderWithHooks, 在這個階段, React 會判斷 current 節點上是否有 memoizedState, 無則 mount, 有則 update

current 節點在 performUnitOfWork 中聲明, 並經過 beginWork 傳遞進 renderWithHooks 中, unitOfWork 是一個 FiberNode, 由於涉及到 Fiber 架構的工做邏輯分析, 咱們先有個概念, 在後續文章中討論這些細節

總結下:

  • Hooks 會隨着 FCC ReRender 而重複執行
  • Hooks 和 State 都保存在一個單向鏈表中, 其中的 State 和 State 單向鏈表中的一致
  • 讀寫 State 存在 mount 和 update 兩條不一樣的路徑
  • 每一個 FCC 都有存有本身的 State 表

回到上面的例子, 第一次 render 後, Counter 節點上的 State 和 Hooks 的兩個鏈表應當是

// State List
{   
    memoizedState:1,
    next: {
        memoizedState: 2,
        next: {
            memoizedState: "Poppins",
            next: null
        }
    }
}

// Hooks List
{
    memoizedState: 1,
    queue: {
        dispatch: fn()
    },
    next: {
        memoizedState: 2,
        queue:{
            dispatch: fn()
        },
        next: {
            memoizedState: "Poppins",
            queue: {
                dispatch: fn()
            }
            next: null
        }
    }
};
複製代碼

這裏簡化告終構, 去掉了某些屬性以便於理解

而後咱們經過點擊按鈕觸發 setCount + 1 來引起 ReRender, 因爲此時 count = 2, 致使原有的第二個 useState 不會執行, 可是 React 並不知道這一點, 他會默認你是守規矩的, 這就致使了一個有意思的結果

const [surname, setSurname] = React.useState('Poppins');
複製代碼

咱們預期 surname 應該是 'Poppins', 由於咱們沒作任何變動, 但實際上 React 此時返回的數組中的 surname 是 2. 由於在更新路徑中, Hook 對應的鏈表裏第二個 memoizedState 是 2, 不是 'Poppins', React 按照順序沿着 Hook 的指針前進並調用對應的 queue 裏的 dispatch, 它並不關心你的真實邏輯, 因而就產生了預期結果和執行結果的不一致, 也就是官網所說的致使了 bug, 但官網中提到的提早執行其實有歧義, 對於 React 來講一切都是有序的, 不存在將後置的 Hook 提早執行, 只是你預期的和它實際乾的沒有對應上, 這裏能夠用圖來講明

第二次執行的時候
React給你的       你想的
1                1
2                'Poppins'
'Poppins'

複製代碼

React 給了一個你認爲是錯誤, 可是它認爲是正確的結果, 不得不說這有點違反直覺, 並且有點反人類, 估計 React 也知道這麼幹有點不太好, 因此 16.13.1 的開發版中測試了下, React 會針對兩種狀況拋出異常

  • Hooks 兩次執行相同順序下的名稱不對應, 會報錯
  • Hooks 兩次執行的數量不一致, 像上面這種也會報錯, 由於第二次渲染中實際只執行了兩次 Hooks, React 會跟蹤 Hooks 的執行, 由於保存了上一次執行的 List, 因此它會對比

關於 React 如何跟蹤這裏涉及到 Fiber 中 workInProgress 部分的設計, 先埋個坑, 後面來填

寫在後頭

文中埋了很多坑, 後續也會逐步填上, 不過也歡迎有興趣的人一塊兒來填坑, 共同揭開這些技術細節

相關文章
相關標籤/搜索