March 04, 2020

(译)为什么React的`setState`是异步?

前言

这篇文章是Dan Abramov 在github上面的一个issue的讨论回答,虽然并不是一个正式发的文章,但是我觉得对于理解也是很重要,能够了解到设计的原因,这样子比大部分搜索到的“复制-粘贴”资料更深入。

原文链接在这里,大家有兴趣可以去看一下原版,以下是我渣渣英语的翻译:

正文

这里有几个想法,在某种意义上,这不是一个完整的回答,但仍然比不回答任何东西有帮助。

第一点,我认为我们为了批量更新而延迟调度(reconciliation)是很有利的。我们认同setState触发同步重新渲染在很多情况下是低效的,如果我们知道我们将要执行几个任务,那么批量更新是一个更好的选择。

举个例子,如果我们在浏览器点击的回调方法中,子组件(Child)与父组件(Parent)都调用了setState,我们不想去重新渲染两次子组件(Child),而是去标识这两个组件都是脏的(dirty),然后在退出浏览器事件(click)之前,把父子组件都重新渲染。

你提出一个问题:为什么我们不能够做同样的事情(批量更新),而是在调度(reconciliation)的最后来通过setState来马上更新this.state.我想目前没有一个明确的答案(两种解决方法(指同步和异步)都有权衡),但是下面是我想到的几个原因:

保证内部一致性(Guaranteeing Internal Consistency)

尽管state的更新是同步的,但是props不是(你不知道props值,除非你重新渲染父组件;如果使用同步的方法更新这些数据(译注:propsstate),批量更新就会超出处理窗口(batching goes out of the window))。

现在React提供的对象props,state,refs,在它们互相看到是内部一致的。这样子就意味着,如果你只使用这些对象,这些对象数据能够根据调度树来保证互相对比(尽管这是一个旧版本的调度树)。为什么这样子做很重要?

当你使用以下的state,如果它同步更新(正如你所想),这种模式是可行的:

1
2
3
4
5
console.log(this.state.value) // 0
this.setState({ value: this.state.value + 1 });
console.log(this.state.value) // 1
this.setState({ value: this.state.value + 1 });
console.log(this.state.value) // 2

然而,假设需要提升数据状态来给到几个组件之间进行共享,你需要把该操作移动到父组件:

1
2
-this.setState({ value: this.state.value + 1 });
+this.props.onIncrement(); // Does the same thing in a parent

我想强调的是,在React app中,app依赖的setState()是React最通用的设计标准类型;在平常中你能够经常调用它

然而,这让我们的代码无法正确运行:

1
2
3
4
5
console.log(this.props.value) // 0
this.props.onIncrement();
console.log(this.props.value) // 0
this.props.onIncrement();
console.log(this.props.value) // 0

这是因为,在你提出的代码中(指上面同步的操作),this.state应该是马上刷新(更新数据),但是this.props不会。我们在没有重新渲染父组件的时候,不能够马上更新this.props,因为这样子(同步)就意味着我们我们就要放弃批量更新(对于一些情况来说,会明显降低表现性能)。

这里也有一些小的例子,说明同步是不能够正常运行。例如,如果你把this.props(还没更新)与this.state(提议马上更新)混合一起,创建一个新的state#122(comment),使用Refs也会有这个问题:#122(comment)

上面这些例子不是所有的理论假设。 事实上,React Redux的绑定通常明确会有这些问题,因为他们把React props与不是React的state数据混合在一起: reduxjs/react-redux#86, reduxjs/react-redux#99, reduxjs/react-redux#292, reduxjs/redux#1415, reduxjs/react-redux#525

我不知道为什么Mobx的使用者们没有碰到这个问题,但是我的直觉是,他们可能在某些情景遇到这个问题,但是他们认为是他们自己的错。或者有可能他们没有直接从props读取数据,而是直接读取MobX变化的数据。

所以现在React是怎么解决的?在React中,this.statethis.props只在调度与刷新完成后才更新。所以你将会看到,在重构完的例子中,执行前后都是打印出0。这样子可以让状态提升的state变得安全。

是的,这样子的调用可能会在某些情况不方便。特别是对于人们以 OO 为背景,仅想通过更改几次state,而不是思考怎样在一处地方去完整更新state。我对这种处理也有同感,但我认为保持集中更新state对于调试debugger过程是非常清晰的。

你仍然有其他一些方法来更改state,通过一些有副作用的可变的对象(mutable object),为了马上能够读取到state。特别是当你不想使用该数据作为渲染的源数据的时候。就如MobX让你做的那样。🙂

如果你知道你所做的目的,你也可以有方法去更新整棵树。这个API为ReactDOM.flushSync(fn)。我认为我们还没有相关的文档关于它,但我们肯定会在16.x的release中加入这个文档。需要注意的是,这个API实际上被调用的时候,在数据更改后强制重新渲染,所以你需要很谨慎的使用它。这种方法不会打破props,state,refs之间内部数据的一致性。

总结一下,React这种模式不能够总是让代码变得简洁,但是是为了在React内部保持数据一致性,还有保证状态提升变得安全。

启用并发更新(Enabling Concurrent Updates)

从概念上面讲,React的行为就好像在每个组件中有一个单一的更新队列。这就是为什么这个讨论是有意义的:我们讨论是否应该马上更新this.state,因为我们对这些更新的应用顺序毫无疑问。然而,事实上并不是这样。haha

最近我们经常讨论“异步渲染”。我认为我们在沟通这方便做得不是很好,但这是技术(R&D)的本质:你在追求一个似乎很有希望的概念,但是你只有花很多时间下去,才能够真正的了解到它的含义。

其中一个解释“异步渲染”的是:React 会在setState()的时候,根据它们的数据来源分配不同的优先级,这些数据来源有:事件回调句柄,网络相应,动画效果等。

例如,如果你在输入一个信息,setStateTextBox组件被调用的时候需要马上刷新。然而,如果你在输入的时候,在接收一个新的信息,这样子可能更好的做法是:一定程度的延迟渲染新的信息冒泡更新,而不是因为进程的阻塞导致这个输入过程变得卡顿。

如果我们让某些更新变得“低优先级”,我们可以把这些渲染分割几个小的任务,在几毫秒内执行;这样子就不会让用户察觉到。

我知道像这样子的性能优化听起来很激动人心或有说服力。你可能会说:“我们在使用MobX不需要性能优化,我们更新跟踪是能够足够快仅为了避免重新渲染”。我认为这个说法不是在所有的情况都是对的(例如:无论 MobX 有多快,你仍然需要创建DOM节点并且在一个新的视图中挂载渲染)。尽管,假设这种情况是对的,并且如果你决定,总是使用一个特殊的JavaScript库包裹着对象是没问题的,用来跟踪数据读取与写入,可能你在这些优化中没有获得收益。

但是异步渲染不只为了性能优化。我们认为这是React组件的模式能做到的根本性转变。

例如,考虑这种情景,当你从一个页面跳转到另外一个。通常是你会在新个页面中显示一个spinner。

然而,如果这个跳转是足够快的(在一秒钟左右),刷新与马上隐藏一个spinner会导致用户体验的下降。更糟糕的事,如果你有多个组件层级,这些组件有不同的异步依赖(数据,代码,图片),最终你会在很短时间内,spinner一个一个地闪烁。这种情况会让app在视觉效果变得不好,让app实际上运行变慢,因为所有的DOM都重排了(reflow)。这也会出现在很多模版代码中。

如果当你执行一个简单的setState来渲染一个不同的视图,这不是很好吗,我们能够"开始"渲染更新视图的时候是在“后台”执行?想象一下你自己没有编写任何协调(coordination)的代码,就能够选择展示一个spinner,如果这次更新需要超过了某个阈值(例如:一秒),否则当异步依赖在整个子树中已完成,React会呈现无缝的过度。而且,当我们在“等待”,旧页面还保持可响应(例如:所以你能够选择另外一个不同的元素(item)去过度),如果这次更新耗费时间很长,React强制让你显示一个spinner。

结果发现,通过现在React的模式还有一个生命周期的调整,我们实际上能够实现(上面说的更新过度)。@acdlite 在过去几周内研究这个功能,也快要发一个RFC。

需要注意的是,这是唯一的可能,因为this.state不是马上更新。如果this.state是马上更新,当目前“旧版本”也能够看到和响应的时候,我们就没有办法在后台去开始渲染一个“新版本”视图。他们那些独立的状态更新就会崩溃。

我不想从 @acdlite 中抢先发布这个内容,但是我希望这听起来有点激动。我想这仍然像蒸汽那样去不断了解这想法。或者像我们不能够真的认识到我们所做的事情。我希望我们能够在接下来几个月说服你,并且你会欣赏React这种灵活的模式。据我所知,由于不是马上刷新state,至少在某种程度上,这种灵活性是可行的。