使用setState更新来自计算密集型函数的React进度

8
我有一个简单的React类,展示this.state.progress(一个数字),并且可以通过updateProgress(progress)函数更新这个状态。
class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      progress: 0,
    }
  }

  updateProgress = (progress) => {this.setState({progress}); };
  render() {
    let {progress} = this.state;
    return <h1>{progress}</h1>;
  }
}

我有一个计算密集型的函数myHeavyFunc,需要显示进度条。我使用循环变量在myHeavyFunc内调用上述提到的updateProgress函数。
myHeavyFunc = async (updateProgress) => {
    let loopLength = 1000000;
    updateProgress(0);
    for(let i=0; i<loopLength; i++) {
      // some processing happens here
      updateProgress((i+1)/loopLength);
    }
}

发生的事情是状态被更新,我可以通过在setState回调中记录进度来确认,但组件直到最后才重新渲染。然而,如果我包括一个小的sleep 1ms,则重新渲染会发生,进度更新(显然时间大大损失,我不喜欢)。

JSFiddle here。这里我在点击进度数字时运行myHeavyFunc。您可以看到当注释掉await sleep(1)时,onClick在一秒钟内完成,但未显示进度。它甚至不会为任何后续点击更改。另一方面,如果没有注释,我会获得进度更新,但只是需要很长时间才能完成!

我知道出于性能考虑,React应该批量更新,但在我的情况下,直到整个循环完成,我甚至看不到一次更新。此外,请注意,我不需要一个同步setState函数,但我需要在状态设置后重新渲染(至少仅在进度元素上)。如果由于批处理而丢失了几个进度更新,我可以接受,但我确实希望它能显示进度。
有没有办法以非阻塞方式运行myHeavyFunc并在UI中更新进度?在React中更新计算密集型函数的进度的正确方法是什么?

更新 fiddle - Saravanabalagi Ramachandran
仅仅将一个函数变成async并不能产生任何神奇的效果。它只是使其返回一个Promise,但如果在返回之前它执行了一些长时间且同步的操作,那么它仍然会在React有机会执行任何操作之前完成运行。 - Robin Zigmond
2
简而言之,使用await sleep(1) 调用的方法是做这件事情的“正确方式”- 我很欣赏这种方式,即使在这种情况下它会使过程变得太长,那么只需在循环的每100步或每1000步调用一次即可。我假设这是基于一个比空循环更现实的例子 - 在这种情况下,如果您展示实际函数或至少更接近实际函数的近似函数,则可能会获得更好的响应。 - Robin Zigmond
如果您调用一个需要几秒钟(或更长时间)才能完成的同步函数,那将是一种可怕的用户体验,原因与React无关 - 无论该函数执行什么操作。为了避免这种情况,您基本上必须在选择的点“暂停”函数,并使用setTimeout或类似方法安排计算的下一部分。(理论上,Web Worker可能是另一种方法,但我对此并不了解。)如果您已经这样做了,那么每次都可以轻松更新一些状态,以更新进度条。 - Robin Zigmond
你说得对,我看到我们可以有两种方法来做到这一点:1. 每 n 更新进度:fiddle;2. 每 n 更新进度:fiddle - Saravanabalagi Ramachandran
显示剩余2条评论
2个回答

3
我建议您尽可能少地通知进度。 进度将从0到100,因此我将仅通知100次进度,并且我还将添加超时,以便用户可以看到进度的过渡:
如果loopLength = 1000000,
1000000/100 = 10000
每10000次迭代,我将调用updateProgress函数。
let myHeavyFunc = async (updateProgress) => {
  let loopLength = 1000000;
  let current = 0;
  for(let i=0; i<loopLength; i++) {
    const result = Math.floor(((i+1)/loopLength)*100);
    if(current != result) {
       setTimeout(()=> {
           updateProgress(result);
       }, result *100);
    }
    current = result;
  }
}

请参见:https://jsfiddle.net/wbzx4j90/1/

这个解决方案是有效的,而且与Robin Zigmond评论中提出的建议非常相似。但是为什么超时时间会随着进展线性增加呢? - Saravanabalagi Ramachandran
是的,想法是在不同的时间调用updateProgress(result),对于result=1,time=100,result=2,time=200,否则如果你定义相同的时间,你将会得到result=1,time=100,result=2,time=100,这样用户就看不到进度过渡了。 - lissettdm
你只需要相同数量的“超时时间”(_timeout time_)即可。超时时间允许浏览器渲染内容。我使用1毫秒来减少开销,可以在这里查看示例:https://jsfiddle.net/saravanabalagi/1pqt0sju/18/。 - Saravanabalagi Ramachandran
我更新了你的fiddle,以反映相同的数字转换,即0到1,其他所有内容都不变。但是你可以看到它比没有超时时花费更多时间。你可以将其与我上面发布的fiddle进行比较,以感知时间差异。 - Saravanabalagi Ramachandran
是的,那只是个例子,你可以用更小的数。你可以试试10或1,而不是100。 - lissettdm
显示剩余2条评论

0
我猜问题出在浏览器重绘上。通常,浏览器每秒会重绘60帧。在你的情况下,你要求浏览器执行比它能承受更多的绘制操作。我建议使用requestAnimationFrame代替for循环,以持续更新DOM。requestAnimationFrame将在浏览器执行下一次重绘之前处理要调用的函数。
  let i = 0
  let loopLength = 1000;

  let myHeavyFunc = (updateProgress) => {
    updateProgress((i+1)/loopLength);
    i++;
  if (i < loopLength) {
    window.requestAnimationFrame(() => myHeavyFunc(updateProgress));
    
  }
}

工作演示 https://jsfiddle.net/gdq5ouat/5/


这样做会太慢,因为每次迭代更新UI都是一个很大的开销,我无法像在问题中提到的那样执行1000000个循环。 - Saravanabalagi Ramachandran

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接