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.Provider的value变更
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可以作为共享数据中部分是极少改变的数据,部分是经常改变的数据的优化方式