不依赖三方库优化redux,我获取了十倍性能提升!

2,361 阅读8分钟

大致背景:我们的业务上有一个类似在线表单的页面,允许用户自己设计页面。有一个比较复杂的页面,最终拖入了上千个组件,这些组件还会相互嵌套

优化效果

优化前页面加载时间中位数为:54s,优化后为:7s。 加载时间下降 92.28%

下面是具体方案

优化方向分析

通过添加日志,发现卡顿的原因是因为这些大量的组件在高频的render(指Reconciliation阶段)

大部分组件的渲染次数高达50多次。

而期望的渲染逻辑:当组件信息请求回来过后,渲染控件的数据已经准备完毕。只需要一次render即可渲染出页面

那么为什么会渲染如此多次?

控件渲染多次的原因

要整清楚为什么多次渲染,首先要明白组件为什么会执行render,一般来说有几种情况:

  • 父组件render导致的子组件render

  • 组件内部hook(state)发生改变

  • 使用了context,contextValue发生改变

  • 使用了状态库,状态库数据发生改变

    经过分析发现,我们组件大量使用了redux。并且redux state tree 非常的庞大,里面任意子树的变化都会从当前节点更新到root

update-tree.png

而组件加载时,reduxStateTree正是处于不断更新的过程,所以导致频繁的render

redux精准更新

组件使用redux的数据,我们可以理解为订阅了其中的部分数据,最理想的方式就是当我们订阅的数据发生改变,组件才render。

想要实现精准更新,分为两部分:首先连接redux的方法需要支持 精准订阅 。其次业务代码使用redux 粒度需要尽可能细

redux 连接器改造

目前我们组件使用redux有两种方式: connect 和 useSelector,我们分别针对两种方式分析

connect本身是一个高阶函数,对目标组件进行包装,通过特定的映射函数和配置来决定是否render目标组件。函数签名如下:

function connect(mapStateToProps?, mapDispatchToProps?, mergeProps?, options?)

其中第一个参数就是对reduxStateTree和props的映射,详情官方文档。当state发生改变或者props发生改变就会触发该函数。生成新的参数

而options中 areStatesEqual 官方文档 则是决定state是否发生改变。so 只要我们能分析出 mapStateToProps 中使用了那些reduxStateTree的数据。再通过areStatesEqual来判断是否需要更新即可

按照这个思路,我们只需要实现依赖收集函数,再对connect做一层封装。最后用正则统一替换掉使用connect函数的地方。

具体实现:

import { equalityFn, IEquality } from '@/utils/equality';
import { PathVisitedCollector } from '@/utils/PathVisitedCollector';
import { get } from 'lodash-es';
import { connect, Connect, Options } from 'react-redux';

/**
 * 基于 mapStateToProps 访问redux的数据路径自动做性能优化
 * 注意 ❌❌❌:
 * connectEnhancer是对connect的高阶函数,并非组件。所以某些情况下性能优化会导致渲染失效,具体情况如下:
 *    在该组件的 mapStateToProps 中访问redux中数据路径,使用了props的数据,并且该组件被多次使用
 *    比如:state.cmps[props.cmpId]
 *    解决方案(二选1)
 *     1、使用原生的connect
 *     2、将该数据从 mapStateToProps 中剔除,放到组件内部通过useSelector使用
 *
 * 性能优化具体方式
 * 1、基于最深路径做数据对比:
 *    比如访问了 cmps.input.title  那么只会对title的值进行对比。如果想要对settings对比,请使用 deptPaths
 */
export const connectEnhancer = function (
  mapStateToProps: any,
  mapDispatchToProps?: any,
  mergeProps?: any,
  options: {
    equalOptions?: IEquality;
    deptPaths?: (paths: string[]) => string[];
  } & Options = {},
) {
  const { equalOptions, deptPaths } = options;
  const pathRef: { c: string[] } = { c: [] };
  const wrapperMapStateTpProps = mapStateToProps
    ? (state: any, ownProps: any) => {
        const collector = new PathVisitedCollector(state);
        mapStateToProps(collector.getRootNode(), ownProps);
        const paths = collector.getAllPaths();
        pathRef.c = paths;
        collector.destory();
        return mapStateToProps(state, ownProps);
      }
    : null;

  options.pure = true;
  options.areStatesEqual = (pre: any, next: any) => {
    const paths = deptPaths?.(pathRef.c) ?? pathRef.c;
    return paths.every((path) => {
      const preVal = get(pre, path);
      const nextVal = get(next, path);
      const isEqual = equalityFn(preVal, nextVal, equalOptions);
      return isEqual;
    });
  };

  options.areOwnPropsEqual = (pre, next) => {
    const isEqual = equalityFn(pre, next, equalOptions);
    return isEqual;
  };

  options.areMergedPropsEqual = (pre, next) => {
    const isEqual = equalityFn(pre, next, equalOptions);
    return isEqual;
  };

  const result = connect(wrapperMapStateTpProps, mapDispatchToProps, mergeProps, options);

  return connect(wrapperMapStateTpProps, mapDispatchToProps, mergeProps, options)
} as Connect;

PathVisitedCollector.ts

import { get, clone, isObject } from 'lodash-es';

type AnyObj = Record<string | symbol, any>;

const CHILDRENS = Symbol('childrens');

export class PathVisitedCollector {
  private root: AnyObj;
  private rootNode: any;
  constructor(obj: AnyObj) {
    this.root = obj;
    this.rootNode = this.proxyNode(obj);
  }

  private getChildNode(node: any, path: string) {
    return node[CHILDRENS]?.[path];
  }

  private proxyNode = (obj: AnyObj) => {
    if (!isObject(obj)) return obj;
    const node: AnyObj = new Proxy(clone(obj), {
      get: (target, p) => {
        const val = get(target, p);
        if (typeof p === 'symbol') return val;
        if (typeof val === 'function') return val;
        const cNode = this.getChildNode(node, p);
        if (cNode) return cNode;
        const childNode = this.proxyNode(val);
        node[CHILDRENS] = node[CHILDRENS] || {};
        node[CHILDRENS][p] = childNode;
        return childNode;
      },
    });

    return node;
  };

  public destory() {
    this.root = null;
    this.rootNode = null;
  }

  public getRootNode() {
    return this.rootNode;
  }

  public getAllPaths() {
    const paths = this.getAllPath(this.rootNode);

    return paths.map((arr) => arr.filter(Boolean).join('.'));
  }

  private getAllPath(node: any, stack: string[] = [], result: string[][] = [], path = '') {
    stack.push(path);
    const cNodes = node?.[CHILDRENS];
    if (!cNodes) {
      result.push([...stack]);
    }
    Object.entries(cNodes || {}).forEach(([p, cNode]) => {
      this.getAllPath(cNode, stack, result, p);
    });
    stack.pop();
    return result;
  }
}

equality.ts

/**
 * 相等比较函数
 */

import { isEqual, isObject } from 'lodash-es';
import { shallowEqual as shallowEqualDiff } from 'react-redux';

export interface IEquality {
  shallowEqual?: boolean;
  deepEqual?: boolean;
  getValue?(val: any): any;
}

export const equalityFn = (
  pre: any,
  next: any,
  options: IEquality = { deepEqual: false, shallowEqual: true },
) => {
  const { getValue } = options;
  const preValue = getValue ? getValue(pre) : pre;
  const nextValue = getValue ? getValue(next) : next;
  if (preValue === nextValue) return true;
  const { deepEqual, shallowEqual = true } = options;
  if (deepEqual) {
    return isEqual(preValue, nextValue);
  }
  if (shallowEqual) {
    if (isObject(preValue) && isObject(nextValue)) {
      return shallowEqualDiff(preValue, nextValue);
    }
  }

  return preValue === nextValue;
};

再次强调注意点:

封装的函数是对 connect 参数的默认值进行处理。他无法访问到组件实例,如果多个实例的 reduxStateTree 依赖路径不一致,会出现错误优化(不渲染组件)

对 mapStateToProps 中对数据路径收集 默认是最深路径 ,如果在函数中返回的数据深度和访问的不一致,可能也会出现组件不渲染的问题。

useSelector

从官方文档可知,函数的第二个参数可以自定义对比函数。用来告诉redux是否需要render组件。

那么只需要对这个函数进行改造,默认给一个对比函数即可

具体实现见:src/redux/design/store/index.ts


export const useSelector = (expression: (state: RootState) => any, options?: IEquality) => {
  let debugRef: { paths: string[]; stack?: string } | null = null;
  // 帮助定位哪个属性发生改变导致render
  if (isDebug) {
    const collector = new PathVisitedCollector(store.getState());
    expression(collector.getRootNode());
    const paths = collector.getAllPaths();
    debugRef = { paths, stack: new Error().stack };
    collector.destory();
  }
  const equalityWrapper = (pre: any, next: any) => {
    const isEqual = equalityFn(pre, next, options);
    if (!isEqual && isDebug && debugRef) {
       log(`${debugRef.paths.join('、')} has changed`, [pre, next], debugRef.stack);
    }

    return isEqual;
  };
  return useReduxSelector(expression, equalityWrapper);
};

业务代码改造

常见错误示例

connect 函数中我们做了自动依赖收集并且做浅比较,一般来说不需要做业务代码改造。只有当存在优化错误的场景才需要改造代码

所以我们主要针对 useSelector的使用进行改造

根据日志提示,我们很容易的找到使用redux错误的地方,下面总结了常见的错误使用方式:

获取大对象,然后自行解构

const state = useSelect(state => state)
const activeId = state.cmps.activeId
const name = state.cmps.name

这种使用方法是最常见的错误,因为state作为root节点,只要数据改变一定会重新render。并且无法做浅比较。如果从root节点做深比较,性能可能会出问题

在改造这类问题之前首先要明白一个问题:我们是否需要state这个大对象?

  1. 如果是组件内部需要state,那么一定是订阅redux的数据深度描述出现了错误。useSelector必须要 精准的描述 需要的数据!

比如:想要的是 state.cmps.activeId 。那么就使用 const activeId = useSelector(state => state.cmps.activeId)

如上图那么就应该改造成

   const name = useSelector(state => state.cmps.name)
   const activedId = useSelector(state => state.cmps.activedId)
   // 或者
   const {name, activedId} = useSelector(state => {
       return {
           name: state.cmps.name,
           activedId: state.cmps.activedId,
       }
   })
  1. 如果是某个工具函数中参数需要state,我们首先要 反思函数设计 上是否存在问题。

    如果必须使用state,可以使用 getState 函数来获取最新的state。 而不是通过 组件内部去订阅 state来传递参数!

同理,类似

useSelector(state => state.cmps)

这种使用大对象的组件,挨着分析是否没有精准描述订阅的数据源。

从修改结果来看,大部分组件都是错误使用了大对象数据,从组件的职责来说,也不应该关心整个数据对象的变化

频繁更新的store数据要谨慎使用

比如:hoverId。如果在高频组件中使用也会出现严重的性能问题

这种一般来说怎么处理呢?如果依赖的hoverId值是为了计算另一个store值,我们可以合并到一个 useSelector 中即可。比如:

// 错误的方法
const hoverId = useSelector(state => state.cmps.f)

let data = useSelector(
  (state) => {
    let hoverData = state.cmps[hoverId];
    // 处理逻辑
    return xxx;
  }
);


// 正确的做法
let data = useSelector(
  (state) => {
    // 此处获取hoverId即可
    const hoverId = state.cmps.hoverId
    let hoverData = state.cmps[hoverId];
    // 处理逻辑
    return xxx;
  }
);

慎用三方hook

部分三方hook为了实现业务,会主动触发render。

最终效果

经过上面的优化过后,第一次加载组件渲染次数降到 1-2次

优化效果很明显:)

但是要想一直保持高性能,还需要平时编码时注意代码质量,关注性能才行

因为目前暂时无法通过工具来限制或者提醒来避免错误实践。

温馨提示

对于普通业务需求来说,redux没有性能问题。请相信CPU会帮你兜底

但是对于复杂业务来说,性能优化永远不是一个滞后的优化手段,而是一开始就要考虑的问题。

否则雪崩一旦开始,没有一片雪花是无辜的~