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

7 个月前

前言

不知道大家有没有过这个疑问,React 中 setState() 为什么是异步的?我一度认为 setState() 是同步的,知道它是异步的之后很是困惑,甚至期待 React 能出一个 setStateSync() 之类的 API。同样有此疑问的还有 MobX 的作者 Michel Weststrate,他认为经常听到的答案都很容易反驳,并认为这可能是一个历史包袱,所以开了一个 issue 询问真正的原因。最终这个 issue 得到了 React 核心成员 Dan Abramov 的回复,Dan 的回复表明这不是一个历史包袱,而是一个经过深思熟虑的设计。

注意:这篇文章根据 Dan 的回复写成,但不是一篇翻译。我忽略了很多不太重要的内容,Dan 的完整回复请看这里

正文

Dan 在回复中表示为什么 setState() 是异步的,这并没有一个明显的答案(obvious answer),每种方案都有它的权衡。但是 React 的设计有以下几点考量:

一、保证内部的一致性

首先,我想我们都同意推迟并批量处理重渲染是有益而且对性能优化很重要的,无论 setState() 是同步的还是异步的。那么就算让 state 同步更新,props 也不行,当父组件重渲染(re-render )了你才知道 props

现在的设计保证了 React 提供的 objects(state,props,refs)的行为和表现都是一致的。为什么这很重要?Dan 举了个栗子:

假设 state 是同步更新的,那么下面的代码是可以按预期工作的:

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

然而,这时你需要将状态提升到父组件,以供多个兄弟组件共享:

-this.setState({ value: this.state.value + 1 });
+this.props.onIncrement(); // 在父组件中做同样的事

需要指出的是,在 React 应用中这是一个很常见的重构,几乎每天都会发生。

然而下面的代码却不能按预期工作:

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 (也就是立即重渲染父组件),就必须放弃批处理(根据情况的不同,性能可能会有显著的下降)。

所以为了解决这样的问题,在 React 中 this.statethis.props 都是异步更新的,在上面的例子中重构前跟重构后都会打印出 0。这会让状态提升更安全。

最后 Dan 总结说,React 模型更愿意保证内部的一致性和状态提升的安全性,而不总是追求代码的简洁性。

二、性能优化

我们通常认为状态更新会按照既定顺序被应用,无论 state 是同步更新还是异步更新。然而事实并不一定如此。

React 会依据不同的调用源,给不同的 setState() 调用分配不同的优先级。调用源包括事件处理、网络请求、动画等。

Dan 又举了个栗子。假设你在一个聊天窗口,你正在输入消息,TextBox 组件中的 setState() 调用需要被立即应用。然而,在你输入过程中又收到了一条新消息。更好的处理方式或许是延迟渲染新的 MessageBubble 组件,从而让你的输入更加顺畅,而不是立即渲染新的 MessageBubble 组件阻塞线程,导致你输入抖动和延迟。

如果给某些更新分配低优先级,那么就可以把它们的渲染分拆为几个毫秒的块,用户也不会注意到。

三、更多的可能性

Dan 最后说到,异步更新并不只关于性能优化,而是 React 组件模型能做什么的一个根本性转变(fundamental shift)。

Dan 还是举了个栗子。假设你从一个页面导航到到另一个页面,通常你需要展示一个加载动画,等待新页面的渲染。但是如果导航非常快,闪烁一下加载动画又会降低用户体验。

如果这样会不会好点,你只需要简单的调用 setState() 去渲染一个新的页面,React “在幕后”开始渲染这个新的页面。想象一下,不需要你写任何的协调代码,如果这个更新花了比较长的时间,你可以展示一个加载动画,否则在新页面准备好后,让 React 执行一个无缝的切换。此外,在等待过程中,旧的页面依然可以交互,但是如果花费的时间比较长,你必须展示一个加载动画。

事实证明,在现在的 React 模型基础上做一些生命周期调整,真的可以实现这种设想。@acdlite 已经为这个功能努力几周了,并且很快会发布一个 RFC(亦可赛艇)。

需要注意的是,异步更新 state 是有可能实现这种设想的前提。如果同步更新 state 就没有办法在幕后渲染新的页面,还保持旧的页面可以交互。它们之间独立的状态更新会冲突。

Dan 最后对 Michel 说到:我希望我们能在接下来几个月说服你,并且你会欣赏到 React 模型的灵活性。据我理解,这种灵活性至少一部分要归功于 state 的异步更新。

2
推荐阅读