我打破了 React Hook 必須按順序、不能在條件語句中調(diào)用的枷鎖

React 官網(wǎng)介紹了 Hook 的這樣一個限制:

不要在循環(huán),條件或嵌套函數(shù)中調(diào)用 Hook, 確保總是在你的 React 函數(shù)的最頂層以及任何 return 之前調(diào)用他們。遵守這條規(guī)則,你就能確保 Hook 在每一次渲染中都按照同樣的順序被調(diào)用。這讓 React 能夠在多次的 useStateuseEffect 調(diào)用之間保持 hook 狀態(tài)的正確。(如果你對此感到好奇,我們在下面會有更深入的解釋。)

這個限制在開發(fā)中也確實會時常影響到我們的開發(fā)體驗,比如函數(shù)組件中出現(xiàn) if 語句提前 return 了,后面又出現(xiàn) Hook 調(diào)用的話,React 官方推的 eslint 規(guī)則也會給出警告。

function App(){
  if (xxx) {
    return null;
  }

  // ? React Hook "useState" is called conditionally. 
  // React Hooks must be called in the exact same order in every component render.
  useState();

  return 'Hello'
}
復(fù)制代碼

其實是個挺常見的用法,很多時候滿足某個條件了我們就不希望組件繼續(xù)渲染下去。但由于這個限制的存在,我們只能把所有 Hook 調(diào)用提升到函數(shù)的頂部,增加額外開銷。

由于 React 的源碼太復(fù)雜,接下來本文會以原理類似但精簡很多的 Preact 的源碼為切入點來調(diào)試、講解。

限制的原因

這個限制并不是 React 團(tuán)隊?wèi){空造出來的,的確是由于 React Hook 的實現(xiàn)設(shè)計而不得已為之。

以 Preact 的 Hook 的實現(xiàn)為例,它用數(shù)組和下標(biāo)來實現(xiàn) Hook 的查找(React 使用鏈表,但是原理類似)。

// 當(dāng)前正在運(yùn)行的組件
let currentComponent

// 當(dāng)前 hook 的全局索引
let currentIndex

// 第一次調(diào)用 currentIndex 為 0
useState('first') 

// 第二次調(diào)用 currentIndex 為 1
useState('second')
復(fù)制代碼

可以看出,每次 Hook 的調(diào)用都對應(yīng)一個全局的 index 索引,通過這個索引去當(dāng)前運(yùn)行組件 currentComponent 上的 _hooks 數(shù)組中查找保存的值,也就是 Hook 返回的 [state, useState]

那么假如條件調(diào)用的話,比如第一個 useState 只有 0.5 的概率被調(diào)用:

// 當(dāng)前正在運(yùn)行的組件
let currentComponent

// 當(dāng)前 hook 的全局索引
let currentIndex

// 第一次調(diào)用 currentIndex 為 0
if (Math.random() > 0.5) {
  useState('first')
}

// 第二次調(diào)用 currentIndex 為 1
useState('second')
復(fù)制代碼

在 Preact 第一次渲染組件的時候,假設(shè) Math.random() 返回的隨機(jī)值是 0.6,那么第一個 Hook 會被執(zhí)行,此時組件上保存的 _hooks 狀態(tài)是:

_hooks: [
  { value: 'first', update: function },
  { value: 'second', update: function },
]
復(fù)制代碼

用圖來表示這個查找過程是這樣的:

第一次渲染

假設(shè)第二次渲染的時候,Math.random() 返回的隨機(jī)值是 0.3,此時只有第二個 useState 被執(zhí)行了,那么它對應(yīng)的全局 currentIndex 會是 0,這時候去 _hooks[0] 中拿到的卻是 first 所對應(yīng)的狀態(tài),這就會造成渲染混亂。

第二次渲染

沒錯,本應(yīng)該值為 second 的 value,莫名其妙的被指向了 first,渲染完全錯誤!

以這個例子來看:

export default function App() {
  if (Math.random() > 0.5) {
    useState(10000)
  }
  const [value, setValue] = useState(0)

  return (
    <div>
      <button onClick={() => setValue(value + 1)}>+</button>
      {value}
    </div>
  )
}
復(fù)制代碼

結(jié)果是這樣:

chaos

破解限制

有沒有辦法破解限制呢?

如果要破解全局索引遞增導(dǎo)致的 bug,那么我們可以考慮換種方式存儲 Hook 狀態(tài)。

如果不用下標(biāo)存儲,是否可以考慮用一個全局唯一的 key 來保存 Hook,這樣不是就可以繞過下標(biāo)導(dǎo)致的混亂了嗎?

比如 useState 這個 API 改造成這樣:

export default function App() {
  if (Math.random() > 0.5) {
    useState(10000, 'key1');
  }
  const [value, setValue] = useState(0, "key2");

  return (
    <div>
      <button onClick={() => setValue(value + 1)}>+</button>
      {value}
    </div>
  );
}
復(fù)制代碼

這樣,通過 _hooks['key'] 來查找,就無所謂前序的 Hook 出現(xiàn)的任何意外情況了。

也就是說,原本的存儲方式是:

_hooks: [
  { value: 'first', update: function },
  { value: 'second', update: function },
]
復(fù)制代碼

改造后:

_hooks: [
  key1: { value: 'first', update: function },
  key2: { value: 'second', update: function },
]
復(fù)制代碼

注意,數(shù)組本身就支持對象的 key 值特性,不需要改造 _hooks 的結(jié)構(gòu)。

改造源碼

來試著改造一下 Preact 源碼,它的 Hook 包的位置在 hooks/src/index.js 下,找到 useState 方法:

export function useState(initialState) {
  currentHook = 1;
  return useReducer(invokeOrReturn, initialState, undefined);
}
復(fù)制代碼

它的底層調(diào)用了 useReducer,所以新增加一個 key 參數(shù)透傳下去:

+ export function useState(initialState, key) {
  currentHook = 1;
+ return useReducer(invokeOrReturn, initialState, undefined, key);
}
復(fù)制代碼

useReducer 原本是通過全局索引去獲取 Hook state:

// 全局索引
let currentIndex

export function useReducer(reducer, initialState, init) {
  const hookState = getHookState(currentIndex++, 2);
  hookState._reducer = reducer;

  return hookState._value;
}
復(fù)制代碼

改造成兼容版本,有 key 的時候優(yōu)先傳入 key 值:

// 全局索引
let currentIndex

+ export function useReducer(reducer, initialState, init, key) {
+  const hookState = getHookState(key || currentIndex++, 2);
   hookState._reducer = reducer;

   return hookState._value;
}
復(fù)制代碼

最后改造一下 getHookState 方法:

function getHookState(index, type) {
  const hooks =
    currentComponent.__hooks ||
    (currentComponent.__hooks = {
      _list: [],
      _pendingEffects: [],
    });

// 傳入 key 值是 string 或 symbol 都可以
+  if (typeof index !== 'number') {
+    if (!hooks._list[index]) {
+      hooks._list[index] = {};
+    }
+  } else {
    if (index >= hooks._list.length) {
      hooks._list.push({});
    }
  }
  // 這里天然支持 key 值取用的方式
  return hooks._list[index];
}
復(fù)制代碼

這里設(shè)計成傳入 key 值的時候,初始化就不往數(shù)組里 push 新狀態(tài),而是直接通過下標(biāo)寫入即可,原本的取狀態(tài)的寫法 hooks._list[index] 本身就支持通過 key 從數(shù)組上取值,不用改動。

至此,改造就完成了。

來試試新用法:

export default function App() {
  if (Math.random() > 0.5) {
    useState(10000, 'key1');
  }
  const [value, setValue] = useState(0, 'key2');

  return (
    <div>
      <button onClick={() => setValue(value + 1)}>+</button>
      {value}
    </div>
  );
}
復(fù)制代碼
ok

自動編譯

事實上 React 團(tuán)隊也考慮過給每次調(diào)用加一個 key 值的設(shè)計,在 Dan Abramov 的 為什么順序調(diào)用對 React Hooks 很重要? 中已經(jīng)詳細(xì)解釋過這個提案。

多重的缺陷導(dǎo)致這個提案被否決了,尤其是在遇到自定義 Hook 的時候,比如你提取了一個 useFormInput

const valueKey = Symbol();

function useFormInput() {
  const [value, setValue] = useState(valueKey);
  return {
    value,
    onChange(e) {
      setValue(e.target.value);
    },
  };
}
復(fù)制代碼

然后在組件中多次調(diào)用它:

function Form() {
  // 使用 Symbol
  const name = useFormInput(); 
  // 又一次使用了同一個 Symbol
  const surname = useFormInput(); 
  // ...
  return (
    <>
      <input {...name} />
      <input {...surname} />
      {/* ... */}
    </>    
  )
}
復(fù)制代碼

此時這個通過 key 尋找 Hook state 的方式就會發(fā)生沖突。

但我的想法是,能不能借助 babel 插件的編譯能力,實現(xiàn)編譯期自動為每一次 Hook 調(diào)用都注入一個 key, 偽代碼如下:

traverse(node) {
  if (isReactHookInvoking(node)) {
    addFunctionParameter(node, getUniqKey(node))
  }
}
復(fù)制代碼

生成這樣的代碼:

function Form() {
+  const name = useFormInput('key_1'); 
+  const surname = useFormInput('key_2'); 
  // ...
  return (
    <>
      <input {...name} />
      <input {...surname} />
      {/* ... */}
    </>    
  )
}

+ function useFormInput(key) {
+  const [value, setValue] = useState(key);
  return {
    value,
    onChange(e) {
      setValue(e.target.value);
    },
  };
}
復(fù)制代碼

key 的生成策略可以是隨機(jī)值,也可以是注入一個 Symbol,這個無所謂,保證運(yùn)行時期不會改變即可。也許有一些我沒有考慮周到的地方,對此有任何想法的同學(xué)都?xì)g迎加我微信 sshsunlight 討論,當(dāng)然單純的交個朋友也沒問題,大佬或者萌新都?xì)g迎。

總結(jié)

本文只是一篇探索性質(zhì)的文章:

  • 介紹 Hook 實現(xiàn)的大概原理以及限制
  • 探索出修改源碼機(jī)制繞過限制的方法

其實本意是幫助大家更好的理解 Hook。

我并不希望 React 取消掉這些限制,我覺得這也是設(shè)計的取舍。

如果任何子函數(shù),任何條件表達(dá)式中都可以調(diào)用 Hook,代碼也會變得更加難以理解和維護(hù)

如果你真的希望更加靈活的使用類似的 Hook 能力,Vue3 底層響應(yīng)式收集依賴的原理就可以完美的繞過這些限制,但更加靈活的同時也一定會無法避免的增加更多維護(hù)風(fēng)險。

感謝大家

我是 ssh,目前就職于字節(jié)跳動的 Web Infra 團(tuán)隊,目前團(tuán)隊在北上廣深杭都還缺人(尤其是北京)。

為此我組建了一個氛圍特別好的招聘社群,大家在里面盡情的討論面試相關(guān)的想法和問題,也歡迎你加入,隨時投遞簡歷給我。

了解更多加入我們前端學(xué)習(xí)圈

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容