context 的性能问题
React 在 render 流程中通过全等判断props是否改变,如果判断为有改变组件将会重新渲染,并且会影响到其子节点,这就是 React 渲染的"传染性"。有两种方式解决这个问题:
- 根据"变与不变分离"的原则优化组件结构
- 使用memo
具体操作可浏览另一篇文章 react性能优化|bailout策略
使用其一或结合使用,通常能解决问题。
React 在 render 流程中通过 Object.is
判断 context 的 value 是否改变,这也会造成类似上面所说的问题,具体来说,当一个组件通过 useContext
读取了一个来自 context 的状态,就意味着它一直会跟着 context 的更新而渲染,即使它订阅的状态没有真的改变,因为 Object.is
不能深入到对象内部比较。 并且,我们并不能像 props 一样通过调整组件结构来解决问题,也没有性能优化API可以使用。
翻译:从 provider 接收到不同的 value 开始,React 会自动重新渲染所有使用特定 context 的所有子级。 先前的只和新的值通过 Object.is 进行比较。 使用 memo 跳过重新渲染并不会阻止子级接收新的 context 值。
来看一个例子:
tsx
const Context = createContext<ContextValue>({});
function App() {
const context = useContext(Context);
return (
<>
<button
onClick={() => {
context.setContext?.(prev => ({ ...prev, count: prev.count! + 1 }));
}}
>
改变context的count
</button>
<div className="wrapper">
<Child1></Child1>
<Child2></Child2>
</div>
</>
);
}
const ContextApp = () => {
const [state, setState] = useState<ContextValue>({
count: 0,
someString: '一个没有被更新的字符串',
});
return (
<Context.Provider value={{ ...state, setContext: setState }}>
<App />
</Context.Provider>
);
};
这是一个使用 context 的简单例子,App 内会更新 context 的 count 值。 其中 Child1 和 Child2 组件使用了来自 context 的 value:
tsx
function Child1() {
const ref = useRef<HTMLDivElement>(null);
useHighlight(ref);
const { count } = useContext(Context);
return (
<div ref={ref} className="child">
<div>使用了context的count:</div>
<div>{count}</div>
</div>
);
}
function Child2() {
const ref = useRef<HTMLDivElement>(null);
useHighlight(ref);
const { someString } = useContext(Context);
return (
<div ref={ref} className="child">
<div>使用了context的someString:</div>
<div>{someString}</div>
</div>
);
}
其中 useHighlight 提供了组件渲染时 dom 高亮的功能。
Child2 并没有使用 count 也重渲染了。
解决方法 inject-context
针对这个问题,很容易能想到通过一个 selector 机制来解决,只不过这个 selector 不能写在使用 context 的组件内部,因为要选择状态必然要先通过 useContext
拿到所有状态,然而一旦使用了就意味着已经订阅了context,选择也就没有了意义。
可以做一个上层高阶组件,在这里做 selector 操作,把选择的值通过 props 传到目标组件,结合 memo 就可以解决问题。
我把这段逻辑封装起来发布了一个 npm 包,名为 inject-context,意思是给组件注入 context 。 使用方式:
tsx
// 单个context
const Componnet = injectContext({
context: Context,
selector: (s) => ({....})
})(你的组件)
// 多个context
const Componnet = injectContext([
{
context: Context1,
selector: (s) => ({....})
},
{
context: Context2,
selector: (s) => ({....})
}
])(你的组件)
可见 inject-context 支持接收多个 context,实现方式是在内部循环调用了 useContext
,这看起来违背了 hooks 的使用规则:
钩子比其他功能更具限制性。您只能在组件(或其他 Hooks)的顶部调用 Hooks。 如果您想useState在条件或循环中使用,请提取一个新组件并将其放在那里。
hooks 有这个限制主要是因为函数组件的 fiber 节点中有一个存放所有使用的 hook 的链表,这些 hook 在函数组件每次运行时都要按顺序执行并且数量不可以改变,如果在条件或循环中使用可能造成数量或顺序改变从而造成错误。
但这里在 inject-context 定义多个 context 实际上是定义死的,并不会改变数量和顺序,也就不会造成问题。
使用 inject-context 重写上面的例子,增加两个 Child 组件:
tsx
const InjectContextChild = injectContext({
context: Context,
selector: s => ({ count: s.count }),
})(function (props) {
const ref = useRef<HTMLDivElement>(null);
useHighlight(ref);
return (
<div ref={ref} className="child">
<div>使用了context的count:</div>
<div>{props.count}</div>
</div>
);
});
const InjectContextChild2 = injectContext({
context: Context,
selector: s => ({ someString: s.someString }),
})(function (props) {
const ref = useRef<HTMLDivElement>(null);
useHighlight(ref);
return (
<div ref={ref} className="child">
<div>使用了context的someString:</div>
<div>{props.someString}</div>
</div>
);
});
再看效果:
深度比较
memo 默认的比较函数只能对比第一层属性,inject-context 扩展了这个默认函数,支持选择性的深度比较,如果选择的某个 context 状态想要深度比较的话,可以通过如下方式:
tsx
const InjectContextChild2 = injectContext<{
someString: ContextValue['someString'];
}>({
context: Context,
selector: s => ({ obj: s.obj }),
// selector 返回的对象的键名
deepKeys: ['obj']
})(....);
指定deepKeys
来告诉 inject-context 你需要深度比较的属性,值就是 selector 返回的对象的键名。
⚠️注意,不要滥用这个特性,深度比较带来的开销可能大于重渲染的开销。
TS 支持
props 类型提示
count 实际被注入到 props 里了,但是并没有良好的类型提示,可以通过给 inject-context 传泛型解决,它能接收两个泛型,第一个是注入的值的类型,第二个是原本 props 的类型。
selector 参数类型
默认的 selector 参数是 any,没有良好的类型提示
可以通过 inject-context 提供的 defineSelector
来提供类型提示。