阅读 4573

React组件的DidMount事件里的setState事件

参考原文:

  1. React 源码剖析系列 - 解密 setState

  2. setState 之后发生了什么 —— 浅谈 React 中的 Transaction

无法多次setState

React组件的componentDidMount事件里使用setState方法,会有一些有趣的事情:

class Example extends React.Component {
  constructor() {
    super();
    this.state = {
      val: 0
    };
  }
  
  componentDidMount() {
    this.setState({val: this.state.val + 1});
    console.log(this.state.val);    // 第 1 次 log

    this.setState({val: this.state.val + 1});
    console.log(this.state.val);    // 第 2 次 log

    setTimeout(() => {
      this.setState({val: this.state.val + 1});
      console.log(this.state.val);  // 第 3 次 log

      this.setState({val: this.state.val + 1});
      console.log(this.state.val);  // 第 4 次 log
    }, 0);
  }

  render() {
    return null;
  }
};
复制代码

运行这段代码,我们可以看到屏幕里打印的是0、0、2、3。

为什么setState不成功

这好像跟我们想象中的不大一样,我们先看下setState流程图,看看这个方法里发生了什么事情

  我们可以看到,如果处于批量更新阶段内,就会把所有更改的操作存入pending队列,当我们已经完成批量更新收集阶段,我们读取pengding队列里的操作,一次性处理并更新state。那么根据上面的执行结果,我们大概可以猜到,前面两个setState操作应该是刚好处于批量更新阶段,这两个操作都被收集到队列里,即state在这个阶段里暂时不会被更改,所以还是保留原始值0。

  当setTiemout的时候,跳出了当前执行的任务队列,估计相应也跳出了批量更新阶段,所以导致现在的操作会立即体现在state(此时经过上面的更改,state已经变成了1)里。所以后面两个操作会导致state值陆续变成2、3。如果用任务队列的方式这么理解,好像是说得通,那么我们关心的是为什么componentDidMount事件里就处于batch update了,也就是batch update其实是什么东西?

查看React源码里,setState里源码对应下面这段:

function enqueueUpdate(component) {
  // ...

  if (!batchingStrategy.isBatchingUpdates) {
    batchingStrategy.batchedUpdates(enqueueUpdate, component);
    return;
  }

  dirtyComponents.push(component);
}
复制代码

也就是由batchingStrategy的isBatchingUpdates属性来决定当前是否处于批量更新阶段,然后再由batchingStrategy来执行批量更新。

那么batchingStrategy是什么?其实它只是一个简单的对象,定义了一个 isBatchingUpdates 的布尔值,和一个 batchedUpdates 方法。下面是一段简化的定义代码:

var batchingStrategy = {
  isBatchingUpdates: false,

  batchedUpdates: function(callback, a, b, c, d, e) {
    // ...
    batchingStrategy.isBatchingUpdates = true;
    
    transaction.perform(callback, null, a, b, c, d, e);
  }
};
复制代码

注意 batchingStrategy 中的 batchedUpdates 方法中,有一个 transaction.perform 调用。这就引出了本文要介绍的核心概念 —— Transaction(事务)。

Transaction

在 Transaction 的源码中有一幅特别的 ASCII 图,形象的解释了 Transaction 的作用。

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

我们可以看到,其实在内部是通过将需要执行的method使用wrapper封装起来,再托管给Transaction提供的perform方法执行,由Transaction统一来初始化和关闭每个wrapper。

解密 setState

那么 Transaction 跟 setState 的不同表现有什么关系呢?首先我们把 4 次 setState 简单归类,前两次属于一类,因为他们在同一次调用栈中执行;setTimeout 中的两次 setState 属于另一类,原因同上。让我们看看componentDidMout 中 setState 调用栈:

而setTimeout 中 setState 的调用栈如下:

我们可以看到,里边的setState是包裹在batchedUpdates的Transaction里执行的。那这次 batchedUpdate 方法,又是谁调用的呢?让我们往前再追溯一层,原来是ReactMount.js中的_renderNewRootComponent方法。也就是说,整个将React组件渲染到DOM中的过程就处于一个大的Transaction中。

接下来的解释就顺理成章了,因为在componentDidMount中调用setState时,batchingStrategy的isBatchingUpdates已经被设为true,所以两次setState的结果并没有立即生效,而是被放进了 dirtyComponents 中。这也解释了两次打印this.state.val都是 0 的原因,新的state还没有被应用到组件中。

再反观setTimeout中的两次setState,因为没有前置的batchedUpdate调用,所以batchingStrategy的isBatchingUpdates标志位是false,也就导致了新的state马上生效,没有走到dirtyComponents分支。也就是,setTimeout中第一次setState时,this.state.val为 1,而setState 完成后打印时this.state.val变成了 2。第二次setState同理。

为什么点击事件多次setState失败

我们再看看下面的例子

var Example = React.createClass({
  getInitialState: function() {
    return {
      clicked: 0
    };
  },

  handleClick: function() {
    this.setState({clicked: this.state.clicked + 1});
    this.setState({clicked: this.state.clicked + 1});
	console.log(this.state.clicked)
  },

  render: function() {
    return <button onClick={this.handleClick}>{this.state.clicked}</button>;
  }
});
复制代码

执行之后,我们可以看到,其实只调用了一遍setState,并且this.state.clicked等于0

详细流程说明

上面的流程图中只保留了部分核心的过程,看到这里大家应该明白了,所有的 batchUpdate 功能都是通过托管给transaction实现的。this.setState 调用后,新的 state 并没有马上生效,而是通过 ReactUpdates.batchedUpdate 方法存入临时队列中。当外层的transaction 完成后,才调用ReactUpdates.flushBatchedUpdates 方法将所有的临时 state merge 并计算出最新的 props 及 state。

纵观 React 源码,使用 Transaction 之处非常之多,React 源码注释中也列举了很多可以使用 Transaction 的地方,比如

  • 在一次 DOM reconciliation(调和,即 state 改变导致 Virtual DOM 改变,计算真实 DOM 该如何改变的过程)的前后,保证 input 中选中的文字范围(range)不发生变化
  • 当 DOM 节点发生重新排列时禁用事件,以确保不会触发多余的 blur/focus 事件。同时可以确保 DOM 重拍完成后事件系统恢复启用状态。
  • 当 worker thread 的 DOM reconciliation 计算完成后,由 main thread 来更新整个 UI
  • 在渲染完新的内容后调用所有 componentDidUpdate 的回调 等等

值得一提的是,React 还将 batchUpdate 方法暴露了出来:

var batchedUpdates = require('react-dom').unstable_batchedUpdates;
复制代码

当你需要在一些非 DOM 事件回调的函数中多次调用 setState 等方法时,可以将你的逻辑封装后调用 batchedUpdates 执行,以此保证 render 方法不会被多次调用。

关注下面的标签,发现更多相似文章
评论