如果你也喜欢使用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进行改造也非常简单,只需要对跨组件的状态进行一一改造即可,还可以渐进式慢慢调整。对于不考虑后续可维护性和可读性的话,可以简单的将一个页面的跨组件状态都放在同一个地方,且这种行为不会影响性能。
网友评论