使用inject-context解决react中context的性能问题

context 的性能问题

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

  1. 根据"变与不变分离"的原则优化组件结构
  2. 使用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 来提供类型提示。

写在最后

github链接: inject-context

相关推荐
Momo__1 小时前
VueUse createReusableTemplate —— 单文件组件内的模板复用神器
前端·vue.js
无名氏同学1 小时前
React 16-19 新特性
react.js
程序员小富1 小时前
我开源了一个开发者专属的智能 JSON 工具,得到了媳妇高度认可
前端·vue.js·后端
小小小小宇1 小时前
程序员如何给 LLM 装工具以及看懂推理过程
前端
写代码的皮筏艇1 小时前
React中的forwardRef
前端·react.js·面试
槑有老呆1 小时前
花三个月工资请了个 AI 程序员,结果它连青岛啤酒股价都查不了
前端
风骏时光牛马1 小时前
Verilog开发常见问题汇总解析
前端
子兮曰1 小时前
AI Coding Method Map:一张图看懂 AI 编程的完整链路
前端·人工智能·后端
不知疲倦的老鸟1 小时前
Node.js 库在浏览器里跑不了的教训
react.js·next.js