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

相关推荐
2分钟速写快排29 分钟前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60611 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角2 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!2 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰3 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi4 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen5 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马5 小时前
AI源码分析:拆解模型底层实现逻辑
前端
BillKu5 小时前
全局样式变量:CSS 自定义属性(--border-color)和SCSS 变量($border-color)的说明
javascript·css·scss
IT_陈寒6 小时前
React子组件莫名其妙重渲染?你可能漏了这个Hook
前端·人工智能·后端