React学习 --- setState 和 React更新机制

562 阅读9分钟

SetState

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

在React开发中我们并不能直接通过修改state的值来让界面发生更新

因为我们修改了state之后,希望React根据最新的State来重新渲染界面,但是这种方式的修改React并不知道数据发生了变化

React并没有实现类似于Vue2中的Object.defineProperty或者Vue3中的Proxy的方式来监听数据的变化

我们必须手动通过setState来告知React数据已经发生了变化,并需要重新渲染界面

setState的更新

setState是异步的操作,我们并不能在执行完setState之后立马拿到最新的state的结果

import { Component } from 'react'

export default class App extends Component {
  constructor() {
    super()

    this.state = {
      msg: 'Hello World'
    }
  }

  render() {
    return (
      <div>
        <h2>{ this.state.msg }</h2>
        <button onClick={() => this.changeMessage()}>change</button>
      </div>
    )
  }

  changeMessage() {
    this.setState({
      msg: 'Hello React'
    })

    console.log(this.state.msg) // => Hello World
  }
}

setState设置为异步的原因是:

  1. setState设计为异步,可以显著的提升性能
    • 如果每次调用 setState都进行一次更新,那么意味着render函数会被频繁调用,界面重新渲染,这样效率是很低的
    • 最好的办法应该是获取到多个更新,之后进行批量更新
  2. 如果同步更新了state,但是还没有执行render函数,那么state和props不能保持同步
    • state和props不能保持一致性,会在开发中产生很多的问题

如何可以获取到更新后的值

  1. 回调函数
    • setState接受两个参数:第二个参数是一个回调函数,这个回调函数会在更新后会执行
    • 格式如下:setState(partialState, callback)
this.setState({
      msg: 'Hello React'
    }, () => {
      console.log(this.state.msg)
    })
  1. 在生命周期函数ComponentDidUpdate中进行获取
componentDidUpdate() {
  console.log(this.state.msg)
}

componentDidUpdate中的代码一般会先于setState中的回调被执行

setState一定是异步吗

其实分成两种情况:

  1. 在组件生命周期或React合成事件中,setState是异步
  2. 在setTimeout/setInterval等定时器函数或者原生dom事件中,setState是同步
import { Component } from 'react'

export default class App extends Component {
  constructor() {
    super()

    this.state = {
      msg: 'Hello World'
    }
  }

  render() {
    return (
      <div>
        <h2>{ this.state.msg }</h2>
        <button onClick={() => this.changeMessage()}>change</button>
      </div>
    )
  }

  changeMessage() {
   setTimeout(() => {
    this.setState({
      msg: 'Hello React'
    })
    console.log(this.state.msg) // => Hello React
   }, 0)
  }
}
import { Component } from 'react'

export default class App extends Component {
  constructor() {
    super()

    this.state = {
      msg: 'Hello World'
    }
  }

  render() {
    return (
      <div>
        <h2>{ this.state.msg }</h2>
        <button id="btn">change</button>
      </div>
    )
  }

  componentDidMount() {
    document.getElementById('btn').addEventListener('click', () => {
      this.setState({
        msg: 'Hello React'
      })

      console.log(this.state.msg) // => Hello React
    })
  }

}

多次执行setState

setState操作不会合并

import { Component } from 'react'

export default class App extends Component {
  constructor() {
    super()

    this.state = {
      counter: 0
    }
  }

  render() {
    return (
      <div>
        <h2>{ this.state.counter }</h2>
        <button onClick={() => this.increment()}>+1</button>
      </div>
    )
  }

  increment() {
    // 等价于执行Object.assign({}, {counter: 0}, { counter: 1 })
    this.setState({
      counter: this.state.counter + 1
    })

    // 此时counter的值并没有更新完毕
    // 此时获取的this.state.counter的值依旧为0
    // 所以此时执行等价于Object.assign({}, {counter: 1}, { counter: 1 })
    this.setState({
      counter: this.state.counter + 1
    })

    // 等价于执行Object.assign({}, {counter: 1}, { counter: 1 })
    this.setState({
      counter: this.state.counter + 1
    })

    // => 最终this.state.counter的值为1
  }
}

setState操作会被合并

import { Component } from 'react'

export default class App extends Component {
  constructor() {
    super()

    this.state = {
      counter: 0
    }
  }

  render() {
    return (
      <div>
        <h2>{ this.state.counter }</h2>
        <button onClick={() => this.increment()}>+1</button>
      </div>
    )
  }

  increment() {
    // 当setState的参数是函数的时候。react在执行的时候,会使用call方法调用这个方法
    // 并将这个方法的返回值设置为下一个setState的preState
    // 本质是执行了const nextState = payload.call(instance, prevState, nextProps);
    this.setState(preState => {
      return ({
        counter: preState.counter + 1
      })
    })

    this.setState(preState => {
      return ({
        counter: preState.counter + 1
      })
    })

    this.setState(preState => {
      return ({
        counter: preState.counter + 1
      })
    })
    
     // => 最终this.state.counter的值为3
  }
}

React更新机制

React渲染流程

IvufKk.png

React更新流程

Ivu0Hy.png

React在props或state发生改变时,会调用React的render方法,会创建一颗不同的树

所以React需要基于这两颗不同的树之间的差别来判断如何有效的更新UI

如果一棵树参考另外一棵树进行完全比较更新,那么即使是最先进的算法,该算法的复杂程度为 O(n^3^),其中 n 是树中元 的数量

如果在React 使用了该算法,那么展示 1000 个元素所需要执行的计算量将在十亿的量级范围

这个开销太过昂贵了,React的更新性能会变得非常低效

所以React对这个算法进行了优化(简化),将其优化成了O(n)

  • 同层节点之间相互比较,不会跨节点比较
  • 不同类型的节点,产生不同的树结构
  • 开发中,可以通过key来指定哪些节点在不同的渲染下保持稳定

对比不同类型的元素

当节点为不同的元素,React会拆卸原有的树,并且建立起新的树

{/* 原始元素节点 */}
<div>
  <Cpn />
</div>

{/* 更新后的元素节点 */}
<span>
  <Cpn />
</span>

此时谁让Cpn组件是可以被复用的,但是实际上,react只会进行同层元素节点的比较

不会进行跨层比较,所以这里的Cpn并不会被复用

同一类型元素的比较

当比对两个相同类型的 React 元素时,React 会保留 DOM 节点,仅比对及更新有改变的属性

<div classname="foo">foo</div>

{/* 需要更为 */}

<div classname="baz">foo</div>

此时React只需要更新classname属性的值即可

随后React会继续使用同样的比较算法(Diff算法)对当前元素的子树进行比较

对子节点的递归比较

在默认条件下,当递归 DOM 节点的子元素时,React 会同 时遍历两个子元素的列表;当产生差异时,生成一个 mutation(突变,改变)

<ul>
  <li>first</li>
  <li>second</li>
</ul>

{/* 更新为 */}

<ul>
  <li>first</li>
  <li>second</li>
  <li>third</li>
</ul>

React同时对新旧子树进行遍历对比操作

此时是在最后插入一条数据的情况

前面两个比较是完全相同的,所以不会产生mutation

最后一个比较,产生一个mutation,所以需要进行相应的更新操作,也就是将其插入到新的 DOM树中即可

但是在最后插入数据是一种理想情况,有的时候是需要在中间插入一条数据,或者是在最后插入一条数据

如果依旧是使用默认的Diff算法就会出现一定的性能损失

<ul>
  <li>first</li>
  <li>second</li>
</ul>

{/* 更新为 */}

<ul>
  <li>third</li>
  <li>first</li>
  <li>second</li>
</ul>

上述例子中,只需要新建一个值为third节点,其余节点只需要进行位移操作即可

但是如果使用默认的diff算法,因为这三个节点都是不一致的,

所以React会对每一个子元素产生一个mutation,也就是这三个节点都会进行更新操作

为此,react要求我们在遍历列表的时候,为每一个子节点添加一个独一无二的的key

react可以通过该key对象节点进行识别,从而对可以复用的节点尽可能的复用

如果是在最后位置插入数据,那么key的作用其实不大

但是如果是在前面插入数据,那么如果存在key,react会尽可能的将可复用的元素进行相应的位移操作,而不是直接对其进行修改

key的注意事项

  • key应该是唯一的

  • key不要使用随机数或index,这对性能是没有优化的

    因为随机数在下一次render时,会重新生成一个数字

    而index在进行数据的增删后,后续节点的index的值也会发生改变

性能优化

SCU

React给我们提供了一个生命周期方法 shouldComponentUpdate(很多时候,我们简称为SCU),这个方法接受参数,并且需要有返回值

参数:

  • nextProps 修改之后,最新的props属性
  • nextState 修改之后,最新的state属性

返回值:

  • 该方法返回值是一个boolean类型

  • 返回值为true,那么就需要调用render方法

  • 返回值为false,那么就不需要调用render方法 (但是该方法是不会阻止初始的UI渲染,也就是初始的render调用是无法被阻止的)

  • 默认返回的是true,也就是只要state发生改变,就会调用render方法

import { Component } from 'react'

export default class App extends Component {
  constructor(props) {
    super(props)

    this.state = {
      message: 'Hello React', // UI不依赖这个数据,该数据发生改变的时候,不需要进行DOM DIFF,也就是不需要调用render方法
      counter: 0 // UI依赖该数据,该数据发生改变的时候,需要进行DOM DIFF,也就是调用render方法
    }
  }

  render() {
    // 初始的时候会被调用一次
    // 默认情况下,只要使用setState更新了数据,render方法就会被再次调用
    // 因为需要生成新旧虚拟DOM树进行diff,如果当前组件中使用了其它组件
    // 那么其它组件也会被一并重新执行render方法,进行新旧DOM对比
    console.log('render 被调用了')

    return (
      <div>
        <h2>{this.state.counter}</h2>
        <button onClick={() => this.increment()}>change counter</button>
        <button onClick={() => this.changeMessage()}>change message</button>
      </div>
    )
  }

  increment() {
    this.setState({
      counter: this.state.counter + 1
    })
  }

  changeMessage() {
    // 虽然UI中并不依赖于message
    // 但是默认只要监听到state的改变,就会重新render,所以最后render方法还是被重新调用了
    this.setState({
      message: 'Hello World'
    })
  }
}

但是在上述例子中,很明显的一点是,UI并不依赖于message对应值,所以即使使用setState更新message的值

也不应该重新执行render方法,此时就可以使用shouldComponentUpdate方法来阻止render方法的执行

shouldComponentUpdate(nextProps, nextState) {
  if (this.state.counter !== nextState.counter) {
    return true
  }

  return false
}

PureComponent

如果所有的类,我们都需要手动来实现 shouldComponentUpdate,那么会给我们开发者增加非常多的工作量

事实上React已经考虑到了这一点,我们只需要将class继承自PureComponent

在PureComponent中存在一个属性isPureComponent来标识这个类是PureComponent的子类

而在pureComponent的中,实现了默认的shouldComponentUpdate方法

!shallowEqual(oldProps, newProps) || !shallowEqual(oldState, newState)

会对新旧的props和新旧的state进行浅层比较,shallowEqual就是react中实现浅层比价的一个方法

如果一个组件是继承自pureComponent的时候,就不需要我们在手动实现SCU方法(如果实现了,会报警告)

如果我们需要使用SCU方法的话,需要将类继承自Component

memo

一个类是通过继承自PureComponent来实现默认的SCU

而函数组件则是通过使用memo函数来实现默认的SCU

memo其实是一个高阶组件,其接收一个组件作为参数

返回一个转换后的新组件,而这个新组件实现了默认的SCU

memo方法其实接收两个参数,第一个是需要包裹的函数组件

第二个参数是一个函数,是我们自定义的SCU函数的实现(一般不自己定义)

而默认的SCU函数的时候就是!shallowEqual(oldProps, newProps)

import { memo } from 'react'

const memoFoo = memo(function Foo() {
  return <h2>Foo</h2>
})

所以在原则上推荐每一个类组件都继承自pureComponent,而每一个函数组件都是要memo方法进行包裹