【性能优化】一百行代码实现自己的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中,可以传入异步函数,不过整个数据更新流程都要调整下,等后续有空了再实现
相关推荐
qiyi.sky几秒前
JavaWeb——Web入门(8/9)- Tomcat:基本使用(下载与安装、目录结构介绍、启动与关闭、可能出现的问题及解决方案、总结)
java·前端·笔记·学习·tomcat
清云随笔22 分钟前
axios 实现 无感刷新方案
前端
鑫宝Code23 分钟前
【React】状态管理之Redux
前端·react.js·前端框架
忠实米线31 分钟前
使用pdf-lib.js实现pdf添加自定义水印功能
前端·javascript·pdf
pink大呲花34 分钟前
关于番外篇-CSS3新增特性
前端·css·css3
少年维持着烦恼.38 分钟前
第八章习题
前端·css·html
我是哈哈hh41 分钟前
HTML5和CSS3的进阶_HTML5和CSS3的新增特性
开发语言·前端·css·html·css3·html5·web
田本初1 小时前
如何修改npm包
前端·npm·node.js
明辉光焱1 小时前
[Electron]总结:如何创建Electron+Element Plus的项目
前端·javascript·electron