likes
comments
collection
share

五千字长文 搞懂 useEffect 闭包问题

作者站长头像
站长
· 阅读数 45

前言

前同事的代码中无意中发现很多,在 useEffect 中修改状态的时候,没有使用到函数式更新和依赖项的对应添加。 所以今天特意写一篇小文章来撸一撸到底是怎么一个事。我们碰到这样的问题应该如何去做,在面试的时候被问到应该如何回答更具竞争力。带着问题我们开始来研究一下这个到底是怎么一个事吧。

产生问题的原因

在 React JavaScript 中,闭包会捕获其创建时的环境。由于 useEffect 的依赖数组并不会自动更新,因此如果依赖项没有在数组中明确列出,闭包捕获的状态或属性值将保持不变。这样,当组件状态更新时,useEffect 内部的代码可能会继续使用旧的状态或属性值,导致逻辑错误。

问题产生的场景

示例:计数器场景

假设有一个计数器组件,在每次计数器变化时要执行一些副作用,例如打印计数器的值:

import React, { useState, useEffect } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log(`Count is: ${count}`);
  }, []); // 依赖数组为空

  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

在上述代码中,useEffect 的依赖数组为空,意味着 useEffect 只在组件挂载时运行一次。即使 count 状态变化,console.log 中打印的 count 始终是初始值 0。

示例:使用 setInterval 的定时 计数器场景

假设我们有一个计数器组件,该组件每秒钟递增一次:

import React, { useState, useEffect } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const interval = setInterval(() => {
      console.log(`Count: ${count}`);
      setCount(count + 1); // 这里的 count 始终是初始值 0
    }, 1000);

    return () => clearInterval(interval); // 清理定时器
  }, []); // 空依赖数组

  return (
    <div>
      <p>{count}</p>
    </div>
  );
}

export default Counter;

解决办法

示例1:解决办法

import React, { useState, useEffect } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log(`Count is: ${count}`);
  }, [count]); // 将 count 列为依赖项

  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

这样,每次 count 发生变化时,useEffect 都会重新执行,从而确保 console.log 中的 count 是最新的值。

分步解决方法

  1. 识别问题: 找出在 useEffect 中使用但未列为依赖项的状态或属性。
  2. 更新依赖数组: 将所有在副作用函数中使用的状态或属性添加到依赖数组中。
  3. 测试: 运行并测试组件,确保副作用函数中的状态或属性是最新的。

通过这种方法,可以避免因闭包捕获旧的状态或属性值而导致的问题。

示例2:解决办法

  1. count 作为依赖项:
    • 在依赖数组中添加 count,这样每次 count 变化时,useEffect 都会重新执行,定时器回调中捕获的 count 就是最新的值。
import React, { useState, useEffect } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const interval = setInterval(() => {
      setCount(count + 1);
    }, 1000);

    return () => clearInterval(interval); // 清理定时器
  }, [count]); // count 依赖

  return (
    <div>
      <p>{count}</p>
    </div>
  );
}

export default Counter;
  1. 使用函数式更新:
    • 使用 setCount 的函数形式,这样 setInterval 内部的回调函数会始终使用最新的 count 值。
import React, { useState, useEffect } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const interval = setInterval(() => {
      setCount(prevCount => prevCount + 1); // 使用函数式更新
    }, 1000);

    return () => clearInterval(interval); // 清理定时器
  }, []); // 空依赖数组

  return (
    <div>
      <p>{count}</p>
    </div>
  );
}

export default Counter;

分步解决方法

  1. 识别问题:

    • useEffect 中的定时器回调函数中使用了状态 count,但未将 count 列为依赖项,导致捕获了初始值。
  2. 使用函数式更新:

    • setCount(count + 1) 修改为 setCount(prevCount => prevCount + 1),确保回调函数始终使用最新的状态。
  3. count 作为依赖项

    • 添加 count 到 useEffect 的第二个参数的数组当中,方便监听

通过这种方法,可以避免因闭包捕获旧的状态而导致的问题。

useEffect 的内部代码是如何实现监听状态和属性值呢

useEffect 的内部实现依赖于 React 的调度和协调机制。当我们将依赖项传递给 useEffect 时,React 会监视这些依赖项的变化,并在依赖项发生变化时重新执行 useEffect 中的副作用函数。

工作原理

  1. 初始挂载:

    • 当组件初次渲染时,useEffect 会立即执行一次。
    • React 会保存当前的依赖数组。
  2. 依赖变化检测:

    • 在后续的渲染中,React 会将新的依赖数组与之前保存的依赖数组进行比较。
    • 如果依赖数组中的任意一个值发生了变化,useEffect 就会重新执行。
  3. 清理副作用:

    • 在执行新的副作用之前,React 会先执行上一次渲染中返回的清理函数(如果有)。
    • 这种清理机制确保了副作用的正确管理,避免了内存泄漏和副作用冲突。

React 内部的实现

以下是一个简化的解释,展示了 useEffect 的核心逻辑:

我们用 伪代码来模拟实现这个 useEffect 功能,这样我们更加能理解清楚,useEffect 出现闭包问题的原因

// 伪代码 
function useEffect(effect, dependencies) {
  const currentDependencies = getCurrentDependencies(dependencies); // 获取当前依赖项中全部的数据收集
  
  if (!dependenciesAreEqual(currentDependencies, dependencies)) {
    // 执行清理函数
    if (typeof currentDependencies.cleanup === 'function') {
      currentDependencies.cleanup();
    }
    
    // 执行副作用函数并保存清理函数
    const cleanup = effect();
    setCurrentDependencies({ dependencies, cleanup });
  }
}

// 新旧状态对比机,实际逻辑比这个复杂
function dependenciesAreEqual(oldDeps, newDeps) {
  if (oldDeps === undefined) {
    return false;
  }
  
  if (oldDeps.length !== newDeps.length) {
    return false;
  }
  
  for (let i = 0; i < oldDeps.length; i++) {
    // 实际这里的比较很复杂,这里省略
    // 判断多种数据类型之间对比,对象,数组,数字,字符串等等
    if (oldDeps[i] !== newDeps[i]) {
      return false;
    }
  }
  
  return true;
}

监听状态或属性值变化

在 React 内部,状态(state)和属性(props)的变化都会触发组件的重新渲染。在重新渲染的过程中,useEffect 会比较新的依赖数组和之前保存的依赖数组,判断是否需要重新执行副作用。

  1. 状态变化:

    • 当状态变化时,组件会重新渲染。
    • useEffect 会检查依赖数组中的状态是否发生了变化。
  2. 属性变化:

    • 当组件接收到新的属性时,组件也会重新渲染。
    • useEffect 会检查依赖数组中的属性是否发生了变化。

示例

以下示例展示了 useEffect 如何通过依赖 数组 监听状态和属性的变化:

import React, { useState, useEffect } from 'react';

function ExampleComponent({ propValue }) {
  const [stateValue, setStateValue] = useState(0);

  useEffect(() => {
    console.log(`State value is: ${stateValue}`);
    console.log(`Prop value is: ${propValue}`);

    return () => {
      console.log('Cleanup on state or prop change');
    };
  }, [stateValue, propValue]); // 监听 stateValue 和 propValue 的变化

  return (
    <div>
      <p>State: {stateValue}</p>
      <p>Prop: {propValue}</p>
      <button onClick={() => setStateValue(stateValue + 1)}>Increment State</button>
    </div>
  );
}

在这个示例中,每当 stateValuepropValue 发生变化时,useEffect 中的副作用函数都会重新执行,并且在执行前会先执行上一次的清理函数。通过这种方法,可以避免因闭包捕获旧的状态而导致的问题。

总结

在这个问题还是比较常见的在我们的日常工作中,主要的解决方法有两个,上文中有提到的。

  1. 在依赖项中添加对应的变量
  2. 在写修改的函数的时候,使用函数式更新的办法来处理。
  3. 遇到定时器的时候要谨慎,确认好变更的到底是什么变量,注意书写规范,减少过多的函数嵌套

希望我的文章能够帮到你进步,希望留个点赞对我来说是莫大的鼓励和帮助,谢谢大家看到这里

转载自:https://juejin.cn/post/7399453056973045798
评论
请登录