React原始碼閱讀:setState

後排的風過發表於2018-08-13

前言

本文主要介紹一些React的設計思想和相關概念,不管是想要閱讀原始碼還是想深入瞭解React的同學看過來呀。歡迎指出錯誤,一起探討一起進步。

同系列文章

因為每次更新文章都要更新一遍目錄太麻煩,所以本系列的文章都整合到Github,有興趣閱讀更多React原始碼閱讀文章的同學可以用力點這裡這裡這裡,如果覺得文章寫得還行的話,對你有小小的幫助,希望可以給顆小星星給我,謝謝?

非同步還是同步?

我們先來探索一下經常被人們問到的一個問題吧,React中的setState是非同步還是同步的呢?誒,先別急著回答,讓我們看個栗子思考下下吧,當點選一次按鈕時,下面的輸出結果和最終顯示的結果你覺得是什麼呢?

class ReactComponent extends React.Component {
  state = {
    count: 0,
  }
  
  add = () => {
    this.setState({ count: this.state.count + 1 });
    this.setState({ count: this.state.count + 1 });
    console.log(this.state.count);

    setTimeout(() => {
      this.setState({ count: this.state.count + 1 });
      this.setState({ count: this.state.count + 1 });
      console.log(this.state.count);
    }, 0);
  }
  
  render() {
    return (
      <div>
        <p>{this.state.count}</p>
        <button onClick={this.add}>+</button>
      </div>
    );
  }
}

ReactDOM.render(
  <ReactComponent/>,
  document.getElementById('root')
);
複製程式碼

想好沒有,下面來公佈答案啦,噔噔噔噔~

...
  add = () => {
    this.setState({ count: this.state.count + 1 });
    this.setState({ count: this.state.count + 1 });
    console.log(this.state.count);  // 0

    setTimeout(() => {
      this.setState({ count: this.state.count + 1 });
      this.setState({ count: this.state.count + 1 });
      console.log(this.state.count);  // 3
    }, 0);
  }
複製程式碼

最終顯示的結果也是3,怎麼樣,是不是很吃驚呢。會產生這樣的結果是因為setState在React合成事件中是非同步的,他會把多次的狀態更新整合為一次,對於同一個狀態的多次更新會覆蓋,只執行最後一次更新,所以第一個輸出的結果就為0啦。而在一些非同步和原生的DOM事件中,React暫時還未做優化處理,所以是同步更新的,在上面的setTimeout裡,執行回撥時狀態已經更新完一次,所以這時的this.state.count為1,再執行兩次同步相加後的結果為3。可以點這裡親自執行

所以通過這個栗子我們以後回答這個問題的時候不要傻傻地回答是非同步還是同步啦,要因不同的情況而定,但在React的17版本可能會全部處理為非同步。

原始碼閱讀技巧

在閱讀原始碼前,我們先來學習一下閱讀原始碼的方法吧,方便小夥伴們自己有興趣可以更深入的閱讀。閱讀原始碼的最好方法肯定是設斷點啦,這樣即減少閱讀錯誤的機率,還大大提高效率,那我們要怎樣開啟除錯模式呢?我這裡使用的編輯器為VS Code,使用其他編輯器的話請自行參考摸索下。

先配置VS Code的配置檔案:

{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "attach",
      "name": "Attach",
      "port": 9229
    }
  ]
}
複製程式碼

我們可以在package.json在找到一句話:

"debug-test": "cross-env NODE_ENV=development node --inspect-brk node_modules/.bin/jest --config ./scripts/jest/config.source.js --runInBand"
複製程式碼

Windows下需要把這句改為:

"debug-test": "cross-env NODE_ENV=development node --inspect-brk node_modules/jest/bin/jest.js --config ./scripts/jest/config.source.js --runInBand",
複製程式碼

只要執行yarn debug-test再按F5就可以開啟測試除錯模式啦。 可是這樣的話會執行所有的測試用例,這顯然不是我們想要的,我們只要執行涉及我們想要閱讀原始碼的那一塊就可以了,所以我通常這樣執行yarn debug-test <測試用例名>,例如yarn debug-test ChangeEventPlugin,這樣就可以只執行我們想要跑的用例,最好自己寫一個想要的測試用例,如果不懂怎麼寫的話可以點波關注,以後有時間會寫下文章(這波廣告植入是不是毫無痕跡?)。

下面就讓我們愉快地設定斷點閱讀程式碼吧

“非同步”實現原理

我們一起來迷失,啊不對,遨遊在浩瀚的原始碼中吧,看看setState的非同步是如何實現的

我們都知道類元件都是通過繼承React.Component來實現的,所以我們先去那裡看看:

function Component(props, context, updater) {
  this.props = props;
  this.context = context;
  // If a component has string refs, we will assign a different object later.
  this.refs = emptyObject;
  // We initialize the default updater but the real one gets injected by the
  // renderer.
  this.updater = updater || ReactNoopUpdateQueue;
}

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

可以看到setState裡呼叫this.updater的一個方法,而通過一句註釋我們可以發現this.updater是通過動態注入的,線索從這裡就斷開啦。然後我費盡千辛萬苦終於找到updater的定義:

const classComponentUpdater = {
  ...
  enqueueSetState(inst, payload, callback) {
    const fiber = ReactInstanceMap.get(inst);
    const currentTime = requestCurrentTime();
    const expirationTime = computeExpirationForFiber(currentTime, fiber);

    const update = createUpdate(expirationTime);
    update.payload = payload;
    if (callback !== undefined && callback !== null) {
      if (__DEV__) {
        warnOnInvalidCallback(callback, 'setState');
      }
      update.callback = callback;
    }

    enqueueUpdate(fiber, update, expirationTime);  // 加入更新佇列
    scheduleWork(fiber, expirationTime);  // 開始安排更新工作
  },
  ...
};
複製程式碼

因為閱讀原始碼的過程太過複雜,就不一一和大家詳細講啦,只列出主要的實現程式碼。

function requestWork(root: FiberRoot, expirationTime: ExpirationTime) {
  addRootToSchedule(root, expirationTime);

  if (isRendering) {
    // Prevent reentrancy. Remaining work will be scheduled at the end of
    // the currently rendering batch.
    return;
  }

  if (isBatchingUpdates) {
    // 如果是合併更新的話進入這裡,合成事件裡的更新是合併更新
    if (isUnbatchingUpdates) {
      // ...unless we're inside unbatchedUpdates, in which case we should
      // flush it now.
      nextFlushedRoot = root;
      nextFlushedExpirationTime = Sync;
      performWorkOnRoot(root, Sync, false);
    }
    return;
  }

  // TODO: Get rid of Sync and use current time?
  if (expirationTime === Sync) {
    performSyncWork();
  } else {
    scheduleCallbackWithExpirationTime(expirationTime);
  }
}
複製程式碼

因為我們知道合成事件裡的setState的多次更新會合併成一次更新,所以setState執行時會跑到第二個if,然後return回去,先不執行更新,所以這就是為什麼在執行完setState後狀態並沒有立刻更新的原因。

為了讓大家可以更好地理解,下面就用一段虛擬碼大概來解釋一下吧

function interactiveUpdates(callback) {
    isBatchingUpdates = true;  // 先把合成更新識別符號設為真
    
    // 執行事件的回撥函式,如果裡面有呼叫到setState
    // 則會發生上面所說的情況,先把更新加入更新佇列
    // 再先返回不執行更新
    callback();  
    
    isBatchingUpdates = false;
    performSyncWork();  // 開始更新
}
複製程式碼

這大概就是setState非同步的實現原理,當然原始碼比這複雜的要多的多。

總結

setState既是“非同步”的也是同步的,由不同情況下決定,所以使用時要小心,我建議都把他當成非同步的來使用,合併更新是通過回撥和更新佇列來實現。

相關文章