细读 React | setState

配图源自 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),该值仅最后一次有效。

八、参考

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,417评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,921评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,850评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,945评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,069评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,188评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,239评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,994评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,409评论 1 304
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,735评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,898评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,578评论 4 336
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,205评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,916评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,156评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,722评论 2 363
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,781评论 2 351

推荐阅读更多精彩内容