发布于 2026-01-06 3 阅读
0

在循环内更新 React 状态 DEV 的全球展示挑战赛,由 Mux 呈现:展示你的项目!

在循环内更新 React 状态

由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!

最近我在使用 React 时遇到了一个非常棘手的小问题。我发现需要在循环内更新 React 的状态变量。一开始我完全没想到这会这么麻烦。


图片描述

底层状态管理

在深入探讨这个问题之前,你需要了解一些关于 React 如何管理状态变量的基本概念。对于经验丰富的 React 老手来说,这些应该早已是常识。但在此之前,明确这些基本规则仍然非常重要。

React 状态是异步的

当你更新 React 变量时,后续的变更操作是异步发生的。你可以通过类似这样的例子来验证这一点:



export const MyComponent = () => {
  const [counter, setCounter] = useState(0);

  const increment = () => {
    setCounter(counter + 1);
    console.log('counter', counter);
  }

  return <>
    <div>
      Counter = {counter}
    </div>
    <button onClick={increment}>
      Increment
    </button>
  </>
}


Enter fullscreen mode Exit fullscreen mode

从用户体验的角度来看,上面的例子运行良好。每次点击按钮Increment,计数器的值都会更新。但是,console.log()输出结果总是比实际更新慢一次这是因为更新是异步进行的。当代码执行到该按钮时console.log(),计数器的值尚未更新。

React批量更新状态

React 在后台做了很多工作来优化性能。通常情况下,这些优化并不会真正影响到我们。因为典型的流程是:

  1. 用户执行某些操作(例如点击按钮)。
  2. 状态变量已更新。
  3. React 的协调过程被触发。
  4. 这个过程意识到虚拟 DOM 和实际 DOM 之间存在差异。
  5. React 会更新实际 DOM 以反映虚拟 DOM 的变化。

在绝大多数情况下,这一切发生得如此之快,以至于肉眼看来几乎是瞬间完成的。但 React 的设计者们也加入了一些安全措施,以防止开发者试图快速连续地更新状态。

当然,这种优化总体来说是一件好事。但当你觉得这些连续的更新确实必要时,它就可能会让你感到困惑。具体来说,React 会特意确保循环中触发的所有状态更新,在循环结束之前都不会实际渲染。


图片描述

循环内状态更新的案例研究

我的网站——https: //paintmap.studio——提供图像/颜色处理功能。虽然其功能完全符合预期,但也带来了一些挑战:

  1. 图片文件可能很大。我可以人为地限制图片文件的大小(无论是以千字节为单位,还是以高度/宽度尺寸为单位),但这会大大降低应用程序的实用性。

  2. 图片并非上传到服务器进行处理。这是一个单页应用程序,没有后端组件,完全在浏览器中运行。因此,我无法做到例如上传图片、进行密集处理,然后承诺在处理完成后通过电子邮件向用户发送最终结果的链接。

  3. 处理图像所需的时间很大程度上取决于用户选择的处理选项。例如,使用 RGB 色彩空间处理图像比使用 Delta-E 2000 快得多。对图像进行抖动处理比不进行抖动处理要花费更多时间。因此,理论上我可以人为地限制用户可用的选项数量。但是,这样做会大大降低应用程序本身的实用性。

事实很简单:有时我需要处理一张特定尺寸、且包含大量处理器密集型选项的图片,这需要一些时间才能完成。在这种情况下,我不想让用户怀疑应用程序是否崩溃。我希望他们明白一切都在按计划进行,他们即将看到处理后的图片。


图片描述

进度条

让用户了解处理正在按计划进行的最有效方法之一是提供进度条。理想情况下,进度条应随着代码的运行实时更新。

当然,这并非什么新鲜事。应用程序提供这类用户反馈已经几十年了。但当我尝试在 React 中使用 Material UI<CircularProgress>组件实现此功能时,却遇到了不少棘手的问题。

为了避免直接让您深入了解 Paintmap Studio 的代码而造成混淆,我特意在 CodeSandbox 中创建了一个简化的演示来说明这个问题。您可以在这里查看该演示:

https://codesandbox.io/s/update-react-state-in-a-loop-04b9ek


图片描述

问题——图示

在代码沙盒演示中,我创建了一个人为设置的慢速进程来演示这个问题。加载此演示后,屏幕上会显示一个名为“……”的按钮START SLOW PROCESS。其目的是,点击该按钮后,会触发一个耗时较长的进程。

但我们不希望用户怀疑网站是否崩溃。因此,按钮点击后,我们需要在屏幕上显示一个插页式弹窗。该弹窗告知用户操作正在进行中,理想情况下,还会提供一个动态进度指示器,让用户了解操作距离完成还有多远。

我们将从以下部分开始绘制插图App.js



// App.js
export const AppState = createContext({});

export const App = () => {
  const [progress, setProgress] = useState(0);
  const [showProcessing, setShowProcessing] = useState(false);

  return (
    <>
      <AppState.Provider
        value={{
          progress,
          setProgress,
          setShowProcessing,
          showProcessing
        }}
      >
        <UI />
      </AppState.Provider>
    </>
  );
};


Enter fullscreen mode Exit fullscreen mode

App.js它主要只是用户体验的一个封装层。不过,我使用了 Context API 来建立一些状态变量,这些变量在下游代码中会很有用。progress它代表我们项目的完成百分比SLOW PROCESSshowProcessing它决定是否显示进度插页。

现在我们来看一下UI.js



// UI.js
export const UI = () => {
  const { progress, showProcessing } = useContext(AppState);
  const { runSlowProcess } = useSlowProcess();

  const handleSlowProcessButton = () => runSlowProcess();

  return (
    <>
      <Backdrop
        sx={{
          color: "#fff",
          zIndex: (theme) => theme.zIndex.drawer + 1
        }}
        open={showProcessing}
      >
        <div className={"textAlignCenter"}>
          <Box
            sx={{
              display: "inline-flex",
              position: "relative"
            }}
          >
            <CircularProgress
              color={"success"}
              value={progress}
              variant={"determinate"}
            />
            <Box
              sx={{
                alignItems: "center",
                bottom: 0,
                display: "flex",
                justifyContent: "center",
                left: 0,
                position: "absolute",
                right: 0,
                top: 0
              }}
            >
              <Typography 
                color={"white"} 
                component={"div"} 
                variant={"caption"}
              >
                {`${progress}%`}
              </Typography>
            </Box>
          </Box>
          <br />
          Slow Process Running...
        </div>
      </Backdrop>
      <Button
        onClick={handleSlowProcessButton}
        size={"small"}
        variant={"contained"}
      >
        Start Slow Process
      </Button>
    </>
  );
};


Enter fullscreen mode Exit fullscreen mode

首先,JSX 中的命名组件,如<Backdrop><Box><CircularProgress><Typography>和 ,均<Button>来自 Material UI。

其次,该组件利用了AppState之前建立的上下文App.js。具体来说,我们将使用 `on`showProcessing来切换插页式广告层的可见性。我们还将使用 `on`progress来向用户显示广告距离SLOW PROCESS完成还有多远。

最后,该组件还导入了一个自定义 Hook。该 Hook 名为useSlowProcess()。我们来看一下:



// useSlowProcess.js
export const useSlowProcess = () => {
  const { setProgress, setShowProcessing } = useContext(AppState);

  const runSlowProcess = async () => {
    const startTime = Math.round(Date.now() / 1000);
    setProgress(0);
    setShowProcessing(true);
    for (let i = 1; i < 101; i++) {
      for (let j = 1; j < 1001; j++) {
        for (let k = 1; k < 1001; k++) {
          for (let l = 1; l < 1001; l++) {
            // do the stuffs
          }
        }
      }
      console.log(i);
      setProgress(i);
    }
    setShowProcessing(false);
    console.log(
      "Elapsed time:",
      Math.round(Date.now() / 1000) - startTime,
      "seconds"
    );
  };

  return {
    runSlowProcess
  };
};


Enter fullscreen mode Exit fullscreen mode

useSlowProcess()返回一个单独的函数:runSlowProcess()。该函数runSlowProcess()执行以下操作:

  1. 它建立了一个基准startTime,以便我们能够计算出实际需要多长时间SLOW PROCESS。(我在 Code Sandbox 上反复测试后,可以告诉你,完成这个过程大约需要 27 秒。)

  2. 它确保我们的progress变量从0.

  3. 然后进行设置showProcessingtrue以便用户可以看到正在播放的插页广告。

  4. 然后它启动了SLOW PROCESS

  5. 外层循环运行 100 次。这意味着外层循环每次完成,就相当于完成了整个过程的 1%。

  6. 每完成一个百分比,我们都会记录console.log()当前进度更新该progress变量。请记住,该变量将用于向用户显示距离完成还有多远。

  7. 当流程完成后,我们将其重置showProcessingfalse。这样应该可以移除正在进行的插页。

  8. 最后,我们计算console.log()了完成该操作所用的总秒数SLOW PROCESS

那么运行这段代码会发生什么呢?嗯……结果非常令人失望。

当用户点击START SLOW PROCESS按钮时,屏幕上不会显示任何插页式广告。这也意味着用户看不到<CircularProgress>进度条,也看不到持续更新的完成百分比。

但为什么会发生这种情况呢?


图片描述

批量头痛

这就是我们遇到 React 批量更新状态变量问题的症结所在。React 检测到我们在showProcessing流程true开始不久就将 `state` 设置为 `true`,但在流程接近尾声时runSlowProcess()又将其设置为 `false` 因此,批量更新的结果就是它直接将 `state` 设置为 `false` 。当然,在我们开始流程时,`state`已经是 `false` 了。所以,将 `state` 的先前值设置为`false`会导致用户根本看不到正在加载的插页式广告。falseshowProcessingfalse falsefalsefalse

progress即使我们解决了这个问题,用户也永远看不到组件内部变量的任何百分比更新<CircularProgress>为什么呢?因为 React 检测到状态变量progress在外部循环中被反复更新for。因此,它会将所有这些更新合并成一个单独的值。当然,这完全违背了设置进度指示器的初衷。


图片描述

令人沮丧的(无)帮助

记住,这个<CircularProgress>组件来自 Material UI。Material UI 有大量的文档,旨在指导你如何使用他们的组件。所以我的第一步就是去查阅文档。但是……它完全没有帮助。

以下是 Material UI 提供的代码示例,用于说明如何更新组件progress中的值<CircularProgress>



export default function CircularDeterminate() {
  const [progress, setProgress] = React.useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      setProgress((prevProgress) => (prevProgress >= 100 ? 0 : prevProgress + 10));
    }, 800);

    return () => {
      clearInterval(timer);
    };
  }, []);

  return (
    <CircularProgress variant="determinate" value={progress} />
  );
}


Enter fullscreen mode Exit fullscreen mode

需要说明的是,Material UI 的代码示例“确实有效”。我的意思是……它确实能动态更新组件progress内部的值<CircularProgress>。但这是我极少数觉得他们的文档几乎毫无用处的情况之一。

你看,他们useEffect()结合使用了 `on` 和 `on`,仅仅基于预设的 800 毫秒延迟setInterval()盲目地更新值。虽然这对于展示组件渲染的示例来说没问题,但它并没有告诉我们如何将这个值与基于进程真实进展的实际、有意义的计算联系起来。progressprogress

面对这个毫无用处的例子,我做了任何一个资深开发者都会做的事:开始谷歌搜索。但我在 Stack Overflow 之类的网站上找到的几乎所有帖子都同样没用。

当你用谷歌搜索这个问题时,你会遇到一个问题:几乎每个开发者都给出了完全相同的“答案”:

不要在循环中更新 React 状态变量。


我理解在很多情况下,你确实希望避免在循环中重复更新 React 状态,但事实是,这个用例恰恰是你应该在循环中更新 React 状态的典型例子。因为progress理想情况下,状态值应该基于算法的实际执行进度来计算,而不是基于某种毫无意义的setInterval()延迟。

我其实经常遇到这种毫无帮助的情况。你试图解决某个棘手的编程问题,但那些只会纸上谈兵的人非但不提供任何有意义的帮助,反而只会回复说你根本不应该这么做。

这就好比看到有人发帖抱怨自己怎么也做不出好吃的舒芙蕾,总是塌陷变形,最后变成一团糟——然后烹饪论坛里某个自作聪明的喷子就回复说:

你本来就不应该做舒芙蕾,做个煎蛋卷就行了。


哇,这有帮助了!谢天谢地,我最终还是解决了这个问题……


图片描述

救援承诺

解决这个问题的关键在于引入延迟setInterval()如果你看一下 Material UI 的示例,他们使用了`$($($($($($($($($($($($($($($($($($(($(( $ (setInterval() ( useEffect()$ useEffect()( useEffect()...

当然,在我的例子中,我试图跟踪 Hook 中函数的执行进度。因此,依赖这种方法并不十分有效useEffect()

useTimeout()但还有另一种方法可以在不使用`or` 的情况下引入延迟useInterval()。你可以使用Promise。Promise(及其相关async/await约定)从根本上改变了 React 的批量更新机制。

更新后的useSlowProcess()代码如下所示:



// useSlowProcess.js
export const useSlowProcess = () => {
  const { setProgress, setShowProcessing } = useContext(AppState);

  const runSlowProcess = async () => {
    const startTime = Math.round(Date.now() / 1000);
    setProgress(0);
    setShowProcessing(true);

    const delay = () => new Promise((resolve) => setTimeout(resolve, 0));

    for (let i = 1; i < 101; i++) {
      for (let j = 1; j < 1001; j++) {
        for (let k = 1; k < 1001; k++) {
          for (let l = 1; l < 1001; l++) {
            // do the stuffs
          }
        }
      }
      console.log(i);
      setProgress(i);
      await delay();
    }
    setShowProcessing(false);
    console.log(
      "Elapsed time:",
      Math.round(Date.now() / 1000) - startTime,
      "seconds"
    );
  };

  return {
    runSlowProcess
  };
};


Enter fullscreen mode Exit fullscreen mode

现在,当您点击该START SLOW PROCESS按钮时,屏幕上会显示进度插页,其中包括<CircularProgress>带有不断更新的progress指示器的组件。

关于这种方法,请注意以下几点:

  1. 我在循环内部更新值之后立即调用了延迟函数。这样做会产生一个副作用,那就是会使 React 退出批量更新流程。progressfor

  2. 延迟时间的长短无关紧要。正如你所见,我设置的延迟时间为零毫秒。这看起来可能不合逻辑,但这正是触发 React 中 DOM 更新所需要的全部时间。

结论

我想澄清一点,在绝大多数情况下,避免在循环内重复更新 React 状态确实是个明智之举。但我之所以想强调这个用例,是因为当你想向用户实时反馈正在进行的进程状态时,这样做就完全合情合理了。

文章来源:https://dev.to/bytebodger/updating-react-state-inside-loops-2dbf