當你在元件裡呼叫 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-dom
、react-dom/server
、react-native
、react-test-renderer
、react-art
都是渲染器樣例(你可以搭建自己的)。
這也是為什麼react
依賴包不管面向哪個平臺都可行,它所有的匯出,例如React.Component
、React.createElement
、React.Children
和最近的Hooks,都獨立於目標平臺,無論你執行 React DOM、React DOM Server或者React Native,你都可以用同一種方式匯入使用元件。
相比之下,渲染器依賴包暴露特定平臺的APIs,如ReactDOM.render()
,可以將React元件插入DOM節點中。每個渲染器都會提供一個類似的API,理想情況下,大多數 元件 不需要從渲染器匯入任何內容,這使它們更靈活。
大多數人認為React的“引擎”在每個渲染器中。不過許多渲染器確實包含了同一份副本程式碼 —— 我們稱為"reconciler"。有個構建步驟將 reconciler 程式碼與渲染器程式碼融合成一份高度優化過的程式碼,以獲得更好的效能。(複製程式碼通常不利於依賴包大小,但絕大多數使用者一次只需要一個渲染器,例如react-dom
)
這裡要說的是,react
依賴包只讓你知道React有哪些功能,但不知道功能是如何實現的。渲染器依賴包(react-dom
、react-native
等)提供了React功能的實現和平臺特性的邏輯。其中一些程式碼是共享的("reconciler"),但更多的是各個渲染器的具體實現。
現在我們知道為什麼有功能時,react
和react-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
,你將使用的渲染器便不知道什麼是Provider
和Consumer
。這也是舊的react-dom
會引發型別無效錯誤的原因。
React Native同樣有這警告。不過不同於 React DOM,一次React更新發布不會“迫使”React Native也立即釋出新版本,它有自己一套發行時間表。更新的渲染器程式碼將單獨同步到React Native程式碼庫中。所以React Native和React DOM同一個功能,可以用上的時間是不同的。
好了,我們現在知道react
依賴包不包含任何有趣的內容,因為具體實現放到react-dom
、react-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)