【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源码

相关推荐
琳沫lerlee7 分钟前
【数组去重、分组和拷贝】
javascript·数组
大土豆的bug记录4 小时前
鸿蒙进行视频上传,使用 request.uploadFile方法
开发语言·前端·华为·arkts·鸿蒙·arkui
maybe02094 小时前
前端表格数据导出Excel文件方法,列自适应宽度、增加合计、自定义文件名称
前端·javascript·excel·js·大前端
HBR666_4 小时前
菜单(路由)权限&按钮权限&路由进度条
前端·vue
A-Kamen5 小时前
深入理解 HTML5 Web Workers:提升网页性能的关键技术解析
前端·html·html5
锋小张6 小时前
a-date-picker 格式化日期格式 YYYY-MM-DD HH:mm:ss
前端·javascript·vue.js
鱼樱前端7 小时前
前端模块化开发标准全面解析--ESM获得绝杀
前端·javascript
yanlele7 小时前
前端面试第 75 期 - 前端质量问题专题(11 道题)
前端·javascript·面试
就是有点傻8 小时前
C#中Interlocked.Exchange的作用
java·javascript·c#
前端小白۞8 小时前
el-date-picker时间范围 编辑回显后不能修改问题
前端·vue.js·elementui