Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

细读 React | setState #115

Open
toFrankie opened this issue Feb 25, 2023 · 0 comments
Open

细读 React | setState #115

toFrankie opened this issue Feb 25, 2023 · 0 comments
Labels
2021 2021 年撰写 React 与 React 相关的文章

Comments

@toFrankie
Copy link
Owner

toFrankie commented Feb 25, 2023

配图源自 Freepik

今天来细聊一下 React 中的 setState()。当然,今时今日大家都可能使用 Functional Component + Hook 替代 Class Component 了吧。尽管如此,也不妨碍我们去探寻那些“过时”的设计。

那么,我们常用的 setState(),有什么鲜为人知的设计呢?

抛出几个问题:

  • setState() 是同步还是异步?
  • setState() 什么场景下立即更新,什么场景批量更新?

一、Props vs State

propsstate 都是普通的 JavaScript 对象。它们都是用来保存信息的,这些信息可以控制组件的渲染输出,而它们的一个重要的不同点就是:props 是传递给组件的(类似于函数的形参),而 state 是在组件内被组件自己管理的(类似于在一个函数内声明的变量)。

二、State 使用

读写组件状态,最简单的示例如下:

// 读取状态
const { count } = this.state
// 更新状态
this.setState = { count: xxx }
1. setState 简述

setState() 是更新用户界面的主要方式,它的作用是将对组件 state 的更改排入队列,并通知 React 需要使用更新后的 state 重新渲染此组件及其子组件。

需要注意的是,使用 setState() 更新状态可能是“异步”的,React 并不会保证 state 的变更会立即生效,因此使得在调用 setState() 后立即读取 this.state 成为了隐患。

举个例子:

class Counter extends React.Component {
  constructor(props) {
    super(props)
    this.state = { count: 0 }
    this.increment = this.increment.bind(this)
  }

  increment() {
    this.setState({ count: this.state.count + 1 })
    this.setState({ count: this.state.count + 1 })
    this.setState({ count: this.state.count + 1 })
    console.log(this.state.count) // 1️⃣
  }

  render() {
    return (
      <div>
        <button onClick={this.increment}>add</button>
        <div>count: {this.state.count}</div>
      </div>
    )
  }
}

假设 count0 开始,我们点击按钮触发 increment 事件处理函数,里面依次更新了三次 count 的状态,“直觉性”的结果应该是 count 更新到 3 且 1️⃣ 处打印结果为 3,这是不对的。事实是 count 只增加了 1,1️⃣ 处打印结果为 0,且 render() 只触发了一次。

为什么???

原因就是上面提到的。使用 setState() 更改状态,React 并不会立即更新组件,它会批量推迟更新。即在 increment() 方法里,四次读取 this.state.count 的值均为 0,即使再重复 N 次也一样,每次触发仅会在原来基础上增加 1

increment() {
  const curCount = this.state.count // 0
  this.setState({ count: curCount + 1 })
  this.setState({ count: curCount + 1 })
  this.setState({ count: curCount + 1 })
  // ...
  console.log(curCount) // 0
}
2. setState 语法

来看看 setState() 的语法,支持两种形式:

// 1️⃣ updater 接受函数类型
setState(updater[, callback])

// 2️⃣ stateChange 接受对象类型
setState(stateChange[, callback])
  • updater:如:(state, props) => stateChange,并返回一个对象。

    state 是对应用变化时组件状态的引用。props 则是当前组件的属性对象。但需要注意的是,尽管 updater 函数中接收的 stateprops 都保证为最新的,但此时组件状态还没改变(关于 this.state 值的更新,下一节详解)。

  • stateChange:接受对象类型,它会将传入的对象浅层合并到新的 state 中。这种形式也是异步的,在同一周期内会对多个 setState 进行批处理更新。

  • callback:第二个参数为可选的回调函数,它将在 setState 完成合并并并重新渲染组件后执行。通常建议使用 componentDidUpdate() 来代替此方式。

因此,

上述示例是 stateChange 对象形式,如下:

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

// setState 操作相当于
Object.assign(
  previousState,
  { count: previousState.count + 1 },
  { count: previousState.count + 1 },
  { count: previousState.count + 1 }
)

如果采用 updater 函数形式,如何得到我们“预期”结果,如下:

increment() {
  const incrementChange = state => ({ count: state.count + 1 })
  this.setState(incrementChange)
  this.setState(incrementChange)
  this.setState(incrementChange)
  console.log(this.state.count) // 需要注意的是,这里仍然是 0
}

这样的话,每触发一次 increment 事件处理函数,count 都能“预期”地增加 3,且只会触发一次 render() 方法。但由于此时 this.state 还没被改变,因此读取的值仍是原本的状态值 0

3. setState 其他用法

在批量更新时,React 总会按照定义顺序进行浅合并。比如:

handleState() {
  this.setState({ a: 1 })
  this.setState({ b: 2 })
  this.setState({ c: 3, a: '1' })
}

// React 会进行浅合并,对多个 setState 进行批量更新,相当于:
handleState() {
  this.setState({ a: '1', b: 2, c: 3 }) // 总是按顺序进行浅合并,因此 a 会被覆盖
}

再看个例子,updaterstateChange 两种形式混用,会产生什么结果?

increment() {
  // 将 this.setState({ count: this.state.count + 1 }) 插入以下 1️⃣ 2️⃣ 3️⃣  不同的位置,得到的结果有什么差异呢?
  const incrementChange = state => ({ count: state.count + 1 })
  // 1️⃣
  this.setState(incrementChange)
  this.setState(incrementChange)
  // 2️⃣
  this.setState(incrementChange)
  // 3️⃣
  // 请问最终 count 会加到几?
}

// 假设 count 初始状态为 0,触发一次 increment 处理函数后,count 最终的状态会是 4、2、1。

我们来分析下原因:

setState() 的作用是将 state 的更新排入队列,然后其接受不同的实参(即对象 stateChange 形式 和函数 updater 形式),从上面的定义中,我们可以得到以下的过程:

以 2️⃣ 为例,注意以下是伪代码,为了更好地理解罢了:

// 假设初始 count 为 0
increment() {
  const incrementChange = state => ({ count: state.count + 1 })
  this.setState(incrementChange)
  this.setState(incrementChange)
  this.setState({ count: this.state.count + 1 })
  this.setState(incrementChange)
}

// state 更新队列(伪代码)
const queue = {
  // ...
}

// 触发一次 increment() 之后,发生以下过程:
// 1. 执行第一个 setState,是函数形式的,它的 state 是应用变化时对组件状态的引用。
//    此时队列为空,state.count 取的值就当前组件的 count 值 0,并基于此加 1,然后放入队列中,即 queue.count 为 1;
// 2. 接着执行第二个 setState,同理。由于队列中存在 count 的引用,因此当前 count = queue.count + 1,
//    再放入队列中,即 queue.count 为 2;
// 3. 执行第三个 setState,由于是对象形式,会发生浅合并,
//    类似于:Object.assgin(queue, { count: this.state.count + 1 }) 的操作,
//    其中 this.state.count 为 0,因此浅合并的结果就是 { count: 1 },然后再存入队列,即 queue.count 为 1
// 4. 执行第四个 setState 同理,队列存在引用,并基于此增加再存入队列,所以 queue.count 为 2.
// 5. 所以最终结果为 2。

其他同理,只要按以上方式去分析的话,都能得到正确答案。若想更深入地了解,请看源码!

三、为什么要使用 setState 来更新 state ?

开头提到了,读取和更新状态的正确方式,应如下:

// 读取状态
const { count } = this.state
// 更新状态
this.setState = { count: xxx }

那么,这样更新状态可以吗?

// bad
this.state.count = xxx

答案是可以的,但不推荐。它不会触发 UI 的更新,因此是无意义的。它类似于 setStateshouldComponetUpdate() { return false } 的结合。

state 是由用户自定义的一个普通 JavaScript 对象而已,当然可以通过 state.xxx = xxx 去更改它,不就是 setter 嘛。但如果结合 React 设计 state 的初衷,我们不应该通过这种方式去更改某个状态的值。

相信大家都听过:

UI=f(State),状态即 UI。具体状态是如何映射用户界面的,这就由 React 去操心就好了。

还有,

请记住,如果某些值未用于渲染或数据流(传递给子孙组件),例如计时器 ID,则不必将其设置为 state。此类值可以在组件实例上定义。

四、state 更新时机

此前写了一篇文章 React 的生命周期都懂了吗? ,提到 Class Component 的生命周期分为 Mounting、Updating、Unmounting 三个阶段。而 setState() 带来的更新,则发生在 Updating 阶段。

state(或 props)发生变化时,会触发以下生命周期:

还包括 React 16.3 提供的 getDerivedStateFromProps() 全新 API。

shouldComponentUpdate()UNSAFE_componentWillUpdate() 被调用的时候,this.state 都未被更新。直到 render() 被调用的时候,this.state 才得到更新。

需要注意的是,当 shouldComponentUpdate() 返回 false 时,会导致本次更新被中断,自然不会调用 render() 了。但是 React 也不会放弃掉对 this.state 的更新(可通过定时器去观察)。这种情况,像 this.state.xxx = xxx 这种方式去更改 this.state 值,但不会触发组件的重新渲染。

因此,可以简单的认为:当调用 setState() 方法对组件状态进行更新时,直到下一次 render() 被调用(或 shouldComponentUpdate() 返回 false)之后,this.state 才得到更新。且 setState 的第二个参数,也是在此时才会被执行,也正是如此,此时 this.state 是最新值(预期值)。

五、setState 批量更新

上面提到,在同一时期内多次进行 setState 操作,会被 React 批量更新,它们会被浅合并,只会触发一次重新渲染。我们所说的 setState 可能是“异步”的,就是因为批量更新机制,使得看起来像“异步”而已,并非真正的异步。

不同版本下,批量更新策略会稍有不同。以下为 Dan 神(React 核心开发)在 Stack Overflow 某贴的原话:

Currently (React 16 and earlier), only updates inside React event handlers are batched by default. There is an unstable API to force batching outside of event handlers for rare cases when you need it.

In future versions (probably React 17 and later), React will batch all updates by default so you won't have to think about this. As always, we will announce any changes about this on the React blog and in the release notes.

翻译过来,大致意思是:在 React 16(或更早)版本,默认情况下只会在 React 事件处理程序中进行批量更新。在未来(可能是 React 17),默认情况下会批处理所有的更新。

总结:

setState() 只在 React 合成事件生命周期函数中是“异步”的,而在原生事件setTimeoutsetInterval 以及网络响应中都是同步的。

举个例子,如下:

// base on React 16.12.0
componentDidMount() {
  fetch('http://192.168.1.102:7701/config')
    .then(() => {
      this.setState({ count: this.state.count + 1 })
      this.setState({ count: this.state.count + 1 })
      this.setState({ count: this.state.count + 1 })
      console.log(this.state.count) // 3
    })
}

我们在 Fetch 或者 XHR 请求的响应处理程序中,使用 setState() 来更新状态,这时每个 setState() 都会立即处理,因此 count 增加了 3 次,即 render() 也触发了 3 次,同时打印结果也为 3

其实针对上述情况,React 也提供了一个 “unstable” 的 API ReactDOM.unstable_batchedUpdates() 进行批量更新。“不稳定”是因为它会在默认启用批量更新后被移除。关于此 API 的使用就不展开了,请看原贴。(PS:基于 React 17.0.2 亲测,如上响应处理程序,仍未启用批量更新)

原生事件和 React 合成事件区别:

class Counter extends React.Component {
  constructor(props) {
    super(props)
    this.state = { count: 0 }
    this.increment = this.increment.bind(this)
    this.refElem = React.createRef()
  }

  componentDidMount() {
    this.refElem.current.addEventListener('click', this.increment, false)
  }

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

  render() {
    return (
      <div>
        {/* React 合成事件 */}
        <button onClick={this.increment}>react add</button>
        {/* 原生事件 */}
        <button ref={this.refElem}>dom add</button>
        <div>count: {this.state.count}</div>
      </div>
    )
  }
}

运行可知,每次触发合成事件 count 会增加 1,而触发原生事件 count 会增加 3

六、setState 是如何实现“异步”的?

随着 React 的不断更新,不同版本的方法,及其逻辑都略有不同,例如旧版是通过 isBatchingUpdates 来判断是否批量更新的,然后新版又变成了 isBatchingEventUpdates(可看源码)。

详看文章:

七、结论

  • setState 只在 React 合成事件生命周期函数中是“异步”的,而在原生事件setTimeoutsetInterval 以及网络响应中都是同步的。

  • setState 的“异步”并不是内部由异步代码实现,其内部本身执行过程和代码都是同步的,只是合成事件和生命周期函数的调用顺序在更新之前,导致在合成事件和生命周期函数中无法立刻拿到更新后的值,形成了所谓的“异步”,当然可以通过第二个参数 callback 回调函数中拿到更新后的结果(该回调函数在组件更新后触发,因此也可在 ComponentDidUpdate 中获取更新后的值)。

  • setState 的批量更新优化也是建立在“异步”(合成事件、生命周期函数)之上的,在原生事件和 setTimeout 中不会批量更新,在批量更新中,如果进行多次 setState,批量更新策略会形成浅合并的效果,若有相同的值(Key),该值仅最后一次有效。

八、参考

@toFrankie toFrankie added the React 与 React 相关的文章 label Feb 25, 2023
@toFrankie toFrankie added the 2021 2021 年撰写 label Apr 26, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
2021 2021 年撰写 React 与 React 相关的文章
Projects
None yet
Development

No branches or pull requests

1 participant