每个框架由于实现原理的区别,都会有些独特的概念。比如:
Vue3
由于其响应式的实现原理,衍生出ref
、reactive
等概念
Svelte
重度依赖自身的编译器,所以衍生出与编译相关的概念(比如其对label
标签的创新性使用)
在React
中,有一个「非常容易」被误用的API
—— useEffect
,今天要介绍的Effect Event
就属于由useEffect
衍生出的概念。
本文一共会涉及三个概念:
Event
(事件)
Effect
(副作用)
Effect Event
(副作用事件)
首先来聊聊Event
与Effect
。useEffect
容易被误用也是因为这两个概念很容易混淆。
在下面的代码中,点击div
会触发点击事件,onClick
是点击回调。其中onClick
就属于Event
:
function App() {
const [num , update] = useState(0);
function onClick() {
update(num + 1);
}
return (
{num}
)
}
Event
的特点是:「是由某些行为触发,而不是状态变化触发的逻辑」。
比如,在上述代码中,onClick
是由「点击事件」这一行为触发的逻辑,num
状态变化不会触发onClick
。
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
,也就是清楚的区分「一段逻辑是由行为触发的,还是状态变化触发的?」
现在,我们已经能清楚的区分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
的实现并不复杂,核心代码如下:
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
的返回值,他包含了两个限制:
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