在 React 函数组件中处理陈旧的 Props 和 States

Avatar of Pedro Rodriguez
Pedro Rodriguez

DigitalOcean 为您的旅程每个阶段提供云产品。 立即开始使用 $200 免费信用额度!

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的函数组件它跟踪用户点击按钮的次数,并在点击另一个按钮时显示一个显示按钮被点击次数的警报。 试试这个

  1. 点击“点击我”按钮。 您将看到点击计数器增加。
  2. 现在点击“显示警报”按钮。 三秒钟后,会弹出一个警报,告诉你点击了多少次“点击我”按钮。
  3. 现在,再次点击“显示警报”按钮,并在三秒钟内弹出的警报之前快速点击“点击我”按钮。

看到发生了什么吗? 页面上显示的计数与警报中显示的计数不匹配。 警报中的数字并不是随机的。 那个数字是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、refupdateState函数,并使它们真正可移植。 自定义钩子来帮忙!

Counter组件之外,我们将定义一个新函数。 我们将其命名为useAsyncReference。 (它可以命名为任何东西,但请注意,使用“use”作为前缀为自定义钩子命名是一个普遍的做法。)我们的新钩子现在将只有一个参数。 我们称它为value

我们之前的解决方案将相同的信息存储了两次:一次存储在 state 中,一次存储在ref中。 我们将通过这次只在ref中保留值来优化它。 换句话说,我们将创建一个ref,并将其初始值设置为value参数。

ref之后,我们将创建一个updateState函数,该函数获取新 state 并将其设置为ref.current属性。

最后,我们返回一个包含refupdateState函数的数组,与 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组件,并删除之前使用的useStaterefupdateState函数,然后实现新的钩子。 返回的数组的第一个值是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 有时看起来像一个充满小怪癖的黑盒子。这些怪癖可能很难处理,就像我们刚刚解决的这个。但如果你有耐心并乐于接受挑战,你很快就会意识到它是一个很棒的框架,使用起来很愉快。