useState更新相同的State,函数组件为什么执行2次?

共 6836字,需浏览 14分钟

 ·

2021-05-22 23:22







这个问题实际很悬,大家可能平时没有注意到,引起我的注意的是掘金的一个掘友问我的一个问题,问题如下:



首先非常感谢这位细心的掘友的报案,我在
React-hooks 原理
中讲到过,对于更新组件的方法函数组件

useState

和类组件的

setState

有一定区别,

useState

源码中如果遇到两次相同的

state

,会默认阻止组件再更新,但是类组件中

setState

如果没有设置

PureComponent

,两次相同的

state

也会更新。


我们回顾一下

hooks

中是怎么样阻止组件更新的。



react-reconciler/src/ReactFiberHooks.js -> dispatchAction

if (is(eagerState, currentState)) { 
     return
}
scheduleUpdateOnFiber(fiber, expirationTime); // 调度更新


如果判断上一次的

state

->

currentState

,和这一次的

state

->

eagerState

相等,那么将直接

return

阻止组件进行

scheduleUpdate

调度更新。


所以我们想如果两次

useState

触发同样的state,那么组件只能更新一次才对,但是事实真的是这样吗?

。



顺着这位掘友提供的线索,我们开始写

demo

进行验证。

const Index = () => {
  const [ number , setNumber  ] = useState(0)
  console.log('组件渲染',number)
  return <div className="page" >
    <div className="content" >
       <span>{ number }</span><br/>
       <button onClick={ () => setNumber(1) } >将number设置成1</button><br/>
       <button onClick={ () => setNumber(2) } >将number设置成2</button><br/>
       <button onClick={ () => setNumber(3) } >将number设置成3</button>
    </div>
  </div>
}
export default class Home extends React.Component{
  render(){
    return <Index />
  }
}


如上demo,三个按钮,我们期望连续点击每一个按钮,组件都会仅此渲染一

次,于是我们开始实验:



效果:



果然,我们通过

setNumber

改变

number

,每次连续点击按钮,组件都会更新2次,按照我们正常的理解,每次赋予

number

相同的值,只会渲染一次才对,但是为什么执行了2次呢?


可能刚开始会陷入困境,不知道怎么破案,但是我们在想

hooks

原理中讲过,每一个函数组件用对应的函数组件的

fiber

对象去保存

hooks

信息。

所以我们只能从

fiber

找到线索。



那么如何找到函数组件对应的fiber对象呢,这就顺着函数组件的父级

Home

入手了,因为我们可以从类组件

Home

中找到对应的fiber对象,然后根据

child

指针找到函数组件

Index

对应的

fiber

。

说干就干,我们将上述代码改造成如下的样子:‍

‍

const Index = ({ consoleFiber }) => {
  const [ number , setNumber  ] = useState(0)
  useEffect(()=>{  
      console.log(number)
      consoleFiber() // 每次fiber更新后,打印 fiber 检测 fiber变化
  })
  return <div className="page" >
    <div className="content" >
       <span>{ number }</span><br/>
       <button onClick={ () => setNumber(1) } >将number设置成1</button><br/>
    </div>
  </div>
}
export default class Home extends React.Component{
  consoleChildrenFiber(){
     console.log(this._reactInternalFiber.child) /* 用来打印函数组件 Index 对应的fiber */
  }
  render(){
    return <Index consoleFiber={ this.consoleChildrenFiber.bind(this) }  />
  }
}

我们重点关心fiber上这几个属性



  • Index fiber

    上的

    memoizedState

    属性,

    react hooks

    原理文章中讲过,函数组件用

    memoizedState

    保存所有的

    hooks

    信息。



  • Index fiber

    上的

    alternate

    属性



  • Index fiber

    上的

    alternate

    属性上的

    memoizedState

    属性。

    是不是很绕😂,马上会揭晓是什么。


  • Index
    组件上的

    useState

    中的

    number

    。


首先我们讲一下

alternate

指针指的是什么?


说到

alternate

就要从

fiber

架构设计说起,每个

React

元素节点,用两颗fiber树保存状态,一颗树保存当前状态,一个树保存上一次的状态,两棵

fiber

树用

alternate

相互指向。

就是我们耳熟能详的
双缓冲

。


初始化打印

效果图:



初始化完成第一次render后,我们看一下fiber树上的这几个状态


第一次打印结果如下

  • fiber
    上的

    memoizedState

    中


    baseState = 0


    即是初始化

    useState

    的值。



  • fiber

    上的

    alternate

    为

    null

    。



  • Index

    组件上的

    number

    为 0。



初始化流程:

首先对于组件第一次初始化,会调和渲染形成一个fiber树(我们
简称为树A

)。

树A的

alternate

属性为

null

。

第一次点击 setNumber(1)

我们第一次点击发现组件渲染了,然后我们打印结果如下:




  • 树A上的

    memoizedState

    中 **

    baseState = 0

    。


  • 树A上的

    alternate

    指向 另外一个

    fiber

    (我们这里称之为树B)。



  • Index

    组件上的

    number

    为 1。



接下来我们打印树B上的

memoizedState



结果我们发现树B上

memoizedState

上的

baseState = 1

。


得出结论:

更新的状态都在树B上,而树A上的 baseState还是之前的0。


我们大胆猜测一下更新流程:

在第一次更新渲染的时候,由于树A中,不存在

alternate

,所以直接复制一份树A作为

workInProgress

(我们这里称之为
树B

)所有的更新都在当前树B中进行,所以 baseState 会被更新成 1,然后用当前的
树B

进行渲染。

结束后树A和树B通过

alternate

相互指向。

树B作为下一次操作的

current

树。

第二次点击 setNumber(1)

第二次打印,组件同样渲染了,然后我们打印fiber对象,效果如下:




  • fiber对象上的

    memoizedState

    中

    baseState

    更新成了 1。



然后我们打印一下

alternate

中

baseState

也更新成了 1。



第二次点击之后 ,树A和树B都更新到最新的 baseState = 1


首先我们分析一下流程:

当我们第二次点击时候,是通过上一次树A中的

baseState = 0

和

setNumber(1)

传入的 1做的比较。

所以发现

eagerState !== currentState

,组件又更新了一次。

接下来会以current树(树B)的

alternate

指向的树A作为新的

workInProgress

进行更新,此时的树A上的 baseState 终于更新成了 1 ,这就解释了为什么上述两个 baseState 都等于 1。

接下来组件渲染完成。

树A作为了新的 current 树。


在我们第二次打印,打印出来的实际是交替后树B,树A和树B就这样交替着作为最新状态用于渲染的

workInProgress

树和缓存上一次状态用于下一次渲染的

current

树。


第三次点击(三者言其多也)


那么第三次点击组件没有渲染,就很好解释了,第三次点击上一次树B中的

baseState = 1

和

setNumber(1)

相等,也就直接走了return逻辑。


通过这个容易忽略的案件,我们学习了双缓冲和更新机制。


双缓冲树:

React 用
workInProgress树(内存中构建的树) 和
current

(渲染树) 来实现更新逻辑。

我们console.log打印的fiber都是在内存中即将

workInProgress
的fiber树。

双缓存一个在内存中构建,在下一次渲染的时候,直接用缓存树做为下一次渲染树,上一次的渲染树又作为缓存树,这样可以防止只用一颗树更新状态的丢失的情况,又加快了

dom

节点的替换与更新。


更新机制:

在一次更新中,首先会获取current树的
alternate作为当前的
workInProgress
,渲染完毕后,workInProgress树变为current树。

我们用如上的树A和树B和已经保存的baseState模型,来更形象的解释了更新机制 。

hooks中的useState进行state对比,用的是缓存树上的state和当前最新的state。

所有就解释了为什么更新相同的state,函数组件执行2次了。



我们用一幅流程图来描述整个流程。


作者:我不是外星人

原文:https://juejin.cn/post/6963053793613185031


推荐阅读

  1. 两行 CSS 代码实现图片任意颜色赋色技术

  2. CSS变量对JS交互组件开发带来的提升与变革

  3. Vue scoped与深度选择器deep的原理

  4. Vue 动态组件按需加载

“在看转发”是最大的支持


浏览 183
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报