大致背景:我们的业务上有一个类似在线表单的页面,允许用户自己设计页面。有一个比较复杂的页面,最终拖入了上千个组件,这些组件还会相互嵌套
优化效果
优化前页面加载时间中位数为:54s,优化后为:7s。 加载时间下降 92.28%
下面是具体方案
优化方向分析
通过添加日志,发现卡顿的原因是因为这些大量的组件在高频的render(指Reconciliation阶段)
大部分组件的渲染次数高达50多次。
而期望的渲染逻辑:当组件信息请求回来过后,渲染控件的数据已经准备完毕。只需要一次render即可渲染出页面
那么为什么会渲染如此多次?
控件渲染多次的原因
要整清楚为什么多次渲染,首先要明白组件为什么会执行render,一般来说有几种情况:
-
父组件render导致的子组件render
-
组件内部hook(state)发生改变
-
使用了context,contextValue发生改变
-
使用了状态库,状态库数据发生改变
经过分析发现,我们组件大量使用了redux。并且redux state tree 非常的庞大,里面任意子树的变化都会从当前节点更新到root
而组件加载时,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这个大对象?
- 如果是组件内部需要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,
}
})
-
如果是某个工具函数中参数需要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会帮你兜底
但是对于复杂业务来说,性能优化永远不是一个滞后的优化手段,而是一开始就要考虑的问题。
否则雪崩一旦开始,没有一片雪花是无辜的~