React中setState真的是非同步的嗎

水星的衛星發表於2018-04-02

在學習react的過程中幾乎所有學習材料都會反覆強調一點setState是非同步的,來看一下react官網對於setState的說明。

setState()認為是一次請求而不是一次立即執行更新元件的命令。為了更為可觀的效能,React可能會推遲它,稍後會一次性更新這些元件。React不會保證在setState之後,能夠立刻拿到改變的結果。

一個很經典的例子如下

// state.count 當前為 0
componentDidMount(){
    this.setState({count: state.count + 1});
    console.log(this.state.count)
}
複製程式碼

如果你熟悉react,你一定知道最後的輸出結果是0,而不是1。

然而事實真的是這樣嗎?

我們再來看一個例子

class Hello extends Component {
  constructor(props) {
    super(props);
    this.state = { counter: 0 };
  }
  render() {
    return <div onClick={this.onClick.bind(this)}>點我</div>;
  }

  componentDidMount() {
    //手動繫結mousedown事件
    ReactDom.findDOMNode(this).addEventListener(
      "mousedown",
      this.onClick.bind(this)
    );

    //延時呼叫onclick事件
    setTimeout(this.onClick.bind(this), 1000);
  }
  onClick(event) {
    if (event) {
      console.log(event.type);
    } else {
      console.log("timeout");
    }
    console.log("prev state:", this.state.counter);
    this.setState({
      counter: this.state.counter + 1
    });
    console.log("next state:", this.state.counter);
  }
}
export default Hello;
複製程式碼

在這個元件中採用3中方法更新state

  • 在div節點中繫結onClick事件
  • 在componentDidMount中手動繫結mousedown事件
  • 在componentDidMount中使用setTimeout呼叫onClick

在點選元件後,你可以猜到結果嗎?輸出結果是:

timeout
"prev state:"
0
"next state:"
1
mousedown
"prev state:"
1
"next state:"
2
click
"prev state:"
2
"next state:"
2
複製程式碼

codesandbox

結果似乎有點出人意料,三種方式只有在div上繫結的onClick事件輸出了可以證明setState是非同步的結果,另外兩種方式顯示setState似乎是同步的。

React的核心成員Dan Abramov也在一次回覆中提到

React中setState真的是非同步的嗎

這到底是這麼回事?

話不多說,直接上原始碼,如果你對react原始碼有一定了解可以接著往下看,如果沒有,可以直接跳到結論(以下分析基於react15,16版本可能有出入)。

setState非同步的實現

在componentWillMount中呼叫setState
//程式碼位於ReactBaseClasses
 * @param {partialState} 設定的state引數
 * @param {callback} 設定state後的回撥
ReactComponent.prototype.setState = function(partialState, callback) {
  invariant(
    typeof partialState === 'object' ||
      typeof partialState === 'function' ||
      partialState == null,
    'setState(...): takes an object of state variables to update or a ' +
      'function which returns an object of state variables.',
  );
  this.updater.enqueueSetState(this, partialState);
  if (callback) {
    this.updater.enqueueCallback(this, callback, 'setState');
  }
};
複製程式碼

setState中呼叫了enqueueSetState方法將傳入的state放到一個佇列中,接下來,看下enqueueSetState的具體實現:

//程式碼位於ReactUpdateQueue.js
   * @param {publicInstance} 需要重新渲染的元件例項
   * @param {partialState} 設定的state
   * @internal
  enqueueSetState: function(publicInstance, partialState) {
      //省略部分程式碼

      //從元件列表中找到並返回需渲染的元件
    var internalInstance = getInternalInstanceReadyForUpdate(
      publicInstance,
      'setState',
    );

    if (!internalInstance) {
      return;
    }

    //state佇列
    var queue =
      internalInstance._pendingStateQueue ||
      (internalInstance._pendingStateQueue = []);
    //將新的state放入佇列
    queue.push(partialState);

    enqueueUpdate(internalInstance);
  },
複製程式碼

enqueueSetState中先是找到需渲染元件並將新的state併入該元件的需更新的state佇列中,接下來呼叫了enqueueUpdate方法,接著來看:

//程式碼位於ReactUpdateQueue.js
function enqueueUpdate(internalInstance) {
  ReactUpdates.enqueueUpdate(internalInstance);
}
//程式碼位於ReactUpdates.js
function enqueueUpdate(component) {
  ensureInjected();

  // Various parts of our code (such as ReactCompositeComponent's
  // _renderValidatedComponent) assume that calls to render aren't nested;
  // verify that that's the case. (This is called by each top-level update
  // function, like setState, forceUpdate, etc.; creation and
  // destruction of top-level components is guarded in ReactMount.)

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

  dirtyComponents.push(component);
  if (component._updateBatchNumber == null) {
    component._updateBatchNumber = updateBatchNumber + 1;
  }
}
複製程式碼

這段程式碼就是實現setState非同步更新的關鍵了,首先要了解的就是batchingStrategy,顧名思義就是批量更新策略,其中通過事務的方式實現state的批量更新,這裡的事務和資料庫中的事務的概念類似,但不完全相同,這裡就不具體展開了,也是react中很有意思的內容。 isBatchingUpdates是該事務的一個標誌,如果為true,表示react正在一個更新元件的事務流中,根據以上程式碼邏輯:

  • 如果沒有在事務流中,呼叫batchedUpdates方法進入更新流程,進入流程後,會將isBatchingUpdates設定為true。
  • 否則,將需更新的元件放入dirtyComponents中,也很好理解,先將需更新的元件存起來,稍後更新。 這就解釋了在componentDidMount中呼叫setState並不會立即更新state,因為正處於一個更新流程中,isBatchingUpdates為true,所以只會放入dirtyComponents中等待稍後更新。
事件中的呼叫setState

那麼在事件中呼叫setState又為什麼也是非同步的呢,react是通過合成事件實現了對於事件的繫結,在元件建立和更新的入口方法mountComponent和updateComponent中會將繫結的事件註冊到document節點上,相應的回撥函式通過EventPluginHub儲存。 當事件觸發時,document上addEventListener註冊的callback會被回撥,回撥函式為ReactEventListener.dispatchEvent,它是事件分發的入口方法。下面我們來看下dispatchEvent:

dispatchEvent: function (topLevelType, nativeEvent) {
    // disable了則直接不回撥相關方法
    if (!ReactEventListener._enabled) {
      return;
    }

    var bookKeeping = TopLevelCallbackBookKeeping.getPooled(topLevelType, nativeEvent);
    try {
      // 放入
      ReactUpdates.batchedUpdates(handleTopLevelImpl, bookKeeping);
    } finally {
      TopLevelCallbackBookKeeping.release(bookKeeping);
    }
}
複製程式碼

看到了熟悉的batchedUpdates方法,只是呼叫方換成了ReactUpdates,再進入ReactUpdates.batchedUpdates。

function batchedUpdates(callback, a, b, c, d, e) {
  ensureInjected();
  return batchingStrategy.batchedUpdates(callback, a, b, c, d, e);
}
複製程式碼

豁然開朗,原來在事件的處理中也是通過同樣的事務完成的,當進入事件處理流程後,該事務的isBatchingUpdates為true,如果在事件中呼叫setState方法,也會進入dirtyComponent流程,即所謂的非同步。

原生事件繫結和setTimeout中setState

在回過頭來看同步的情況,原生事件繫結不會通過合成事件的方式處理,自然也不會進入更新事務的處理流程。setTimeout也一樣,在setTimeout回撥執行時已經完成了原更新元件流程,不會放入dirtyComponent進行非同步更新,其結果自然是同步的。

順便提一下,在更新組建時,將更新的state合併到原state是在componentWillUpdate之後,render之前,所以在componentWillUpdate之前設定的setState可以在render中拿到最新值。

總結

1.在元件生命週期中或者react事件繫結中,setState是通過非同步更新的。 2.在延時的回撥或者原生事件繫結的回撥中呼叫setState不一定是非同步的。

這個結果並不說明setState非同步執行的說法是錯誤的,更加準確的說法應該是setState不能保證同步執行。

Dan Abramov也多次提到今後會將setState徹底改造為非同步的,從js conf中提到的suspend新特新也印證了這一點。

相關文章