React中的“反直觉”(1):关于useState

关于React的争论在近日又引发了强烈讨论。有人说,React的新版文档写的很好,对React中易错的误区进行了生动易于理解的阐释。也有人说,React的发展在不断加重开发者的心智负担。作为一个受众如此之广的框架,遇到这些相当"反直觉"的概念误区,应当想办法做改进。而开发者不仅接受着这些框架带来的不适,还夸赞文档写得好,是否对React有点太过宽容了?

但不论如何,对于React的开发者来说,这些"反直觉"的概念,为了避免出错,还是应当对相关概念形成一个有效的认知。在本人阅读文档的过程中,以自己的视角做了一些梳理。文中不会深入地去解析原理,更多是想建立一个React希望我们建立的心智模型(感觉被React pua了)。

本系列的内容基于React18

那让我们从最常用的概念说起:useState
注:本文中的 setState 均指调用 useState 返回的更新函数,而非 class 组件中的 setState

setState 后发生了什么?

state 快照

React的官方文档将 state 比喻成一张快照,组件的每次重新渲染,会去使用拿到的最新的 state 快照,计算它的 props、事件处理函数和内部变量,从而去更新 UI。

简单来说,当 React 重新渲染一个组件时:

  1. React 会再次执行该函数组件
  2. 函数会根据当前渲染时的 state 快照返回新的 JSX 快照
  3. React 拿到新的 JSX 快照去进行 DOM 更新

在有了这样一个基本认知后,我们再思考一个关于 setState 很经典的例子:

jsx 复制代码
 export default function Counter() {
   const [number, setNumber] = useState(0);
 ​
   return (
     <>
       <h1>{number}</h1>
       <button onClick={() => {
         setNumber(number + 1);
         setNumber(number + 1);
         setNumber(number + 1);
       }}>+3</button>
     </>
   )
 }

上述例子的结果对于大部分 React 开发者都是显然的,最终点击按钮后,number 只会加1。这是因为setState 只会为下一次渲染变更 state 的值 ,在onClick触发的那一次渲染中,number 的值始终为0,即便在调用了 setNumber(number + 1) 之后,number 的值也仍然是 0,因为此时仍停留在上一次渲染中。

然后我们再思考一个经典问题:

setState 到底是同步还是异步?

先说结论:setState 不应该将被理解为异步,但它表现的很像"异步" 。例如我们在同一个事件处理函数中,在更新了 state 后立即去取该 state 的值,会发现其还是更新前的值。

但下面的例子又能很好地证明 setState 并非是一个异步任务,最终alert的值还是0:

jsx 复制代码
 export default function Counter() {
   const [number, setNumber] = useState(0);
 ​
   return (
     <>
       <h1>{number}</h1>
       <button onClick={() => {
         setNumber(number + 5);
         setTimeout(() => {
           alert(number);
         }, 3000);
       }}>+5</button>
     </>
   )
 }

state 会带给我们这种似异步而又非异步的感觉,和 React 的调度机制密切相关。

在前文中我们已经了解了关于 state 快照的概念。事实上,React 只会使用触发交互时的快照进行调度。React 为了避免多次重复渲染,会对 state 做批量更新,并将更新操作放在事件处理函数中的所有代码都运行完毕之后。这样的机制使得整个更新过程,看上去很像异步。但整个过程是一个同步过程,只是调用顺序上,state 的更新被置后了。

这一机制使得一个 state 变量的值永远不会在一次渲染的内部发生变化,即使其事件处理函数的代码是异步的(闭包的原因)。

在一次事件处理函数中,React 不会在调用了 setState 后立即重新执行组件函数(即组件的重新渲染),而是每次调用 setState 后,将该操作放入一个队列中。事件处理函数运行结束后,React 才触发一次更新,并在下次调用组件函数时的 useState 中去处理更新队列。所以,在同一事件处理函数中,连续的 state 更新并不会触发多次渲染,而只会渲染一次。

但在 React18 之前会有所不同,某些场景下并不会触发批量更新 。

jsx 复制代码
 export default function Counter() {
   const [number, setNumber] = useState(0);
   console.log('render')
 ​
   return (
     <>
       <h1>{number}</h1>
       <button onClick={() => {
         setTimeout(() => {
           setNumber(2)
           setNumber(3)
         }, 3000);
       }}>update</button>
     </>
   )
 }
 ​
 // React 18
 // ->  render
 ​
 // React < 18
 // ->  render
 // ->  render

React 18之前,会根据一个叫做 ExecutionContext 的全局变量来判断是否进行批量更新。我们绑定在 JSX 上的事件被称为 SyntheticEvent(合成事件),由 React 的事件系统进行调度。在触发合成事件时,React 会设置 ExecutionContext ,而在 setState 中识别到上下文后,React 就知道应该做批量更新,从而减少渲染次数。但在异步函数、 js 原生事件、计时器中,执行过程并不在 React 的管控范围之内。而对于这些无法控制的更新操作,React 会提高其优先级(个人理解可能是因为外部更新或许是紧急的但 React 无法预知),并在每一次 setState 中重新触发一次渲染。

而在React 18 解决了这一问题,所有的 state 更新都将是自动批量更新的。

github.com/reactwg/rea...

React如何进行批量更新

上文我们了解到 React 会对 state 更新做批处理。React 会对 state 的更新会被放入一个队列,在完成一次事件处理中的其他工作之后在下次 useState 中按顺序进行处理 state 更新。

之前的例子告诉我们,像这样在一次事件处理中多次更新同一个state,最终值只会 +1:

js 复制代码
 setNumber(number + 1);
 setNumber(number + 1);
 setNumber(number + 1);

但再少数场景下,我们可能确实会需要在下次渲染前多次更新同一个state。那么如上的场景如果希望达成 +3 的效果,可以写成这样:

js 复制代码
 const [number, setNumber] = useState(0);
 ​
 // onClick处理函数
 setNumber(n => n + 1);
 setNumber(n => n + 1);
 setNumber(n => n + 1);

这种方式称为更新函数,该函数的入参是队列中的前一个更新返回的 state ,并返回此次更新后的值,这样每一次都能拿到前一次更新的结果去做操作,就能达到 +3 的效果,整个队列的计算过程如下所示:

更新队列 n 返回值
n => n + 1 0 0 + 1 = 1
n => n + 1 1 1 + 1 = 2
n => n + 1 2 2 + 1 = 3

而类似的,其实可以把setState(x) 理解为等价于 setState(n => x)

这样,我们也对更新队列有了一个基本概念。通过 React 官方的例子能很好地理解队列的运作:

scss 复制代码
 const [number, setNumber] = useState(0);
 ​
 // onClick处理函数
 setNumber(number + 5);
 setNumber(n => n + 1);
 setNumber(42);
更新队列 n 返回值
n => 5 0(未使用) 5
n => n + 1 5 5 + 1 = 6
n => 42 6(未使用) 42

React 重新渲染时(调用 useState ),React 会进行更新队列的处理,因此:

更新函数必须是纯函数并且只能返回更新后的值,否则会在渲染期间带来不可预料的错误。可以想象一下,如果我们在更新函数中去执行一些副作用,由于实际的更新操作是在下一次调用 useState 时进行的,也就是下一次渲染的期间,而在渲染函数中执行副作用,很有可能会触发一些意料之外的更新。

引用类型的 state 更新

state 能够被设置为任意类型,但是对于对象、数组类型来说,不正确的操作很容易造成意料之外的错误。

我们首先应该建立一个基本原则:把所有存放在 state 中的 JavaScript 引用类型都视为只读的 。换句话说,我们应该避免对引用类型state 做突变( mutation ) 操作。

突变这个词来源于 JavaScript ,通过下面的例子能够很好理解什么是突变:

js 复制代码
 let obj = {
     wardens: 900,
     animals: 800
  }
 obj.animals = 90
 ​
 ​
 let arr = ['lion', 'dog', 'fish']
 arr[0] = 'bird'

对于 js 对象或者数组来说,这样的操作不会被禁止,而且是相当常见的操作。但当作为 React 的 state 时,mutation 操作会带来疑惑。我们可能会期望在更改了对象的某个值后,组件的视图会更新,但实际上并不会。因为 React 必须要通过对应的 setState 函数去触发重新渲染,突变操作不会触发组件的更新。

因此在对引用类型 state 做更新时,正确的做法是创建一个新的引用类型并调用对应 state 的 set 函数

js 复制代码
 const [person, setPerson] = useState({
   firstName: 'Barbara',
   lastName: 'Hepworth',
   email: 'bhepworth@sculpture.com'
 });
 ​
 function handleFirstNameChange(e) {
   setPerson({
     ...person,
     firstName: e.target.value
   });
 }

最佳实践

建议一定读一下React官方文档关于这部分的内容:

react.dev/learn/updat...

react.dev/learn/updat...

更新对象类型的 state

  • 展开语法:{ ...obj }(浅拷贝)
  • 深层嵌套对象更新,需要从你更新的位置开始自底向上为每一层都创建新的拷贝
  • 太深的嵌套应当考虑扁平化

更新数组类型的 state

  • 普通数组

    避免使用 (会改变原始数组) 推荐使用 (会返回一个新数组)
    添加元素 pushunshift concat[...arr] 展开语法
    删除元素 popshiftsplice filterslice
    替换元素 splicearr[i] = ... 赋值 map
    排序 reversesort 先将数组复制一份
    • 总之,应当使用能够返回新数组而不会改变原数组的方法去进行更新操作
  • 对象数组

    • 对需要更新的数组位置的对象,按照更新对象类型state的原则进行更新

反直觉的原因

很多响应式框架会利用 Proxy 来实现响应式更新,因此直接对原变量进行操作,也能够触发更新。但 React 并不是,因此必须调用对应的 setState 去触发组件更新。

React 对此的解释是:

React 并不依赖于 mutation ,所以你不需要对对象进行任何特殊操作。它不需要像很多"响应式"的解决方案一样去劫持对象的属性、总是用代理把对象包裹起来,或者在初始化时做其他工作。这也是为什么 React 允许你把任何对象存放在 state 中------不管对象有多大------而不会造成有任何额外的性能或正确性问题的原因。

但如果我们希望能实现类似 mutation 一样更简洁的写法,React 给出的建议是使用 use-immer。实现方式也是类似用 Proxy 对象包裹 state。

Reference

  1. Adding Interactivity - React
  2. Managing State - React

本人为入职半年前端小白一枚,还在持续学习中,文中有误的地方还请大佬们指点🧐

相关推荐
GISer_Jing8 小时前
React核心功能详解(一)
前端·react.js·前端框架
FØund40411 小时前
antd form.setFieldsValue问题总结
前端·react.js·typescript·html
疯狂的沙粒11 小时前
如何在 React 项目中应用 TypeScript?应该注意那些点?结合实际项目示例及代码进行讲解!
react.js·typescript
鑫宝Code12 小时前
【React】React Router:深入理解前端路由的工作原理
前端·react.js·前端框架
沉默璇年21 小时前
react中useMemo的使用场景
前端·react.js·前端框架
红绿鲤鱼1 天前
React-自定义Hook与逻辑共享
前端·react.js·前端框架
loey_ln1 天前
FIber + webWorker
javascript·react.js
zhenryx1 天前
前端-react(class组件和Hooks)
前端·react.js·前端框架
water1 天前
Nextjs系列——新版本路由的使用
前端·javascript·react.js
老码沉思录1 天前
React Native 全栈开发实战班 - 性能与调试之打包与发布
javascript·react native·react.js