React元件的DidMount事件裡的setState事件

wind4gis發表於2018-01-12

參考原文:

  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流程圖,看看這個方法裡發生了什麼事情

React元件的DidMount事件裡的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 呼叫棧:

React元件的DidMount事件裡的setState事件

而setTimeout 中 setState 的呼叫棧如下:

React元件的DidMount事件裡的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

詳細流程說明

React元件的DidMount事件裡的setState事件

上面的流程圖中只保留了部分核心的過程,看到這裡大家應該明白了,所有的 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 方法不會被多次呼叫。

相關文章