参考原文:
无法多次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 方法不会被多次调用。