前言
在React
中,Context
是一种用于在组件之间共享数据的方法,可以避免通过逐层传递props
的方式来传递数据。通过Context
,您可以在组件树中直接传递数据,而不必在每个级别手动传递props
。这在需要在多个组件之间共享数据时非常有用。
但是,当使用Context
时,如果一个数据发生变更,所有依赖该数据的组件都会重新渲染,即使它们可能并不需要更新。这可能导致性能问题,特别是在组件层级较深或数据依赖关系复杂的情况下。
不必要的重新渲染可能会影响应用的性能,并增加资源消耗。因此,过度依赖Context
可能会导致性能下降和不必要的渲染。
另类的使用方式
既然任一数据发生变更,所有依赖该数据的组件都会重新渲染,那能否不让数据发生变更呢?
我在之前的文章一种被动触发的 React Context 使用模式讲过一种使用方式,Context.Provider
组件在value
中仅暴露一个注册函数,用来将依赖Context
中的业务处理函数进行注册。当state
发生变化时,逐个调用所有的注册函数,将最新的state
作为参数传入。业务处理函数内部判断是否更新该组件。useSelector
使用了类似的方式。
在Redux
中,使用useSelector
可以帮助避免不必要的重新渲染。useSelector
是React-Redux
提供的一个hook
,它允许组件从Redux store
中选择并订阅部分数据。当Redux store
中的数据发生变化时,只有与useSelector
中选择的数据相关的组件会重新渲染,而不是所有依赖于Context
的组件都重新渲染。这种精确选择数据的方式可以提高性能,避免不必要的渲染,同时保持数据的一致性。
最近对useSelector
的实现原理感兴趣,查了下资料,发现原理比预想中的要简单,因此自己手动做了一个简单的实现。
useSelector 实现
以包含两个计数器的组件为例,一般我们会将组件依赖的值放入value
中,这样value
中的任何一个值变化,都会引起业务组件的更新。现在,我们试着存储一个不可变对象。
js
// store 存在 context 里面
const context = createContext<{ store: any }>({} as any);
function MyProvider({ children }) {
// 使用useState,只初始化一次store
const [store] = useState(() =>
createStore((state = { count1: 0, count2: 0 }, action) => {
switch (action.type) {
case 'inc1':
return { ...state, count1: state.count1 + 1 };
case 'dec1':
return { ...state, count1: state.count1 - 1 };
case 'inc2':
return { ...state, count2: state.count2 + 1 };
case 'dec2':
return { ...state, count2: state.count2 - 1 };
default:
return state;
}
})
);
// 将 store 存在 context 里面给 hook 用
const ctx = useMemo(() => {
return { store };
}, [store]);
return <context.Provider value={ctx}>{children}</context.Provider>;
}
store
是不变对象,因此Context
对应的value
值永远不会变化。那怎么做到在业务组件中使用和变更store
中的值呢?下面我们先来看下createStore
的实现。
js
// 简单实现 Redux 中的 createStore 函数
export function createStore(reducer) {
let state;
let listeners = [];
const getState = () => state;
const dispatch = (action) => {
state = reducer(state, action);
listeners.forEach((listener) => listener());
};
const subscribe = (listener) => {
listeners.push(listener);
return () => {
listeners = listeners.filter((l) => l !== listener);
};
};
dispatch({}); // 初始化 state
return { getState, dispatch, subscribe };
}
函数返回了一个对象{ getState, dispatch, subscribe }
。但是业务组件中,不能直接使用store
,需要依赖两个hook
来实现取值和派发
js
function useMySelector(fn) {
// 这个计数器是为了强制更新组件
const [, setCount] = useState(0);
const { store } = useContext(context);
// 调用 fn 来获取状态
const prevState = fn(store.getState());
useEffect(() => {
// 订阅状态变化事件
return store.subscribe(() => {
// 获取最新状态
const newState = fn(store.getState());
/**
* 重点代码
*
* 比较新旧状态,如果有变化才强制更新
*
* 使用 lodash.isEqual 来对对象进行深比较
*/
if (!isEqual(prevState, newState)) {
setCount((c) => c + 1);
}
});
}, [store, fn, prevState]);
return prevState;
}
function useMyDispatch() {
// 这个比较简单,只是返回 store.dispatch
const { store } = useContext(context);
return store.dispatch;
}
准备工作做完了,可以上业务组件了。准备两个最简单的计数器~
js
/**
* APP
*
* count1 在 Component1 中使用
*
* 预期更新 count1,Component2 不会被重新渲染,反之亦然。
*/
function Component1() {
const count = useMySelector((s) => s.count1);
const dispatch = useMyDispatch();
// 用于确认组件是否被重新渲染
console.log('re1');
return <span onClick={() => dispatch({ type: 'inc1' })}>{count}</span>;
}
function Component2() {
const count = useMySelector((s) => s.count2);
const dispatch = useMyDispatch();
// 用于确认组件是否被重新渲染
console.log('re2');
return <span onClick={() => dispatch({ type: 'inc2' })}>{count}</span>;
}
export default function App() {
return (
<MyProvider>
<div className="App">
<h1>
<Component1 />
</h1>
<h2>
<Component2 />
</h2>
</div>
</MyProvider>
);
}
组件化
上面用最简单的例子实现了useSelector
,虽然代码不多,但每次都重新写一遍也不合适。这里抽取一些通用逻辑,并添加 TS 定义。
js
import { useState, useContext, useEffect, Context } from 'react';
import { isEqual } from 'lodash';
export interface IStore<T, K> {
getState: () => T;
dispatch: <Key extends string & keyof K>(props: { type: Key; payload?: K[Key] }) => void;
subscribe: (fn: () => void) => () => void;
}
export function createStore<T, K>(
reducer: <Key extends string & keyof K>(state: T, action: { type: Key; payload: K[Key] }) => T
): IStore<T, K> {
let state;
let listeners = [];
const getState = () => state;
const dispatch = (action) => {
state = reducer(state, action);
listeners.forEach((listener) => listener());
};
const subscribe = (listener) => {
listeners.push(listener);
return () => {
listeners = listeners.filter((l) => l !== listener);
};
};
dispatch({}); // 初始化 state
return { getState, dispatch, subscribe };
}
export function useMySelector<T, Y, K>(
context: Context<{ store: IStore<T, Y> }>,
fn: (state: T) => K
) {
// 这个计数器是为了强制更新组件
const [, setCount] = useState(0);
const { store } = useContext(context);
// 调用 fn 来获取状态
const prevState = fn(store.getState());
useEffect(() => {
// 订阅状态变化事件
return store.subscribe(() => {
// 获取最新状态
const newState = fn(store.getState());
/**
* 重点代码
*
* 比较新旧状态,如果有变化才强制更新
*
* 使用 lodash.isEqual 来对对象进行深比较
*/
if (!isEqual(prevState, newState)) {
setCount((c) => c + 1);
}
});
}, [store, fn, prevState]);
return prevState;
}
export function useMyDispatch<T, K>(context: Context<{ store: IStore<T, K> }>) {
// 这个比较简单,只是返回 store.dispatch
const { store } = useContext(context);
return store.dispatch;
}
在使用时,需定义store
中的存储的state
接口定义以及dispatch
可以支持的type
和对应的payload
。
js
interface IState {
count1: number;
count2: number;
}
interface IDispatchProps {
inc1: Record<string, unknown>; // 因为此type不需要payload,使用 Record<string, unknown>代替any
inc2: Record<string, unknown>;
dec1: Record<string, unknown>;
dec2: Record<string, unknown>;
}
// store 存在 context 里面
const context = createContext<{
store: IStore<IState, IDispatchProps>;
}>({} as any);
function MyProvider({ children }) {
const [store] = useState(() =>
createStore<IState, IDispatchProps>((state = { count1: 0, count2: 0 }, action) => {
switch (action.type) {
case 'inc1':
return { ...state, count1: state.count1 + 1 };
case 'dec1':
return { ...state, count1: state.count1 - 1 };
case 'inc2':
return { ...state, count2: state.count2 + 1 };
case 'dec2':
return { ...state, count2: state.count2 - 1 };
default:
return state;
}
})
);
// 将 store 存在 context 里面给 hook 用
const ctx = useMemo(() => {
return { store };
}, [store]);
return <context.Provider value={ctx}>{children}</context.Provider>;
}
/**
* APP
*
* count1 在 Component1 中使用
*
* 预期更新 count1,Component2 不会被重新渲染,反之亦然。
*/
function Component1() {
const count = useMySelector(context, (s) => s.count1);
const dispatch = useMyDispatch(context);
// 用于确认组件是否被重新渲染
console.log('re1');
return <span onClick={() => dispatch({ type: 'inc1', payload: {} })}>{count}</span>;
}
function Component2() {
const count = useMySelector(context, (s) => s.count2);
const dispatch = useMyDispatch(context);
// 用于确认组件是否被重新渲染
console.log('re2');
return <span onClick={() => dispatch({ type: 'inc2' })}>{count}</span>;
}
export default function App() {
return (
<MyProvider>
<div className="App">
<h1>
<Component1 />
</h1>
<h2>
<Component2 />
</h2>
</div>
</MyProvider>
);
}
封装完成后,再使用useSelector
就可以像使用Context
+useReduce
的组合方式来快速搭建自己的Context
上下文了。同时useMySelector
和useMyDispatch
会自动依据传入的context
,解析出返回的值。当错误的传入 type
或 type
对应的 payload
时,ts 会自动提示。
优点
- 除了
lodash
,无任何外部依赖,直接复制代码即可。 - 改造成本低,基本开箱即用。
- 通过TS限定了
dispatch
的动作,不会传入错误的类型和数据。 - 可以像使用
Context
的方式使用,支持多级嵌套和多实例。不同的上下文之间无耦合。
优化
useSelector
中,可以传入自定义的比较函数。因为是个demo,所以各位可以自行实现dispatch
中,可以传入异步函数,不过整个数据更新流程都要调整下,等后续有空了再实现