setState是如何知道該怎麼做的?

boomyao發表於2019-02-13

當你在元件裡呼叫 setState時,你覺得發生了什麼?

import React from 'react';
import ReactDOM from 'react-dom';

class Button extends React.Component {
  constructor(props) {
    super(props);
    this.state = { clicked: false };
    this.handleClick = this.handleClick.bind(this);
  }
  handleClick() {
    this.setState({ clicked: true });
  }
  render() {
    if (this.state.clicked) {
      return <h1>Thanks</h1>;
    }
    return (
      <button onClick={this.handleClick}>
        Click me!
      </button>
    );
  }
}

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

很明顯,React會隨著新的{ clicked: true} 狀態重渲染元件(component),更新DOM,匹配返回 <h1>Thanks</h1> 元素(element)。

似乎很簡單。不過問題來了,是 React 乾的還是 React DOM 乾的?

更新DOM聽起來像 React DOM 負責的,但我們呼叫 this.setState(),和 React DOM 似乎沒有關聯,React.Component 這個基類是在React中宣告的。

那麼React.Component中的setState()是如何更新DOM的?

免責宣告:與 多數 其他 文章 一樣,這篇文章,對React實際使用來說不是必須的,它適合喜歡追尋萬物原理的朋友們,謹慎選擇


我們可能認為 React.Component 包含了更新DOM的邏輯。

但是如果是這樣的話,this.setState()如何在其他環境奏效?例如,React Native 的元件也擴充套件了React.Component,它們就像前面那樣呼叫this.setState(),且 React Native 使用在Android和iOS原生檢視而不是DOM。

你可能也會對 React的 Test Renderer 或 Shallow Renderer 有些印象,這兩種測試方案都可以渲染普通元件並在其中呼叫this.setState(),但它們和DOM都沒關係。

如果你用過像React ART這樣的渲染器(renderer),你可能也知道頁面有可能使用多個渲染器(例如,ART元件執行於React DOM樹中),這使得全域性標誌或變數不再可靠。

所以,針對不同平臺程式碼,React.Component以某種委託方式處理state更新。在我們弄清楚怎麼回事前,先深入探討下如何及為什麼要分離包(packages)。


有一種常見的誤解,即React的“引擎”在 react 依賴包中,這不是真的。

實際上,自從React 0.14拆分依賴包以來,react依賴包特意地只暴露 定義 元件(components)的APIs,React絕大多數 實現 都放在 “渲染器”,

react-domreact-dom/serverreact-nativereact-test-rendererreact-art都是渲染器樣例(你可以搭建自己的)。

這也是為什麼react依賴包不管面向哪個平臺都可行,它所有的匯出,例如React.ComponentReact.createElementReact.Children和最近的Hooks,都獨立於目標平臺,無論你執行 React DOM、React DOM Server或者React Native,你都可以用同一種方式匯入使用元件。

相比之下,渲染器依賴包暴露特定平臺的APIs,如ReactDOM.render(),可以將React元件插入DOM節點中。每個渲染器都會提供一個類似的API,理想情況下,大多數 元件 不需要從渲染器匯入任何內容,這使它們更靈活。

大多數人認為React的“引擎”在每個渲染器中。不過許多渲染器確實包含了同一份副本程式碼 —— 我們稱為"reconciler"。有個構建步驟將 reconciler 程式碼與渲染器程式碼融合成一份高度優化過的程式碼,以獲得更好的效能。(複製程式碼通常不利於依賴包大小,但絕大多數使用者一次只需要一個渲染器,例如react-dom)

這裡要說的是,react依賴包只讓你知道React有哪些功能,但不知道功能是如何實現的。渲染器依賴包(react-domreact-native等)提供了React功能的實現和平臺特性的邏輯。其中一些程式碼是共享的("reconciler"),但更多的是各個渲染器的具體實現。


現在我們知道為什麼有功能時,reactreact-dom依賴包需要同時更新了,比如說,在React 16.3新增 Context API 時,React依賴包會暴露React.createContext()

React.createContext()實際上並沒有 實現 context功能,React DOM 與 React DOM Server 的實現是不同的。例如,createContext返回一些 plain objects:

// A bit simplified
function createContext(defaultValue) {
  let context = {
    _currentValue: defaultValue,
    Provider: null,
    Consumer: null
  };
  context.Provider = {
    $$typeof: Symbol.for('react.provider'),
    _context: context
  };
  context.Consumer = {
    $$typeof: Symbol.for('react.context'),
    _context: context,
  };
  return context;
}
複製程式碼

當你在程式碼裡使用<MyContext.Provider>或者<MyContext.Consumer>時,渲染器 決定如何處理它們。React DOM可能以一種方式跟蹤context,而React DOM Server可能會採用另一種方式。

如果你更新react到16.3+而沒更新react-dom,你將使用的渲染器便不知道什麼是ProviderConsumer這也是舊的react-dom會引發型別無效錯誤的原因

React Native同樣有這警告。不過不同於 React DOM,一次React更新發布不會“迫使”React Native也立即釋出新版本,它有自己一套發行時間表。更新的渲染器程式碼將單獨同步到React Native程式碼庫中。所以React Native和React DOM同一個功能,可以用上的時間是不同的。


好了,我們現在知道react依賴包不包含任何有趣的內容,因為具體實現放到react-domreact-native等渲染器中了。但是這沒能解決我們的問題,React.Component中的setState()是如何與對應的渲染器“交流的”。

答案是每個渲染器在建立的class上設定一個特殊欄位。這個欄位叫做updater。這不是由你設定的,而是React DOM、React DOM Server、React Native在你例項class後給你加上的:

// Inside React DOM
const inst = new YourComponent();
inst.props = props;
inst.updater = ReactDOMUpdater;

// Inside React DOM Server
const inst = new YourComponent();
inst.props = props;
inst.updater = ReactDOMServerUpdater;

// Inside React Native
const inst = new YourComponent();
inst.props = props;
inst.updater = ReactNativeUpdater;
複製程式碼

檢視React.Component中的setState實現,它所做的就是將任務全部委託給例項此元件的渲染器:

// 簡化後的程式碼
setState(partialState, callback) {
  // 用`updater` 反饋給渲染器
  this.updater.enqueueSetState(this, partialState, callback);
}
複製程式碼

React DOM Server 也許打算 忽略state更新並警告你,而React DOM和React Native會用複製來的"reconciler"去處理它

這也是為什麼即使this.setState()定義在React依賴包中,依然可以更新DOM。它會獲取由React DOM設定的this.updater,並讓React DOM排程和處理更新。


我們現在知道class了,那Hooks是怎麼做的?

當大家第一次看到Hooks API,很可能會想:useState怎麼“知道該怎麼做”?猜想是它的this.setState()比基於React.Component的更“神奇”。

但正如我們今天看到的,基於class的setState()實現一直是一種錯覺,除了呼叫指向當前的渲染器之外,它不參與任何操作。useState Hook也同樣如此

Hooks使用dispatcher物件而不是updater欄位。在你呼叫 React.useState()React.useEffect()、或者其他內建Hook時,這些都會轉發給當前的dispatcher。

// In React (簡化)
const React = {
  // 真正的屬性隱藏得有點深,你可以嘗試去找找看!
  __currentDispatcher: null,

  useState(initialState) {
    return React.__currentDispatcher.useState(initialState);
  },

  useEffect(initialState) {
    return React.__currentDispatcher.useEffect(initialState);
  },
  // ...
};
複製程式碼

而每種渲染器在元件渲染之前會設定dispatcher:

// In React DOM
const prevDispatcher = React.__currentDispatcher;
React.__currentDispatcher = ReactDOMDispatcher;
let result;
try {
  result = YourComponent(props);
} finally {
  // Restore it back
  React.__currentDispatcher = prevDispatcher;
}
複製程式碼

例如,React DOM Server的實現在這兒,React DOM和React Native共享的 reconciler 實現在這兒

這就是像react-dom這樣的渲染器需要獲取同一個react依賴包的原因,否則,你的元件不會“看到”這個dispatcher!如果在同一棵元件樹中存在多個React副本,就有可能發生問題。不過這樣容易出現隱蔽bug,所以Hooks會強迫你在發生前就解決依賴包重複問題。

雖然我們不鼓勵這樣做,但為了更適用於某些情景,你可以在技術上自行覆蓋dispatcher(__currentDispatcher是我編造的,不過你可以在程式碼庫中找到真實的名稱),例如,React DevTools會用一個專門定製的dispatcher通過捕獲JavaScript堆疊軌跡來描繪反饋Hooks樹。不要在家重複這樣做了

這也意味著Hooks本身並不依賴於React。如果將來有更多的類庫想複用React裡的Hooks理念,理論上dispatcher可以挪過去用並且作為一個更少“可怕”名稱的一流API展現出來。在開發過程中,我們應該避免過早抽象概念,直到我們不得不這麼做了。

updater欄位和__currentDispatcher物件都形成於一個叫 依賴注入 的通用程式設計原理。這兩種情況裡,渲染器將諸如setState之類的功能實現“注入”到通用的React依賴包中,元件因此以宣告為主。

在使用React時,你不需要思考這些是怎麼跑起來的。我們希望React開發者花更多的時間在應用程式程式碼上,而不是像依賴注入這些抽象概念上。但如果你想知道this.setState()或者useState是如何知道怎麼做的,我希望這會有所幫助。


翻譯原文How Does setState Know What to Do?(2018-12-09)

相關文章