【性能优化】一百行代码实现自己的useSelector

前言

React中,Context是一种用于在组件之间共享数据的方法,可以避免通过逐层传递props的方式来传递数据。通过Context,您可以在组件树中直接传递数据,而不必在每个级别手动传递props。这在需要在多个组件之间共享数据时非常有用。

但是,当使用Context时,如果一个数据发生变更,所有依赖该数据的组件都会重新渲染,即使它们可能并不需要更新。这可能导致性能问题,特别是在组件层级较深或数据依赖关系复杂的情况下。

不必要的重新渲染可能会影响应用的性能,并增加资源消耗。因此,过度依赖Context可能会导致性能下降和不必要的渲染。

另类的使用方式

既然任一数据发生变更,所有依赖该数据的组件都会重新渲染,那能否不让数据发生变更呢?

我在之前的文章一种被动触发的 React Context 使用模式讲过一种使用方式,Context.Provider组件在value中仅暴露一个注册函数,用来将依赖Context中的业务处理函数进行注册。当state发生变化时,逐个调用所有的注册函数,将最新的state作为参数传入。业务处理函数内部判断是否更新该组件。useSelector使用了类似的方式。

Redux中,使用useSelector可以帮助避免不必要的重新渲染。useSelectorReact-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上下文了。同时useMySelectoruseMyDispatch会自动依据传入的context,解析出返回的值。当错误的传入 typetype 对应的 payload 时,ts 会自动提示。

优点

  1. 除了lodash,无任何外部依赖,直接复制代码即可。
  2. 改造成本低,基本开箱即用。
  3. 通过TS限定了dispatch的动作,不会传入错误的类型和数据。
  4. 可以像使用Context的方式使用,支持多级嵌套和多实例。不同的上下文之间无耦合。

优化

  1. useSelector中,可以传入自定义的比较函数。因为是个demo,所以各位可以自行实现
  2. dispatch中,可以传入异步函数,不过整个数据更新流程都要调整下,等后续有空了再实现
相关推荐
玩电脑的辣条哥2 小时前
Python如何播放本地音乐并在web页面播放
开发语言·前端·python
ew452182 小时前
ElementUI表格表头自定义添加checkbox,点击选中样式不生效
前端·javascript·elementui
suibian52352 小时前
AI时代:前端开发的职业发展路径拓宽
前端·人工智能
Moon.92 小时前
el-table的hasChildren不生效?子级没数据还显示箭头号?树形数据无法展开和收缩
前端·vue.js·html
垚垚 Securify 前沿站2 小时前
深入了解 AppScan 工具的使用:筑牢 Web 应用安全防线
运维·前端·网络·安全·web安全·系统安全
工业甲酰苯胺5 小时前
Vue3 基础概念与环境搭建
前端·javascript·vue.js
mosquito_lover16 小时前
怎么把pyqt界面做的像web一样漂亮
前端·python·pyqt
柴柴的小记8 小时前
前端vue引入特殊字体不生效
前端·javascript·vue.js
柠檬豆腐脑9 小时前
从前端到全栈:新闻管理系统及多个应用端展示
前端·全栈
bin91539 小时前
DeepSeek 助力 Vue 开发:打造丝滑的颜色选择器(Color Picker)
前端·javascript·vue.js·ecmascript·deepseek