likes
comments
collection
share

如何解决react中context的性能问题

作者站长头像
站长
· 阅读数 79

React在render流程中通过全等判断props是否改变,如果判断为有改变组件将会重新渲染,并且会影响到其子节点,这就是React渲染的”传染性“。有两种方式解决这个问题:

  1. 根据”变与不变分离“的原则优化组件结构
  2. 使用memo

使用其一或结合使用,通常能解决问题。

React在render流程中通过Object.is判断context的value是否改变,这也会造成类似上面所说的问题,具体来说,当一个组件通过useContext订阅了一个来自context的状态,就意味着它一直会跟着context的更新而渲染,即使它订阅的状态没有真的改变,因为Object.is不能深入到对象内部比较。

然而,我们并不能像props一样通过调整组件结构来解决问题,也没有性能优化API可以使用。

在使用context+reducer进行状态管理的应用,context带来的性能问题通常会比较明显。这篇文章的诞生,也是因为我在开发针对于可视化大屏的编辑器时碰到了这个问题。

这个编辑器的物料组件是基于echarts开发的,echarts渲染会比简单组件更耗时,当我在画布上拖入了比较多的组件,任何操作context状态的动作都会有明显的掉帧,比如缩放画布、更改某一个组件的属性,都会导致画布上所有组件重新渲染。

针对这个问题,我一开始有两个解决思路

  1. 模仿react-redux的useSelector,从context中选择组件需要的状态。但很快pass掉了❌,要选择状态,必然要先通过useContext拿到所有状态,然而一旦使用了就意味着我们已经订阅了context,选择也就没有了意义。
  2. 写一个高阶组件,在这里从context中选择(或者说订阅)状态,通过props传给子组件,结合memo就可以解决问题。

就像这样:

const withContext = (Comp: typeof React.Component, contextValueKeys: string[])=>{
  const selectValue : any= {};
  const selector = (ctxv: any)=>{
    contextValueKys.forEach((k:string) => {
      selectValue[k] = ctxv[k];
    });
  }
  const contextValue = selector(useContext(Context));
  return React.memo(props => <Comp {...props} {...selectValue} />)
}

当然这段代码不完善,仅仅是提供一个思路,感觉很简单,但设计过程还是遇到了一些问题,下面我以需求驱动的方式详细记录一下📝,我把这个它命名为withContext

怎么订阅多个context?

我们一般不会把很多个关联性不大的状态都放在一个context里,那怎么在组件内订阅多个context呢?首先为了通用性不可能在组件内把需要的context都写上,肯定是想把需要的context通过参数传过去,但这意味着组件内一定会在循环或条件判断里使用useContext,这违反了hooks的使用原则。

钩子比其他功能更具限制性。您只能在组件(或其他 Hooks)的顶部调用 Hooks。 如果您想useState在条件或循环中使用,请提取一个新组件并将其放在那里。

hooks有这个限制主要是因为函数组件的fiber节点中有一个存放所有使用的hook的链表,这些hook在函数组件每次运行时都要按顺序执行并且数量不可以改变,如果在条件或循环中使用可能造成数量或顺序改变从而造成错误。

我们如果在withContext循环使用useContext时保证不会使hook数量或顺序改变,理论上是可以的。

type WithContextsItem = {
  propName?: string; // 注入props中的propName,不传默认为"context"
  context: Context<any>; // Context实例
  selector: WithContextSelector; // 从Context中选值的字段配置
};
type WithContexts = WithContextsItem[];

const withContext = (
  contexts: WithContexts,
  Comp: FC<any> | ComponentClass,
) => {
  const WithContextMemoComponent = memo(
    (props) => <Comp {...props} />,
  );

  const component = (props: any) => {
    const result: Record<string, any> = {};
    contexts.forEach((context) => {
      const state = useContext(context.context);

      const contextSelector = (
        contextValue: any,
        selector: WithContextSelector
      ) => {
        const selectState: Record<string, any> = {};
        // 根据传入的数据路径从context中选出订阅的状态,下面给出具体逻辑
        return selectState;
      };

      const selectState = contextSelector(state, context.selector);
      result[context.propName ?? "context"] = selectState;
    });
    return <WithContextMemoComponent {...props} {...result} />;
  };
  component.displayName = "WithContext";

  return component;
};

我们给withContext传入一个数组,把要订阅的context传进入,内部循环调用useContext。因为传入的实参不会改变,所以withContext的循环也不会改变,useContext的数量和顺序也不会改变。

选出的状态应该通过什么名字传给props?

提示:这个问题不重要,一扫而过即可。

主要是体现在开发体验上,如果我们直接把选出的状态通过原名传给props,那么在给组件定义其他props时就需要考虑到重名问题,定义props和context属性名时需要互相考虑对方。我还是比较喜欢从把某个context取出的状态都放在一个对象下。从上面的代码看到我们定义的参数类型中,每个context都有一个propName属性,那么从这个context取出的状态就都会放在名为传入的propName的对象中。

就像下面这样:

// 传入的一个context的propName为"themeContext"
<Component themeContext={...} />

memo只能比较一层属性,需要深层比较时怎么办?

memo默认的比较函数只能对比第一层属性。

// memo默认的比较函数
function shallowEqual(objA: mixed, objB: mixed): boolean {
  if (Object.is(objA, objB)) {
    return true;
  }

  if (
    typeof objA !== 'object' ||
    objA === null ||
    typeof objB !== 'object' ||
    objB === null
  ) {
    return false;
  }

  const keysA = Object.keys(objA);
  const keysB = Object.keys(objB);

  if (keysA.length !== keysB.length) {
    return false;
  }

  // Test for A's keys different from B.
  for (let i = 0; i < keysA.length; i++) {
    const currentKey = keysA[i];
    if (
      !hasOwnProperty.call(objB, currentKey) ||
      !is(objA[currentKey], objB[currentKey])
    ) {
      return false;
    }
  }

  return true;
}

上面一小节我们把取出的状态放在了一个对象里,那注入到props里的对象每次又都是一个新对象,该怎么办呢?这个问题并不只是因为上一小节的做法而出现的,我们本身就可能有需要深度比较的需求,比如我们在context里订阅的是一个对象,对象的内容没有改变但是对象本身改变了,我们依然不希望组件渲染。

虽然这不是一个合理的现象,并且如果使用了immer等不可变数据库或改变context值时有注意这一点,是不会出现这个问题的,但我依然希望withContext也是一道避免因此重新渲染的防线。

memo接收第二个参数,我们传入一个深度比较的函数可以解决这个问题,比如lodashisEqual,但isEqual的性能开销可能是很大的(props层级越深越大),用在这里并不适合。

我们更应该选择性的深度比较,还是使用原版的shallowEqual,当遍历到存储选中状态的对象时,需要深入比较一层,注意一层即可,再执行一次shallowEqual,这并不会造成多大的性能开销。其他需要深度比较的属性(也就是从context选出的状态),可以通过参数形式告诉withContext,对于需要深度比较的状态,会使用lodashisEqual比较,当然需要注意⚠️,慎用这个特性,至少要明确状态的层级不过深,isEqual的性能开销不可以忽略。

到这里需求已经基本考虑周全了,给出全部代码,结合类型定义更容易理解:

import isEqual from "lodash/isEqual";
import { ComponentClass, Context, FC, memo, useContext } from "react";

type WithContextSelectorItem = {
  key: string; // 对象键名
  path: (string | number)[]; // 选值的路径数组
  deep?: boolean; // memo比较时是否采用深度比较(慎用)
};
// 字符串形式是 {key: 'xxx', path: ['xxx']}的简写
type WithContextSelector = (WithContextSelectorItem | string)[];

type WithContextsItem = {
  propName?: string; // 注入props中的propName,不传默认为"context"
  context: Context<any>; // Context实例
  selector: WithContextSelector; // 从Context中选值的字段配置
};
type WithContexts = WithContextsItem[];

type WithContextOptions = {
  displayName?: string;
};

// 在shallowEqual基础上扩展
function deepEqual(
  oldProps: Record<string, any>,
  newProps: Record<string, any>,
  shallowKeys: string[] | null,
  deepKeys: string[]
) {
  if (Object.is(oldProps, newProps)) {
    return true;
  }

  if (
    typeof oldProps !== "object" ||
    oldProps === null ||
    typeof newProps !== "object" ||
    newProps === null
  ) {
    return false;
  }

  const oldKeys = Object.keys(oldProps);
  const newKeys = Object.keys(newProps);

  if (oldKeys.length !== newKeys.length) {
    return false;
  }

  for (let i = 0; i < oldKeys.length; i++) {
    const currentKey = oldKeys[i];
    if (!Object.hasOwnProperty.call(newProps, currentKey)) {
      return false;
    }
    const inShallow = shallowKeys && shallowKeys.includes(currentKey);
    const inDeep = deepKeys.includes(currentKey);
    if (
      typeof oldProps[currentKey] === "object" &&
      typeof newProps[currentKey] === "object" &&
      (inShallow || inDeep)
    ) {
      if (inShallow) {
        // 进一步浅比较
        const isEq = deepEqual(
          oldProps[currentKey],
          newProps[currentKey],
          null,
          deepKeys
        );
        if (!isEq) return false;
      } else {
        // 深比较
        const isEq = isEqual(oldProps[currentKey], newProps[currentKey]);
        if (!isEq) return false;
      }
    } else if (!Object.is(oldProps[currentKey], newProps[currentKey])) {
      return false;
    }
  }

  return true;
}

/**
 * @description: 用于把context的值注入到props中,并使用memo比较props
 * @param contexts 一个数组,具体结构:
 * [
 *  {
 *    propName: "context",
 *    context: Context,
 *    selector: [{key:"key1", path: ['xxx', 'xxx']}],
 *  }
 * ]
 * @param Comp 组件
 * @param options
 */
const withContext = (
  contexts: WithContexts,
  Comp: FC<any> | ComponentClass,
  options?: WithContextOptions
) => {
  const memoDeepKeys: string[] = [];

  const WithContextMemoComponent = memo(
    (props) => <Comp {...props} />,
    (newProps, oldProps) =>
      deepEqual(
        newProps,
        oldProps,
        contexts.map((o) => o.propName ?? "context"),
        memoDeepKeys
      )
  );
  WithContextMemoComponent.displayName = "WithContextMemoComponent";

  const component = (props: any) => {
    const result: Record<string, any> = {};
    contexts.forEach((context) => {
      const state = useContext(context.context);

      const contextSelector = (
        contextValue: any,
        selector: WithContextSelector
      ) => {
        const selectState: Record<string, any> = {};
        selector.forEach((field) => {
          let temp = contextValue;
          if (typeof field === "string") {
            selectState[field] = temp[field];
          } else {
            let pointer = 0;
            const len = field.path.length;
            while (pointer < len && typeof temp === "object" && temp != null) {
              const currentKey = field.path[pointer];
              if (currentKey) temp = temp[currentKey];
              pointer++;
            }
            selectState[field.key] = temp;
            if (field.deep === true && !memoDeepKeys.includes(field.key)) {
              memoDeepKeys.push(field.key);
            }
          }
        });
        return selectState;
      };

      const selectState = contextSelector(state, context.selector);
      result[context.propName ?? "context"] = selectState;
    });
    return <WithContextMemoComponent {...props} {...result} />;
  };
  component.displayName = options?.displayName ?? "WithContext";

  return component;
};

export default withContext;

withContext还设置了第三个options形参,是为了方便以后扩展功能,目前只支持传入displayName,这也是为开发体验考虑的,使用withContext包裹组件后,组件名会受到影响,这主要会影响到我们使用react开发者工具,我们可以传入想设置的组件名。

效果

写完发现代码量还不小😄,在编辑器上使用后,性能提升3到4倍。

以画布缩放操作为例,使用前一次缩放操作大致需要30毫秒左右,看react开发者工具可以看到画布上所有组件都会渲染:

如何解决react中context的性能问题

如何解决react中context的性能问题

使用后一次缩放大致需要8毫秒左右,画布上组件都没有重新渲染,看react开发者工具,虽然增加了一些组件层级,但渲染速度提升了:

如何解决react中context的性能问题

如何解决react中context的性能问题

转载自:https://juejin.cn/post/7245874747388805157
评论
请登录