【实用!】聊聊React组件状态设计,一定能帮你避坑~

前言 react函数组件写法十分灵活,数据传递也非常方便,但如果对react的理解不够深入,就会遇到很多问题,比如数据变了视图没变,父组件状态变了子组件状态没有及时更新等等,对于复杂的组件来说,可能产生的问题会更多,混乱的代码也更容易出现。
随着自己踩的坑多了,就越来越意识到数据状态的合理设计对于React组件的重要性,大部分常见问题都是由于数据传递和修改混乱导致的。
依照开发经验和官方文档,我对react组件状态设计做了一些些总结,希望能帮大家理理思路。
组件的数据与状态 在聊组件状态设计之前,先聊聊组件的状态与数据,因为重点是组件状态设计,对部分前置知识只会做简单介绍,如果想详细了解,我在文中也有推荐文章,感兴趣的同学可以自己浏览哈。
组件中的数据状态来源有statepropsstate由组件自身维护的,可以调用setState进行修改。而props是外部传入的,是只读的,要想修改只能由props传入修改的方法。
组件的state
当我们调用setState修改state时,会触发组件的重新渲染,同步数据和视图。在这个过程中,我们可以思考几个问题:

  1. 数据是怎样同步到视图的?
  2. state的状态是怎样保存的?
  3. why重渲染后state没有被重置为初始值?
一、数据是怎样同步到视图的? 先了解一下fiber 在react16之前,react的虚拟dom树是树结构的,算法以深度优先原则,递归遍历这棵树,找出变化了的节点,针对变化的部分操作原生dom。因为是递归遍历,缺点就是这个过程同步不可中断,并且由于js是单线程的,大量的逻辑处理会占用主线程过久,浏览器没有时间进行重绘重排,就会有渲染卡顿的问题。
React16出现之后优化了框架,推出了时间片与任务调度的机制,js逻辑处理只占用规定的时间,当时间结束后,不管逻辑有没有处理完,都要把主线程的控制权交还给浏览器渲染进程,进行重绘和重排。
而异步可中断的更新需要一定的数据结构在内存中来保存工作单元的信息,这个数据结构就是Fiber。[1] (引用文章链接在文末,推荐)
fiber树以链表结构保存了元素节点的信息,每个fiber节点保存了足够的信息,树对比过程可以被中断,当前的fiber树为current fiber,在renderer阶段生成的fiber树称为workInProgress fiber,两棵树之间对应的节点由alternate指针相连,react会diff对比这两颗树,最终再进行节点的复用、增加、删除和移动。
调用setState之后发生了什么?
  1. 首先生成调用函数生成一个更新对象,这个更新对象带有任务的优先级、fiber实例等。
  2. 再把这个对象放入更新队列中,等待协调。
  3. react会以优先级高低先后调用方法,创建Fiber树以及生成副作用列表。
  4. 在这个阶段会先判断主线程是否有时间,有的话先生成workInProgress tree并遍历之。
  5. 之后进入调教阶段,将workInProgress treecurrent Fiber对比,并操作更新真实dom。
二、state的状态是怎样保存的? 在fiber节点上,保存了memoizedState,即当前组件的hooks按照执行顺序形成的链表,这个链表上存着hooks的信息,每种类型的hooks值并不相同,对于useState而言,值为当前state
(小贴士: 深入理解react hooks原理,推荐阅读《React Hooks 原理》)
函数组件每次state变化重渲染,都是新的函数,拥有自身唯一不变的state值,即memoizedState上保存的对应的state值。(capture value特性)。
这也是为什么明明已经setState却拿不到最新的state的原因,渲染发生在state更新之前,所以state是当次函数执行时的值,可以通过setState的回调或ref的特性来解决这个问题。
二、why重渲染后state没有被重置为初始值? 为什么组件都重渲染了,数据不会重新初始化?
可以先从业务上理解,比如两个select组件,初始值都是未选中,select_A选中选项后,select_B再选中,select_A不会重置为未选,只有刷新页面组件重载时,数据状态才会初始化为未选。
知道state状态是怎样保存的之后,其实就很好理解了。
来划重点了——重渲染≠重载,组件并没有被卸载,state值仍然存在在fiber节点中。并且useState只会在组件首次加载时初始化state的值。
常有小伙伴遇到组件没正常更新的场景就纳闷,父组件重渲染子组件也会重渲染,但为什么子组件的状态值不更新?就是因为rerender只是rerender,不是重载,你不人为更新它的state,它怎么会重置/更新呢?
ps:面对有些非受控组件不更新状态的情况,我们可以通过改变组件的key值,使之重载来解决。
组件的props
当组件的props变化时,也会发生重渲染,同时其子组件也会重渲染。
【实用!】聊聊React组件状态设计,一定能帮你避坑~
文章图片

[图1-1]
如图1-1所示,组件A的stateprops.data作为子组件1的props传入,当组件A的props发生变化时,子组件1的props也发生变化,会重渲染,然而子组件2是非受控组件,父组件A重渲染后它也会重渲染,然而数据状态没有变化的它,本来不需要重新渲染的,这就造成了浪费。针对这样不需要重复渲染的组件或状态,优化组件的方式也有很多,比如官方提供的React.Memo,pureComponent,useMemo,useCallback等等。
react组件状态设计 数据与视图互相影响,复杂组件中往往有props也有state,怎样规定组件使用的数据应该是组件自身状态,还是由外部传入props,怎样规划组件的状态,是编写优雅的代码的关键。
如何设计数据类型?props?state?常量?
先来看react官方文档中的段落:
通过问自己以下三个问题,你可以逐个检查相应数据是否属于 state:
  1. 该数据是否是由父组件通过 props 传递而来的?如果是,那它应该不是 state。
  2. 你能否根据其他 state 或 props 计算出该数据的值?如果是,那它也不是 state。
  3. 该数据是否随时间的推移而保持不变?如果是,那它应该也不是 state。[2]
我们逐个把这些规则用代码具象化,聊聊不遵循规则写出的代码可能会产生的陷阱。
1.该数据是否是由父组件通过 props 传递而来的?如果是,那它应该不是 state。
// 组件Test import React, {useState} from 'React'export default (props: { something: string // *是父组件维护的状态,会被修改 }) => {const [stateOne, setStateOne] = useState(props.something)return ( <> {stateOne} ) }

这段代码中useState的使用方式估计是很多新手小伙伴会写出来的,把something作为props传入,又重新将something作为初始值赋给了组件Test的状态stateOne
这么做会存在什么问题?
  • 前文我们说过,props变化会引发组件及其子组件的重渲染,但是,重渲染不等于重载,useState的初始值只有在组件首次加载的时候才会赋给state,重渲染时,是不能重新赋值的,并且当前fiber树上仍然保存了hooks的数据,即当前的state状态值, 所以无论props.something怎么改变,页面上展示的stateOne值不会随之改变,一直是组件Test当前stateOne的值。
也就是说something从受控变成失控了。违背了我们传值的本意。
那有什么解决办法呢?——可以在组件Test里,通过useEffect监听props.something的变化,重新setState
// 组件Test import React, {useState} from 'React'export default (props: { something: string }) => {const [stateOne, setStateOne] = useState()useEffect(() => { setStateOne(props.something) // 如果没有别的副作用,加一层state是不是看起来很多余 }, [props.something])return ( <> {stateOne} ) }

可能有小伙伴会说,“我不是拿了props的值直接用呀,props的数据需要做一些修改后再使用,这不是需要一个中间变量去接收吗?用state难道不对吗?”
这里我们引入第二个规则 —— “你能否根据其他 state 或 props 计算出该数据的值?如果是,那它也不是 state。”
2.你能否根据其他 state 或 props 计算出该数据的值?如果是,那它也不是 state。 state相较props最大的不同,就是state是可修改的,props是只读的,所以我们想要对props的数据做些修改后再使用的时候,可能自然而然会想到用state作为中间变量去缓存,但是,这种场景使用useState却显得大材小用了,因为只在props变化的时候,你需要用到setState去重置state值,没有其他操作需要setState,这个时候我们就不需要用到state
所以这个场景可以直接使用变量去接收数据重新计算后的结果,或者,更好的办法是使用useMemo,用新的变量去接收props.something计算后的值。
// 组件Test import React, {useState} from 'React'export default (props: { something: number[] }) => { // 方式一 每次重渲染都会重新计算 // const newSome = props.something.map((num) => (num + 1))// 方式二 props.something变化时会重新计算 const newSome = useMemo(() = { return props.something.map((num) => (num + 1)) }, [props.something])return ( <>{newSome.map((num) => num)} ) }

还有一种情况,props传递的数据作为单纯的常量,而非父组件维护的状态,也就是说不会再次更新,子组件渲染需要这些数据,并且会操作这些数据,这时候是可以用state去接收的。
// 组件Test import React, {useState} from 'React'export default (props: { something: string // 在父组件中表现为不会改变的常量 }) => {const [stateOne, setStateOne] = useState()return ( <> {stateOne} ) }

还有一种更复杂一些的情况,子组件需要父组件的状态A,根据A进行数据的重组,并且又需要改动这些新的数据,父组件的对状态A也有它自己的作用,不能直接被子组件改变为子组件需要的数据。这种情况也是可以用state去接收的,因为子组件是需要去修改state的,并不是仅仅依赖props的值得到新的值。
// 父组件 export default (props) => {const [staffList, setStaffList] = useState([])// 异步请求后setStaffList(请求结果)return ({/* {staffList相关的展示} */} ) }// 子组件 const Comp = ({staffList}) => {const [list, setList] = useState(staffList)useEffect(() => { const newStaffList = staffList.map((item) => ({ ...item, isShow: true })) setList() }, [staffList])const onHide = useCallBack((index) => { // ... 为 克隆list隐藏下标为index项后的数据 setList(...) }, []) // 写的时候别忘记填入依赖return ({ list.map((staff, index) => ( staff.isShow &&onHide(index)}>{staff.name} )) }) }

3.该数据是否随时间的推移而保持不变?如果是,那它应该也不是 state。 这条就非常好理解了,随时间的推移而保持不变,就是指从组件加载的时候起,到组件卸载,都是一样的值,对于这样的数据,我们用一个常量去声明就好了,放在组件外组件内都问题不大,组件内用useMemo包裹。
// 组件Test import React, {useState} from 'React'const writer = '蔚蓝C'export default () => {// const writer = useMemo(() => '蔚蓝C', [])return ( <>{writer} ) }

补充一点,用react的小伙伴应该多少对受控组件这个概念有了解(不了解的快去看文档),从我的理解简而言之就是,当组件中有数据受父级组件的控制(数据的来源和修改的方式都由父级组件提供,作为props传入),就是受控组件,反之当组件的数据完全由自身维护,父级组件即没有提供数据也影响不了数据的变化,这样的组件是非受控组件。
这是一个非常好的概念,我觉得从理解上来说,“受控对象”的颗粒度可以细分到单个变量会更好理解,因为复杂组件的状态类型往往不止一种,有从父级传递的也有自身维护的。有时候思考组件状态的时候,往往脑子里会思考这个状态是否应该受控。
react的数据传递是单向的,即从上至下传递,相对父子组件来说,只有子组件可以是受控的,子组件需要修改父组件的数据,一定是父组件提供给它修改数据的方法。
顺便推荐一个可以让父组件和子组件都可以控制同一个状态的hooks,阿里的hooks库——ahooks,里面的useControllableValue
状态应该放在哪一级组件?组件自身?父组件?祖先组件?
关于state状态应该放在哪一级组件,在react官方文档中,有下面两段话:
在 React 中,将多个组件中需要共享的 state 向上移动到它们的最近共同父组件中,便可实现共享 state。这就是所谓的“状态提升”。[3]
对于应用中的每一个 state:
  • 找到根据这个 state 进行渲染的所有组件。
  • 找到他们的共同所有者(common owner)组件(在组件层级上高于所有需要该 state 的组件)。
  • 该共同所有者组件或者比它层级更高的组件应该拥有该 state。
  • 如果你找不到一个合适的位置来存放该 state,就可以直接创建一个新的组件来存放该 state,并将这一新组件置于高于共同所有者组件层级的位置。[2]
描述非常详细,也很好理解。因为react数据流向是单向的,兄弟组件的通信一定是要借助父组件作为“中间层”,当兄弟组件需要用到同一个状态时,比起各自维护状态再通过父级互相通知这样,麻烦切舍近求远的方法当然是把组件共同状态提到最近的共同父组件中,由父组件去管理状态。
react的Context,就是层级较多的复杂组件的状态管理方案,把状态提到最顶层,使每一级组件都能获取到顶层传递的数据和方法。
关于这个小标题,强烈推荐阅读React官方文档-React哲学,非常新手友好,也适合复习和整理思路,我就不再赘述啦。
后记 以前我看同组大佬同事的代码,总是产生我好菜的感觉(现在也是,手动狗头),觉得我怎么就没想到代码要这么规划呢。但其实写代码的时候我们往往不能一步到位,都是边写边思考,边随着需求改进的,比如一个复杂的组件初期,是一个简单的组件,状态的传递可能也就一两层,随着封装的组件增多,层级增多,我们又自然而然会考虑使用Context... 即便是大佬也是一样哒。
文章中很多坑都是我自己亲身踩过的,我自己也有做笔记,这次整理的内容,其实重点还是在讲述react函数组件的状态管理,怎样写代码是较为规范的,能一定程度避免性能的浪费,避免可能的隐患,像是一个请求重复执行了两次、数据交互弄得极其复杂、难以维护之类的。
都是经验的分享,如果觉得有帮助的话,希望大家看完多点赞收藏转发~
that's all,thank u~ 下篇文章见~
引用
【【实用!】聊聊React组件状态设计,一定能帮你避坑~】[1]react源码解析7.Fiber架构
[2]React官方文档-React哲学
[2]React官方文档-React状态提升

    推荐阅读