Featured image of post 深入理解React的状态更新机制

深入理解React的状态更新机制

setState究竟是异步还是同步?

“框架设计里到处都体现了权衡的艺术”。

霍春阳在《Vue.js 设计与实现》用这句话作为全书的开头,这句话不仅是描述 Vue 的,在 React 或者其他所有框架中,都体现着权衡的艺术。

前言:响应式基础

我们知道,包括 React 在内的现代前端框架中,除了像 svelte 之类的框架将模板直接编译成真实 dom 外,大多数都是依赖vdom+diff算法实现的数据视图层双向绑定,也就是所谓的响应式 MVVM。以虚拟 dom 为基础的响应式框架会在组件内部状态发生变化时,重新调用内部的渲染器(render)函数构建新的虚拟 dom 树(即re-render),然后通过diff算法找到需要更新的节点,并且只更新有变更的节点。从而实现视图层的响应式更新。

React 的状态更新是依赖组件内部state的改变进行的,当组件内部的state改变了,该组件就会重新调用render函数,触发页面更新,而state的改变需要通过 setState 的调用来实现。

组件状态与更新

在 React 刚刚引入函数组件的时候,函数组件的内部并不涉及状态的更新,也就是说,函数组件必须是一个纯函数(关于纯函数,可以参考后文详细介绍函数式编程思想,简单来说就是对于确定的输入,产生确定的输出,不受外部变量影响),只能用来渲染固定的内容,其内部没有自己的state,也没有生命周期函数。通常来说,组件库的组件就是无状态的,其展现形式只由传入的props决定。而在引入hooks之后,React 给予了函数组件维护自身状态的能力,函数组件从此可以完全取代类组件,大大降低了开发者对类组件的学习成本和心智负担。

阮一峰老师这样介绍React hooks

“React Hooks 的意思是,组件尽量写成纯函数,如果需要外部功能和副作用,就用钩子把外部代码"钩"进来。 React Hooks 就是那些钩子。”

React 的hooks基本上都以use开头如useStateuseMemouseEffect等。在函数式组件中定义状态,需要使用useState进行声明:

1
const [state, setState] = useState(initialValue);

任何对于状态的改变,都需要通过与状态一同定义的setState进行修改,只有通过setState更改状态才会重新调用render函数更新视图。

但是问题出现了,setState可能并不是同步更新的,下面的代码就是一个典型的例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
const [count, setCount] = useState(0);

const handleClick = () => {
  setCount(count + 1);
  console.log(count);
  setCount(count + 1);
  console.log(count);
  setCount(count + 1);
  console.log(count);
};

当我们触发事件之后,会发现,第一次触发时浏览器会打印三个0,而第二次触发时则会打出三个1。这个例子中很好地说明了setState的异步更新机制,每次我们访问count时,只能拿到未更新前的状态,对于第一次触发,虽然我们调用了三次setCount,但实际上相当于调用了三次setCount(1)(事实上由于 React 会合并更新,所以实际上只更新了一次)React 也仅仅re-render了一次。

这本质上是由 React 的进程调度决定的。setState本身并不是异步代码,只是因为 React 的性能优化机制体现为异步。其在 React 的生命周期函数或者作用域下为异步,在原生的环境下为同步。

对于任何函数,只要进入了 React 的调度流程,那就是异步的。只要没有进入 React 的调度流程,那就是同步的。由 React 引发的事件处理(如onClick等)就会进入 React 的调度流程;而诸如 setTimeoutsetInterval 或者直接在 DOM 上绑定原生事件等,这些都不会走 React 的调度流程。

而之所以我们说 React 会“合并 setState的更新”,实际上是基于批处理Batching)进行的,这是 React 优化性能的一个重要方式。

在 React 的setState函数实现中,会根据一个变量isBatchingUpdates判断是直接更新state还是放到队列中回头再说,而isBatchingUpdates默认是false,也就表示setState默认会同步更新state。而batchedUpdates这个函数会把isBatchingUpdates修改为true

为了合并setState,我们需要一个队列来保存每次setState的数据,然后在一段时间后,清空这个队列并渲染组件,这个队列就是dirtyComponents。当isBatchingUpdatestrue时,将会执行dirtyComponents.push(component),将组件pushdirtyComponents队列。 调用setState时,其实已经调用了batchedUpdates,此时isBatchingUpdates便是true。因此展示出异步合并更新

再看这个例子,这里的loading在一个事件中被改变了两次,却发生了两次re-render

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
const handleClick = async () => {
  setLoading(true);
  await sleep(1000); // 封装setTimeout
  setLoading(false);
};

return (
  <>
    <button onClick={handleClick}>更新</button>
    <div>{loading ? <h1>onLoading...</h1> : <h1>success!</h1>}</div>
  </>
);

事实上,写成这样的形式也会发生两次re-render

1
2
3
4
5
const handleClick = async () => {
  await sleep();
  setLoading(true);
  setLoading(false);
};

而只要去掉第一行await sleep(),就不会再发生任何re-render,一次都不会。

Tips:可能很多人没有注意到,即使函数使用了async进行声明,只要其内部不包括异步代码或使用await,那么整个函数仍然是同步执行的。

例如这个函数中,控制台会按照12的顺序打印,即使test使用了async进行声明。

1
2
3
4
5
const test = async () => {
  console.log("1");
};
test();
console.log(2);

可以看出,在使用asyncawait之后,整个函数成为了一个异步函数,而在异步函数内,setState就不会再合并更新

为什么需要异步更新

RFClarification: why is setState asynchronous? #11527

Dan Abramov在 11527 号 Issue 中详细阐述了把setState设计为异步执行的原因。

“We agree that setState() re-rendering synchronously would be inefficient in many cases, and it is better to batch updates if we know we’ll likely get several ones.”

“For example, if we’re inside a browser click handler, and both Child and Parent call setState, we don’t want to re-render the Child twice, and instead prefer to mark them as dirty, and re-render them together before exiting the browser event.”

“Even if state is updated synchronously, props are not. (You can’t know props until you re-render the parent component, and if you do this synchronously, batching goes out of the window.)”

首先是显而易见的性能消耗问题,如果我们每调用一次setStatere-render一次,那么无疑是庞大的性能开销。除此之外,其实最主要的还是Guaranteeing Internal Consistency,即保证内部状态的协调一致。

我们知道,在 React 中,组件化开发都是围绕props展开的,父组件的状态、方法可以通过props向子组件传递,由子组件共享。这是一个单向数据流props 在组件挂载之后,对子组件而言就已经固定了,不会再发生改变,哪怕父组件内部的状态发生了更新,但只要没有re-render,子组件就永远不知道父组件发生了什么变化。

如果setState是同步更新的,在单独的组件中似乎没有什么问题,我们可以通过三次console得到三个不同的值:

1
2
3
4
5
6
setCount(count + 1);
console.log(count); // 1
setCount(count + 1);
console.log(count); // 2
setCount(count + 1);
console.log(count); // 3

但如果我们需要将状态进行提升,以让其他组件共享:

1
2
// setCount(count + 1);
props.onIncrement();

那么我们就会遇到这个问题:

1
2
3
4
5
console.log(props.count) // 0
props.onIncrement()
console.log(props.count) // 0
props.onIncrement()
console.log(props.count) // 0

在这里,每次调用onIncrement函数后,父组件中的值确确实实发生了改变,但是对于子组件而言,它获取上级状态的唯一途径就是通过props,而如果不重新渲染父组件,我们就不能立即刷新props。而且,如果父组件和子组件都在同一个click事件中都调用了相同的setState ,那么子组件在这里就会被re-render两次,产生不必要的性能消耗。

因此,同步执行setState不仅会带来极大的性能问题,也必然会和props的更新产生冲突,正如官方文档和Dan Abramov所说:

“这样会破坏掉 propsstate 之间的一致性,造成一些难以 debug 的问题。”

“We can’t immediately flush this.props without re-rendering the parent, which means we would have to give up on batching (which, depending on the case, can degrade the performance very significantly).”

所以,考虑到种种原因,React 最终保留了setState的异步设计,并且通过批处理的合并更新,不仅减少了性能消耗,也保证了内部状态更新的协调一致。

如何使用最新的状态

对第一个例子进行改造:

1
2
3
4
5
6
7
const handleClick = async () => {
  setCount(prevState => prevState + 1);
  setCount(prevState => prevState + 1);
  setCount(prevState => prevState + 1);
  setCount(prevState => prevState + 1);
  setCount(prevState => prevState + 1);
};

通过使用prevState来使用前一个状态,这样每一个setState都会拿到最新的state值。体现在页面上就是每次点击,都会让count自增5,并且只会re-render一次。

但这样还不够,这样做只能让count在页面上显示最新值,而我们有时候会希望,能够在更新State之后直接使用最新的状态做一些事。在类组件中,setState函数接收第二个参数作为callback,但在函数组件中,则没有第二个参数,这里我们应该如何解决呢?

答案是使用useEffect副作用函数。

用法很简单,第一个参数是回调,第二个参数是依赖数组

1
2
3
useEffect(() => {
  console.log(count);
}, [count]);

万事俱备,此时我们就可以在每一次count更新后,拿到最新的count,并且执行函数了。这里其实已经可以窥见 React 函数式编程思想的一角了,关于函数式编程在 React 及其周边生态库中的体现,我会在下一篇文章中详细阐述。

Built with Hugo
主题 StackJimmy 设计