Jansiel Notes

useContext 的使用优化

我们经常会使用Context进行组件间的数据共享,但是使用不注意,也会引入一些组件无效渲染的性能问题。

相关链接:useContext使用优化 | Shawkry's blog

初始化测试项目

1npx create-react-app my-app --template typescript && code my-app
2

在index.tsx中,移除React.StrictMode组件包裹,直接调用App组件即可:

1root.render(
2  //  <React.StrictMode>
3  <App />,
4  //  </React.StrictMode>
5);
6

为什么要去掉React.StrictMode组件?因为使用这个组件在开发模式下会重复调用生命周期,如果在这个组件内部console.log,会发现输出两次。 这是用于帮助检查生命周期预期之外的副作用。 不过可以放心的是这仅适用于开发模式,在生产模式下不会重复调用生命周期。本文为减少干扰所以将其去除。

常规用法

 1// 创建context
 2const MyContext = React.createContext<{ [key: string]: any }>({});
 3export const ParentComp = React.memo(() => {
 4  console.log("ParentComp, 渲染了");
 5  const [value, setValue] = useState(0);
 6  const setRandomData = useCallback(() => {
 7    setValue(Math.random() * 10000);
 8  }, []);
 9  return (
10    <MyContext.Provider value={{ value, setRandomData }}>
11      <div>
12        ParentComp
13        <ChildComp1 />
14        <ChildComp2 />
15      </div>
16    </MyContext.Provider>
17  );
18});
19
20// 读取context数据
21export const ChildComp1 = React.memo(() => {
22  console.log("ChildComp1, 渲染了");
23  const { setRandomData } = useContext(MyContext);
24  return (
25    <div>
26      child1, <button onClick={setRandomData}>按钮</button>
27    </div>
28  );
29});
30
31export const ChildComp2 = React.memo(() => {
32  console.log("ChildComp2, 渲染了");
33  const { value } = useContext(MyContext);
34  return <div>child2, {value}</div>;
35});
36

结果

点击"按钮"后,浏览器控制台的输出结果:

ParentComp, 渲染了
ChildComp1, 渲染了
ChildComp2, 渲染了

也就是说点击组件ChildComp1的按钮居然会导致ParentComp 、ChildComp1、ChildComp2 都重新渲染了。 但实际上ChildComp1只是使用上下文中的方法,该方法不变所以并不需要重新渲染,这是无效渲染可以优化的。

分析原因

1点击触发onClick事件
2-> setRandomData触发
3> setValue(setState)
4> ParentComp重新渲染
5> MyContext.Providervalue变更
6> 每一个子组件因为使用useContext(MyContext)都将触发重渲染
7

既然会触发重渲染,我在业务组件外包一层,让业务组件缓存起来可以吗?

改进方案一:props传入

定义一个组件ChildComp1Data,把ChildComp1组件中从context中获取数据的逻辑转移到这个组件,然后数据作为props传入到ChildComp1。

把ChildComp1改成以下:

 1// 读取context数据
 2export const ChildComp1 = React.memo(
 3  ({ setRandomData }: { setRandomData: () => void }) => {
 4    console.log("ChildComp1, 渲染了");
 5    return (
 6      <div>
 7        child1, <button onClick={setRandomData}>按钮</button>
 8      </div>
 9    );
10  },
11);
12
13const ChildComp1Data = React.memo(() => {
14  const { setRandomData } = useContext(MyContext);
15  console.log("ChildComp1Data, 渲染了");
16  return <ChildComp1 setRandomData={setRandomData} />;
17});
18

运行结果

ParentComp, 渲染了
ChildComp1Data, 渲染了
ChildComp2, 渲染了

可以看到触发按钮后,ChildComp1组件不会被重新渲染,只是重新渲染了ChildComp1Data,相比起原有实现,确实优化了(因为没有很多逻辑需要重新执行)

问题

但是每一个组件都需要额外去封装一个获取数据的数据层组件(好像用处也不是很大)。

改进方案二:拆分多个Context

把方法和值拆开,放置到两个Context上:

 1// 创建context
 2const ValueContext = React.createContext<number>(0);
 3const MethodContext = React.createContext<() => void>(() => {});
 4
 5// 设置context数据
 6export const ParentComp = React.memo(() => {
 7  console.log("ParentComp, 渲染了");
 8  const [value, setValue] = useState(0);
 9  const setRandomData = useCallback(() => {
10    setValue(Math.random() * 10000);
11  }, []);
12  return (
13    <MethodContext.Provider value={setRandomData}>
14      <ValueContext.Provider value={value}>
15        <div>
16          ParentComp
17          <ChildComp1 />
18          <ChildComp2 />
19        </div>
20      </ValueContext.Provider>
21    </MethodContext.Provider>
22  );
23});
24
25// 读取context数据
26export const ChildComp1 = React.memo(() => {
27  console.log("ChildComp1, 渲染了");
28  const setRandomData = useContext(MethodContext);
29  return (
30    <div>
31      child1, <button onClick={setRandomData}>按钮</button>
32    </div>
33  );
34});
35export const ChildComp2 = React.memo(() => {
36  console.log("ChildComp2, 渲染了");
37  const value = useContext(ValueContext);
38  return <div>child2, {value}</div>;
39});
40

这种拆分方式也可以作为部分数据经常变、部分数据是常量的Context优化。

结果 点击"按钮"后,浏览器控制台的输出结果:

ParentComp, 渲染了
ChildComp2, 渲染了

可以看到,ChildComp1不会被渲染,已经达到优化的目的。

问题

但是问题又来了,一个上下文如果有多个方法怎么办?

改进方案三:使用Ref包装方法对象

传入一个不会变的对象到上下文,比如用useRef包装方法对象。

使用用Ref改进:

 1// 创建context
 2const ValueContext = React.createContext<number>(0);
 3const MethodContext = React.createContext<{ [key: string]: any }>({});
 4
 5// 设置context数据
 6export const ParentComp = React.memo(() => {
 7  console.log("ParentComp, 渲染了");
 8  const [value, setValue] = useState(0);
 9  const setRandomData = useCallback(() => {
10    setValue(Math.random() * 10000);
11  }, []);
12  const resetData = useCallback(() => {
13    setValue(0);
14  }, []);
15  const ref = useRef({});
16  useEffect(() => {
17    ref.current = {
18      setRandomData,
19      resetData,
20    };
21  }, [setRandomData, resetData]);
22  return (
23    <MethodContext.Provider value={ref}>
24      <ValueContext.Provider value={value}>
25        <div>
26          ParentComp
27          <ChildComp1 />
28          <ChildComp2 />
29        </div>
30      </ValueContext.Provider>
31    </MethodContext.Provider>
32  );
33});
34
35// 读取context数据
36export const ChildComp1 = React.memo(() => {
37  console.log("ChildComp1, 渲染了");
38  const context = useContext(MethodContext);
39  const setRandomData = () => {
40    const setRandomData = context.current.setRandomData;
41    setRandomData();
42  };
43  return (
44    <div>
45      child1, <button onClick={setRandomData}>按钮</button>
46    </div>
47  );
48});
49export const ChildComp2 = React.memo(() => {
50  console.log("ChildComp2, 渲染了");
51  const value = useContext(ValueContext);
52  return <div>child2, {value}</div>;
53});
54

结果

ParentComp, 渲染了
ChildComp2, 渲染了

ChildComp1不会被渲染

问题

  • 可以看到,麻烦,非常麻烦,引入了current
  • 新增方法后,还得放置到ref上
  • 为了provider上的value不变,所有共享数据放在同一个state上,这样各个数据耦合程度高,更新数据时很麻烦。

有没有既能数据更新方便,也能减少组件无效重渲染的问题?

我们使用Redux能解决这个问题,当然,如果使用Redux我们就不需要使用useContext了,这篇文章就没必要存在了 , 对于小规模的系统,但是想实现组件间的共享,我们现在可以引出本文的另外一位主角:useReducer。

改进方案四*:结合useReducer

结合useReducer使用,reducer就是一个迷你Redux,数据触发和Redux很类似。

如果不了解,简单解释一下:

  • 通过dispatch去触发更新数据,参数传入一个type
  • 触发更新数据后,useReducer中的reducer函数执行具体的逻辑,执行什么逻辑根据dispatch传入的type来决定
  • reducer执行完成后,需要返回一个新state数据,去更新数据,注意合并旧数据

更多内容可以看看useReducer的官方说明

那让我们直接开始改造:

 1import React, { useContext, useReducer } from "react";
 2interface ValueType {
 3  count: number;
 4}
 5// 创建context
 6const ValueContext = React.createContext<ValueType>({ count: 0 });
 7const MethodContext = React.createContext<any>({});
 8
 9// 设置context数据
10export const ParentComp = React.memo(() => {
11  console.log("ParentComp, 渲染了");
12  const [value, dispatch] = useReducer(
13    (preState: ValueType, { type }: { type: string }) => {
14      switch (type) {
15        case "setRandomData":
16          return {
17            ...preState,
18            count: Math.random() * 10000,
19          };
20        case "addData":
21          return {
22            ...preState,
23            count: preState.count + 1,
24          };
25        case "resetData":
26          return {
27            ...preState,
28            count: 0,
29          };
30      }
31    },
32    { count: 0 },
33  );
34  return (
35    <MethodContext.Provider value={dispatch}>
36      <ValueContext.Provider value={value}>
37        <div>
38          ParentComp
39          <ChildComp1 />
40          <ChildComp2 />
41        </div>
42      </ValueContext.Provider>
43    </MethodContext.Provider>
44  );
45});
46
47// 读取context数据
48export const ChildComp1 = React.memo(() => {
49  console.log("ChildComp1, 渲染了");
50  const dispatch = useContext(MethodContext);
51  const setRandomData = () => {
52    dispatch({ type: "setRandomData" });
53  };
54  return (
55    <div>
56      child1, <button onClick={setRandomData}>按钮</button>
57    </div>
58  );
59});
60export const ChildComp2 = React.memo(() => {
61  console.log("ChildComp2, 渲染了");
62  const { count } = useContext(ValueContext);
63  return <div>child2, {count}</div>;
64});
65

结果

ParentComp, 渲染了
ChildComp2, 渲染了

ChildComp1不会被渲染,而且一个上下文可以不断新增多个方法,比如上述例子中新增的addData、resetData。 当然,定义很多方法时候,可以自行抽离方法,不需要把所有的逻辑代码都直接写在reducer中。

总结

  • 并不是说使用useContext就一定要搭配useReducer使用,只是其作为一种推荐优化手段
  • 如果组件业务逻辑不多,组件内逻辑也不多,组件内性能做好优化,context的使用按常规方式消耗的性能也影响不大
  • 如果组件不多,也可以使用改进方案1,相对比较简单,容易理解,不会引入useReducer去额外处理
  • 改进方案2可以作为共享数据中部分是极少改变的数据,部分是经常改变的数据的优化方式