【openInula茶话会】第二期:逃离闭包陷阱 - 函数组件中闭包陈旧值问题最佳实践

作者:陈海芹 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 已经变为 falseonDelete 中读到的 active 仍然是 true

问题分析

这个问题的根源在于 JavaScript 的闭包机制。当我们创建 onDelete 函数时,它捕获了当时的 active 值。这个函数被传递给 useWatermarkObserver 去创建MutationObeserver,并在之后的某个时间点被调用。但是,当它被调用时,它仍然使用的是组件创建时捕获的 active 值,而不是最新的值。

这就导致了一个令人困惑的情况:组件的 props 已经更新,但回调函数中的值却没有更新。

类似问题的常见场景

闭包陈旧值问题在 Hook 使用中非常普遍。以下是一些常见的场景:

  1. 定时器中的状态更新
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>;
}
  1. 事件监听器中的状态访问
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>;
}
  1. 异步操作中的状态访问
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 的核心思想是:

  1. 使用 useRef 来存储最新的函数定义。
  2. 在每次渲染后更新这个 ref。
  3. 返回一个稳定的函数引用,但在调用时使用最新的函数定义。

让我们来看看 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

  1. 解决闭包陈旧值问题:它确保回调函数总是使用最新的 props 和 state。
  2. 性能优化:返回一个稳定的函数引用,减少不必要的重渲染。
  3. 简化代码 :不需要像 useCallback 那样手动指定依赖数组,减少了出错的可能性。
  4. 灵活性:可以在各种异步操作或事件处理程序中使用,不限于特定场景。

结论

在 Hook 使用中,闭包陈旧值问题是一个常见的陷阱。useEventCallback 提供了一个优雅的解决方案,它不仅解决了问题,还带来了额外的性能优化和代码简化的好处。对于简单的场景,使用 useRef 缓存值也是一个不错的选择。

目前React19已经提供实验性的api:useEffectEvent。如果希望openInula也提供类似API,也可以在评论回复一起参与讨论~

参考资料

1.Separating Events from Effects -- React

2.深入useEffectEvent源码

相关推荐
崔庆才丨静觅8 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60619 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了9 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅9 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅9 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅10 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment10 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅10 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊10 小时前
jwt介绍
前端
爱敲代码的小鱼10 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax