作者:陈海芹 openInula TAC 成员/核心贡献者
《openInula茶话会》专栏每周不定期分享业务知识、实战经验与开发技巧,帮助社区成员快速提升技能,开启技术探索之旅! 本期介绍《逃离闭包陷阱 - 函数组件中闭包陈旧值问题最佳实践》,欢迎查阅。
问题背景
在使用函数组件时,我们经常会遇到一个棘手的问题:闭包中的值无法及时更新。 最近,我在开发一个水印组件时就遇到了这个问题。让我们来看看这个问题是如何发生的,以及如何优雅地解决它。
问题描述
我们的水印组件代码大致如下:
在浏览器MutationObserver注册了水印元素被删除的回调函数。
javascript
function Watermark({ active, userName, theme }) {
const canvasRef = useRef(null);
const [createWatermarkObserver, removeWatermarkObserver] = useWatermarkObserver({
watermarkLayerRef: ref,
onDelete: () => {
if (active) { // BUG:这里会读取旧值
document.body.append(canvasRef.current);
}
},
// ...其他代码
});
// ...其他代码
}
问题出在 onDelete
回调函数中。 在调试过程中,我们发现即使 props
中的 active
已经变为 false
,onDelete
中读到的 active
仍然是 true
。
问题分析
这个问题的根源在于 JavaScript 的闭包机制。当我们创建 onDelete
函数时,它捕获了当时的 active
值。这个函数被传递给 useWatermarkObserver
去创建MutationObeserver,并在之后的某个时间点被调用。但是,当它被调用时,它仍然使用的是组件创建时捕获的 active
值,而不是最新的值。
这就导致了一个令人困惑的情况:组件的 props 已经更新,但回调函数中的值却没有更新。
类似问题的常见场景
闭包陈旧值问题在 Hook 使用中非常普遍。以下是一些常见的场景:
- 定时器中的状态更新
javascript
function CounterWithDelay() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
console.log(`Current count: ${count}`);// 这里的 count 总是 0
setCount(count + 1);
}, 1000);
return () => clearInterval(timer);
}, []); // 空依赖数组导致闭包捕获初始值
return <div>Count: {count}</div>;
}
- 事件监听器中的状态访问
javascript
function WindowSizeTracker() {
const [size, setSize] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => {
console.log(`Previous size: ${size}`);
setSize(window.innerWidth);
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []); // 空依赖数组导致闭包捕获初始值
return <div>Window width: {size}</div>;
}
- 异步操作中的状态访问
javascript
function AsyncDataFetcher() {
const [data, setData] = useState(null);
const [count, setCount] = useState(0);
const fetchData = async () => {
const response = await fetch('https://api.example.com/data');
const result = await response.json();
console.log(`Count state: ${count}`); // 请求期间多次点击button,count总是 0
setData(result);
};
useEffect(() => {
fetchData();
}, []);
return <button onClick={() => {setCount(count + 1)}}>{count}</button>;
}
解决方案
1. 使用 ref 缓存 active 值(中间方案)
一个简单的中间方案是使用 useRef
来缓存 active
的值,并在每次渲染时更新它:
javascript
function Watermark({ active, userName, theme }) {
const ref = useRef(null);
const activeRef = useRef(active);
useEffect(() => {
activeRef.current = active;
}, [active]);
const [createWatermarkObserver, removeWatermarkObserver] = useWatermarkObserver({
watermarkLayerRef: ref,
onDelete: () => {
if (activeRef.current) {
document.body.append(ref.current);
}
},
// ...其他代码
});
// ...其他代码
}
这个方案可以解决问题,但它需要我们手动管理每个可能发生变化的值,这在复杂组件中可能会变得繁琐。
2. 使用 useEventCallback(推荐方案)
为了更优雅地解决这个问题,我们可以使用一个自定义 Hook:useEventCallback
。这个 Hook 的核心思想是:
- 使用
useRef
来存储最新的函数定义。 - 在每次渲染后更新这个 ref。
- 返回一个稳定的函数引用,但在调用时使用最新的函数定义。
让我们来看看 useEventCallback
的实现:
javascript
import { useCallback, useRef, useEffect } from 'react';
function useEventCallback(fn) {
const ref = useRef(fn);
useEffect(() => {
ref.current = fn;
});
return useCallback((...args) => {
const fn = ref.current;
return fn(...args);
}, []);
}
应用解决方案
使用 useEventCallback
后,我们的水印组件代码变成了这样:
javascript
function Watermark({ active, userName, theme }) {
const ref = useRef(null);
const onDelete = useEventCallback(() => {
if (active) {
document.body.append(ref.current);
}
});
const [createWatermarkObserver, removeWatermarkObserver] = useWatermarkObserver({
watermarkLayerRef: ref,
onDelete,
// ...其他代码
});
// ...其他代码
}
现在,每次 onDelete
被调用时,它都会使用最新的 active
值,因为 useEventCallback
确保了我们总是使用最新的函数定义。
为什么推荐使用 useEventCallback
- 解决闭包陈旧值问题:它确保回调函数总是使用最新的 props 和 state。
- 性能优化:返回一个稳定的函数引用,减少不必要的重渲染。
- 简化代码 :不需要像
useCallback
那样手动指定依赖数组,减少了出错的可能性。 - 灵活性:可以在各种异步操作或事件处理程序中使用,不限于特定场景。
结论
在 Hook 使用中,闭包陈旧值问题是一个常见的陷阱。useEventCallback
提供了一个优雅的解决方案,它不仅解决了问题,还带来了额外的性能优化和代码简化的好处。对于简单的场景,使用 useRef
缓存值也是一个不错的选择。
目前React19已经提供实验性的api:useEffectEvent
。如果希望openInula也提供类似API,也可以在评论回复一起参与讨论~