JavaScript 的一个方面总是让我抓狂:闭包。 我经常使用 React,它们有时会导致陈旧的 props 和 state。 我们将深入了解这到底意味着什么,但问题是,我们用来构建 UI 的数据可能以意想不到的方式完全错误,这你知道的,很糟糕。
陈旧的 props 和 states
长话短说:当异步执行的代码引用不再最新的 prop 或 state 时,就会出现这种情况,因此,它返回的值不是最新的值。
为了更清楚地说明,让我们使用 React 文档中相同的陈旧引用示例进行演练。
function Counter() {
const [count, setCount] = useState(0);
function handleAlertClick() {
setTimeout(() => {
alert("You clicked on: " + count);
}, 3000);
}
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>Click me</button>
<button onClick={handleAlertClick}>Show alert</button>
</div>
);
}
(实时演示)
这里没什么花哨的。 我们有一个名为Counter
的函数组件。 它跟踪用户点击按钮的次数,并在点击另一个按钮时显示一个显示按钮被点击次数的警报。 试试这个
- 点击“点击我”按钮。 您将看到点击计数器增加。
- 现在点击“显示警报”按钮。 三秒钟后,会弹出一个警报,告诉你点击了多少次“点击我”按钮。
- 现在,再次点击“显示警报”按钮,并在三秒钟内弹出的警报之前快速点击“点击我”按钮。
看到发生了什么吗? 页面上显示的计数与警报中显示的计数不匹配。 警报中的数字并不是随机的。 那个数字是setTimeout
内部异步函数定义时count
变量的值,也就是点击“显示警报”按钮的那一刻。
这就是闭包的工作原理。 我们不会在这篇文章中详细介绍它们的具体细节,但这里有一些文档更详细地介绍了它们。
让我们专注于如何避免在我们的 states 和 props 中出现这些陈旧的引用。
React 在相同文档中提供了一个关于如何处理陈旧的 dates 和 props 的提示,该文档中提取了示例。
如果您有意要从某些异步回调中读取最新的 state,您可以将其保存在
ref
中,对其进行变异,并从中读取。
通过将值异步存储在ref
中,我们可以绕过陈旧的引用。 如果你需要了解更多关于函数组件中ref
的信息,React 的文档中有更多信息。
那么,问题来了:我们如何将 props 或 state 保存在ref
中?
让我们先用最脏的方式来做。
将 props 和 state 存储在 ref 中的脏方法
我们可以很容易地使用useRef()
创建一个 ref,并使用count
作为它的初始值。 然后,无论在何处更新 state,我们将ref.current
属性设置为新值。 最后,在代码的异步部分使用ref.current
而不是count
。
function Counter() {
const [count, setCount] = useState(0);
const ref = useRef(count); // Make a ref and give it the count
function handleAlertClick() {
setTimeout(() => {
alert("You clicked on: " + ref.current); // Use ref instead of count
}, 3000);
}
return (
<div>
<p>You clicked {count} times</p>
<button
onClick={() => {
setCount(count + 1);
ref.current = count + 1; // Update ref whenever the count changes
}}
>
Click me
</button>
<button
onClick={() => {
handleAlertClick();
}}
>
Show alert
</button>
</div>
);
}
(实时演示)
继续执行与上次相同的操作。 点击“显示警报”,然后在警报在三秒钟内弹出之前点击“点击我”。
现在我们有了最新值!
这是它工作的原因。 当异步回调函数在setTimeout
中定义时,它会保存对它使用的变量的引用,在本例中是count
。 这样,当 state 更新时,React 不仅会更改值,而且内存中的变量引用也会完全不同。
这意味着——即使 state 的值是非原始的——你在异步回调中使用的变量在内存中也不相同。 通常会在不同函数中保持其引用的对象现在具有不同的值。
使用ref
如何解决这个问题? 如果我们再次快速查看 React 的文档,我们会发现一个有趣但容易被忽略的信息
[…]
useRef
将在每次渲染时为您提供相同的ref
对象。
无论我们做什么,它都不会改变。 在组件的生命周期内,React 将在内存中为我们提供完全相同的 ref 对象。 任何回调,无论它何时定义或执行,都会使用同一个对象。 没有更多陈旧的引用。
将 props 和 state 存储在 ref 中的更干净的方法
说实话……以这种方式使用ref
是一种丑陋的修复。 如果您的 state 在一千个不同的地方被更新了怎么办? 现在您必须更改代码并在所有这些地方手动更新ref
。 这是不允许的。
我们将通过在 state 发生变化时自动让ref
获取 state 的值来使它更具可扩展性。
让我们首先从“点击我”按钮中删除对ref
的手动更改。
接下来,我们创建一个名为updateState
的函数,该函数在需要更改 state 时被调用。 该函数以新 state 作为参数,并将ref.current
属性设置为新 state,并使用相同的值更新 state。
最后,让我们用新的updateState
函数替换 React 为我们提供的原始setCount
函数,该函数用于更新 state。
function Counter() {
const [count, setCount] = useState(0);
const ref = useRef(count);
// Keeps the state and ref equal
function updateState(newState) {
ref.current = newState;
setCount(newState);
}
function handleAlertClick() { ... }
return (
<div>
<p>You clicked {count} times</p>
<button
onClick={() => {
// Use the created function instead of the manual update
updateState(count + 1);
}}
>
Click me
</button>
<button onClick={handleAlertClick}>Show alert</button>
</div>
);
}
(实时演示)
使用自定义钩子
更干净的解决方案效果很好。 它与脏方法一样完成了工作,但只调用了一个函数来更新 state 和ref
。
但猜猜看? 我们可以做得更好。 如果我们需要添加更多 states 呢? 如果我们想在其他组件中也这样做呢? 让我们获取 state、ref
和updateState
函数,并使它们真正可移植。 自定义钩子来帮忙!
在Counter
组件之外,我们将定义一个新函数。 我们将其命名为useAsyncReference
。 (它可以命名为任何东西,但请注意,使用“use”作为前缀为自定义钩子命名是一个普遍的做法。)我们的新钩子现在将只有一个参数。 我们称它为value
。
我们之前的解决方案将相同的信息存储了两次:一次存储在 state 中,一次存储在ref
中。 我们将通过这次只在ref
中保留值来优化它。 换句话说,我们将创建一个ref
,并将其初始值设置为value
参数。
在ref
之后,我们将创建一个updateState
函数,该函数获取新 state 并将其设置为ref.current
属性。
最后,我们返回一个包含ref
和updateState
函数的数组,与 React 对useState
所做的非常相似。
function useAsyncReference(value) {
const ref = useRef(value);
function updateState(newState) {
ref.current = newState;
}
return [ref, updateState];
}
function Counter() { ... }
我们遗漏了一些东西! 如果我们查看useRef
文档,我们会发现更新ref
不会触发重新渲染。 因此,虽然ref
具有更新后的值,但我们不会在屏幕上看到这些更改。 我们需要在每次ref
更新时强制重新渲染。
我们需要的是一个伪造的 state。 值并不重要。 它只是为了触发重新渲染。 我们甚至可以忽略 state,只保留它的更新函数。 我们将该更新函数称为forceRender
,并将其初始值设置为false
。
现在,在updateState
中,我们在将ref.current
设置为newState
后,通过调用forceRender
并向其传递与当前 state 不同的 state 来强制重新渲染。
function useAsyncReference(value) {
const ref = useRef(value);
const [, forceRender] = useState(false);
function updateState(newState) {
ref.current = newState;
forceRender(s => !s);
}
return [ref, updateState];
}
function Counter() { ... }
获取它具有的任何值并返回相反的值。 state 并不真正重要。 我们只是在改变它,以便 React 检测到 state 发生变化并重新渲染组件。
接下来,我们可以清理Count
组件,并删除之前使用的useState
、ref
和updateState
函数,然后实现新的钩子。 返回的数组的第一个值是ref
形式的 state。 我们将继续称它为 count,其中第二个值是用于更新 state/ref
的函数。 我们将继续称它为setCount
。
我们还必须更改对 count 的引用,因为现在它们都必须是count.current
。 并且我们必须调用setCount
而不是调用updateState
。
function useAsyncReference(value) { ... }
function Counter() {
const [count, setCount] = useAsyncReference(0);
function handleAlertClick() {
setTimeout(() => {
alert("You clicked on: " + count.current);
}, 3000);
}
return (
<div>
<p>You clicked {count.current} times</p>
<button
onClick={() => {
setCount(count.current + 1);
}}
>
Click me
</button>
<button onClick={handleAlertClick}>Show alert</button>
</div>
);
}
使其与 props 一起使用
我们有一个真正可移植的解决方案来解决我们的问题。但猜猜看… 我们还需要做一些额外的事情。具体来说,我们需要使该解决方案与 props 兼容。
让我们将“显示警告”按钮和 `handleAlertClick` 函数放到 `Counter` 组件之外的新组件中。我们将称其为 `Alert`,它将接受一个名为 `count` 的 prop。这个新组件将在三秒延迟后,在警告框中显示我们传递给它的 `count` prop 值。
function useAsyncReference(value) { ... }
function Alert({ count }) {
function handleAlertClick() {
setTimeout(() => {
alert("You clicked on: " + count);
}, 3000);
}
return <button onClick={handleAlertClick}>Show alert</button>;
}
function Counter() { ... }
在 `Counter` 中,我们将用 `Alert` 组件替换“显示警告”按钮。我们将 `count.current` 传递给 `count` prop。
function useAsyncReference(value) { ... }
function Alert({ count }) { ... }
function Counter() {
const [count, setCount] = useAsyncReference(0);
return (
<div>
<p>You clicked {count.current} times</p>
<button
onClick={() => {
setCount(count.current + 1);
}}
>
Click me
</button>
<Alert count={count.current} />
</div>
);
}
(实时演示)
好的,现在再次执行测试步骤。看到了吗?即使我们在 `Counter` 中使用了对 count 的安全引用,但 `Alert` 组件中对 `count` prop 的引用并非异步安全的,我们的自定义钩子不适合与 props 一起使用… 至少现在还不适合。
幸运的是,解决方案相当简单。
我们要做的就是向我们的 `useAsyncReference` 钩子添加第二个参数,名为 `isProp`,并将 `false` 作为初始值。在我们返回包含 `ref` 和 `updateState` 的数组之前,我们设置一个条件。如果 `isProp` 为 `true`,我们将 `ref.current` 属性设置为 `value`,并且只返回 `ref`。
function useAsyncReference(value, isProp = false) {
const ref = useRef(value);
const [, forceRender] = useState(false);
function updateState(newState) {
ref.current = newState;
forceRender(s => !s);
}
if (isProp) {
ref.current = value;
return ref;
}
return [ref, updateState];
}
function Alert({ count }) { ... }
function Counter() { ... }
现在让我们更新 `Alert` 以使用该钩子。请记住将 `true` 作为第二个参数传递给 `useAsyncReference`,因为我们正在传递一个 prop,而不是一个状态。
function useAsyncReference(value) { ... }
function Alert({ count }) {
const asyncCount = useAsyncReference(count, true);
function handleAlertClick() {
setTimeout(() => {
alert("You clicked on: " + asyncCount.current);
}, 3000);
}
return <button onClick={handleAlertClick}>Show alert</button>;
}
function Counter() { ... }
(实时演示)
再试一次。现在无论你使用状态还是 props,它都能完美运行。
还有一件事…
我还有一个更改想要进行。React 的 `useState` 文档 告诉我们,如果新状态与之前状态相同,React 将跳过重新渲染。我们的解决方案没有这样做。如果我们再次将当前状态传递给钩子的 `updateState` 函数,无论如何我们都会强制重新渲染。让我们改变这一点。
让我们将 `updateState` 的主体放在一个 if 语句中,并在 `ref.current` 与新状态不同时执行它。比较必须使用 `Object.is()` 完成,就像 React 一样。
function useAsyncReference(value, isProp = false) {
const ref = useRef(value);
const [, forceRender] = useState(false);
function updateState(newState) {
if (!Object.is(ref.current, newState)) {
ref.current = newState;
forceRender(s => !s);
}
}
if (isProp) {
ref.current = value;
return ref;
}
return [ref, updateState];
}
function Alert({ count }) { ... }
function Counter() { ... }
现在我们终于完成了!
React 有时看起来像一个充满小怪癖的黑盒子。这些怪癖可能很难处理,就像我们刚刚解决的这个。但如果你有耐心并乐于接受挑战,你很快就会意识到它是一个很棒的框架,使用起来很愉快。
你也可以使用 `useEffect`
例如
这样做的缺点是 ref 将在渲染之后更新,这将使你一直落后一步。
你必须保留要用于渲染的状态和要用于异步回调的 ref。
我唯一能想到的改进是修改钩子返回值的类型,使其与初始化值的类型相同。不要返回 `[ref, updateState]`,而是返回 `[ref.current, updateState]`。使用该钩子的组件不应该知道它是一个 Ref,因为它没有提供任何功能来做到这一点。
嗯,这里的问题是 ref 实际上没有任何特殊的机制来避免过时值保留在闭包中。它只是一个对象,所以“过时”值只是一个引用,它本身并没有改变。
如果你从钩子中返回 `ref.current`,你基本上就使整个“将对象存储在闭包中”失效了,因为你的组件只获取 ref 的当前值,如果它存储在闭包中(我已经说了多少次“闭包”了?)。所以,从某种意义上说,你只是构建了一个非常复杂的 `setState` 克隆。
或者… 你可以使用 var 代替 const :)
这样也可以,对吧?
我的做法是将 `handleAlertClick` 抽象出来,因为它不依赖于我们的 UI 逻辑,然后通过一个 getter 公开对状态的访问,而不是公开解析后的值。
不过,干得不错,这正是让人感到担忧的事情!很棒的做法!
React.useCallback 也能方便地用于此类情况。
谢谢 Pedro,这真的帮助我理解了如何使用 ref!
我做了一个图示来尝试可视化数据是如何移动的,如果有人感兴趣的话。很难表示所有连接,所以我尝试优先显示重要的连接。
https://www.dropbox.com/s/z656mdqwe5rdyne/refs%20and%20stale%20props-01.pdf?dl=0
我的理解正确吗?Alert 组件创建了自己的 ref,但随后用 Counter ref 的 .current 值更新该 ref?
我仍然不明白为什么 ref 有效。这个解释似乎与 React 文档中所说的内容相矛盾。
那一部分也让我感到困惑!我读了好几遍才意识到,尽管这部分文字以“这就是它工作的原因”开头,但他实际上说的是为什么旧版本不起作用。
关键是他说 `setTimeout` 通过使用 `count` 来保存对它的引用,但这适用于早期有问题的代码,而不是修复后的版本——修复后的版本只使用 `ref`,不使用 `count`。
对于 `isProp` 选项,如果你去掉所有未使用的代码,它不就和使用 `useRef` 完全一样了吗?