参考原文:
React 源码剖析系列 - 解密 setState
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 方法不会被多次调用.
来源: https://juejin.im/post/5a575c72518825734f52a049