參考原文:
無法多次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流程圖,看看這個方法裡發生了什麼事情
![React元件的DidMount事件裡的setState事件](https://i.iter01.com/images/5b829e76bb9302d98edcfa820f5a26e3876fdd2f3dc481d39a479dfc8888d7e2.jpg)
我們可以看到,如果處於批量更新階段內,就會把所有更改的操作存入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 呼叫棧:
![React元件的DidMount事件裡的setState事件](https://i.iter01.com/images/58b8780c317e7feb0b004229748a217314c4ea33d9b01d0cef405aac4ae6fe0f.jpg)
而setTimeout 中 setState 的呼叫棧如下:
![React元件的DidMount事件裡的setState事件](https://i.iter01.com/images/674143ca187debc7c48b3b54555484b02454b35aef8681505d82e6a9b9039a33.jpg)
我們可以看到,裡邊的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
詳細流程說明
![React元件的DidMount事件裡的setState事件](https://i.iter01.com/images/664f0ac7d02d8c2d17eab4686250378b3ab8911f459ed4bd726bbf99ec82b222.jpg)
上面的流程圖中只保留了部分核心的過程,看到這裡大家應該明白了,所有的 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 方法不會被多次呼叫。