因为在代码中,一个简单的示例通常会绘制一千个单词,这里有一个简单的 CodeSandbox 演示来说明差异,以及为什么,如果您想要基于 状态值的更新更新,“更新函数”(方法一)是最好的:
https://codesandbox.io/s/stack-overflow-demo-nmjiy?file=/src/App.js
这是一个独立的 sn-p 中的代码:
<div id="root"></div><script src="https://unpkg.com/react@17.0.2/umd/react.development.js"></script><script src="https://unpkg.com/react-dom@17.0.2/umd/react-dom.development.js"></script><script src="https://unpkg.com/@babel/standalone@7.16.7/babel.min.js"></script>
<script type="text/babel" data-type="module" data-presets="env,react">
function App() {
const [count, setCount] = React.useState(0);
// this uses the "good way" but it doesn't really matter here
const incrementPlain = () => setCount((oldCount) => oldCount + 1);
const incrementWithTimeoutBad = () =>
setTimeout(() => setCount(count + 1), 3000);
const incrementWithTimeoutGood = () =>
setTimeout(() => setCount((oldCount) => oldCount + 1), 3000);
return (
<div>
<div>Current count: {count}</div>
<div>
<button onClick={incrementPlain}>
Increment (doesn't matter which way)
</button>
</div>
<div>
<button onClick={incrementWithTimeoutBad}>
Increment with delay (bugged)
</button>
<button onClick={incrementWithTimeoutGood}>
Increment with delay (good)
</button>
</div>
</div>
);
}
ReactDOM.render(<App />, document.getElementById('root'));
</script>
这里我们有一个简单的数字“计数”状态,它显示在标记中,还有 3 个不同的按钮都会增加它。
上面的只是直接增加 - 我碰巧在这里使用了函数形式(“方法 1”),因为我更喜欢这种风格,原因希望能变得清晰,但正如我的评论所说,它没有在这里实际上并不重要。
以下两个使用您在问题中概述的两种不同方法,并在延迟后这样做。为了简单起见,我在这里使用setTimeout 完成了此操作 - 虽然这不是特别现实,但在动作调用 API 端点的实际应用程序中通常会看到类似的效果(尽管人们希望这通常不会被视为只要 3 秒,通过更快的请求总是可以观察到相同的问题 - 我只是放慢了速度以便更容易触发错误)。
要查看差异,请尝试对底部的 2 个按钮中的每一个进行以下操作:
- 点击按钮
- 在 3 秒超时之前单击顶部的按钮(再次增加计数)
您应该会看到明显的行为差异:
- 使用“方法 1”(右侧的按钮,我在这里称其为“良好”),超时结束后计数会增加第二次。
- 使用“方法 2”(左侧按钮,我称之为“窃听”),无论您等待多长时间,中间单击顶部按钮所产生的值都不会进一步增加李>
(如果您快速单击底部按钮多次,然后单击顶部按钮,您会看到这一点更加明显。为了获得更违反直觉的效果,请尝试按一次或多次“被窃听”的底部按钮,然后单击顶部按钮不止一次,都在 3 秒的时间间隔内。)
为什么会这样?好吧,之所以会出现“错误”行为,是因为 setTimeout 内部的函数是外部变量 count 的闭包,该变量位于完整组件函数的范围内。这意味着当以count + 1 作为参数调用它时,它会将计数更新为1,而不是定义函数时的计数。假设您从首先加载 count 为 0 的组件开始执行上述顺序,然后发生的更详细的顺序是:
- 底部按钮单击安排回调在 3 秒后发生。由于此时
count 等于0,因此它的参数count + 1 等于1。
- 顶部按钮单击重新呈现组件,计数现在等于 1。
- 稍后触发在第一步设置的回调,并将计数设置为 1。这不会导致任何明显的机会,因为计数已经是 1。(如果您尝试多次单击顶部按钮,那么现在显示 2 或更多,这实际上会减少计数器,因为正如我所解释的,它将始终设置为 1。)
如果你对 JS 闭包有点了解,你可能会奇怪为什么闭包中访问的count 还是 0,之前不是更新为 1 吗?不,它不是,这可能是违反直觉的。注意count 是如何用const 声明的?没错——它从来没有真正改变过。 UI 更新的原因是因为setCount 导致 React 重新渲染你的组件,这意味着与该组件对应的整个外部函数被再次调用。这设置了一个全新的环境,带有一个 new count 变量。 React 的内部确保 useState 调用现在返回 1 作为当前计数,因此这是组件的新“实例”中的值 - 但从放入事件中的函数的角度来看,这无关紧要3秒后排队开火。就它而言,计数变量 - 不再在范围内,而是在回调中“记住”,因为所有封闭变量都是 - 从未从 0 改变。等于 1 的计数完全在不同的范围内,并且永远无法访问第一个回调。
函数参数形式 - “方法 1” - 如何解决这个问题?非常简单地。它根本不包含任何闭包 - 该函数内部的变量,为了准确性和消除外部count 的歧义,我在这里将其称为oldCount - 与count 无关外部。这是 React 本身将在内部调用的函数的参数。当 React 确实调用该函数时,它总是提供它拥有的“最新”状态值。所以你不必担心“过时的闭包”或类似的事情——你说的是“无论最近的值是什么,都将计数更新为多一”,React 会处理剩下的事情。
我在这里将方法 2 称为“错误”,因为我认为如果您单击了设置为执行增量的按钮,那么在超时后发生增量是合理的。但这并不总是你想要的。如果您真的希望更新基于第一次单击按钮时的值,那么您当然会更喜欢方法 2,而方法 1 似乎有问题。从某种意义上说,情况更是如此。我强烈推荐阅读 Dan Abramov 的 this 帖子——核心 React 开发人员之一——它解释了类组件和函数之间的关键区别,它基于许多关于闭包的相同论点,这些论点在这里发挥作用,通常你 确实希望事件处理程序引用渲染时的值,而不是在 API 请求或超时后实际触发时。
但是那篇文章与状态更新函数的“方法 1”形式没有任何关系,文章中甚至都没有提到。那是因为它与给出的示例无关 - 没有(明智的)方法来重写这些示例以使用它。但是当您确实想要更新状态值时基于其先前的值 - 可能会发生在 OP 示例中否定布尔值或将计数器递增为在我看来,我认为你总是希望“以前的值”是最新的更自然。有 2 个按钮都应该增加一个值,尽管方式不同 - 我认为如果同时单击它们,根据时间的不同,将其称为错误是合理的。
但这当然取决于每个单独的组件或应用程序来决定。我希望我在这里所做的是解释区别是什么,并为您提供选择可能最好的基础。但我相信 90+% 的情况下,如果您可以选择使用函数参数(“方法 1”),它会更好,除非您知道它不是。