likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 4

context 的性能问题

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

  1. 根据”变与不变分离“的原则优化组件结构
  2. 使用memo

使用其一或结合使用,通常能解决问题。

React 在 render 流程中通过 Object.is 判断 context 的 value 是否改变,这也会造成类似上面所说的问题,具体来说,当一个组件通过 useContext 读取了一个来自 context 的状态,就意味着它一直会跟着 context 的更新而渲染,即使它订阅的状态没有真的改变,因为 Object.is 不能深入到对象内部比较。 并且,我们并不能像 props 一样通过调整组件结构来解决问题,也没有性能优化API可以使用。

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

翻译:从 provider 接收到不同的 value 开始,React 会自动重新渲染所有使用特定 context 的所有子级。 先前的只和新的值通过 Object.is 进行比较。 使用 memo 跳过重新渲染并不会阻止子级接收新的 context 值。

来看一个例子:

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:

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解决react中context的性能问题

解决方法 inject-context

针对这个问题,很容易能想到通过一个 selector 机制来解决,只不过这个 selector 不能写在使用 context 的组件内部,因为要选择状态必然要先通过 useContext 拿到所有状态,然而一旦使用了就意味着已经订阅了context,选择也就没有了意义。

可以做一个上层高阶组件,在这里做 selector 操作,把选择的值通过 props 传到目标组件,结合 memo 就可以解决问题

我把这段逻辑封装起来发布了一个 npm 包,名为 inject-context,意思是给组件注入 context 。

使用方式:

  • 单个 context
import React from 'react';
import injectContext from 'inject-context';

const MyContext = React.createContext({ count: 0 });

const App = props => {
  // context的count被注入到props中
  return <div>{props.count}</div>;
};

const Componnet = injectContext({
  context: MyContext,
  selector: s => ({ count: s.count }),
})(App);
  • 多个 context
import React from 'react';
import injectContext from 'inject-context';

const MyContext1 = React.createContext({ count: 0 });
const MyContext2 = React.createContext({ count: 0 });

const App = props => {
  return <div>{props.count1 + props.count2}</div>;
};

const Componnet = injectContext([
  {
    context: MyContext1,
    selector: s => ({ count1: s.count }),
  },
  {
    context: MyContext2,
    selector: s => ({ count2: s.count }),
  },
])(App);

可见 inject-context 支持接收多个 context,实现方式是在内部循环调用了 useContext,这看起来违背了 hooks 的使用规则:

钩子比其他功能更具限制性。您只能在组件(或其他 Hooks)的顶部调用 Hooks。 如果您想useState在条件或循环中使用,请提取一个新组件并将其放在那里。

hooks 有这个限制主要是因为函数组件的 fiber 节点中有一个存放所有使用的 hook 的链表,这些 hook 在函数组件每次运行时都要按顺序执行并且数量不可以改变,如果在条件或循环中使用可能造成数量或顺序改变从而造成错误。

但这里在 inject-context 定义多个 context 实际上是定义死的,并不会改变数量和顺序,也就不会造成问题。

使用 inject-context 重写上面的例子,增加两个 Child 组件:

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>
  );
});

再看效果:

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

深度比较

inject-context 使用 memo 来达到避免不必要渲染的目的,memo 默认的比较函数只对比第一层属性,也就是对于 memo 来说,下面两个 props 是不同的:

const prevProps = {
  obj: { key: 1 }
}
const newProps = {
  obj: { key: 1 }
}

inject-context 扩展了 memo 的默认比较函数,支持选择性的深度比较,如果选择的某个 context 状态想要深度比较的话,可以通过如下方式:

const InjectContextChild2 = injectContext<{
  someString: ContextValue['someString'];
}>({
  context: Context,
  selector: s => ({ obj: s.obj }),
  // selector 返回的对象的键名
  deepKeys: ['obj']
})(....);

指定deepKeys来告诉 inject-context 你需要深度比较的属性,值就是 selector 返回的对象的键名。

⚠️注意,不要滥用这个特性,深度比较带来的开销可能大于重渲染的开销。

TS 支持

props 类型提示

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

count 实际被注入到 props 里了,但是并没有良好的类型提示,可以通过给 inject-context 传泛型解决,它能接收两个泛型,第一个是注入的值的类型,第二个是原本 props 的类型。

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

selector 参数类型

默认的 selector 参数是 any,没有良好的类型提示

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

可以通过 inject-context 提供的 defineSelector 来提供类型提示。 用 defineSelector 包裹一下传入的参数即可。

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

写在最后

不建议所有使用 context 的地方都这样优化,只建议在性能消耗严重的组件上使用。

另外也可以考虑拆分 context 来优化,理论上只要 context 粒度够细,就不存在性能问题,只不过实际上拆的过细可能会影响开发体验和可维护性。

感谢你读到这里。

如果你觉得我写的还不错,欢迎关注我的公众号【HxY码迹】。

github链接: inject-context