skip to content
OnionTalk

Deep In React(五)setState中的黑魔法

在React官方文档中有这么一句话React does not guarantee that the state changes are applied immediately。在我最开始使用React的时候,我只是简单的把这句话当做React这个框架的约束,但是随着使用的深入,setState这个函数也让我觉得越来越神秘。在这篇文章中,我将通过反思自己在使用react中遇到的关于setState的一些问题,深入react源码,分析setState这个函数。

以下代码全部基于 React15(React16 代码太复杂了看不懂哇- -)。

setState 不一定是同步的

在 React 官方文档中有这么一句话state-updates-may-be-asynchronous

下面这两个很经典也是新人很容易糊涂的场景就是由上面这句模棱两可的话带来的。

class Demo extends Component {
  state = {
    count: 1,
  };

  onClickHandler = () => {
    this.setState({ count: this.state.count + 1 });
    console.log(this.state.count); // console.log 结果 1
    this.setState({ count: this.state.count + 1 });
    console.log(this.state.count); // console.log 结果 1
  };

  render() {
    const { count } = this.state;
    return <button onClick={this.onClickHandler}>{count}</button>;
  }
}

class SetTimeoutDemo extends Component {
  state = {
    count: 1,
  };

  onClickSetTimeoutHandler = () => {
    setTimeout(() => {
      this.setState({ count: this.state.count + 1 });
      console.log(this.state.count); // console.log 结果 2
      this.setState({ count: this.state.count + 1 });
      console.log(this.state.count); // console.log 结果 3
    }, 0);
  };

  render() {
    const { count } = this.state;
    return <button onClick={this.onClickSetTimeoutHandler}>{count}</button>;
  }
}

stackBlitz 的 demo 在这 ClickerSetTimeoutClicker

上面的两个结果非常令人疑惑,在我刚刚接触 React 的时候,我并不是特别理解为什么这种写法会产生这样的差异,只是简单的相信这是 React 的一种 Magic。

setState 的内部实现

但是所有的 Magic 其实都有踪可循,经过一番调查,我大致理清楚了 setState 内部实现的调用关系。

setState 的内部调用栈如上图所示,略去一些细枝末节的代码之后,简化为如下的流程图。

在这个流程图中,有几个非常重要的地方需要关注

  1. 所有的 setState 时更新的 state 都以 partial state 的形式进入一个队列中,等待在 batchUpdate 中进行一次更新
  2. batch update 有一个 isBatchingUpdate 的锁,当正在进行 batching update 时,无法再次触发 batching update,当前 component 被 push 到 dirtyComponent 数组中等待后续更新

以事务(transaction)的方式进行更新

在 batch update 中,React 使用事务机制进行更新,事务机制的运行原理如下

/**
 * <pre>
 *                       wrappers (injected at creation time)
 *                                      +        +
 *                                      |        |
 *                    +-----------------|--------|--------------+
 *                    |                 v        |              |
 *                    |      +---------------+   |              |
 *                    |   +--|    wrapper1   |---|----+         |
 *                    |   |  +---------------+   v    |         |
 *                    |   |          +-------------+  |         |
 *                    |   |     +----|   wrapper2  |--------+   |
 *                    |   |     |    +-------------+  |     |   |
 *                    |   |     |                     |     |   |
 *                    |   v     v                     v     v   | wrapper
 *                    | +---+ +---+   +---------+   +---+ +---+ | invariants
 * perform(anyMethod) | |   | |   |   |         |   |   | |   | | maintained
 * +----------------->|-|---|-|---|-->|anyMethod|---|---|-|---|-|-------->
 *                    | |   | |   |   |         |   |   | |   | |
 *                    | |   | |   |   |         |   |   | |   | |
 *                    | |   | |   |   |         |   |   | |   | |
 *                    | +---+ +---+   +---------+   +---+ +---+ |
 *                    |  initialize                    close    |
 *                    +-----------------------------------------+
 * </pre>
 *
 */

事务会在创造时注入多个 wrapper,每个 wrapper 是一个有着 initializeclose 两个函数的对象。当执行 perform(anyMethod) 的时候,调用顺序依次为

/**
 wrapper1.initialize -> wrapper2.initialize -> anymethod -> wrapper1.close -> wrapper2.close
*/

batch update 中的事务

React 实现了两个 Wrapper 用作 batch update,在接入事务后,batch update 的流程如下

React 在一次事务中完成 batch update 锁的打开和关闭,来保证 batch update 的进行。

再回头看看我们之前的问题

回到我们之前的问题

在解释过 setState 内部的工作原理之后,其实对于上面这种奇怪的输出已经不难理解了。React 在事件触发时就已经处在了一个大的事务之中isBatchingUpdate 被置成了 true ,随后的 setState 在调用时会进入 dirtyComponent 队列,在下一次 batch update 中进行更新。所以在下一次 batch update 之前, this.state 都不会得到更新。所以事实上调用结果如下。

//this.state.count = 1
this.setState({ count: this.state.count + 1 });
// 等于this.setState({count: 2})
this.setState({ count: this.state.count + 1 });
// 等于this.setState({count: 2})

而如果 setState 函数进行了 setTimeout 的包裹,由于EventLoop的特点,会保证 setState 一定是在前一条 message 之后,也就是上一次 batch update 完之后进行执行, isBatchingUpdatefalse=,此时的 =setState 会直接触发一次完整的 batch update,保证 this.state 被同步更新。而下一次再进行 setTimeout 包裹的 setState 操作原理同上。

因为相同的原因,在组件生命周期中调用 setState 方法也会和事件触发类似, setState 并不会跟预期中的一样进行同步更新。

还有什么方式可以同步更新 state?

在上面的例子中,我们提到,在使用 React 封装的事件时会进入一个事务,使得 isBatchingUpdatetrue 。 而当我们使用原生的事件机制时(比如 addEventListener ),由于缺少了 React 的封装,会使得 setState 直接触发 batch update 更新,从而同步更新 state。

class RawDemo extends Component {
  constructor() {
    super();
    this.state = {
      count: 1,
    };
  }

  componentDidMount() {
    document.querySelector('#foo').addEventListener('click', () => {
      this.setState({ count: this.state.count + 1 });
      console.log(this.state.count);
      this.setState({ count: this.state.count + 1 });
      console.log(this.state.count);
    });
  }

  render() {
    return <button id="foo">{this.state.count}</button>;
  }
}

DEMO 在这

总结

  • React 中的 batch update 使用事务进行完成
  • React 通过 isBatchingUpdate 来控制是否更新组件,当 isBatchingUpdatetrue 时,组件会被推入 dirtyComponent 数组中而不会即时更新
  • 普通情况下之所以 setState 表现为非同步,原因是在 React 封装的事件绑定(或者在生命周期)中调用 setState 处于一个大的事务中, isBatchingUpdate 已经被置为 true
  • 除了通过 setTimout , 还可以通过原生的事件绑定机制来同步更新 state(并不推荐使用)。

参考资料

setState:这个 API 设计到底怎么样

React - setState 源码分析(小白可读)