Ahooks 的 UseClickAway 在 React 17 中不工作了,該怎么辦?

最近公司的前端項(xiàng)目從 React 16 升級(jí)到了 React 17,導(dǎo)致 ahooks 的 useClickAway 不能按預(yù)期工作。
下面西瓜哥我就來說說到底發(fā)生了什么事。
ahooks 中的 useClickAway
ahooks 是阿里巴巴維護(hù)的第三方 React Hook 庫,里面封裝了很多好用的 hook。
比如經(jīng)常用到的組件掛載以及卸載的 useMount、useUnmount,還有支持自動(dòng)請(qǐng)求、手動(dòng)請(qǐng)求、防抖等各種功能請(qǐng)求 useRequest,以及可以將狀態(tài)同步存取到 localStorage 的 useLocalStorageState。
當(dāng)你想要寫一個(gè)與業(yè)務(wù)無關(guān)的第三方 ahooks,你可以去 ahooks 里面找找,大概率能夠找到,是比較優(yōu)秀的 hook 庫。
其中,useClickAway 的作用是 監(jiān)聽目標(biāo)元素外的點(diǎn)擊事件。
useClickAway 接受的第一個(gè)參數(shù)是一個(gè)事件回調(diào)函數(shù)。
第二個(gè)參數(shù)是被排除的目標(biāo)元素,可以是 ref 或 DOM 元素,或者是它們組成的數(shù)組,
第三個(gè)是需要監(jiān)聽的事件類型字符串或事件字符串?dāng)?shù)組。第三個(gè)參數(shù)是可選的,不使用的話默認(rèn)用點(diǎn)擊事件 'click'。
下面是一個(gè)常用的寫法:
useClickAway(() => {
console.log('點(diǎn)擊到元素外的地方');
}, ref);
useClickAway 的核心底層原理
核心底層原理是,是在 document 上綁定了一個(gè)冒泡事件。當(dāng)事件冒泡到 document 時(shí),會(huì)判斷事件目標(biāo)元素是否為傳入的 ref 下的子元素。
如果是,什么都不做。如果不是,執(zhí)行回調(diào)函數(shù)。
這里給出 useClickAway 的源碼地址,感興趣的話可以研究一下:
https://github.com/alibaba/hooks/blob/v3.5.0/packages/hooks/src/useClickAway/index.ts。
useClickAway 的問題
如果你在 React 16 中使用 useClickAway,一切都表現(xiàn)良好。
但如果是 React 17 及以上版本使用,在一些情況下會(huì)有問題。
我們有這么一個(gè)場景。
點(diǎn)擊一個(gè)搜索按鈕,會(huì)出現(xiàn)一個(gè)輸入框,此時(shí)用戶需要在這個(gè)輸入框內(nèi)輸入文字來搜索。如果點(diǎn)擊到搜索按鈕外的地方,輸入框會(huì)消失。

核心實(shí)現(xiàn)如下:
function App() {
const [visible, setVisible] = useState(false);
const inputRef = useRef();
useClickAway(() => {
setVisible(false);
}, inputRef);
return (
<div>
<button onClick={() => setVisible(true)}>搜索</button>
{visible && <input ref={inputRef} autoFocus />}
</div>
);
}
這里提供一個(gè)線上 demo(用的是 React 17 版本):
https://codesandbox.io/s/f54siy。
在 React 16 的時(shí)候,上面的寫法是正常的。但升級(jí)到 17 后,你會(huì)發(fā)現(xiàn)點(diǎn)擊 button 后什么事情都沒有發(fā)生。
React 17 的事件系統(tǒng)改造
?原因在于 React 17 對(duì)事件系統(tǒng)進(jìn)行了改造。
16 升級(jí)到 17 后,React 將事件委托到 ReactDOM 掛載的根節(jié)點(diǎn)上,比如 div#app,而不再是原來 document。

?首先,我們要知道的是,當(dāng)調(diào)用 setVisible(true) 改變組件狀態(tài)時(shí),組件就立即被重新渲染了,然后調(diào)用了 useClickAway。狀態(tài)更新后的組件重渲染是同步的,此時(shí)我們的事件流其實(shí)還沒有結(jié)束。
需要注意的是,更新狀態(tài)后的組件重新渲染,可能是同步,也可能是異步的。?
在 React 16 中,事件都委托到了 document 上。
我們點(diǎn)擊 button 元素,產(chǎn)生了一個(gè)事件流,當(dāng)點(diǎn)擊事件流動(dòng)到 document 時(shí),我們將 visible 設(shè)置為 true,組件進(jìn)行了一次同步的重新渲染,并調(diào)用 useClickAway,做了個(gè) document 上的冒泡事件綁定。
就像下面這樣:
document.addEventListener('click', () => {
console.log('顯示輸入框')
// React 16 中 useClickAway 綁定事件的時(shí)機(jī)
document.addEventListener('click', () => {
console.log('隱藏輸入框');
});
});
// 點(diǎn)擊后的輸出內(nèi)容為:
// 顯示輸入框
在一個(gè)元素的事件觸發(fā)過程中,往這個(gè)元素上注冊(cè)新的相同類型的事件響應(yīng)函數(shù),這個(gè)新的響應(yīng)函數(shù)不會(huì)在此次事件流上立即觸發(fā)。
所以,前面的 useClickAway 寫法在 React 16 是正常的。
但是,在 React 17 中就不同了,事件委托下放到了 div#app 中。

點(diǎn)擊按鈕,事件流冒泡到 div#app 元素,執(zhí)行事件回調(diào)函數(shù)將 visible 設(shè)置為了 true,并重新渲染組件,執(zhí)行 useClickAway 再給 document 綁定了新的事件響應(yīng)函數(shù)。
此時(shí)事件流沒有結(jié)束,繼續(xù)冒泡到 document,將 visible 又設(shè)置回了 false。
所以,visible 在短暫地變成 true 后,又變回了 false,無事發(fā)生。
document.querySelector('#app').addEventListener('click', () => {
console.log('顯示輸入框')
// React 17 中 useClickAway 綁定事件的時(shí)機(jī)
document.addEventListener('click', () => {
console.log('隱藏輸入框');
});
});
// 點(diǎn)擊后的輸出內(nèi)容為:
// 顯示輸入框
// 隱藏輸入框
解決方案
方案 1:阻止冒泡
<button
onClick={(e) => {
e.stopPropagation();
setVisible(true);
}}
>
我們給按鈕加上阻止事件冒泡,提前結(jié)束事件流,使其不流到 document 上,就不會(huì)觸發(fā) document 的點(diǎn)擊事件。
但這樣也是有隱患的,e.stopPropagation 是破壞性的。
如果我們?cè)谄渌牡胤揭獙懸恍┨厥獾呐袛嗍Ы惯壿嫞惨玫筋愃?useClickAway 的做法,我們點(diǎn)到這個(gè) button 上就會(huì)讓其他地方的邏輯走不通。
CSS 中的 overflow: hidden; 也具有破壞性,如果設(shè)置了該屬性的容器內(nèi)部的元素超出了容器范圍,會(huì)被截?cái)唷?/span>
方案 2:修改綁定事件類型為 mousedown / touchstart
useClickAway(
() => setVisible(false),
inputRef,
['mousedown', 'touchstart']
);
mousedown 在 click 事件之前就結(jié)束了,所以在 click 事件流過程中不會(huì)觸發(fā)它。
touchstart 是為了兼容移動(dòng)設(shè)備的情況。因?yàn)橛|屏?xí)r,touchstart 一定會(huì)觸發(fā),mousedown 不一定,順帶一提,click 也不一定。
其他的比較優(yōu)秀的第三方 React Hooks 庫,比如 react-use 的 useClickAway,其實(shí)就是用 mousedown 和 touchstart 作為默認(rèn)事件類型。
還有百度的 react-hooks 庫,其下的 useClickOutside 不支持自定義事件類型,但也是用的 mousedown 和 touchstart。
方案 3:將 button 元素也傳給 useClickAway
useClickAway(
() => setVisible(false),
[inputRef, buttonRef]
);
這樣就可以把 button 也排除在觸發(fā)條件外。
但這樣寫很繁瑣。如果輸入框要封裝成一個(gè)組件,你還得把 buttonRef 傳入到這個(gè)組件中。
方案 4:延遲輸入框出現(xiàn)時(shí)機(jī)
<button
onClick={() => {
setTimeout(() => {
setVisible(true);
});
}}
>
通過 setTimeout 的方式,確保輸入框的出現(xiàn)在同步的事件流之后才出現(xiàn),然后才觸發(fā) useClickAway 綁定邏輯。
結(jié)尾
React 16 升級(jí)為 17 后,React 中混合事件托管綁定到了 React 組件樹掛載的 div#app 上,不再是之前的 document。
這讓默認(rèn)注冊(cè)為 click 事件類型的 useClickAway 在一些場景下,表現(xiàn)上和 React 16 有一些不同。
對(duì)于上面的場景以及解決方案,我認(rèn)為最好的是第二種:給 useClickAway 的事件類型設(shè)置為 mousedown 和 touchstart。這種方法更有普適性。























