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

相关推荐
萌萌哒草头将军3 小时前
⚡⚡⚡尤雨溪宣布开发 Vite Devtools,这两个很哇塞 🚀 Vite 的插件,你一定要知道!
前端·vue.js·vite
游离状态的猫14 小时前
JavaScript性能优化实战:从瓶颈定位到极致提速
开发语言·javascript·性能优化
小彭努力中4 小时前
7.Three.js 中 CubeCamera详解与实战示例
开发语言·前端·javascript·vue.js·ecmascript
浪裡遊4 小时前
跨域问题(Cross-Origin Problem)
linux·前端·vue.js·后端·https·sprint
滿4 小时前
Vue3 Element Plus el-tabs数据刷新方法
javascript·vue.js·elementui
LinDaiuuj4 小时前
判断符号??,?. ,! ,!! ,|| ,&&,?: 意思以及举例
开发语言·前端·javascript
敲厉害的燕宝5 小时前
Pinia——Vue的Store状态管理库
前端·javascript·vue.js
Aphasia3115 小时前
react必备JavaScript知识点(二)——类
前端·javascript
玖玖passion5 小时前
数组转树:数据结构中的经典问题
前端
呼Lu噜5 小时前
WPF-遵循MVVM框架创建图表的显示【保姆级】
前端·后端·wpf