[React技術內幕] setState的祕密

請叫我王磊同學發表於2017-08-22

  對於大多數的React開發者,setState可能是最常用的API之一。React作為View層,通過改變data從而引發UI的更新。React不像Vue這種MVVM庫,直接修改data並不能檢視的改變,更新狀態(state)的過程必須使用setState。   

setState介紹

  setState的函式簽名如下:

setState(partialState,callback)複製程式碼

我們看到setState接受兩個引數,一個是partialState,它是新的state用來更新之前的state。callback作為回撥函式,會在更新結束之後執行。舉個常見的例子

this.setState({
    value: this.state.value + 1
})複製程式碼

  上面這個例子執行的結果是將state中value的值增加1。但事實真的如此簡單嗎?我們看下面的程式碼:   

class Example extends React.Component {
    constructor(props) {
        super(props);
    }

    state = {
        value: 0
    }

    render() {
        return (
            <div>
                <div>The Value: {this.state.value}</div>
                <button onClick={::this._addValue}>add Value</button>
            </div>
        );
    }

    _addValue() {
        this.setState({
            value: this.state.value + 1
        })
        this.setState({
            value: this.state.value + 1
        })
    }
}複製程式碼

  如果你認為點選"addValue"按妞時每次會增加2的話,說明你可能對setState不是很瞭解。事實上如果你真的需要每次增加2的話,你的_addValue函式應該這麼寫:   

_addValue() {
    this.setState((preState,props)=>({
        value: preState.value + 1
    }))

    this.setState((preState,props)=>({
        value: preState.value + 1
    }))
}複製程式碼

  我們可以看到其實引數partialState不僅可以是一個物件,也可以是一個函式。該函式接受兩個引數: 更新前的state(preState)與當前的屬性(props),函式返回一個物件用於更新state。為什麼會產生這個問題,答案會在後序解答。
  
  其實上面的例子中,如果你真的需要每次增加2的話,你也可以這麼寫,雖然下面的寫法不是很優美:

_addValue() {
    setTimeout(()=>{
        this.setState({
            value: this.state.value + 1
        });
        this.setState({
            value: this.state.value + 1
        });
    },0)
}複製程式碼

  你現在是否眉頭一皺,發現setState並沒有這麼簡單。
  

  關於setState的介紹,官方文件是這麼介紹的:

Sets a subset of the state. Always use this to mutate
state. You should treat this.state as immutable.

There is no guarantee that this.state will be immediately updated, so
accessing this.state after calling this method may return the old value.

There is no guarantee that calls to setState will run synchronously,
as they may eventually be batched together. You can provide an optional
callback that will be executed when the call to setState is actually
completed.

  翻譯過來(意譯)相當於:

setState用來設定state的子集,永遠都只使用setState更改state。你應該將this.state視為不可變資料。

並不能保證this.state會被立即更新,因此在呼叫這個方法之後訪問this.state可能會得到的是之前的值。

不能保證呼叫setState之後會同步執行,因為它們可能被批量更新,你可以提供可選的回撥函式,在setState真正地完成了之後,回撥函式將會被執行。

  通篇幾個字眼讓我們很難辦,不保證可能,到底什麼時候才會同步更新,什麼時候才會非同步更新?可能真的需要我們研究一下。   

setState的實現  

  
  React元件繼承自React.Component,而setState是React.Component的方法,因此對於元件來講setState屬於其原型方法,首先看setState的定義:   

function ReactComponent(props, context, updater) {
  this.props = props;
  this.context = context;
  this.refs = emptyObject;
  this.updater = updater || ReactNoopUpdateQueue;
}

ReactComponent.prototype.setState = function (partialState, callback) {
  this.updater.enqueueSetState(this, partialState);
  if (callback) {
    this.updater.enqueueCallback(this, callback);
  }
};複製程式碼

  我們首先看setState,首先呼叫的是this.updater.enqueueSetState,先明確this.updater是什麼,在React中每個元件有擁有一個this.updater,是用來驅動state更新的工具物件。當我們在元件中的建構函式中呼叫super時實質呼叫的就是函式ReactComponent。其中有:   

this.updater = updater || ReactNoopUpdateQueue;複製程式碼

  沒有傳入引數updater引數時,this.updater的值就是ReactNoopUpdateQueue。 而ReactNoopUpdateQueue實際是沒有什麼意義的,只相當於一個初始化的過程。而ReactNoopUpdateQueue.enqueueSetState主要起到一個在非生產版本中警告(warning)的作用。真正的updater是在renderer中注入(inject)的。因此如果你在constructor中嘗試呼叫this.helper.isMounted會返回false,表明元件並沒有安裝(mount),如果你呼叫setState,也會給出相應的警告。   

  constructor(props) {
    super(props);
    //這是指個演示,this.isMounted函式已經被廢棄
    console.log(this.updater.isMounted())
    this.setState({
        value: 1
    })
}複製程式碼

  上面的警告就是ReactNoopUpdateQueue中負責列印的。告訴我們在非安裝或已解除安裝的元件上是不能使用setState函式的。
  
  在ReactCompositeComponentMixin中的函式mountComponent中有下面的語句:

inst.updater = ReactUpdateQueue;複製程式碼

那我們來看看ReactUpdateQueue中的enqueueSetState:


var ReactUpdatedQueue = {
  enqueueSetState: function (publicInstance, partialState) {
    var internalInstance = getInternalInstanceReadyForUpdate(publicInstance, 'setState');

    if (!internalInstance) {
      return;
    }

    var queue = internalInstance._pendingStateQueue 
                || (internalInstance._pendingStateQueue = []);
    queue.push(partialState);

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

我們通過this.updater.enqueueSetState(this, partialState);這裡的this是元件的例項,例如在最開始的例子中,this指的就是函式Example的例項(class實質就是函式function的語法糖)。如下圖:

通過執行函式

var internalInstance = getInternalInstanceReadyForUpdate(publicInstance, 'setState');複製程式碼

  我們得到的internalInstance實質就是元件例項的React內部表達,包含了元件例項的內部的一些屬性,例如:
  


  internalInstance的屬性很多,但我們需要關注的只有兩個:_pendingStateQueue(待更新佇列)與_pendingCallbacks(更新回撥佇列)。根據程式碼

 var queue = internalInstance._pendingStateQueue 
                || (internalInstance._pendingStateQueue = []);
 queue.push(partialState);複製程式碼

  如果_pendingStateQueue的值為null,將其賦值為空陣列[],並將partialState放入待更新state佇列_pendingStateQueue。最後執行enqueueUpdate(internalInstance);。因此下一步我們需要研究一下enqueueUpdate

function enqueueUpdate(internalInstance) {
  ReactUpdates.enqueueUpdate(internalInstance);
}複製程式碼
var ReactUpdates = {
    enqueueUpdate: function enqueueUpdate(component) {
        ensureInjected();
        if (!batchingStrategy.isBatchingUpdates) {
            batchingStrategy.batchedUpdates(enqueueUpdate, component);
            return;
        }

        dirtyComponents.push(component);
    }
}複製程式碼

  首先執行的ensureInjected()其實也是一個保證ReactUpdates.ReactReconcileTransactionbatchingStrategy是否存在,否則給出相應的警告,當然上面兩個的作用之後會給出。接下來會根據batchingStrategy.isBatchingUpdates的值做出不同的行為,如果是true的話,直接將internalInstance放入dirtyComponents,否則將執行batchingStrategy.batchedUpdates(enqueueUpdate, component)。那麼我們要了解一下batchingStrategy是幹什麼的。首先看batchingStrategy的定義:   

var ReactDefaultBatchingStrategy = {
  isBatchingUpdates: false,
  batchedUpdates: function(callback, a, b, c, d, e) {
    var alreadyBatchingUpdates = ReactDefaultBatchingStrategy.isBatchingUpdates;
    ReactDefaultBatchingStrategy.isBatchingUpdates = true;
    if (alreadyBatchingUpdates) {
      callback(a, b, c, d, e);
    } else {
      transaction.perform(callback, null, a, b, c, d, e);
    }
  },
};複製程式碼

  batchingStrategy實質上就是一種批量更新策略,其屬性isBatchingUpdates表示的是否處於批量更新的過程中,開始預設值為false。batchedUpdates就是執行批量更新的方法。當isBatchingUpdatesfalse時,執行transaction.perform(callback, null, a, b, c, d, e)。否則當isBatchingUpdatestrue時,直接執行callback。但在我們這裡,其實不會執行到這兒,因為當isBatchingUpdatestrue時,直接就將component中放入dirtyComponents中。關於程式碼中的transaction我們需要了解下React中的事務Transaction。

Transaction

  關於React中的事務Transaction,原始碼中給出了下面的ASCII圖:

/**
 * <pre>
 *                       wrappers (injected at creation time)
 *                                      +        +
 *                                      |        |
 *                    +-----------------|--------|--------------+
 *                    |                 v        |              |
 *                    |      +---------------+   |              |
 *                    |   +--|    wrapper1   |---|----+         |
 *                    |   |  +---------------+   v    |         |
 *                    |   |          +-------------+  |         |
 *                    |   |     +----|   wrapper2  |--------+   |
 *                    |   |     |    +-------------+  |     |   |
 *                    |   |     |                     |     |   |
 *                    |   v     v                     v     v   | wrapper
 *                    | +---+ +---+   +---------+   +---+ +---+ | invariants
 * perform(anyMethod) | |   | |   |   |         |   |   | |   | | maintained
 * +----------------->|-|---|-|---|-->|anyMethod|---|---|-|---|-|-------->
 *                    | |   | |   |   |         |   |   | |   | |
 *                    | |   | |   |   |         |   |   | |   | |
 *                    | |   | |   |   |         |   |   | |   | |
 *                    | +---+ +---+   +---------+   +---+ +---+ |
 *                    |  initialize                    close    |
 *                    +-----------------------------------------+
 * </pre>
 */複製程式碼

  
  其實上面的形象的解釋了React中的事務Transaction,React Transaction會給方法包裝一個個wrapper,其中每個wrapper都有兩個方法:initializeclose。當執行方法時,需要執行事務的perform方法。perform方法會首先一次執行wrapperinitialize,然後執行函式本身,最後執行wrapperclose方法。
  定義Transaction需要給建構函式混入Transaction.Mixin,並需要提供一個原型方法getTransactionWrappers用於返回wrapper陣列。下面我們看下ReactDefaultBatchingStrategy中的transaction是如何定義的:

var RESET_BATCHED_UPDATES = {
  initialize: emptyFunction,
  close: function() {
    ReactDefaultBatchingStrategy.isBatchingUpdates = false;
  },
};

var FLUSH_BATCHED_UPDATES = {
  initialize: emptyFunction,
  close: ReactUpdates.flushBatchedUpdates.bind(ReactUpdates),
};

var TRANSACTION_WRAPPERS = [FLUSH_BATCHED_UPDATES, RESET_BATCHED_UPDATES];

function ReactDefaultBatchingStrategyTransaction() {
  this.reinitializeTransaction();
}

Object.assign(
  ReactDefaultBatchingStrategyTransaction.prototype,
  Transaction.Mixin,
  {
    getTransactionWrappers: function() {
      return TRANSACTION_WRAPPERS;
    },
  }
);

var transaction = new ReactDefaultBatchingStrategyTransaction();複製程式碼

  其中wrapperRESET_BATCHED_UPDATES負責在close階段重置ReactDefaultBatchingStrategyisBatchingUpdatesfalse。而wrapperFLUSH_BATCHED_UPDATES負責在close執行flushBatchedUpdates。   

setState更新的過程  

  我們再次回顧一下更新的過程,如果處於批量更新的過程中(即isBatchingUpdates為true),則直接將元件傳入dirtyComponents。如果不是的話,開啟批量更新,用事務transaction.perform執行enqueueUpdate,這時候isBatchingUpdates經過上次執行,已經是true,將被直接傳入dirtyComponents。那麼傳入更新的元件傳入dirtyComponents之後會發生什麼?
  
  我們知道,batchedUpdates是處於一個事務中的,該事務在close階段做了兩件事,首先是將ReactDefaultBatchingStrategy.isBatchingUpdates置為false,即關閉批量更新的標誌位,第二個就是呼叫了方法ReactUpdates.flushBatchedUpdatesflushBatchedUpdates中會涉及到Virtual DOM到真實DOM的對映,這不是我們這篇文章的重點(最重要的是我自己也沒有參透這邊的邏輯),這部分我們只會簡要的介紹流程。

//程式碼有省略
var flushBatchedUpdates = function() {
  while (dirtyComponents.length) {
    if (dirtyComponents.length) {
      var transaction = ReactUpdatesFlushTransaction.getPooled();
      transaction.perform(runBatchedUpdates, null, transaction);
      ReactUpdatesFlushTransaction.release(transaction);
    }
    //......
  }
};複製程式碼

  我們發現在函式flushBatchedUpdates中是以事務ReactUpdatesFlushTransaction的方式執行了函式runBatchedUpdates,追根溯源我們來看看runBatchedUpdates幹了什麼。

function runBatchedUpdates(transaction) {
  var len = transaction.dirtyComponentsLength;
  dirtyComponents.sort(mountOrderComparator);

  for (var i = 0; i < len; i++) {
    var component = dirtyComponents[i];
    var callbacks = component._pendingCallbacks;
    component._pendingCallbacks = null;
    //.....
    ReactReconciler.performUpdateIfNecessary(component, transaction.reconcileTransaction);
    //.......
    if (callbacks) {
      for (var j = 0; j < callbacks.length; j++) {
        transaction.callbackQueue.enqueue(
          callbacks[j],
          component.getPublicInstance()
        );
      }
    }
  }
}複製程式碼

  首先函式將dirtyComponents以元件中的_mountOrder進行了遞增排序,其目的就是保證更新順序,即父元件保證其子元件之前更新。然後在元件中獲得setState完成之後的回撥函式,開始執行ReactReconciler.performUpdateIfNecessary。又得看看這個函式:

performUpdateIfNecessary: function (internalInstance, transaction) {
    internalInstance.performUpdateIfNecessary(transaction);
}複製程式碼

  performUpdateIfNecessary執行元件例項的原型方法performUpdateIfNecessary,我們再去看看元件例項是如何定義的這個方法:

var ReactCompositeComponentMixin = {
  performUpdateIfNecessary: function(transaction) {
    //......
    if (this._pendingStateQueue !== null || this._pendingForceUpdate) {
      this.updateComponent(
        transaction,
        this._currentElement,
        this._currentElement,
        this._context,
        this._context
      );
    }
  }
}複製程式碼

  上面程式碼是perfromUpdateIfNecessary的省略版本,主要呼叫的其中的this.updateComponent方法:   

updateComponent: function(
    transaction,
    prevParentElement,
    nextParentElement,
    prevUnmaskedContext,
    nextUnmaskedContext
  ) {
    var inst = this._instance;
    var willReceive = false;
    var nextContext;
    var nextProps;

    // 驗證元件context是否改變
    // ......

    // 驗證是否是props更新還是元件state更新
    if (prevParentElement === nextParentElement) {
      nextProps = nextParentElement.props;
    } else {
      //存在props的更新  
      nextProps = this._processProps(nextParentElement.props);
      willReceive = true;
    }
    //根據條件判斷是否呼叫鉤子函式componentWillReceiveProps
    if (willReceive && inst.componentWillReceiveProps) {
      inst.componentWillReceiveProps(nextProps, nextContext);
    }
    //計算新的state
    var nextState = this._processPendingState(nextProps, nextContext);

    var shouldUpdate =
      this._pendingForceUpdate ||
      !inst.shouldComponentUpdate ||
      inst.shouldComponentUpdate(nextProps, nextState, nextContext);

    if (shouldUpdate) {
      this._pendingForceUpdate = false;
      this._performComponentUpdate(
        nextParentElement,
        nextProps,
        nextState,
        nextContext,
        transaction,
        nextUnmaskedContext
      );
    } else {
      this._currentElement = nextParentElement;
      this._context = nextUnmaskedContext;
      inst.props = nextProps;
      inst.state = nextState;
      inst.context = nextContext;
    }
  }複製程式碼

  updateComponent方法已經做了相關的註釋,其實裡面不僅涉及到state的改變導致的重新渲染,還有props的更新導致的重新渲染。在計算新的state時呼叫了_processPendingState:   

{
  _processPendingState: function(props, context) {
    var inst = this._instance;
    var queue = this._pendingStateQueue;
    var replace = this._pendingReplaceState;
    this._pendingReplaceState = false;
    this._pendingStateQueue = null;

    if (!queue) {
      return inst.state;
    }

    if (replace && queue.length === 1) {
      return queue[0];
    }

    var nextState = Object.assign({}, replace ? queue[0] : inst.state);
    for (var i = replace ? 1 : 0; i < queue.length; i++) {
      var partial = queue[i];
      Object.assign(
        nextState,
        typeof partial === 'function' ?
          partial.call(inst, nextState, props, context) :
          partial
      );
    }

    return nextState;
  }
}複製程式碼

  這一部分程式碼相對來說不算是很難,replace是存在是由於之前被廢棄的APIthis.replaceState,我們現在不需要關心這一部分,現在我們可以回答剛開始的問題,為什麼給setState傳入的引數是函式時,就可以解決剛開始的例子。   

Object.assign(
    nextState,
    typeof partial === 'function' ?
        partial.call(inst, nextState, props, context) :
        partial
);複製程式碼

如果我們傳入的是物件

this.setState({value: this.state.value + 1 });
this.setState({value: this.state.value + 1})複製程式碼

  我們現在已經知道,呼叫setState是批量更新,那麼第一次呼叫之後,this.state.value的值並沒有改變。兩次更新的value值其實是一樣的,所以達不到我們的目的。但是如果我們傳遞的是回撥函式的形式,那麼情況就不一樣了,partial.call(inst, nextState, props, context)接受的state都是上一輪更新之後的新值,因此可以達到我們預期的目的。
  
  _processPendingState在計算完新的state之後,會執行_performComponentUpdate:

function _performComponentUpdate(
    nextElement,
    nextProps,
    nextState,
    nextContext,
    transaction,
    unmaskedContext
  ) {
    var inst = this._instance;

    var hasComponentDidUpdate = Boolean(inst.componentDidUpdate);
    var prevProps;
    var prevState;
    var prevContext;
    if (hasComponentDidUpdate) {
      prevProps = inst.props;
      prevState = inst.state;
      prevContext = inst.context;
    }

    if (inst.componentWillUpdate) {
      inst.componentWillUpdate(nextProps, nextState, nextContext);
    }

    this._currentElement = nextElement;
    this._context = unmaskedContext;
    inst.props = nextProps;
    inst.state = nextState;
    inst.context = nextContext;

    this._updateRenderedComponent(transaction, unmaskedContext);

    if (hasComponentDidUpdate) {
      transaction.getReactMountReady().enqueue(
        inst.componentDidUpdate.bind(inst, prevProps, prevState, prevContext),
        inst
      );
    }
}複製程式碼

  我們可以看到,這部分內容涉及到了幾方面內容,首先在更新前呼叫了鉤子函式componentWillUpdate,然後更新了元件的屬性(props、state、context),執行函式_updateRenderedComponent(這部分涉及到render函式的呼叫和相應的DOM更新,我們不做分析),最後再次執行鉤子函式componentDidUpdate
  
  到目前為止,我們已經基本介紹完了setState的更新過程,只剩一個部分沒有介紹,那就是setState執行結束之後的回撥函式。我們知道,setState函式中如果存在callback,則會有:   

  if (callback) {
    this.updater.enqueueCallback(this, callback);
  }複製程式碼

  call函式會被傳遞給this.updater的函式enqueueCallback,然後非常類似於setState,callback會儲存在元件內部例項中的_pendingCallbacks屬性之中。我們知道,回撥函式必須要setState真正完成之後才會呼叫,那麼在程式碼中是怎麼實現的。大家還記得在函式flushBatchedUpdates中有一個事務ReactUpdatesFlushTransaction:   

//程式碼有省略
var flushBatchedUpdates = function() {
  while (dirtyComponents.length) {
    if (dirtyComponents.length) {
      //從事務pool中獲得事務例項
      var transaction = ReactUpdatesFlushTransaction.getPooled();
      transaction.perform(runBatchedUpdates, null, transaction);
      //釋放例項
      ReactUpdatesFlushTransaction.release(transaction);
    }
    //......
  }
};複製程式碼

  我們現在看看ReactUpdatesFlushTransaction的wrapper是怎麼定義的:

var UPDATE_QUEUEING = {
  initialize: function() {
    this.callbackQueue.reset();
  },
  close: function() {
    this.callbackQueue.notifyAll();
  },
};複製程式碼

  我們看到在事務的close階段定義了this.callbackQueue.notifyAll(),即執行了回撥函式,通過這種方法就能保證回撥函式一定是在setState真正完成之後才執行的。到此為止我們基本已經解釋了setState大致的流程是怎樣的,但是我們還是沒有回答之前的一個問題,為什麼下面的兩種程式碼會產生不同的情況:   

//未按預期執行
_addValue() {
    this.setState({
        value: this.state.value + 1
    })
    this.setState({
        value: this.state.value + 1
    })
}
//按預期執行
_addValue() {
    setTimeout(()=>{
        this.setState({
            value: this.state.value + 1
        });
        this.setState({
            value: this.state.value + 1
        });
    },0)
}複製程式碼

  這個問題,其實真的要追本溯源地去講,是比較複雜的,我們簡要介紹一下。在第一種情況下,如果打斷點追蹤你會發現,在第一次執行setState前,已經觸發了一個 batchedUpdates,等到執行setState時已經處於一個較大的事務,因此兩個setState都是會被批量更新的(相當於非同步更新的過程,thi.state.value值並沒有立即改變),執行setState只不過是將兩者的partialState傳入dirtyComponents,最後再通過事務的close階段的flushBatchedUpdates方法去執行重新渲染。但是通過setTimeout函式的包裝,兩次setState都會在click觸發的批量更新batchedUpdates結束之後執行,這兩次setState會觸發兩次批量更新batchedUpdates,當然也會執行兩個事務以及函式flushBatchedUpdates,這就相當於一個同步更新的過程,自然可以達到我們的目的,這也就解釋了為什麼React文件中既沒有說setState是同步更新或者是非同步更新,只是模糊地說到,setState並不保證同步更新。
  
  這篇文章對setState的介紹也是比較淺顯的,但是希望能起到一個拋磚迎玉的作用。setState之所以需要會採用一個批量更新的策略,其目的也是為了優化更新效能。但對於平常的使用中,雖然我們不會關心或者涉及到這個問題,但是我們仍然可以使用React開發出高效能的應用,我想這也就是我們喜歡React的原因:簡單、高效!

相關文章