如果你也喜歡使用react的函數組件,並喜歡使用react原生的hook進行狀態管理,但為了跨組件狀態流而不得不引入redux,MboX這種具有自己獨立的狀態管理的重量級/對象級的狀態流框架的話,本文會給你提供一種新的極其輕量的解決跨組件狀態流方案。
Context的問題
首先探討如果不採用redux,mobx,使用原生的react的跨組件共享狀態方案Context,會具備那些問題?
react原生的跨組件通信為Context。在使用Context進行組件之間通信時,需要進行狀態提升,提升到需要通信的組件的公共的祖先節點之中。這會導致當數據的變化時祖先節點產生re-render, 從而祖先節點中的整個組件樹都會re-render,帶來非常大的性能損失。react官方推薦使用React.memo包裹函數,降低非必要組件渲染。如:
const Context = React.createContext<any>({})
const SubCompA: React.FC<{}> = React.memo(() => {
console.log('渲染了A');
const { number } = React.useContext(Context);
return (<div>
{number}
</div>);
});
const SubCompC: React.FC<{}> = React.memo(() => {
console.log('渲染了C');
const { setNumber } = React.useContext(Context);
return (<button className='__button' onClick={() => {
setNumber(10);
}}>我是按鈕</button>);
});
const SubCompB: React.FC<{}> = React.memo(() => {
console.log('渲染了B');
return (<div>
<SubCompC />
</div>);
});
const SubCompD: React.FC<{}> = React.memo(() => {
console.log('渲染了D');
return (<div></div>);
});
const Root: React.FC<{}> = React.memo(() => {
console.log('渲染了Root');
const [number, setNumber] = React.useState(1);
return (<Context.Provider value={{ number, setNumber }}>
<SubCompA />
<SubCompB />
<SubCompD />
</Context.Provider>);
});
在本案例中,點擊按鈕後,會導致組件SubCompA, SubCompC, Root組件re-render,但SubCompC, Root都是不受期望的re-render。且在實際使用情況下,性能會損失更大,因為:
- 不會把每一個狀態單獨放到一個的Context中。當Context中包含多個狀態時,任何一個狀態發生變化後,不管有沒有依賴具體發生變化的那個狀態,所有使用了該Context的組件都會更新,導致re-render的非法擴散(不受期望的re-render)。
- 非常依靠
React.memo發揮效果,但在實際開發過程,使React.memo保持完美運行是一件非常困難的事情。如不應該傳遞給組件的屬性值使用對象和函數的字面量。
如下面的對於組件的使用:
const CompA: React.FC<{}> = React.memo(() => {
return (<div>1</div>);
});
const Root: React.FC<{}> = React.memo(() => {
return (<CompA objectProp={{ name: 'joy' }} onClick={() => {
// ....
}} />);
});
在本案例中,上文對於CompA進行React.memo包裹將沒有一點意義。需要調整為:
const CompA: React.FC<{}> = React.memo(() => {
return (<div>1</div>);
});
const Root: React.FC<{}> = React.memo(() => {
const objectProp = React.useMemo(() => ({ name: 'joy' }));
const handleClick = React.useCallback(() => {
// ....
}, []);
return (<CompA objectProp={objectProp} onClick={handleClick} />);
});
這裏並不是想説memo沒有必要。memo是提升性能的一個很重要的手段,在平常開發過程中,非常需要嚴格遵循,努力使memo發揮作用。
綜上所述,Context中的性能損失,主要的原因是狀態提升導致更大範圍的組件re-render造成。
新的方案
為了解決原生Context的問題,不能進行狀態進行提升,而是在不同的組件中存在多個相同含義的狀態,然後通過統一的機制管理這些狀態的值,使它實際效果跟Context狀態提升的狀態一致即可。管理機制可以採取事件。
如:
const eventEmitter = new EventEmitter();
const CompA: React.FC<{}> = React.memo(() => {
const [age, setAge] = React.useState(0);
React.useEffect(() => {
eventEmitter.addListener('updateAge', setAge);
}, []);
return (<div>{state}</div>);
});
const CompB: React.FC<{}> = React.memo(() => {
return (<div onClick={() => {
eventEmitter.emit('updateAge', 10);
}}>1</div>);
});
const Root: React.FC<{}> = React.memo(() => {
return (<>
<CompA />
<CompB />
</>);
});
但實際場景中,不能這樣使用,因為:
- 在複雜系統中,需要的管理的狀態流非常龐大,隨着迭代事件名也非常難以管理,為解決重名問題慢慢也會蜕變成redux或者MboX那種採取對象命名空間;
- 相同意義的狀態,實際上還是會存在多個狀態(不同組件上),這些狀態除了受到受到事件的管理,還能自己控制,極易帶來數據沒有保持一致的風險;
解決事件名的問題,可以採取動態創建隨機的事件名來解決。在需要通信的組件共同的祖先節點中,封裝一個事件監聽管理器中,屏蔽掉內部事件名的邏輯:
const eventEmitter = new EventEmitter();
function useSharedState() {
const eventNameRef = React.useRef<string>(`SHARE_STATE_${String(Math.random()).slice(2)}`);
React.useEffect(() => {
const eventName = eventNameRef.current;
return () => {
// 註銷事件
if (emitter.eventNames().includes(eventName)) {
emitter.removeAllListeners(eventName);
emitter.off(eventName);
}
};
}, []);
const emit = React.useCallback((value) => {
emitter.emit(eventNameRef.current, value);
}, []);
const addListener = React.useCallback((callback) => {
eventEmitter.addListener(eventNameRef.current, callback);
}, []);
const channel = React.useMemo(() => ({
emit, addListener,
}), []);
return channel;
}
const Context = React.createContext<any>({});
const CompA: React.FC<{}> = React.memo(() => {
const { channel } = React.useContext(Context);
React.useEffect(() => {
channel.addListener(setAge);
}, []);
return (<div>{state}</div>);
});
const CompB: React.FC<{}> = React.memo(() => {
return (<div onClick={() => {
channel.emit(10);
}}>1</div>);
});
const Root: React.FC<{}> = React.memo(() => {
const channel = useSharedState();
return (<Context.Provider value={{ channel }}>
<CompA />
<CompB />
</Context.Provider>);
});
為了節省內存的使用,所有的事件通信將使用同一個事件流。
為了保證狀態值一致性更加可控,也為了使「狀態」看起來更加像一個狀態,還需要將每個組件中的狀態的使用和更新進行封裝起來:
const eventEmitter = new EventEmitter();
function useSharedState() {
const eventNameRef = React.useRef<string>(`SHARE_STATE_${String(Math.random()).slice(2)}`);
React.useEffect(() => {
const eventName = eventNameRef.current;
return () => {
// 註銷事件
if (emitter.eventNames().includes(eventName)) {
emitter.removeAllListeners(eventName);
emitter.off(eventName);
}
};
}, []);
const setValue = React.useCallback((value) => {
emitter.emit(eventNameRef.current, value);
}, []);
const addListener = React.useCallback((callback) => {
eventEmitter.addListener(eventNameRef.current, callback);
}, []);
const useValue = React.useMemo(() => {
return () => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [state, setState] = React.useState(valueRef.current);
React.useLayoutEffect(() => {
addListener(setState);
}, []);
return state;
};
}, []);
const channel = React.useMemo(() => ({ useValue, setValue }), []);
return channel;
}
在組件的共同祖先節點中,會創建一個複雜的狀態通信管理器,可以稱之為通道。通道通過Context下傳到各個需要的組件,由於通道都是常量值,本身是不會觸發任何組件的re-render。利用通道可以創建狀態,此時才會創建一個真正的react狀態,狀態的更新將會導致當前的組件的re-render。同時通道封裝了對這個狀態的值更新邏輯,當在任何一個組件中更新當前react狀態時,都會通過事件同步到其他組件的同樣業務含義的react狀態,達到「感覺就是一個狀態」的效果。
至此,一個跨組件的react狀態流就已經實現。然後為了提高可用性,參考一些signal相關設計添加一些api,支持一些特殊場景,在增加億點點細節,變為:
import * as React from 'react';
import EventEmitter from 'eventemitter3';
import isFunction from 'lodash.isfunction';
export type Value<A> = (A | ((prevState: A) => A));
export type Dispatch<A> = (value: Value<A>) => void;
export type UseValue<A> = () => A;
export type GetValue<A> = () => A;
export type SubscribeCallback<A> = (value: A) => void;
export type Subscribe<A> = (callback: SubscribeCallback<A>) => () => void;
const emitter = new EventEmitter();
export interface Channel<S> {
/**
* 獲取信號最新值,該值不支持響應式
*/
getValue: GetValue<S>;
/**
* 獲取信號值的hook,注意符合hook的使用規範
*/
useValue: UseValue<S>;
/**
* 設置信號值
*/
setValue: Dispatch<S>;
/**
* 信號值變化的訂閲函數
*/
subscribe: Subscribe<S>;
}
export default function useSharedState<S>(
initialState: S | (() => S),
): Channel<S> {
const eventNameRef = React.useRef<string>(`SharedState_${String(Math.random()).slice(2)}`);
const initialValue: S = React.useMemo(() => {
if(isFunction(initialState)) {
return initialState();
}
return initialState;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const valueRef = React.useRef<S>(initialValue);
React.useEffect(() => {
const eventName = eventNameRef.current;
return () => {
if (emitter.eventNames().includes(eventName)) {
emitter.removeAllListeners(eventName);
emitter.off(eventName);
}
};
}, []);
const dispatch: Dispatch<S> = React.useCallback<Dispatch<S>>((value) => {
valueRef.current = isFunction(value) ? value(valueRef.current) : value;
emitter.emit(eventNameRef.current, valueRef.current);
}, []);
const subscribe: Subscribe<S> = React.useCallback<Subscribe<S>>((callback) => {
// 避免重複註冊
emitter.off(eventNameRef.current, callback);
emitter.addListener(eventNameRef.current, callback);
// 註銷
return () => {
emitter.off(eventNameRef.current, callback);
};
}, []);
const useValue: UseValue<S> = React.useMemo<UseValue<S>>(() => {
return () => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [state, setState] = React.useState<S>(valueRef.current);
const subscribeFn = React.useCallback<SubscribeCallback<S>>((value) => {
setState(value);
}, []);
// eslint-disable-next-line react-hooks/rules-of-hooks
React.useLayoutEffect(() => {
const unsubscribe = subscribe(subscribeFn);
return () => {
unsubscribe();
};
}, [subscribeFn]);
return state;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const getValue: GetValue<S> = React.useCallback<GetValue<S>>(() => {
return valueRef.current;
}, []);
const sharedState = React.useMemo<Channel<S>>(() => ({
useValue, getValue, setValue: dispatch, subscribe,
}), []);
return sharedState;
}
相關庫已經發布到npm上,為@joyer/react-use-shared-state, 歡迎體驗。
支持react>16.18, 特別聲明支持18版本, 本人項目中已經使用並上線2年多
優勢
- 非常輕量,改方案想要解決的問題非常簡單,本質上也就是一個事件流工具;
- 由於輕量,所以靈活。
- 不依賴react.memo,連equals計算消耗都沒有;
- 保持跟useState同樣的顆粒度。當你不需要redux,mobx這些基於對象的狀態流,不喜歡抽象什麼領域,模型的情況下,使用改方案體驗非常友好,使用體驗也是非常接近於useState;
- 性能卓越,非常容易做到「真正需要渲染的地方才渲染」的效果;
- 非常容易集成到已有系統。就算接手的系統已經是一座「屎山」,使用react-use-shared-state進行改造也非常簡單,只需要對跨組件的狀態進行一一改造即可,還可以漸進式慢慢調整。對於不考慮後續可維護性和可讀性的話,可以簡單的將一個頁面的跨組件狀態都放在同一個地方,且這種行為不會影響性能。