如何解决react中context的性能问题
React在render流程中通过全等判断props是否改变,如果判断为有改变组件将会重新渲染,并且会影响到其子节点,这就是React渲染的”传染性“。有两种方式解决这个问题:
- 根据”变与不变分离“的原则优化组件结构
- 使用memo
使用其一或结合使用,通常能解决问题。
React在render流程中通过Object.is
判断context的value是否改变,这也会造成类似上面所说的问题,具体来说,当一个组件通过useContext
订阅了一个来自context的状态,就意味着它一直会跟着context的更新而渲染,即使它订阅的状态没有真的改变,因为Object.is
不能深入到对象内部比较。
然而,我们并不能像props一样通过调整组件结构来解决问题,也没有性能优化API可以使用。
在使用context+reducer进行状态管理的应用,context带来的性能问题通常会比较明显。这篇文章的诞生,也是因为我在开发针对于可视化大屏的编辑器时碰到了这个问题。
这个编辑器的物料组件是基于echarts开发的,echarts渲染会比简单组件更耗时,当我在画布上拖入了比较多的组件,任何操作context状态的动作都会有明显的掉帧,比如缩放画布、更改某一个组件的属性,都会导致画布上所有组件重新渲染。
针对这个问题,我一开始有两个解决思路
- 模仿
react-redux
的useSelector,从context中选择组件需要的状态。但很快pass掉了❌,要选择状态,必然要先通过useContext
拿到所有状态,然而一旦使用了就意味着我们已经订阅了context,选择也就没有了意义。 - 写一个高阶组件,在这里从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
接收第二个参数,我们传入一个深度比较的函数可以解决这个问题,比如lodash
的isEqual
,但isEqual
的性能开销可能是很大的(props层级越深越大),用在这里并不适合。
我们更应该选择性的深度比较,还是使用原版的shallowEqual
,当遍历到存储选中状态的对象时,需要深入比较一层,注意一层即可,再执行一次shallowEqual
,这并不会造成多大的性能开销。其他需要深度比较的属性(也就是从context选出的状态),可以通过参数形式告诉withContext
,对于需要深度比较的状态,会使用lodash
的isEqual
比较,当然需要注意⚠️,慎用这个特性,至少要明确状态的层级不过深,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开发者工具可以看到画布上所有组件都会渲染:
使用后一次缩放大致需要8毫秒左右,画布上组件都没有重新渲染,看react开发者工具,虽然增加了一些组件层级,但渲染速度提升了:
转载自:https://juejin.cn/post/7245874747388805157