如何处理 useEffect 闭包内的陈旧状态值?

IT技术 reactjs react-hooks
2021-04-29 13:25:15

下面的示例是一个 Timer 组件,它有一个按钮(用于启动计时器)和两个显示经过秒数和经过秒数乘以 2 的标签。

但是,它不起作用(CodeSandbox Demo)

代码

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

const Timer = () => {
  const [doubleSeconds, setDoubleSeconds] = useState(0);
  const [seconds, setSeconds] = useState(0);
  const [isActive, setIsActive] = useState(false);

  useEffect(() => {
    let interval = null;
    if (isActive) {
      interval = setInterval(() => {
        console.log("Creating Interval");
        setSeconds((prev) => prev + 1);
        setDoubleSeconds(seconds * 2);
      }, 1000);
    } else {
      clearInterval(interval);
    }
    return () => {
      console.log("Destroying Interval");
      clearInterval(interval);
    };
  }, [isActive]);

  return (
    <div className="app">
      <button onClick={() => setIsActive((prev) => !prev)} type="button">
        {isActive ? "Pause Timer" : "Play Timer"}
      </button>
      <h3>Seconds: {seconds}</h3>
      <h3>Seconds x2: {doubleSeconds}</h3>
    </div>
  );
};

export { Timer as default };

问题

在 useEffect 调用中,“seconds”值将始终等于上次渲染 useEffect 块时(上次更改 isActive 时)的值。这将导致setDoubleSeconds(seconds * 2)语句失败。React Hooks ESLint 插件给了我一个关于这个问题的警告,内容如下:

React Hook useEffect 缺少依赖项:'seconds'。包括它或删除依赖项数组。如果“setDoubleSeconds”需要“seconds”的当前值,您还可以用 useReducer 替换多个 useState 变量。(react-hooks/exhaustive-deps)eslint

正确的是,将“秒”添加到依赖项数组(并更改setDoubleSeconds(seconds * 2)setDoubleSeconds((seconds + 1) * )将呈现正确的结果。然而,这有一个令人讨厌的副作用,会导致在每次渲染时创建和销毁间隔(每次渲染时console.log("Destroying Interval")触发)。

所以现在我正在查看 ESLint 警告中的另一个建议“如果‘setDoubleSeconds’需要‘seconds’的当前值,您也可以用 useReducer 替换多个 useState 变量”

我不明白这个建议。如果我创建一个减速器并像这样使用它:

import React, { useState, useEffect, useReducer } from "react";

const reducer = (state, action) => {
    switch (action.type) {
        case "SET": {
            return action.seconds;
        }
        default: {
            return state;
        }
    }
};

const Timer = () => {
    const [doubleSeconds, dispatch] = useReducer(reducer, 0);
    const [seconds, setSeconds] = useState(0);
    const [isActive, setIsActive] = useState(false);

    useEffect(() => {
        let interval = null;
        if (isActive) {
            interval = setInterval(() => {
                console.log("Creating Interval");
                setSeconds((prev) => prev + 1);
                dispatch({ type: "SET", seconds });
            }, 1000);
        } else {
            clearInterval(interval);
        }
        return () => {
            console.log("Destroying Interval");
            clearInterval(interval);
        };
    }, [isActive]);

    return (
        <div className="app">
            <button onClick={() => setIsActive((prev) => !prev)} type="button">
                {isActive ? "Pause Timer" : "Play Timer"}
            </button>
            <h3>Seconds: {seconds}</h3>
            <h3>Seconds x2: {doubleSeconds}</h3>
        </div>
    );
};

export { Timer as default };

过时值的问题仍然存在(CodeSandbox Demo (using Reducers))。

问题

那么对于这个场景有什么建议呢?我是否会降低性能并简单地将“秒”添加到依赖项数组中?我是否创建另一个依赖于“秒”的 useEffect 块并在其中调用“setDoubleSeconds()”?我是否将“seconds”和“doubleSeconds”合并为一个状态对象?我使用 refs 吗?

此外,您可能会想“为什么不简单地将<h3>Seconds x2: {doubleSeconds}</h3>更改<h3>Seconds x2: {seconds * 2}</h3>并删除 'doubleSeconds' 状态?”。在我的实际应用程序中,doubleSeconds 被传递给子组件,我不希望子组件知道秒是多少映射到 doubleSeconds,因为它使 Child 的可重用性降低。

谢谢!

2个回答

您可以通过几种方式访问​​效果回调中的值,而无需将其添加为 dep。

  1. setState. 您可以通过其设置器点击状态变量的最新值。
setSeconds(seconds => (setDoubleSeconds(seconds * 2), seconds));
  1. 参考 您可以将 ref 作为依赖项传递,它永远不会改变。但是,您需要手动使其保持最新状态。
const secondsRef = useRef(0);
const [seconds, setSeconds] = useReducer((_state, action) => (secondsRef.current = action), 0);

然后,您可以使用secondsRef.current来访问seconds代码块,而无需触发 deps 更改。

setDoubleSeconds(secondsRef.current * 2);

在我看来,您永远不应该忽略 deps 数组中的依赖项。如果您需要不更改 deps,请使用上述 hack 来确保您的值是最新的。

始终首先考虑是否有比将值写入回调更优雅的方式来编写代码。在你的例子doubleSeconds中可以表示为的导数seconds

const [seconds, setSeconds] = useState(0);
const doubleSeconds = seconds * 2;

有时应用程序并不那么简单,因此您可能需要使用上述技巧。

  • 我是否会降低性能并简单地将“秒”添加到依赖项数组中?
  • 我是否创建另一个依赖于“秒”的 useEffect 块并在其中调用“setDoubleSeconds()”?
  • 我是否将“seconds”和“doubleSeconds”合并为一个状态对象?
  • 我使用 refs 吗?

它们都可以正常工作,尽管我个人更愿意选择第二种方法:

useEffect(() => {
    setDoubleSeconds(seconds * 2);
}, [seconds]);

然而:

在我的实际应用程序中,doubleSeconds 被传递给 Child 组件,我不希望 Child 组件知道 seconds 是如何映射到 doubleSeconds 的,因为它使 Child 的可重用性降低

值得怀疑的。子组件可以像下面这样实现:

const Child = ({second}) => (
  <p>Seconds: {second}s</p>
);

父组件应如下所示:

const [seconds, setSeconds] = useState(0);
useEffect(() => {
  // change seconds
}, []);

return (
  <React.Fragment>
    <Child seconds={second} />
    <Child seconds={second * 2} />
  </React.Fragment>
);

这将是一种更清晰简洁的方式。