react学习11:React context

60 阅读6分钟

在业务代码中用 context 可能不多,大家更偏向于全局的状态管理库,比如 redux、mobx,但在 antd 等组件库里用的特别多。

首先,过一遍 context 的用法,context 使用 createContext 的 api 创建:

import { createContext } from 'react'; 
const countContext = createContext(111);

任意层级的组件可以从中取值,function 组件使用 useContext 的 react hook:

import { useContext } from 'react';

function Ccc() {
  const count = useContext(countContext);
  return <h2>context 值为:{count}</h2>
}

修改 Context 中的值使用 Provider 的 api:

import { createContext } from 'react';

const countContext = createContext(111);

function Bbb() {
  return <div>
      <countContext.Provider value={333}>
        <Ccc></Ccc>
      </countContext.Provider>
  </div>
} 

总结来说就是用 createContext 创建 context 对象,用 Provider 修改其中的值, function 组件使用 useContext 的 hook 来取值

这样的 context 机制就能实现任意层级的传值,比如这样三层组件:

import { createContext, useContext } from 'react';

const countContext = createContext(111);

function Aaa() {
  return <div>
      <countContext.Provider value={222}>
        <Bbb></Bbb>
      </countContext.Provider>
  </div>
} 

function Bbb() {
  return <div><Ccc></Ccc></div>
}

function Ccc() {
  const count = useContext(countContext);
  return <h2>context 的值为:{count}</h2>
}

export default Aaa;

在 vue3 中,也有同样的功能provide / inject,使用如下:

在父组件中使用 provide 提供数据:

<!-- 父组件 Parent.vue -->
<template>
  <div>
    <h2>父组件</h2>
    <Child /> <!-- 子组件 -->
  </div>
</template>

<script setup>
import { provide } from 'vue'
import Child from './Child.vue'

// 提供基本类型数据
provide('message', '来自父组件的消息')

// 提供对象
const user = { name: '张三', age: 20 }
provide('userInfo', user)

// 提供方法(可用于子组件修改父组件数据)
const changeName = (newName) => {
  user.name = newName
}
provide('changeName', changeName)
</script>

在子组件中使用 inject 接收数据:

<!-- 子组件 Child.vue -->
<template>
  <div>
    <h3>子组件</h3>
    <p>接收的消息:{{ msg }}</p>
    <p>用户信息:{{ user.name }},{{ user.age }}</p>
    <button @click="handleChange">修改名字</button>
    <GrandChild /> <!-- 孙子组件(也能接收) -->
  </div>
</template>

<script setup>
import { inject } from 'vue'
import GrandChild from './GrandChild.vue'

// 接收数据(第二个参数为默认值,可选)
const msg = inject('message', '默认消息') // 若找不到 'message',则使用默认值
const user = inject('userInfo')
const changeName = inject('changeName')

// 调用父组件提供的方法
const handleChange = () => {
  changeName('李四')
}
</script>

context 在项目里一般没咋用过,这个一般用来干啥呀?真的不常用么?

并不是,antd 里就有大量的 context 应用,只是你不知道而已。

不信看下这个:

import { Form, Input } from 'antd';
import { useEffect } from 'react';

const App = () => {
  const [form]= Form.useForm();
  
  useEffect(() => {
    form.setFieldsValue({
      a: {
        b: {
          c: 'ccc'
        }
      },
      d: {
        e : 'eee'
      }
    })
  }, []);

  return (
    <Form form={form}>
      <Form.Item name={['d', 'e']}>
        <Input/>
      </Form.Item>
    </Form>
  )
}
export default App;

这是 antd 的 Form 组件的用法:

通过 useForm 拿到 form 对象,设置到 Form 组件里,然后用 form.setFieldsValue 设置的字段值就能在 Form.Item 里取到。

Form.Item 只需要在 name 里填写字段所在的路径就行,也就是 ['d', 'e'] 这个。

image.png

有的同学可能会问了,为啥这里只设置了个 name,它下面的 Input 就有值了呢?

我们让 Form.Item 渲染一个自定义的组件试一下,比如这样:

image.png

image.png

这就是为啥 Input 能有值,因为传入了 value 参数。

而且变化了也能同步到 fields,因为传入了 onChange 参数。

有的时候我们要对保存的值做一些修改,就可以这样写:

function MyInput(props) {
    const { value, onChange } = props;
    function onValueChange(event) {
      onChange(event.target.value.toUpperCase());
    }
    return <Input value={value} onChange={onValueChange}></Input>
}

所以说,Form.Item 会给子组件传入 value、onChange 参数用来设置值和接受值的改变,同步到 form 的 fields。

那这跟 context 有什么关系呢?

当然有呀,Form.Item 是怎么拿到 form 对象的呢?我们不是只传给了 Form 组件么,怎么会到了 Form.Item 手里的?

肯定是有一个传递 form 对象的 context,Form 组件往其中设置值,Item 组件从其中取值。

我们看下源码就知道了:

image.png

Form 组件里用 useForm 创建了 form 对象,参数为 props 传入的 form。

然后它把这个 form 对象通过 Provider 放到了 FieldContext 里:

image.png

这个 FieldContext 自然是通过 createContext 的 api 创建的。

image.png

fieldContext 里就有 getFieldsValue、setFieldsValue 等 form 对象的方法了。

然后就是 Form.Item 了。

FormItem 加上了 Row、Col 等组件来布局,还加上了 Label 的部分,最后再渲染传入的 children。

其中有个 WrappedField 的子组件,这里面就取出了 FieldContext,作为参数传给了子组件:

image.png

而 namePath 也就是 ['d', 'e'] 的部分已经有了。

从 filedContext 里用 getFiledsValue 取出全部的 store,然后再通过 namePath 取出想要的值传给子组件,这不就完成了 Form.Item 的功能了么?

image.png

这就是为什么 form 里设置了 fields,在 Form.Item 里就能取出值来的原因。

小结一下:antd 的 Form 通过 FieldContext 保存了 form 对象,在 FormItem 组件里取出 FieldContext,并根据 namePath 取出对应的值,传递给子组件。这就完成了 form 的 field 值的设置。

在跨层传递数据方面,确实很好用,在组件库里有很多应用。

但是它也有一些缺点。

import { FC, PropsWithChildren, createContext, useContext, useState } from "react";

interface ContextType {
  aaa: number;
  bbb: number;
  setAaa: (aaa: number) => void;
  setBbb: (bbb: number) => void;
}

const context = createContext<ContextType>({
  aaa: 0,
  bbb: 0,
  setAaa: () => {},
  setBbb: () => {}
});

const Provider: FC<PropsWithChildren> = ({ children }) => {
  const [aaa, setAaa] = useState(0);
  const [bbb, setBbb] = useState(0);

  return (
    <context.Provider
      value={{
        aaa,
        bbb,
        setAaa,
        setBbb
      }}
    >
      {children}
    </context.Provider>
  );
};

const App = () => (
  <Provider>
    <Aaa />
    <Bbb />
  </Provider>
);

const Aaa = () => {
  const { aaa, setAaa } = useContext(context);
  
  console.log('Aaa render...')

  return <div>
    aaa: {aaa}
    <button onClick={() => setAaa(aaa + 1)}>加一</button>
  </div>;
};

const Bbb = () => {
  const { bbb, setBbb } = useContext(context);
  
  console.log("Bbb render...");
  
  return <div>
    bbb: {bbb}
    <button onClick={() => setBbb(bbb + 1)}>加一</button>
  </div>;
};

export default App;

用 createContext 创建了 context,其中保存了 2 个useState 的 state 和 setState 方法。

用 Provider 向其中设置值,在 Aaa、Bbb 组件里用 useContext 取出来渲染。

可以看到,修改 aaa 的时候,会同时触发 bbb 组件的渲染,修改 bbb 的时候,也会触发 aaa 组件的渲染。

因为不管修改 aaa 还是 bbb,都是修改 context 的值,会导致所有用到这个 context 的组件重新渲染。

这就是 Context 的问题。

解决方案也很容易想到:拆分成两个 context 不就不会互相影响了?

import { FC, PropsWithChildren, createContext, useContext, useState } from "react";

interface AaaContextType {
  aaa: number;
  setAaa: (aaa: number) => void;
}

const aaaContext = createContext<AaaContextType>({
  aaa: 0,
  setAaa: () => {}
});

interface BbbContextType {
  bbb: number;
  setBbb: (bbb: number) => void;
}

const bbbContext = createContext<BbbContextType>({
  bbb: 0,
  setBbb: () => {}
});

const AaaProvider: FC<PropsWithChildren> = ({ children }) => {
  const [aaa, setAaa] = useState(0);

  return (
    <aaaContext.Provider
      value={{
        aaa,
        setAaa
      }}
    >
      {children}
    </aaaContext.Provider>
  );
};

const BbbProvider: FC<PropsWithChildren> = ({ children }) => {
  const [bbb, setBbb] = useState(0);

  return (
    <bbbContext.Provider
      value={{
        bbb,
        setBbb
      }}
    >
      {children}
    </bbbContext.Provider>
  );
};

const App = () => (
  <AaaProvider>
    <BbbProvider>
      <Aaa />
      <Bbb />
    </BbbProvider>
  </AaaProvider>
);

const Aaa = () => {
  const { aaa, setAaa } = useContext(aaaContext);
  
  console.log('Aaa render...')

  return <div>
    aaa: {aaa}
    <button onClick={() => setAaa(aaa + 1)}>加一</button>
  </div>;
};

const Bbb = () => {
  const { bbb, setBbb } = useContext(bbbContext);
  
  console.log("Bbb render...");
  
  return <div>
    bbb: {bbb}
    <button onClick={() => setBbb(bbb + 1)}>加一</button>
  </div>;
};

export default App;

在 antd 里,也是不同的数据放到不同的 context 里,但这样也会导致 Provider 嵌套过深:

<context1.Provider value={}>
  <context2.Provider value={}>
    <context3.Provider value={}>
      <context4.Provider value={}>
        <context5.Provider value={}>
          {children}
        </context5.Provider>
      </context4.Provider>
    </context3.Provider>
  </context2.Provider>
</context1.Provider>

所以 context 来存放一些配置数据还好,比如 theme、size 等,用来存很多业务数据就不大合适了。

这时候可以用 redux、zustand、jotai 等状态管理库。它们都不是基于 context 实现的,那自然也没有 context 这种问题。

它们虽然也是集中存放的数据,但是内部做了处理,更新某个 state 不会导致依赖其它 state 的组件重新渲染。

此外,不用状态管理库,不拆分 context,也可以解决,比如用 memo,memo 会对新旧 props 做对比,只有 props 变化了才会渲染。

这样就能避免没必要的渲染。

import {
  type FC,
  type PropsWithChildren,
  createContext,
  memo,
  useCallback,
  useContext,
  useState,
} from 'react'

interface CounterContext {
  aaa: number
  bbb: number
  setAaa: (aaa: number) => void
  setBbb: (bbb: number) => void
}

const context = createContext<CounterContext>({
  aaa: 0,
  bbb: 0,
  setAaa: () => {},
  setBbb: () => {},
})

const Provider: FC<PropsWithChildren> = ({ children }) => {
  const [aaa, setAaa] = useState(0)
  const [bbb, setBbb] = useState(0)

  return (
    <context.Provider
      value={{
        aaa,
        bbb,
        setAaa,
        setBbb,
      }}
    >
      {children}
    </context.Provider>
  )
}

const App = () => (
  <Provider>
    <WrappedAaa />
    <WrappedBbb />
  </Provider>
)

const WrappedAaa = () => {
  console.log('WrappedAaa render...')
  const { aaa, setAaa } = useContext(context)

  return <Aaa aaa={aaa} setAaa={setAaa} />
}

interface AaaProps {
  aaa: number
  setAaa: (aaa: number) => void
}

const Aaa = memo((props: AaaProps) => {
  const { aaa, setAaa } = props

  console.log('Aaa render...')

  return (
    <div>
      aaa: {aaa}
      <button onClick={() => setAaa(aaa + 1)}>加一</button>
    </div>
  )
})

const WrappedBbb = () => {
  console.log('WrappedBbb render...')
  const { bbb, setBbb } = useContext(context)

  return <Bbb bbb={bbb} setBbb={setBbb} />
}

interface BbbProps {
  bbb: number
  setBbb: (bbb: number) => void
}

const Bbb = memo((props: BbbProps) => {
  const { bbb, setBbb } = props

  console.log('Bbb render...')

  return (
    <div>
      bbb: {bbb}
      <button onClick={() => setBbb(bbb + 1)}>加一</button>
    </div>
  )
})

export default App

这里需要注意的是,当执行setAaa(aaa + 1)时,WrappedBbb组件都会执行,也就是会打印console.log('WrappedBbb render...'),因为它也使用了useContext,即使给WrappedBbb组件加上memo也会执行答应。