React useEvent:磚家說(shuō)的沒(méi)問(wèn)題
之前寫了一篇文章《React Hooks 使用誤區(qū),駁官方文檔[1]》,文中拋出了兩個(gè)觀點(diǎn):
- 不是所有的依賴都必須放到依賴數(shù)組中
- deps 參數(shù)不能緩解閉包問(wèn)題
這兩個(gè)觀點(diǎn)引起了劇烈的討論,當(dāng)然大多數(shù)人還是持反對(duì)意見(jiàn)的,甚至質(zhì)疑我不會(huì)用 Hooks,(⊙o⊙)… 我想說(shuō)我寫的 Hooks 比你吃的鹽都多(開玩笑 ?? ~)。
然后呢,知乎上來(lái)了個(gè)提問(wèn)《如何看待《React Hooks 使用誤區(qū),駁官方文檔》?[2]》,大家依舊是討論激烈,甚至 #黃玄 大佬也親自來(lái)回答了。
很多同學(xué)極力反對(duì)我的觀點(diǎn),剛開始我還想一爭(zhēng)高下,后來(lái)實(shí)在沒(méi)精力一個(gè)一個(gè)對(duì)線。
這不,React 官方來(lái)幫我助陣了?React 官方為啥出 useEvent?就是發(fā)現(xiàn)以前要求的依賴寫法,實(shí)在有太大問(wèn)題,不加一個(gè)新的 API,官方示例都沒(méi)法寫了 ??。
以前一直覺(jué)得 React Hooks 教程,包括 Dan 寫的 useEffect 教程[3],都只是寫了基礎(chǔ)場(chǎng)景,對(duì)于稍微復(fù)雜點(diǎn)的場(chǎng)景,都避而不談。因?yàn)檫@些復(fù)雜場(chǎng)景,在之前的規(guī)則下,確實(shí)是沒(méi)法玩。
什么是 useEvent
關(guān)于 useEvent 是什么,官方 RFC[4] 文檔有非常詳細(xì)的解釋,并且目前社區(qū)上也有非常多的文章介紹(其實(shí)很多介紹都是有問(wèn)題的)。接下來(lái)用一個(gè)官方文檔上的一個(gè)例子,來(lái)認(rèn)識(shí)一下 useEvent。需求很簡(jiǎn)單,我們希望 url 變化的時(shí)候,上報(bào)下當(dāng)前 url和 username。
function Page({ route, currentUser }) {
useEffect(() => {
logAnalytics('visit_page', route.url, currentUser.name);
}, [route.url]);
// ...
}
如上代碼,會(huì)有 warning,告訴我們 currentUser.name要放到 deps 中。修正后代碼是這樣:
function Page({ route, currentUser }) {
useEffect(() => {
logAnalytics('visit_page', route.url, currentUser.name);
}, [route.url, currentUser.name]);
// ...
}
但這樣明顯滿足不了我們的業(yè)務(wù)需求,因?yàn)?currentUser.name變化后,也觸發(fā)了上報(bào)請(qǐng)求。
很多杠精就問(wèn),為啥你的需求要這樣設(shè)計(jì)?為啥 currentUser.name變化后不要上報(bào)?你的需求不合理吧?這個(gè)你去問(wèn) dan 吧!
以前的解決方案可能有兩個(gè):
- 忽略警告,把 eslint-plugin-react-hooks卸載掉
- 通過(guò) ref 來(lái)標(biāo)記 currentUser.name
function Page({ route, currentUser }) {
const ref = useRef(currentUser.name);
ref.current = currentUser.name;
useEffect(() => {
logAnalytics('visit_page', route.url, ref.current);
}, [route.url]);
// ...
}
兩個(gè)方案都有缺點(diǎn):
- 打破了所謂的 React 對(duì) deps 的限制規(guī)則
- 寫法太麻煩,項(xiàng)目復(fù)雜后要定義無(wú)數(shù)個(gè) ref
基于 useEvent 改造起來(lái)就很簡(jiǎn)單了。
function Page({ route, currentUser }) {
// ? Stable identity
const onVisit = useEvent(visitedUrl => {
logAnalytics('visit_page', visitedUrl, currentUser.name);
});
useEffect(() => {
onVisit(route.url);
}, [route.url]); // ? Re-runs only on route change
// ...
}
useEvent 會(huì)將一個(gè)函數(shù)「持久化」,同時(shí)可以保證函數(shù)內(nèi)部的變量引用永遠(yuǎn)是最新的。如果你用過(guò) ahooks 的 useMemoizedFn,實(shí)現(xiàn)的效果是幾乎一致的。再?gòu)?qiáng)調(diào)下 useEvent 的兩個(gè)特性:
- 函數(shù)地址永遠(yuǎn)是不變的
- 函數(shù)內(nèi)引用的變量永遠(yuǎn)是最新的
useEvent 可以用來(lái)代替 useCallback,以前這樣寫,在 text 變化的時(shí)候,函數(shù)地址會(huì)變化。
function Chat() {
const [text, setText] = useState('');
// ?? A different function whenever `text` changes
const onClick = useCallback(() => {
sendMessage(text);
}, [text]);
return <SendButton onClick={onClick} />;
}
通過(guò) useEvent 代替 useCallback 后,不用寫 deps 函數(shù)了,并且函數(shù)地址永遠(yuǎn)是固定的,text也永遠(yuǎn)是最新的。
function Chat() {
const [text, setText] = useState('');
// ? Always the same function (even if `text` changes)
const onClick = useEvent(() => {
sendMessage(text);
});
return <SendButton onClick={onClick} />;
}
useEvent 是怎么實(shí)現(xiàn)的
useEvent 的實(shí)現(xiàn)原理比較簡(jiǎn)單,但現(xiàn)在看到的社區(qū)上的介紹文章幾乎都有問(wèn)題。
// (!) Approximate behavior
function useEvent(handler) {
const handlerRef = useRef(null);
// In a real implementation, this would run before layout effects
useLayoutEffect(() => {
handlerRef.current = handler;
});
return useCallback((...args) => {
// In a real implementation, this would throw if called during render
const fn = handlerRef.current;
return fn(...args);
}, []);
}
上面的代碼是官方提供的一個(gè)示例代碼,需要重點(diǎn)注意這句注釋 In a real implementation, this would run before layout effects,翻譯過(guò)來(lái)就是 “在真實(shí)的實(shí)現(xiàn)中,這里用的 Hooks 執(zhí)行時(shí)機(jī)在 useLayoutEffect之前”。
這里一定是不能用 useLayoutEffect來(lái)更新 ref的,因?yàn)樽咏M件的 useLayoutEffect比父組件的執(zhí)行更早,如果這樣用的話,子組件的 useLayoutEffect中訪問(wèn)到的 ref一定是舊的。
所以官方為了實(shí)現(xiàn) useEvent,一定是要加一個(gè)在 useLayoutEffect執(zhí)行的 Hooks 的,并且這個(gè) Hooks 應(yīng)該不會(huì)開放給普通用戶使用的。
另外 React 要求不要在 render 中直接調(diào)用 useEvent返回的函數(shù),原理也是一樣的,在 render 中訪問(wèn)的函數(shù)一定是舊的,因?yàn)? useLayoutEffect還沒(méi)執(zhí)行呢。
useMemoizedFn 和 useEvent 的差異
在 React 18 之前,社區(qū)上有很多類似 useEvent 的實(shí)現(xiàn),比如 ahooks[5] 的 useMemoizedFn,類似下面這樣:
function useMemoizedFn(fn) {
const fnRef = useRef(fn);
fnRef.current = useMemo(() => fn, [fn]);
return useCallback((...args) => {
return fnRef.current.apply(args);
}, []);
}
之前很多同學(xué)問(wèn),為啥不用 useLayoutEffect,是不是有問(wèn)題?現(xiàn)在應(yīng)該明白了吧?我們需要一個(gè)比useLayoutEffect執(zhí)行更早的 Hooks,很遺憾的是之前更沒(méi)有,所以只能放到 render 中。
為什么之前官方?jīng)]有提供類似的 Hooks?useMemoizedFn 有問(wèn)題嗎?之前 React Issue #16956[6] 上對(duì)類似的封裝做了很多討論,官方的態(tài)度一直是 “在 concurrent 下可能會(huì)存在問(wèn)題” ,也就是官方也吃不準(zhǔn)未來(lái)會(huì)不會(huì)出問(wèn)題。隨著 React 18 發(fā)布,concurrent 模式穩(wěn)定之后,官方發(fā)現(xiàn),這種寫法不會(huì)有問(wèn)題,索性就自己提供了一個(gè)。
在 React 18 之前,因?yàn)闆](méi)有 concurrent,所以 useMemoizedFn 不會(huì)有任何問(wèn)題。在 React 18 之后,我目前也沒(méi)看到有什么問(wèn)題。不過(guò)為了穩(wěn)妥起見(jiàn),后面 ahooks 的 useMemoizedFn 會(huì)做一次升級(jí),向官方的 useEvent 看齊。
最后用知乎上一個(gè)同學(xué)的評(píng)論結(jié)尾“面多了加水,水多了加面”。
參考資料
[1]React Hooks 使用誤區(qū),駁官方文檔: https://zhuanlan.zhihu.com/p/450513902
[2]如何看待《React Hooks 使用誤區(qū),駁官方文檔》?: https://www.zhihu.com/question/508780830
[3]useEffect 教程: https://overreacted.io/a-complete-guide-to-useeffect/
[4]RFC: https://github.com/reactjs/rfcs/blob/useevent/text/0000-useevent.md
[5]ahooks: https://github.com/alibaba/hooks[6]#16956: https://github.com/facebook/react/issues/16956