React 性能优化之 setState

React 组件状态

React 把组件看成是一个状态机(State Machines)。通过与用户的交互,实现不同状态,然后渲染 UI,让用户界面和数据保持一致。

setState() 则是 React 组件状态更新的入口,调用 setState() 会对一个组件的 state 对象安排一次更新。当 state 改变了,该组件就会重新渲染。

一次组件更新的过程其实很复杂,包括 React 生命周期钩子的执行、虚拟DOM 的创建、diff 对比、真实DOM 的创建 等等。

setState 批量/合并 更新

那么问题来了,是不是每次调用 setState() 都会触发组件重新渲染呢?

如果不确定的话,我们就来做个试验验证一下。由于 React 组件每次渲染都会调用 componentDidUpdate 生命周期方法,我们可以在这个方法中打个日志:

export default class App extends Component {
  constructor (p) {
    super(p)
    this.state = {
      name: "peak",
      age: 10
    }
  }

  componentDidUpdate() {
    // 组件更新时触发
    console.log("组件更新")
  }

  handleClick = () => {
    this.setState({
      name: "peak1"
    })
    this.setState({
      age: 11
    })
  }

  render () {
    return (
      <View>
        <Text>姓名:{this.state.name},年龄:{this.state.age}</Text>
        <TouchableOpacity onPress={this.handleClick}>
          <Text>更新组件</Text>
        </TouchableOpacity>
      </View>
    )
  }
}

可以发现点击按钮触发 setState() 时,componentDidUpdate 只走了一次,也就是说,并不是每次调用 setState() 都会触发组件重新渲染。

在这个案例中,多个 setState() 被合并成了一次更新,这就是 setState() 的批量更新,或者称为 合并更新。

setState() 的合并更新还有另一种表达方式,就是我们常说的 异步,异步的 setState() 表现为:调用 setState() 之后无法立刻获取到最新的 this.state。通过下面的日志可以直观的发现这一点:

handleClick = () => {
    this.setState({
      name: "peak1"
    })
    console.log("name=",this.state.name) // 打印:peak
    this.setState({
      age: 11
    })
    console.log("age=",this.state.age) // 打印:10
}

What?还有同步的 setState?

实际上合并更新是 React 的一种优化策略,目的在于避免频繁的触发组件重新渲染,但是这个优化是有条件的,并不是所有的 setState() 都能被合并。

下面是 setState 的伪代码:

setState(newState) {
    if (this. isBatchingUpdates) {
        this.updateQueue.push(newState)
        return 
    }

    // 下面是真正的更新: 修改 this.state,dom-diff, lifeCycle...
    ...
}

setState 会通过一个变量来判断当前状态变更是否能够被合并,如果可以合并,就会将本次更新缓存起来,等到后面来一次性更新;如果不可以合并,就会立即更新组件。

意思就是,当 isBatchingUpdates 为 false 时,setState() 会立即触发组件渲染,同时 this.state 的值也会相应的变化,我们能够立即拿到最新的 this.state 值。此时的 setState() 表现并非是 异步,而是 同步 的。

从这里可以看出,setState(x) 并不等于 this.state = x。修改 this.state 的时机被 React 封装了一层,只有当真正去渲染组件的时候 this.state 的值才会变化。这就造成了我们看到的 同步异步 的现象。

有的人可能会说,同步 很好啊,我能够立即获取到最新的 this.state 值,很直观。有这种想法的人忽略了一个重要的问题,就是 在同步场景中,每次调用 setState() 变更状态都会触发组件重新渲染,导致性能下降。 正因为如此,所以 React 才引入合并更新来避免组件频繁的重新渲染。

那么问题又来了,既然 同步 更新会导致性能下降,那为什么 React 不直接全都用 异步 呢,这样就能合并更新了。为了找到答案,我们接着往下看。

setState 什么时候是同步,什么时候是异步?

React 的更新是基于 Transaction(事务)的,Transacation 就是给目标函数包裹一下,加上前置和后置的 hook,在开始执行之前先执行 initialize hook,结束之后再执行 close hook,这样搭配上 isBatchingUpdates 这样的布尔标志位就可以实现目标函数调用栈内的多次 setState() 全部入 pending 队列,结束后统一 apply 了。

这里的 目标函数 指的是 React 控制的函数,这样的函数主要有两类:React 合成事件生命周期钩子; 而 setTimeout 这样的异步方法是脱离事务的,React 管控不到,所以就没法对其中的 setState() 进行合并了。

我们结合下面的 Demo 来具体分析一下:

class App extends React.Component {
    handleClick = () => {
        this.setState({x: 1})
        this.setState({x: 2})
        this.setState({x: 3})

        setTimeout(() => {
            this.setState({x: 4})
            this.setState({x: 5})
            this.setState({x: 6})
        }, 0)
    }   

    render() {
        return (<View>
          <TouchableOpacity onPress={this.handleClick}>
              <Text>更新组件</Text>
          </TouchableOpacity>
        </View>
    }
}

1、handleClick 是 React 合成事件的回调,React 有控制权,在开始执行该函数的时候会将 isBatchingUpdates 设置为 true,所以 x 为 1、2、3 是合并的;
2、开始执行 setTimeout,这里会将 setTimeout 的回调函数加入了事件循环的宏任务中,等待主线程完成所有任务后来进行调度;
3、handleClick 结束之后 isBatchingUpdates 被重新设置为 false;
4、此时主线程的函数已出栈,开始执行 setTimeout 的回调函数,由于 isBatchingUpdates 的值已经变为了 false,所以 x 为 4、5、6 没有被合并更新,每一次的 setState() 都是同步执行的;
5、总共触发了 4 次组件渲染,其中有 2 次是冗余的。

总结为如下:
  • 由 React 控制的事件处理程序、生命周期钩子中的 setState() 是异步的;
  • React 控制之外的事件中调用 setState() 是同步的。比如网络请求、setTimeout、setInterval、Promise 等;
  • setState() 的 “异步” 并不是说内部由异步代码实现,其实本身执行的过程和代码都是同步的,只是合成事件和钩子函数的调用顺序在更新之前,导致在合成事件和钩子函数中没法立马拿到更新后的值,形成了所谓的 “异步”。

由此可以看出 React 对于 setState() 的同步更新其实是迫于无奈,是 React 无法控制的。React 当然想目标函数中的 setState() 都是异步更新的,这样性能也是最好的,能够避免组件频繁的更新渲染,但是条件不允许,React 办不到。

那我们能不能在写代码的时候规避同步的 setState() 调用呢?这是不可能的,除非你的程序非常简单且不需要跟后台进行通信,只要你的程序要请求网络接口,那么就会产生同步的 setState() 调用。那难道就没有办法对同步的 setState() 进行优化,让其合并更新吗?

setState 手动合并(同步转异步)

React 合成事件、生命周期钩子 都在 React 的控制范围内,所以它能够将他们自动加入 React 事务中,让其中的 setState() 合并更新。对于 React 无法控制的目标函数,React 其实也有提供手动加入事务的 API,就是 unstable_batchedUpdates

我们将上面 setTimeout 中的代码做一下调整:

class App extends React.Component {
    handleClick = () => {
        this.setState({x: 1})
        this.setState({x: 2})
        this.setState({x: 3})

        setTimeout(() => {
            // 手动将目标函数加入 React 事务中,让其合并更新
            unstable_batchedUpdates(() => {
              this.setState({x: 4})
              this.setState({x: 5})
              this.setState({x: 6})
            })
        }, 0)
    }   

    render() {
        return (<View>
          <TouchableOpacity onPress={this.handleClick}>
              <Text>更新组件</Text>
          </TouchableOpacity>
        </View>
    }
}

x 为 1、2、3 在一个可控的目标函数中,是合并更新的;而 x 为 4、5、6 使用了 unstable_batchedUpdates 加入事务,也是合并更新的。总共有 2 次更新,相较于之前的 4 次减少了 2 次。

unstable_batchedUpdates API 的原理如下:

function unstable_batchedUpdates(fn) {
    this.isBatchingUpdates = true
    fn()
    this.isBatchingUpdates = false
    const finalState = ...  //通过this.updateQueue合并出finalState
    this.setState(finaleState)
}

这个 API 在 React 和 React Native 中的引入方式有所不同:

  • react 中通过 react-dom 进行引入
import { unstable_batchedUpdates } from "react-dom";
  • react-native 中则直接从 react-native 库中引入
import { unstable_batchedUpdates } from "react-native";

React 的这个 API 确实能够将同步的setState() 转换为异步来进行合并更新,避免组件频繁渲染。

但是根据其前缀 unstable 也可以看出来,这个 API 不是稳定的。实际上这是 React 实验性的 API 之一,并没有全力推给到开发者去使用,所以如果不是特别影响性能,可以不用强制用这个 API 去合并 setState()

setState 的隐藏 API

我们在使用 setState 时用的最多就是给它传一个对象,像下面这样:

this.setState({count: 1})
如果 setState 中的 count 需要依赖之前的值,你会怎么处理:

1、第一种方法:使用 setState 的第二个参数

this.setState({ count: this.state.count + 1 }, () => {
    // 依赖当前 count 的值
    this.setState({ count: this.state.count + 1 })
})

setState() 的第二个参数接收一个函数,这个函数会在当前 setState() 更新完组件之后触发。这种写法有两个缺陷:

  • 破坏了 React 合并更新的优化,会导致组件渲染两次;
  • 同时这种写法会导致嵌套太深,很不美观。

2、第二种方法:将 setState 转为同步执行

setTimeout(() => {
  this.setState({ count: this.state. count + 1 })
  this.setState({ count: this.state. count + 1 })
})

通过 setTimeout 能够将 setState() 转为同步代码,这样就能够立即获取到最新的 this.state 值。这个方法不存在嵌套,但是和上面一样,会导致组件渲染两次。

3、终极方法:使用函数式的 setState
setState 其实有一个隐藏 API,第一个参数除了能够接收对象之外,还能够接收一个函数。这个函数接收先前的 state 作为参数,同时返回本次需要变更的 state,如下:

this.setState((state) => {
  return { count: state.count + 1 }
})
this.setState((state) => {
  return { count: state.count + 1 }
})

函数式的 setState() 能够保证第一个函数参数中的 state 是合并了之前所有状态的,这样后面的函数就能拿到前面函数执行的结果。但是这个过程中并不会改变 this.state 的值,意思就是会等函数执行完后才去进行渲染更新,所以组件只会渲染一次,没有破坏 React 合并更新的优化。

在同一个目标函数中不要混用函数式和对象式这两种API
// 1
this.setState((state) => {
  return { count: state.count + 1 }
})
// 2
this.setState({ count: this.state.count + 1 })
// 3
this.setState((state) => {
  return { count: state.count + 1 }
})

1、假设一开始的 state.count 为 10
2、第一次执行函数式 setState,count 为 11
3、第二次执行对象式 setStatethis.state 仍然是没有更新的状态,所以 this.state.count 还是 10,加 1 以后又变回了 11
4、最后再执行函数式 setState,回调函数中的 state.count 的值是第二步中的到的 11,这里再加 1,所以最终 count 的结果是 12。

可以发现第二个对象式 setState 将第一个函数式设置的 count 抹掉了,正确的做法是都调整为函数式的 setState,不然可能就会造成上面的问题。所以要避免函数式和对象式的 setState 混用,不然自己可能都会搞迷糊。

总结

在使用 React 作为开发框架的项目中,setState() 应该是我们接触使用最多的 API,大家都习以为常的认为 setState() 是异步更新的,实际上有很多同步更新的场景被大家所忽略,从而忽视了对于 setState 也能进行性能优化的场景。

文章提到的 setState 性能优化主要包含两方面:

  • 适时地考虑使用 unstable_batchedUpdates 来手动合并更新,解决 React 无法自动合并更新的场景。由于这个 API 不稳定,所以未来可能会失效,但目前在 RN 0.64.2 及之前的版本中验证还是可以使用的,暂时可以不用担心;
  • 使用函数式的 setState() 来更新那些依赖于当前的 state 的 state。

本文为原创,转载请注明出处

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

推荐阅读更多精彩内容

  • React 为高性能应用设计提供了许多优化方案,本文列举了其中的一些最佳实践。 在以下场景中,父组件和子组件通常会...
    Maco_wang阅读 1,096评论 0 7
  • 什么样的app才是一个优秀的app呢? 安装包的体积小 启动速度快 使用流畅、不卡顿 用户交互友好 报错或者闪退次...
    林锐涛阅读 4,703评论 1 13
  • 一.JSX的优点 1.书写简单 以html的方式书写代码 2.直接在jsx标签上注册事件 3.可以使用大括号语法 ...
    糖醋鱼_阅读 916评论 0 10
  • setState的同步和异步 1.为什么使用setState 开发中我们并不能直接通过修改state的值来让界面发...
    wenzi8705_GG阅读 363评论 0 2
  • 我是黑夜里大雨纷飞的人啊 1 “又到一年六月,有人笑有人哭,有人欢乐有人忧愁,有人惊喜有人失落,有的觉得收获满满有...
    陌忘宇阅读 8,523评论 28 53