“框架设计里到处都体现了权衡的艺术”。
霍春阳在《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
开头如useState
、useMemo
、useEffect
等。在函数式组件中定义状态,需要使用useState
进行声明:
|
|
任何对于状态的改变,都需要通过与状态一同定义的setState
进行修改,只有通过setState
更改状态才会重新调用render
函数更新视图。
但是问题出现了,setState
可能并不是同步更新的,下面的代码就是一个典型的例子:
|
|
当我们触发事件之后,会发现,第一次触发时浏览器会打印三个0
,而第二次触发时则会打出三个1
。这个例子中很好地说明了setState
的异步更新机制,每次我们访问count
时,只能拿到未更新前的状态,对于第一次触发,虽然我们调用了三次setCount
,但实际上相当于调用了三次setCount(1)
(事实上由于 React 会合并更新,所以实际上只更新了一次)React 也仅仅re-render
了一次。
这本质上是由 React 的进程调度决定的。setState
本身并不是异步代码,只是因为 React 的性能优化机制体现为异步。其在 React 的生命周期函数或者作用域下为异步,在原生的环境下为同步。
对于任何函数,只要进入了 React 的调度流程,那就是异步的。只要没有进入 React 的调度流程,那就是同步的。由 React 引发的事件处理(如
onClick
等)就会进入 React 的调度流程;而诸如setTimeout
、setInterval
或者直接在DOM
上绑定原生事件等,这些都不会走 React 的调度流程。
而之所以我们说 React 会“合并 setState
的更新”,实际上是基于批处理
(Batching)进行的,这是 React 优化性能的一个重要方式。
在 React 的
setState
函数实现中,会根据一个变量isBatchingUpdates
判断是直接更新state
还是放到队列中回头再说,而isBatchingUpdates
默认是false
,也就表示setState
默认会同步更新state
。而batchedUpdates
这个函数会把isBatchingUpdates
修改为true
为了合并
setState
,我们需要一个队列来保存每次setState
的数据,然后在一段时间后,清空这个队列并渲染组件,这个队列就是dirtyComponents
。当isBatchingUpdates
为true
时,将会执行dirtyComponents.push(component)
,将组件push
到dirtyComponents
队列。 调用setState
时,其实已经调用了batchedUpdates
,此时isBatchingUpdates
便是true
。因此展示出异步合并更新
再看这个例子,这里的loading
在一个事件中被改变了两次,却发生了两次re-render
。
|
|
事实上,写成这样的形式也会发生两次re-render
:
|
|
而只要去掉第一行await sleep()
,就不会再发生任何re-render
,一次都不会。
Tips:可能很多人没有注意到,即使函数使用了
async
进行声明,只要其内部不包括异步代码或使用await
,那么整个函数仍然是同步执行的。例如这个函数中,控制台会按照
1
、2
的顺序打印,即使test
使用了async
进行声明。
1 2 3 4 5
const test = async () => { console.log("1"); }; test(); console.log(2);
可以看出,在使用async
和await
之后,整个函数成为了一个异步函数,而在异步函数内,setState
就不会再合并更新
了
为什么需要异步更新
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.)”
首先是显而易见的性能消耗问题,如果我们每调用一次setState
就re-render
一次,那么无疑是庞大的性能开销。除此之外,其实最主要的还是Guaranteeing Internal Consistency
,即保证内部状态的协调一致。
我们知道,在 React 中,组件化开发都是围绕props
展开的,父组件的状态、方法可以通过props
向子组件传递,由子组件共享。这是一个单向数据流
,props
在组件挂载之后,对子组件而言就已经固定了,不会再发生改变,哪怕父组件内部的状态发生了更新,但只要没有re-render
,子组件就永远不知道父组件发生了什么变化。
如果setState
是同步更新的,在单独的组件中似乎没有什么问题,我们可以通过三次console
得到三个不同的值:
|
|
但如果我们需要将状态进行提升,以让其他组件共享:
|
|
那么我们就会遇到这个问题:
|
|
在这里,每次调用onIncrement
函数后,父组件中的值确确实实发生了改变,但是对于子组件而言,它获取上级状态的唯一途径就是通过props
,而如果不重新渲染父组件,我们就不能立即刷新props
。而且,如果父组件和子组件都在同一个click
事件中都调用了相同的setState
,那么子组件在这里就会被re-render
两次,产生不必要的性能消耗。
因此,同步执行setState
不仅会带来极大的性能问题,也必然会和props
的更新产生冲突,正如官方文档和Dan Abramov所说:
“这样会破坏掉
props
和state
之间的一致性,造成一些难以 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
的异步设计,并且通过批处理的合并更新,不仅减少了性能消耗,也保证了内部状态更新的协调一致。
如何使用最新的状态
对第一个例子进行改造:
|
|
通过使用prevState
来使用前一个状态,这样每一个setState
都会拿到最新的state
值。体现在页面上就是每次点击,都会让count
自增5
,并且只会re-render
一次。
但这样还不够,这样做只能让count
在页面上显示最新值,而我们有时候会希望,能够在更新State
之后直接使用最新的状态做一些事。在类组件中,setState
函数接收第二个参数作为callback
,但在函数组件中,则没有第二个参数,这里我们应该如何解决呢?
答案是使用useEffect
副作用函数。
用法很简单,第一个参数是回调,第二个参数是依赖数组
|
|
万事俱备,此时我们就可以在每一次count
更新后,拿到最新的count
,并且执行函数了。这里其实已经可以窥见 React 函数式编程思想的一角了,关于函数式编程在 React 及其周边生态库中的体现,我会在下一篇文章中详细阐述。