我打破了 React Hook 必須按順序、不能在條件語句中調(diào)用的枷鎖!
React 官網(wǎng)介紹了 Hook 的這樣一個限制:
不要在循環(huán),條件或嵌套函數(shù)中調(diào)用 Hook, 確??偸窃谀愕?React 函數(shù)的最頂層以及任何 return 之前調(diào)用他們。遵守這條規(guī)則,你就能確保 Hook 在每一次渲染中都按照同樣的順序被調(diào)用。這讓 React 能夠在多次的
useState和useEffect調(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'
}
其實是個挺常見的用法,很多時候滿足某個條件了我們就不希望組件繼續(xù)渲染下去。但由于這個限制的存在,我們只能把所有 Hook 調(diào)用提升到函數(shù)的頂部,增加額外開銷。
由于 React 的源碼太復雜,接下來本文會以原理類似但精簡很多的 Preact[1] 的源碼為切入點來調(diào)試、講解。
限制的原因
這個限制并不是 React 團隊憑空造出來的,的確是由于 React Hook 的實現(xiàn)設計而不得已為之。
以 Preact 的 Hook 的實現(xiàn)為例,它用數(shù)組和下標來實現(xiàn) Hook 的查找(React 使用鏈表,但是原理類似)。
// 當前正在運行的組件
let currentComponent
// 當前 hook 的全局索引
let currentIndex
// 第一次調(diào)用 currentIndex 為 0
useState('first')
// 第二次調(diào)用 currentIndex 為 1
useState('second')
可以看出,每次 Hook 的調(diào)用都對應一個全局的 index 索引,通過這個索引去當前運行組件 currentComponent 上的 _hooks 數(shù)組中查找保存的值,也就是 Hook 返回的 [state, useState]
那么假如條件調(diào)用的話,比如第一個 useState 只有 0.5 的概率被調(diào)用:
// 當前正在運行的組件
let currentComponent
// 當前 hook 的全局索引
let currentIndex
// 第一次調(diào)用 currentIndex 為 0
if (Math.random() > 0.5) {
useState('first')
}
// 第二次調(diào)用 currentIndex 為 1
useState('second')
在 Preact 第一次渲染組件的時候,假設 Math.random() 返回的隨機值是 0.6,那么第一個 Hook 會被執(zhí)行,此時組件上保存的 _hooks 狀態(tài)是:
_hooks: [
{ value: 'first', update: function },
{ value: 'second', update: function },
]
用圖來標識這個查找過程是這樣的:

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

沒錯,本應該值為 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>
)
}
結(jié)果是這樣:

破解限制
有沒有辦法破解限制呢?
如果要破解全局索引遞增導致的 bug,那么我們可以考慮換種方式存儲 Hook 狀態(tài)。
如果不用下標存儲,是否可以考慮用一個全局唯一的 key 來保存 Hook,這樣不是就可以繞過下標導致的混亂了嗎?
比如 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>
);
}
這樣,通過 _hooks['key'] 來查找,就無所謂前序的 Hook 出現(xiàn)的任何意外情況了。
也就是說,原本的存儲方式是:
_hooks: [
{ value: 'first', update: function },
{ value: 'second', update: function },
]
改造后:
_hooks: [
key1: { value: 'first', update: function },
key2: { value: 'second', update: function },
]
注意,數(shù)組本身就支持對象的 key 值特性,不需要改造 _hooks 的結(jié)構(gòu)。
改造源碼
來試著改造一下 Preact 源碼,它的 Hook 包的位置在 hooks/src/index.js[2] 下,找到 useState 方法:
export function useState(initialState) {
currentHook = 1;
return useReducer(invokeOrReturn, initialState, undefined);
}
它的底層調(diào)用了 useReducer,所以新增加一個 key 參數(shù)透傳下去:
+ export function useState(initialState, key) {
currentHook = 1;
+ return useReducer(invokeOrReturn, initialState, undefined, key);
}
useReducer 原本是通過全局索引去獲取 Hook state:
// 全局索引
let currentIndex
export function useReducer(reducer, initialState, init) {
const hookState = getHookState(currentIndex++, 2);
hookState._reducer = reducer;
return hookState._value;
}
改造成兼容版本,有 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;
}
最后改造一下 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];
}
這里設計成傳入 key 值的時候,初始化就不往數(shù)組里 push 新狀態(tài),而是直接通過下標寫入即可,原本的取狀態(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>
);
}

自動編譯
事實上 React 團隊也考慮過給每次調(diào)用加一個 key 值的設計,在 Dan Abramov 的 為什么順序調(diào)用對 React Hooks 很重要?[3] 中已經(jīng)詳細解釋過這個提案。
多重的缺陷導致這個提案被否決了,尤其是在遇到自定義 Hook 的時候,比如你提取了一個 useFormInput:
const valueKey = Symbol();
function useFormInput() {
const [value, setValue] = useState(valueKey);
return {
value,
onChange(e) {
setValue(e.target.value);
},
};
}
然后在組件中多次調(diào)用它:
function Form() {
// 使用 Symbol
const name = useFormInput();
// 又一次使用了同一個 Symbol
const surname = useFormInput();
// ...
return (
<>
<input {...name} />
<input {...surname} />
{/* ... */}
</>
)
}
此時這個通過 key 尋找 Hook state 的方式就會發(fā)生沖突。
但我的想法是,能不能借助 babel 插件的編譯能力,實現(xiàn)編譯期自動為每一次 Hook 調(diào)用都注入一個 key,
偽代碼如下:
traverse(node) {
if (isReactHookInvoking(node)) {
addFunctionParameter(node, getUniqKey(node))
}
}
生成這樣的代碼:
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);
},
};
}
key 的生成策略可以是隨機值,也可以是注入一個 Symbol,這個無所謂,保證運行時期不會改變即可。也許有一些我沒有考慮周到的地方,對此有任何想法的同學都歡迎加我微信 sshsunlight[4] 討論,當然單純的交個朋友也沒問題,大佬或者萌新都歡迎。
總結(jié)
本文只是一篇探索性質(zhì)的文章:
介紹 Hook 實現(xiàn)的大概原理以及限制 探索出修改源碼機制繞過限制的方法
其實本意是幫助大家更好的理解 Hook。
我并不希望 React 取消掉這些限制,我覺得這也是設計的取舍。
如果任何子函數(shù),任何條件表達式中都可以調(diào)用 Hook,代碼也會變得更加難以理解和維護。
如果你真的希望更加靈活的使用類似的 Hook 能力,Vue3 底層響應式收集依賴的原理就可以完美的繞過這些限制,但更加靈活的同時也一定會無法避免的增加更多維護風險。
如果你覺得這篇內(nèi)容對你挺有啟發(fā),我想邀請你幫我三個小忙:
點個「在看」,讓更多的人也能看到這篇內(nèi)容(喜歡不點在看,都是耍流氓 -_-) 歡迎加我微信「TH0000666」一起交流學習... 關注公眾號「前端Sharing」,持續(xù)為你推送精選好文。
參考資料
Preact: https://github.com/preactjs/preact
[2]hooks/src/index.js: https://github.com/preactjs/preact/blob/master/hooks/src/index.js
[3]為什么順序調(diào)用對 React Hooks 很重要?: https://overreacted.io/zh-hans/why-do-hooks-rely-on-call-order/#%E7%BC%BA%E9%99%B7-2-%E5%91%BD%E5%90%8D%E5%86%B2%E7%AA%81
[4]sshsunlight: https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/017d568dc1d14cd883cc3238350a39ec~tplv-k3u1fbpfcp-watermark.image
