一個(gè)新的React概念:Effect Event
大家好,我卡頌。
每個(gè)框架由于實(shí)現(xiàn)原理的區(qū)別,都會有些獨(dú)特的概念。比如:
- Vue3由于其響應(yīng)式的實(shí)現(xiàn)原理,衍生出ref、reactive等概念。
- Svelte重度依賴自身的編譯器,所以衍生出與編譯相關(guān)的概念(比如其對label標(biāo)簽的創(chuàng)新性使用)。
在React中,有一個(gè)「非常容易」被誤用的API —— useEffect,今天要介紹的Effect Event就屬于由useEffect衍生出的概念。
被誤用的useEffect
本文一共會涉及三個(gè)概念:
- Event(事件)
- Effect(副作用)
- Effect Event(副作用事件)
首先來聊聊Event與Effect。useEffect容易被誤用也是因?yàn)檫@兩個(gè)概念很容易混淆。
Event的概念
在下面的代碼中,點(diǎn)擊div會觸發(fā)點(diǎn)擊事件,onClick是點(diǎn)擊回調(diào)。其中onClick就屬于Event:
function App() {
const [num , update] = useState(0);
function onClick() {
update(num + 1);
}
return (
<div onClick={onClick}>{num}</div>
)
}
Event的特點(diǎn)是:「是由某些行為觸發(fā),而不是狀態(tài)變化觸發(fā)的邏輯」。
比如,在上述代碼中,onClick是由「點(diǎn)擊事件」這一行為觸發(fā)的邏輯,num狀態(tài)變化不會觸發(fā)onClick。
Effect的概念
Effect則與Event相反,他是「由某些狀態(tài)變化觸發(fā)的,而不是某些行為觸發(fā)的邏輯」。
比如,在下述代碼中,當(dāng)title變化后document.title會更新為title的值:
function Title({title}) {
useEffect(() => {
document.title = title;
}, [title])
// ...
}
上述代碼中useEffect的邏輯就屬于Effect,他是由title變化觸發(fā)的。除了useEffect外,下面兩個(gè)Hook也屬于Effect:
- useLayoutEffect(不常用)
- useInsertionEffect(很不常用)
為什么容易誤用?
現(xiàn)在問題來了:Event與Effect的概念完全不同,為什么會被誤用?
舉個(gè)例子,在項(xiàng)目的第一個(gè)版本中,我們在useEffect中有個(gè)初始化數(shù)據(jù)的邏輯:
function App() {
const [data, updateData] = useState(null);
useEffect(() => {
fetchData().then(data => {
// ...一些業(yè)務(wù)邏輯
// 更新data
updateData(data);
})
}, []);
// ...
}
隨著項(xiàng)目發(fā)展,你又接到一個(gè)需求:提交表單后更新數(shù)據(jù)。
為了復(fù)用之前的邏輯,你新增了options狀態(tài)(保存表單數(shù)據(jù)),并將他作為useEffect的依賴:
function App() {
const [data, updateData] = useState(null);
const [options, updateOptions] = useState(null);
useEffect(() => {
fetchData(options).then(data => {
// ...一些業(yè)務(wù)邏輯
// 更新data
updateData(data);
})
}, [options]);
function onSubmit(opt) {
updateOptions(opt);
}
// ...
}
現(xiàn)在,提交表單后(觸發(fā)onSubmit回調(diào))就能復(fù)用之前的數(shù)據(jù)初始化邏輯。
這么做實(shí)在是方便,以至于很多同學(xué)認(rèn)為這就是useEffect的用法。但其實(shí)這是典型的「useEffect誤用」。
仔細(xì)分析我們會發(fā)現(xiàn):「提交表單」顯然是個(gè)Event(由提交的行為觸發(fā)),Event的邏輯應(yīng)該寫在事件回調(diào)中,而不是useEffect中。正確的寫法應(yīng)該是這樣:
function App() {
const [data, updateData] = useState(null);
useEffect(() => {
fetchData().then(data => {
// ...一些業(yè)務(wù)邏輯
// 更新data
updateData(data);
})
}, []);
function onSubmit(opt) {
fetchData(opt).then(data => {
// ...一些業(yè)務(wù)邏輯
// 更新data
updateData(data);
})
}
// ...
}
上述例子邏輯比較簡單,兩種寫法的區(qū)別不大。但在實(shí)際項(xiàng)目中,隨著項(xiàng)目不斷迭代,可能出現(xiàn)如下代碼:
useEffect(() => {
fetchData(options).then(data => {
// ...一些業(yè)務(wù)邏輯
// 更新data
updateData(data);
})
}, [options, xxx, yyy, zzz]);
屆時(shí),很難清楚fetchData方法會在什么情況下執(zhí)行,因?yàn)椋?/p>
- useEffect的依賴項(xiàng)太多了
- 很難完全掌握每個(gè)依賴項(xiàng)變化的時(shí)機(jī)
所以,在React中,我們需要清楚的區(qū)分Event與Effect,也就是清楚的區(qū)分「一段邏輯是由行為觸發(fā)的,還是狀態(tài)變化觸發(fā)的?」
useEffect的依賴問題
現(xiàn)在,我們已經(jīng)能清楚的區(qū)分Event與Effect,按理說寫項(xiàng)目不會有問題了。但是,由于「Effect的機(jī)制問題」,我們還面臨一個(gè)新問題。
假設(shè)我們有段聊天室代碼,當(dāng)roomId變化后,要重新連接到新聊天室。在這個(gè)場景下,聊天室的斷開/重新連接依賴于roomId狀態(tài)的變化,顯然屬于Effect,代碼如下:
function ChatRoom({roomId}) {
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => {
connection.disconnect()
};
}, [roomId]);
// ...
}
接下來你接到了新需求 —— 當(dāng)連接成功后,彈出「全局提醒」:
「全局提醒」是否是黑暗模式,受到theme props影響。useEffect修改后的代碼如下:
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
connection.on('connected', () => {
showNotification('連接成功!', theme);
});
return () => connection.disconnect();
}, [roomId, theme]);
但這段代碼有個(gè)嚴(yán)重問題 —— 任何導(dǎo)致theme變化的情況都會導(dǎo)致聊天室斷開/重新連接。畢竟,theme也是useEffect的依賴項(xiàng)。
在這個(gè)例子中,雖然Effect依賴theme,但Effect并不是由theme變化而觸發(fā)的(他是由roomId變化觸發(fā)的)。
為了應(yīng)對這種場景,React提出了一個(gè)新概念 —— Effect Event。他指那些「在Effect內(nèi)執(zhí)行,但Effect并不依賴其中狀態(tài)的邏輯」,比如上例中的:
() => {
showNotification('連接成功!', theme);
}
我們可以使用useEffectEvent(這是個(gè)試驗(yàn)性Hook)定義Effect Event:
function ChatRoom({roomId, theme}) {
const onConnected = useEffectEvent(() => {
showNotification('連接成功!', theme);
});
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
connection.on('connected', () => {
onConnected();
});
return () => {
connection.disconnect()
};
}, [roomId]);
// ...
}
在上面代碼中,theme被移到onConnected(他是個(gè)Effect Event)中,useEffect雖然使用了theme的最新值,但并不需要將他作為依賴。
useEffectEvent源碼解析
useEffectEvent的實(shí)現(xiàn)并不復(fù)雜,核心代碼如下:
function updateEvent(callback) {
const hook = updateWorkInProgressHook();
// 保存callback的引用
const ref = hook.memoizedState;
// 在useEffect執(zhí)行前更新callback的引用
useEffectEventImpl({ref, nextImpl: callback});
return function eventFn() {
if (isInvalidExecutionContextForEventFunction()) {
throw new Error(
"A function wrapped in useEffectEvent can't be called during rendering.",
);
}
return ref.impl.apply(undefined, arguments);
};
}
其中ref變量保存「callback的引用」。對于上述例子中:
const onConnected = useEffectEvent(() => {
showNotification('連接成功!', theme);
});
ref保存對如下函數(shù)的引用:
() => {
showNotification('連接成功!', theme);
}
useEffectEventImpl方法接受ref和callback的最新值為參數(shù),在useEffect執(zhí)行前會將ref中保存的callback引用更新為callback的最新值。
所以,當(dāng)在useEffect中執(zhí)行onConnected,獲取的就是ref中保存的下述閉包的最新值:
() => {
showNotification('連接成功!', theme);
}
閉包中的theme自然也是最新值。
useEffectEvent與useEvent
仔細(xì)觀察下useEffectEvent的返回值,他包含了兩個(gè)限制:
return function eventFn() {
if (isInvalidExecutionContextForEventFunction()) {
throw new Error(
"A function wrapped in useEffectEvent can't be called during rendering.",
);
}
return ref.impl.apply(undefined, arguments);
};
第一個(gè)限制比較明顯 —— 下面這行代碼限制useEffectEvent的返回值只能在useEffect回調(diào)中執(zhí)行(否則會報(bào)錯(cuò)):
if (isInvalidExecutionContextForEventFunction()) {
// ...
}
另一個(gè)限制則比較隱晦 —— 返回值是個(gè)全新的引用:
return function eventFn() {
// ...
};
如果你不太明白「全新的引用」為什么是個(gè)限制,考慮下返回一個(gè)useCallback返回值:
return useCallback((...args) => {
const fn = ref.impl;
return fn(...args);
}, []);
這將會讓useEffectEvent的返回值成為不變的引用,如果再去掉「只能在useEffect回調(diào)中執(zhí)行」的限制,那么useEffectEvent將是加強(qiáng)版的useCallback。
舉個(gè)例子,如果破除上述限制,那么對于下面的代碼:
function App({a, b}) {
const [c, updateC] = useState(0);
const fn = useCallback(() => a + b + c, [a, b, c])
// ...
}
用useEffectEvent替代useCallback,代碼如下:
const fn = useEffectEvent(() => a + b + c)
相比于useCallback,他有2個(gè)優(yōu)點(diǎn):
- 不用顯式聲明依賴
- 即使依賴變了,fn的引用也不會變,簡直是性能優(yōu)化的最佳選擇
那么React為什么要為useEffectEvent加上限制呢?
實(shí)際上,useEffectEvent的前身useEvent就是遵循上述實(shí)現(xiàn),但是由于:
- useEvent的定位應(yīng)該是Effect Event,但實(shí)際用途更廣(可以替代useCallback),這不符合他的定位
- 當(dāng)前React Forget(能生成等效于useMemo、useCallback代碼的官方編譯器)并未考慮useEvent,如果增加這個(gè)hook,會提高React Forget實(shí)現(xiàn)的難度
所以,useEvent并沒有正式進(jìn)入標(biāo)準(zhǔn)。相反,擁有更多限制的useEffectEvent反而進(jìn)入了React文檔[1]。
總結(jié)
今天我們學(xué)到三個(gè)概念:
- Event:由某些行為觸發(fā),而不是狀態(tài)變化觸發(fā)的邏輯。
- Effect:由某些狀態(tài)變化觸發(fā)的,而不是某些行為觸發(fā)的邏輯。
- Effect Event:在Effect內(nèi)執(zhí)行,但Effect并不依賴其中狀態(tài)的邏輯。
其中Effect Event在React中的具體實(shí)現(xiàn)是useEffectEvent。相比于他的前身useEvent,他附加了2條限制:
- 只能在Effect內(nèi)執(zhí)行。
- 始終返回不同的引用。
在我看來,Effect Event的出現(xiàn)完全是由于Hooks實(shí)現(xiàn)機(jī)制上的復(fù)雜性(必須顯式指明依賴)導(dǎo)致的心智負(fù)擔(dān)。
畢竟,同樣遵循Hooks理念的Vue Composition API就沒有這方面問題。
參考資料
[1]React文檔:https://react.dev/learn/separating-events-from-effects。