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