本文從動機脈絡聊聊對react生態中的狀態相關技術的演化過程。
個人理解,歡迎討論
響應式渲染框架
這裏只聊react的狀態和視圖渲染相關內容,不聊底層的Virtual DOM
react是一個mvvm框架,作為一個響應式渲染設計,當自身的模型(狀態)發生變化時,會自動刷新(re-render)當前視圖顯示最新的模型(狀態)數據。
那是如何監聽狀態發生變化呢?react本着極簡的api設計理念,遵循函數式編程中的不可變對象理念,對於狀態實現的特別簡單,只提供了一個setStateAPI。在react組件中,需要視圖發生變化時,只需要對調用setState進行數據時圖進行改變,就會觸發當前組件的re-render,完成更新。所以此時可以理解為,react是響應式設計的渲染框架,但其狀態不是響應式模式,是一個命令式狀態框架。如下面的代碼:
class Page extends React.Component<{}, {no: number}> {
constructor() {
super({});
this.state = {
no: 0,
};
}
render() {
console.log('render執行');
return <h1 onClick={() => {
this.setState({
no: 0
});
}}>Hello, {this.state.no}</h1>;
}
}
當執行setState時,雖然狀態no前後都是0, 但是組件的render還是會被重新執行。
這樣簡單的設計,既是react的優點又是react的缺點。優點是react只提供最底層的狀態更新api, 使用者可以使用市面上任意的其他狀態框架,整個框架不顯得笨重。缺點就是不能開箱即用,加重使用者心智負擔。
react中的單向數據流設計,在整個組件樹中,只允許狀態從頭流到葉子節點的原因是什麼呢?這是由於每一個組件的子節點都是執行在render函數中,類似於一個遞歸樹。噹噹前組在re-render時,就會重新調用子節點重新執行render,也就整個當前組件以下的整個組件都會re-render。所以當只有父向子的單向數據流時,這個調用流程只需要調用一次就可以把當前變化後的數據“響應”到視圖上。代碼效果:
const Foo: React.FC<Record<string, unknown>> = () => {
console.log('Foo被重新渲染了');
return (
<div>
Foo
</div>
);
}
const Bar: React.FC<Record<string, unknown>> = () => {
console.log('Bar被重新渲染了');
return (
<div>
Bar
</div>
);
}
class Parent extends React.Component {
constructor() {
super({});
this.state = {
no: 0,
};
}
render() {
console.log('Parent被重新渲染了');
return (
<div onClick={() => this.setState((preState) => ({ no: preState.no + 1 }))}>
<Foo />
<Bar />
</div>
);
}
}
當Parent中的狀態發生變化時,會發現Parent, Foo, Bar組件都發生了re-render。
react這種觸發時圖更新的機制在絕大多數情況下都會造成性能損失。因為數據更新是常態,特別是在一些持續觸發的事件中,每一次都更新整個節點樹,當業務場景體量稍微大一點導致react組件節點非常多時,碰到持續更新狀態的情況下性能就會非常差。這也就導致react生態中,狀態理念,框架層出不窮的根本原因。
雖然組件重新調用渲染函數(render)由於Virtual DOM的diff算法不一定更新dom結構(也就是最終視圖),但是render函數的反覆執行,也開銷特別大。
react單向數據流的規定保證噹噹前組件發生變化時,只需要重新渲染自己, 不會去渲染父組件和兄弟組件。所以下面的用法:
const Foo: React.FC = ({ actionRef }) => {
console.log('Foo被重新渲染了');
const [no, setNo] = React.useState(0);
React.useImperativeHandle(actionRef, () => ({ no }));
return (
<div onClick={() => setNo(preNo => preNo + 1)}>
Foo
</div>
);
}
const Bar: React.FC = ({ no }) => {
console.log('Bar被重新渲染了');
return (
<div>
Bar, {no}
</div>
);
}
const Parent: React.FC<Record<string, unknown>> = (props) => {
console.log('Parent被重新渲染了');
const actionRef = React.useRef(null);
return (
<div>
<Foo actionRef={actionRef} />
<Bar no={(actionRef.current || {}).no || 0} />
</div>
);
};
當在組件Foo中觸發狀態改變,只會觸發Foo組件re-render,雖然Parent和Bar也都是用了Foo的數據(注意是數據而不是狀態,通過ref傳遞了), 但是不會re-reder。
SCU
為了解決react默認狀態變更時觸發整個當前組件整個子節點樹更新的性能問題,react提供了SCU(shouldComponentUpdate)機制。使用者可以在這個生命週期函數中,根據觸發當前組件re-render的props,state和context跟當前還未re-render值進行對比,決定該組件是否的re-render。
為了簡化SCU的操作,react提供了PureComponent提供默認的比對算法,也就是對屬性集對象(props),狀態集對象(state)和上下文對象context進行頂層屬性的對比(淺對比),對象值採用的是引用對比方式。這樣在祖先節點狀態更新觸發整個節點樹更新時,當前組件會判斷如果傳入的屬性對比後發現沒有更新時,或者當前組件調用了setState但是狀態的值沒有發生變化時,都會跳過本組件的re-render,進而提高性能。此時React從命令式響應框架轉為比對式數據響應框架(Comparison reactivity)。
如下面的代碼點擊文本將不會觸發render的函數重新執行(如果不是繼承PureComponent的話render中的日誌會持續打印):
class Welcome extends React.PureComponent<{}, {name: string}> {
constructor() {
super({});
this.state = {
name: '123',
};
}
render() {
console.log('render執行');
return <h1 onClick={() => {
this.setState({
name: '123'
});
}}>Hello, {this.state.name}</h1>;
}
}
但PureComponent也會導致如下面代碼的問題:
class Welcome extends React.PureComponent<{}, {foo: {name: string}}> {
constructor() {
super({});
this.state = {
foo: {name: '123'},
};
}
render() {
console.log('render執行');
return <h1 onClick={() => {
const { foo } = this.state;
foo.name = '456';
this.setState({ foo });
}}>Hello, {this.state.foo.name}</h1>;
}
}
當我們點擊後是期望視圖有更新,顯示為456,但實際情況下不會。因為如上文所説,PureComponent只會對比props,state和context中頂級屬性值,並且對象值只採用引用對比(淺對比模式)。而在代碼中,狀態foo對象雖然內容變了,但是引用不變,所以react會認為狀態沒有發生改變,從而跳過更新。為了解決這個問題,react提出了不可變狀態對象的理念。簡單的理解為,存放在state中的對象數據,在自身引用沒有發生變化時,不允許其內部的值發生變化,也就是下面的代碼是不推薦的:
const { address, user, dataList } = this.state;
// 禁止在user引用值沒有變化時,改變了其內部值
user.name = 'foo';
// 特別容易發生在數組中
dataList.push('newItem');
// 下面這種是常犯的一種
address.city = 'changsha';
this.setState({
address,
});
而是推薦下面這種:
this.setState({
user: {
name: 'foo',
...this.state.user,
},
dataList: [...this.state.dataList, 'newItem'],
address: {
city: 'changsha',
...address,
},
});
整個react渲染就像動畫片放映一樣,不是局部內容的變化,而是一幀一幀的整體替換。當需要畫面變化時,就需要構建從上一幀複製內容到下一幀,然後在變化。禁止直接對老的幀直接改動。
上面的案例中,平常開發中稍微注意就可以遵循。但在一些複雜的場景下,如可編輯表格的每個行數據操作,在不方便對整個狀態對象(深度封裝下)進行創建新的對象時,就容易誤操作。
為了避免無意中沒有遵循react的immutable理念,可以採取兩種方式:
- 使用一些保證狀態為不可變對象的的lint規則(本人尚未發現社區有這一塊的內容);
- 使用immutable.js;
Hook
在class componets開發過程中,如果使用原生的react狀態的話,將會有以下缺陷(使用hook動機):
- 在組件之間複用狀態邏輯很難;
react中一切皆組件,對於公共代碼可以封裝成新的組件。但是對於一些公共的狀態邏輯,在mixin被廢除之後,卻沒有提供好的方式去封裝。而Hook可以在不改變組件結構,就可以複用狀態邏輯。相對於使用控制組件,Hook使用簡單,二次封裝非常快速。相對於使用mixin,Hook可以理解為mixin的升級版,維持住了調用鏈,解決了mixin中調試困難的難題。 - 複雜組件變得難以理解
由於以前狀態邏輯難以服用,就會導致一些組件中堆砌了大量的狀態邏輯。特別是一些作為控制組件的容器組件,其中堆滿了各種子組件之間用於狀態通信的邏輯。使用Hook之後,可以快速方便的對狀態進行分類放入不同的模塊中,組件代碼乾淨清爽。 - 完全函數式編程
使用hook可以完全擺脱class變成,擺脱怪異的this工作方式。函數式編程更稱靈活,可測試。hook可以理解為函數式編程中狀態的實現,不僅僅在react中使用。
hook中的關於狀態這一塊的api為useState,可以理解為把class中的this.State可以拆成多份去執行,一個useState就是一個狀態,廢棄了狀態集對象概念。並且在useState中一個更大的進步是吸取了以前教訓,直接引入了對比式更新,如果設置根當前值一樣的值時,整個組件將不會re-render:
const Foo: React.FC<Record<string, unknown>> = (props) => {
const [no, setNo] = React.useState(0);
console.log('Foo重新渲染了');
return (
<div onClick={() => setNo(0)}>
Foo, {no}
</div>
);
};
比對式更新不僅僅在useState中被使用,在其他的hook如useMemo, useEffect中的deps的參數中,都採取同樣的方式。
有了比對式更新,hook引語了一些響應式狀態流中的計算屬性概念(useMemo)。
跨組件傳遞狀態
上文中的狀態都是處於單個組件內部,在實際的場景中,還需要考慮在組件之間進行狀態通信。
react自帶方案
對於簡單的向另一個組件內傳遞狀態,可以使用props。props可以看作是父組件的狀態。當父組件的狀態發生變化時,會觸發當前組件的re-render(默認情況下),從而獲取到了最新的狀態。這種方式跟木偶組件有點像,容器(父)組件負責狀態邏輯,展示節點將狀態呈現在視圖。
如果一個組件需要接收祖先節點的狀態,此時如果使用props的話,會特別繁瑣,需要在整個樹路徑上都維持這個屬性傳遞下來(props透傳)。這種方式造成了整個鏈路都耦合底層組件的狀態使用,違反了編程原則造成後期維護特別困難。為了解決這個問題,react提供了Context方案。
但Context也只解決了同一個鏈路下組件的通信問題,如果是兄弟節點,或者是“親戚”(沒有直系關係)節點之間如何通信呢?react推薦使用狀態提升方式:對於需要通信的兩個組件,首先找到它們的共有祖先節點(對應組件可以稱為容器組件或者控制組件),然後將需要通信的數據作為這個祖先節點的狀態。當任意一個組件改變共享狀態時,會觸發整個祖先節點的re-render,默認情況下,這個祖先節點的所有子節點也會re-render, 也就是另一個組件就會獲取到最新的狀態值,完成整個狀態傳遞。
除react自帶方案之後,下面將會講幾種react生態中常見幾種類型的狀態庫。他們有的是基於react自帶方案的工具庫,有的是為了解決狀態提升而採取的其他方式。
unstated-next
unstated-next是unstated在react hooks中理念的重新實現。一個偽代碼的實現為:
function createContainer(useHook) {
const Context = React.createContext(null);
function Provider(props) {
const value = useHook(props.initialState);
return React.createElement(Context.Provider, { value }, props.children);
}
function useContainer() {
return React.useContext(Context) ?? throw new Error("Component must be wrapped with <Container.Provider>");
}
return { Provider, useContainer };
}
簡單的理解就是,將你自定義的hooks中的狀態存儲在context中進行組件共享。
那麼它的優點就是:簡單,其實就是對context二次封裝,雖然react16中context相對於以前版本簡便性已經有了極大的提高,但是在修改context中數據的方式下沉到自組件中還是比較繁瑣,而unstated-next恰恰可以解決這個點,可以狀態跟update函數快速維護。如:
function useCounter() {
let [count, setCount] = useState(initialState)
let decrement = () => setCount(count - 1)
let increment = () => setCount(count + 1)
return { count, setCount, decrement, increment }
}
let Counter = createContainer(useCounter)
同時它也解決了一個狀態提升帶來的問題:當一個容器下的組件需要通信的數據過多時,會發現這個容器下堆滿了各種狀態。且不同組件之間相互通信的狀態直接堆積在控制節點中,非常難以維護。unstated-next可以幫助我們對堆砌在容器內點中的各種狀態進行封裝管理,維護在單獨的數據文件中,保持容器組件的清爽。
缺點:本質上是一個工具庫,狀態提升帶來的其他問題它都有。
unstated是unstated-next在class components時代同理念庫,也是對context的二次封裝工具庫,不在單獨拿出來講解。
簡單事件流實現
狀態提升的方式解決組件通信會導致狀態集中在上層組件中,在渲染的過程中也會導致過多的額外組件re-render,造成性能低下。那有什麼方式可以精準的找到並只渲染需要渲染的組件呢?可以用事件流。
如下面的代碼:
import { TinyEmitter } from 'tiny-emitter';
const emitter = new TinyEmitter();
const Foo: React.FC<Record<string, unknown>> = () => {
const [message, setMessage] = React.useState<string>('init');
React.useEffect(() => {
emitter.on('updateMessage', (messageArg: string) => {
setMessage(messageArg);
});
}, []);
return (
<div>
Foo: {message}
</div>
);
}
const Bar: React.FC<Record<string, unknown>> = () => {
return (
<div onClick={() => {
emitter.emit('updateMessage', 'barClick');
}}>
Bar
</div>
);
}
const Normal: React.FC<Record<string, unknown>> = () => {
console.log('normal被重新渲染了');
return (
<div>
Normal
</div>
);
}
const Parent: React.FC<Record<string, unknown>> = (props) => {
console.log('props', props);
return (
<div>
<Foo />
<Bar />
<Normal />
</div>
);
};
當Bar組件跨組件非直系節點觸發Foo狀態變化時,只有Foo組件會re-render, 其他兄弟節點Normal和父節點Parent都不會re-render, 連Bar自己都不會re-render。
在實際使用過程中,一般不會像案例這樣使用。事件流偏向於命令式編程,且只是傳遞了想要修改狀態的指令,對於如何修改,需要放在監聽器裏面,也就是提供修改狀態的組件內部。也就是外部組件在當前組件沒有提供狀態修改事件之前,是無法進行狀態修改的。在需要大量數據狀態需要通信時,由於事件流太偏向於底層,大量開發時不方便複用。一般都是基於事件流中改造為發佈訂閲模式,進行聲明式的狀態管理。
如將上面的案例中事件流狀態傳遞,流程圖示意:
這裏做一個小的知識點(個人以前的疑惑,所以花費篇幅説明),在案例中事件的方式並不需要使用Context,為什麼那些狀態框架,如redux, mobx都有一個放在根節點位置Provider呢? 如:
import { Provider } from 'react-redux'
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
)
這是由於如上面的簡單事件流案例,會有一個事件實例emitter,如果需要支持多實例或者方便在組件中獲取到,就需要提供一個Provider存放在Context中。但是由於不是將數據存在了Context中,而是狀態管理器的實例存儲在Context中,所以狀態變化時,是不會觸發頂層節點re-render從而導致整個節點樹都re-render。基於事件流的框架會通過事件精準的找到目標組件並觸發他的re-render。所以相對來説,redux, mbox這種狀態管理框架,比使用react原生提供的方案性能會好的多。
redux
redux將所有的狀態都收歸在自己內部,不在使用react狀態。狀態由react中託管到redux後,對比於上面的簡單事件流流程過程,就變成了:
使用redux時,組件中通過dispatch觸發事件,事件的值傳遞為aciton(真實事件系統裏面稱為event對象),這個事件首先會被reducer監聽到。redux規定只能在reducer中修改狀態,當狀態修改之後,就會觸發的subscribe,在subscribe會觸發目標的組件re-render, 如案例:
import { createStore } from 'redux'
function counterReducer(state = { value: 0 }, action) {
switch (action.type) {
case 'counter/incremented':
return { value: state.value + 1 }
case 'counter/decremented':
return { value: state.value - 1 }
default:
return state
}
}
let store = createStore(counterReducer)
store.subscribe(() => console.log(store.getState()))
store.dispatch({ type: 'counter/incremented' }) // 會打印出{value: 1}
store.dispatch({ type: 'counter/incremented' }) // 會打印出{value: 2}
store.dispatch({ type: 'counter/decremented' }) // 會打印出{value: 1}
redux中使用reducer替代了react中的setState函數,沒有命令式的改變狀態的含義(但個人覺得其實就是放在了命令式調用dispatch而已),也就是説一旦觸發了reducer的執行,就意味有狀態由發生變化。就會觸發監聽器subscribe。
根據上面的案例,會發現任意一個狀態發生變化時(執行dispatch),所有的副作用都會執行。這需要在subscribe中對組件進行是否需要re-render時,需要深入判斷當前組件依賴的狀態是否發生變化。在react-redux(8.0版本connect)中實現的邏輯是:
actualChildProps = useSyncExternalStore(
subscribeForReact,actualChildPropsSelector,
getServerState
? () => childPropsSelector(getServerState(), wrapperProps)
: actualChildPropsSelector
)
其中useSyncExternalStore為官方提供的hook,也就是説當redux中的狀態發生變化時,就會觸發各個connectHOC中的訂閲器,訂閲器會執行傳遞進去的mapStateToProps函數,獲取當前組件需要從store獲取的狀態,拿到狀態後,還會進行淺對比(跟react hook對比算法一致,對比各個狀態的引用值),如果發現狀態沒有變化,那麼返回的是一個歷史值(不會觸發更新),如果狀態有變化,則返回新的狀態對象,觸發當前組件re-render。
在新版本的redux中,直接提供了hook useSelector來觸發目標組件的re-render, 核心邏輯跟connect中基本一致:
const { store, subscription, getServerState } = useReduxContext()!
const selectedState = useSyncExternalStoreWithSelector(
subscription.addNestedSub,
store.getState,
getServerState || store.getState,
selector,
equalityFn
)
在上下文中獲取當前的store實例,然後實現React.useSyncExternalStore, 在store中的狀態發生變化時,根據選擇器判斷是否需要觸發當前狀態發生改變,從而決定當前的組件是否需要re-render。
另外由於所有的狀態都是推薦使用redux去管理(單一數據源),那麼存放在redux中的狀態將會非常多。為了方便管理,redux提供了命名空間的概念。
從上面的討論可以看出,redux跟react的思想是極其相近的,都是遵循狀態的不可變immutable,都採用比較式數據響應框架(Comparison reactivity)。redux提出了一些新的理念(方法論),利用一些編程範式,規範整個狀態的變化週期,防止誤操作。但對於如何準確的找到需要渲染的組件,redux還是在react-redux中使用老辦法,對於狀態進行前比較。這導致其還是沒有解決react中的狀態管理的一些缺點:
- immutable編程帶來的一些心智負擔
- 誤操作導致一些非必要的組件re-render
而對於redux中無法準確找到需要re-render的組件的難題,而社區慢慢出現利用一些代理的技術手段(es5中的Object.definePropert或者es6的ProxyAPI)進行狀態管理自動收集的方式來解決。這些解決方案可以降低開發者心智負擔和難度,下文將要講解的mbox就是這其中的一種。
mobx
類似框架: Recoil, zustand, jotai
react是一直走不可變對象immutable理念,但mutable實在是太香了,特別競爭框架vue,Solidjs,Svelte都採用了。故meta公司也出了一個mutable框架,支持在react中使用訂閲響應式狀態管理(Subscription reactivity)。
mobx跟redux一樣,都是將所有的狀態從react中拿出來自己管。但不同於redux的單一數據流理念,可以根據需要通信的數據靈活創建不同的狀態對象,方便搭配hook使用。
相對比於簡單事件流,在狀態放入內部管理後,mobx不僅利用Object.definePropert或者ProxyAPI對創建的數據對象進行攔截監聽,還能收集到使用這些狀態的代碼自動設置監聽器(在mobx中叫做派生)。這樣在對象的值發生變化時,就會自動觸發事件,執行對應的監聽器。
在這種方式下,需要開發者做的事情就只剩下定義狀態,聲明副作用(派生函數)即可。整個流程為:
在這裏我們不過多的討論mobx狀態的底層原理和它的一些新的概念。對於我們關注的mobx如何將它內部的狀態變化後觸發對應的組件re-render的流程,通過下面的代碼可以用於討論:
const state = observable({ value: 0 });
const increment = action(() => {
state.value++
});
autorun(() => {
console.log("Energy level:", state.value);
})
increment(); // Energy level: 1
action中可以直接改變狀態,當某個狀態被改變後,mobx會自動執行它的的派生函數(類似於上文説的監聽器),並且由於整個狀態都是響應式的,所以派生函數可以延長,實現具有緩存作用的計算屬性機制。最後會觸發一個派生函數(autorun)。mobx會自動對派生函數中使用的狀態進行收集,保證只有使用的狀態發生變化時才會觸發該autorun函數。
整個效果可以看到,在mobx中,完全可以廢棄setState這種命令式的通知框架狀態已經更新的方式。採用Object.definePropert或者ProxyAPI實現聲明式的監聽到狀態的變化。並且通知具體的組件的re-render,也不需要中間加一個對比層,直接通過執行過程中維護的監聽隊列,自動完成對應組件的更新觸發。
關於mobx是符合自動收集到派生函數中狀態的使用信息,從而自動根據狀態的變化只觸發需要變化的申請操作是如何實現的。個人猜測是在初次執行的時候,對狀態的get操作進行攔截收集的。推測代碼如下:
const state = observable({
value1: 0,
value2: 0,
});
const increment = action(() => {
state.value1++;
});
autorun(() => {
console.log("autorun1 value1:", state.value1);
})
let a = false;
autorun(() => {
if (a) {
console.log("autorun2 value1:", state.value1);
}
console.log("autorun2 value2:", state.value2);
})
const Foo: React.FC<Record<string, unknown>> = () => {
return (
return <h1 onClick={() => {
a = true;
increment();
}}>Hello, {this.state.no}</h1>;
);
};
當調用increment函數後,你會發現autorun2也不會被執行,只有autorun1函數被執行了。這是由於在第一次執行兩個autorun函數時,由於對於變量a為false,導致只收集到了autorun2對value2的依賴,所以當value1發生變化時,autorun2還是不會執行。 對上面的代碼進行下改造:
const state = observable({
value1: 0,
value2: 0,
value3: 0,
});
const increment = action(() => {
state.value1++;
if (state.value1 > 5 && !state.value3) {
state.value3 = 1;
}
});
autorun(() => {
console.log("state1 value1:", state.value1);
})
autorun(() => {
if (state.value3 > 0) {
console.log("state2 value:", state.value1, state.value3);
}
console.log("state2 value2:", state.value2);
})
可以發現,前面五次執行increment對state.value1的變化,不會觸發autorun2的執行,當第五次對state.value3也進行改變後,後續的每一次state.value1的變化(此時state.value3已經不在變化)也會觸發autorun2的執行,所以可以推測出收集不僅僅在第一次執行的時候收集完畢就一成不變,而是在每次執行後會更新對應狀態的監聽隊列。