第三章:使用Context来共享组件状态

自React 16.3 以来,React为我们提供了ContextContext本身与状态无关,但它是一个不依赖属性进行跨组件传值的机制。通过把Context与组件状态组合在一起,我们就可以构造全局属性了。

此外,React 16.8 还提供了useContext钩子。通过使用useContextuseState(其实useReducder)也行,我们就自定义用于全局状态的钩子了。

Context并不完全是为了全局状态而设计的。Context的一个众所周知的缺陷在于,所有Context的消费者会在其状态更新时,进行重新渲染。而这其中可能会有不必要的重新渲染。所以,人们更推荐把全局状态进行拆分。

在这一章节,我们会结合具体的例子讲述Context的常见用法。我们还好讨论结合TypScript使用Context的技巧。这么做的目的是,你可以更加自信地用Context处理全局状态。

在这一章,我们会讨论下面这些主题

  • 探索useStateuseContext
  • 理解Context
  • 创建一个存储全局对象的Context
  • Context的最佳实践

探索useStateuseContext

通过组合useStateuseContext,我们可以创建一个全局状态。让我们回顾一下如何在不使用 useContext 的情况下使用 useStateuseContext 对于静态值是如何起作用的,以及我们如何将 useStateuseContext 结合起来使用。

使用useState而不使用useContext

在深入useContext之前,我们先回顾一下如何使用useState

下面是代码:

js 复制代码
const App = () => {
    const [count, setCount] = useState(0);
    return <Parent count={count} setCount={setCount} />;
};

这是很基础的一个用法。

然后,我们再创建一个Parent组件。Parent组件把两个属性传递给Component1Component2,如下:

js 复制代码
const Parent = ({ count, setCount }) => {
    <>
        <Component1 count={count} setCount={setCount} />
        <Component2 count={count} setCount={setCount} />
    </>
}

这种从父组件向子组件传递属性的操作是一项重复性的任务,通常被称为 "属性钻取"。

下面是Component1组件和Component2组件的代码:

js 复制代码
const Component1 = ({ count, setCount }) => (
    <div>
        {count}
        <button onClick={() => setCount((c) => c + 1)}>
            +1
        </button>
    </div>
    );
    const Component2 = ({ count, setCount }) => (
    <div>
        {count}
        <button onClick={() => setCount((c) => c + 2)}>
            +2
        </button>
    </div>
);

这两个组件,是纯组件。这意味着,它们展示的内容,仅仅依赖于属性。当然,Component1Component2还是有细微不同的。

这个例子没啥问题。在这种情况下,父组件不一定需要了解 count 状态,并且在父组件中隐藏 count 状态的存在可能是有意义的。

结合一个静态值使用useContext

React允许在使用组件时,不使用属性。

下面这个例子,会展示如何使用一个静态值来使用Context。它对于不同的值,有不同的providers,而消费者组件会在最近的providers获取对应的值。

首先,我们可以用createContext来定义一个颜色:

js 复制代码
const ColorContext = createContext('black');

在这个例子中,我们有一个黑色的默认颜色。这个默认值可以用于任何providers。

接下来,我们要定义一个消费者组件。它会读取Context的颜色,并展示这个颜色:

js 复制代码
const Component = () => {
    const color = useContext(ColorContext);
    return <div style={{ color }}>Hello {color}</div>;
}

Component会读取Context的color。但此时,我们并不知道color为何,它是依赖于Context的。

之后,我们可以定义一个App组件。值这个组件中,我们可以定义多个provider,为其设置不同的颜色,代码如下:

js 复制代码
const App = () => (
    <>
        <Component />
        <ColorContext.Provider value="red">
            <Component />
        </ColorContext.Provider>
        <ColorContext.Provider value="green">
            <Component />
        </ColorContext.Provider>
        <ColorContext.Provider value="blue">
            <Component />
            <ColorContext.Provider value="skyblue">
                <Component />
            </ColorContext.Provider>
        </ColorContext.Provider>
    </>
);

第一个Component会展示 black,因为它没有被任何provider包裹。第二个和第三个,分别展示redgreen。第三个会展示 blue。最后一个会展示skyblue,因为包裹它最近的provider的值是skyblue

多个提供者(provider)以及重用消费者(consumer)组件是 React 上下文(Context)的一项重要功能。如果对于你的应用场景来说,这项功能并不重要,那么你可能就不需要使用 React 上下文。我们将在第四章《通过订阅共享模块状态》中讨论不使用上下文的订阅方法

结合useContext使用useState

现在,我们看看useContextuseState可以如何组合。我们可以传递状态和Context的更新函数,这样就不用传递props了。

下面这个例子,展示了一个用useStateuseContext实现的简单的 count 状态。我们定义了一个count状态,及其更新函数setCount函数。而Parent组件并不传递组件,Component1Component2组件通过 useContext来获取状态。

首先,我们为 count 状态创建了一个Context。这个Context里的count有一个默认的静态值,和一个空的setCount回退函数。代码是这样的:

js 复制代码
const CountStateContext = createContext({
    count: 0,
    setCount: () => {},
})

这个默认值可以帮助TS来推断类型。然而,在大多数情况下,我们更需要一个状态,而非一个静态值,因为默认值其实没啥用。之后,我们会讨论使用Context的最佳实践。

App组件有count状态及其setter函数,并把它们传递给了Context provider 组件,如下面的代码所示:

js 复制代码
const App = () => {
    const [count, setCount] = useState(0);
    
    return (
        <CountStateContext.Provier
            value={ { count, setCount } }
        >
            <Parent />
        </CountStateContext.Provier>
    )
}

传递给 CountStateContext.Provier 的值是一个 成员 为 countsetCount 的对象。这个对象的结构和默认值的结构是一样的。

我们定义了一个Parent组件。不像上一部分的例子,我们不用再传递属性了。代码如下所示:

js 复制代码
const Parent = () => {
    <>
        <Component1 />
        <Component2 />
    </>
}

虽然Parent组件被包裹在了 Context provider里面,Parent组件并不知道count状态的存在。而Parent组件内的组件,依然可以通过Context使用count

最后,我们要定义Component1组件 和 Componen2组件。它们不再通过属性来获取countsetCount,而是通过Contetxt来获取:

js 复制代码
const Component1 = () => {
    const { count, setCount } = useContext(CountStateContext);
    
    return (
        <div>
            {count}
            <button onClicak={() => setCount((c) => c + 1 )}>
                +1
            </button>
        </div>
    )
}

const Component2 = () => {
    const { count, setCount } = useContext(CountStateContext);
    
    return (
        <div>
            {count}
            <button onClicak={() => setCount((c) => c + 2 )}>
                +2
            </button>
        </div>
    )
}

那么,这些组件通过Context得到了什么值呢?它们通过离自己最近的provider来获取值。我们可以生成多个providers,这样就会有多个各自独立的count状态了。而生成多个独立的Context,是React Context一项重要的能力。

在这一部分,我们学习了如何使用React Context来创建一个全局状态。接下来,我们要深入理解React Context的运行。

理解Context

当一个上下文提供者(Context provider)拥有一个新的上下文值时,所有的上下文消费者(Context consumers)都会接收到这个新值并重新渲染。这意味着提供者中的值会被传播到所有的消费者那里。理解上下文的传播机制及其局限性对我们来说是很重要的。

Context的传播是如何运作的。

当一个Context的provider有了新的Context值时,这个Context的所有消费者会接收这个新值,并进行重新渲染。这意味着,Context的值的变化,会传导到其所有的消费者。

有时,一个子组件会因为两个原因重新渲染 -一个是因为其父组件重新渲染,另一个是因为Context的值发生了变化。

为了阻止不是因为Context值变化而引起的重新渲染,我们可以使用内容提升的技巧,或者使用memo。memo用于包裹一个组件,并过滤掉属性没有变化时的重新渲染。

让我们看一个使用memo的例子。

js 复制代码
const ColorContext = createContext('black');

'black'是一个默认值,会在组件树上没有Context provider时使用。

之后,我们定义ColorComponent组件,同时使用renderCount来统计组件被重新渲染的次数,如下所示:

js 复制代码
const ColorComponent = () => {
    const color = useContext(ColorContext);
    const renderCount = useRef(1);
    
    useEffect(() => {
        renderCount.current +=1;
    })
    
    return (
        <div style={{ color }}>
            Hellor {color} (renders: {renderCount.current})
        </div>
    )
}

我们用useRef来定义 renderCountrenderCount.current代表的就是渲染次数。每当useEffect被触发时,renderCount.current就会递增。

接下来,便是MemoedColorComponent组件

js 复制代码
const MemoedColorComponent = memo(ColorComponent);

memo函数会返回一个基于基准组件的,被记忆化的组件。被记忆化的组件会在属性不变时不再渲染。

之后,我们在生成一个不使用useContextDummyComponent组件:

js 复制代码
const DummyComponent = () => {
    const renderCount = useRef(1);
    
    useEffect(() => {
        renderCount.current +=1;
    })
    
    return (
        <div style={{ color }}>
            Dummy (renders: {renderCount.current})
        </div>
    )
}

这个组件用于比较ColorComponent组件的行为。

同理,我们再生成一个MemoedDummyComponent组件:

js 复制代码
const MemoedDummyComponent = memo(DummyComponent);

接下来,我们定义一个Parent组件,用于展示刚刚定义的四个组件:

js 复制代码
const Parent = () => {
    <ul>
        <li><DummyComponent></li>
        <li><MemoedDummyComponent></li>
        <li><ColorComponent></li>
        <li><MemoedColorComponent></li>
    </ul>
}

最后,我们写一个App组件。在这个App组件定义colorsetColor。之后,我们可以通过一个input 来设置颜色:

js 复制代码
const App = () => {
    const [color, setColor] = useState('red');
    return (
        <ColorContext.Provider value={color}>
            <input
                value={color}
                onChange={ (e) => setColor(e.target.value) }
            />
            <Parent />
        </ColorContext.Provider>
    )
}

这个组件会这样运行:

  1. 首先,渲染所有的组件。
  2. 如果你在input里输入了新的值,App组件会重新渲染,因为useState被调用了。
  3. 3之后,ColorContext.Provider的到来一新值,与此同时,Parent组件也重新渲染了。
  4. DummyComponent渲染了,但是MemoedDummyComponent并没有
  5. ColorComponent会因为两个原因重新渲染:其父组件重新渲染了,Context的值变动了
  6. MemoedColorComponent重新渲染了,因为Context变动了

在Context的值为对象时的局限

如果Context值是原始类型,当然是简单的。但如果其值为引用类型,就有很多要注意的了。一个对象也许有多个值,而这个Context的消费者用不到这么多值。

在下面的例子中,我们以一个值为对象的Context展开。

首先,我们定义一个值为 count1count2 的对象,再把它传递给 Context:

js 复制代码
const CountContext = createContext({ count1: 0, count2: 0 });

为了使用这个CountContext,我们可以定义一个展示count1Counter1。我们可以用 renderCount 来展示渲染次数。我们还可以定义一个MemoedCounter1组件。代码如下:

js 复制代码
const Couner1 = () => {
    const { count1 } = useContext(CountContext);
    const renderCount = useRef(1);
    useEffect(() => {
        renderCount.current += 1;
    });
    
    return (
        <div>
            Count1: {count1} (renders: {renderCount.current})
        </div>
    )
}

const MemoedCounter1 = memo(Counter1);

注意,Counter1组件会使用Context的 count1

同理,我们可以定义一个展示 count2Counter2组件,并定义一个MemoedCounter2组件。

js 复制代码
const Couner2 = () => {
    const { count2 } = useContext(CountContext);
    const renderCount = useRef(1);
    useEffect(() => {
        renderCount.current += 1;
    });
    
    return (
        <div>
            Count2: {count2} (renders: {renderCount.current})
        </div>
    )
}

const MemoedCounter2 = memo(Couner2);

Parent组件以两个被缓存的组件作为子组件:

js 复制代码
const Parent = () => {
    <>
        <MemoedCounter1 />
        <MemoedCounter2 />
    </>
}

最后,我们要定义一个App组件。在App组件,我们定义了两个count及其setter函数,并把两个count注入到Context中:

js 复制代码
const App = () => {
    const [count1, setCount1] = useState(0);
    const [count2, setCount2] = useState(0);
    return (
        <CountContext.Provider value={{ count1, count2 }}>
            <button onClick={() => setCount1((c) => c + 1)}>
                {count1}
            </button>
            <button onClick={() => setCount2((c) => c + 1)}>
                {count2}
            </button>
            <Parent />
        </CountContext.Provider>
    );
};

注意,这两个按钮的位置很重要!

这两个count,count1count2是 各自的独立的。Counter1组件只消费了count1Counter2组件只消费了count2.因此,理想上来说,Counter1应该只在count1变化时才重新渲染。反之,则意味着产生了不必要的重新渲染。在这个例子中,Counter1会在count2变化时重新渲染。

这种不必要的重新渲染,在使用React Context时还是要注意的。

在这一部分,我们学习了React Context的局限性,尤其是其值为引用类型时。接下来,我们要学习用React Context实现全局状态的典型实践。

用Context创建全局状态

基于React Context的行为,我们会讨论用Context 创建全局状态的两种方案:

  • 创建小块状态
  • 创建一个使用 useReducer 的状态并通过多个 Context 进行传播

让我们看看每个方案。

创建小块状态

第一个方案是把全局状态进行细分。所以,我们不再把全局状态放在一个对象里,而是为每一个状态创建一个Context。

下面这个例子,创建了两个count状态,并为每一个状态创建了其对应的Context provider。

首先,我们创建了两个Context,Count1ContextCount2Context

js 复制代码
type CountContextType = {
    number,
    Dispatch<SetStateAction<number>>
}

const Count1Context = createContext<CountContextType>([
    0,
    () => {}
])

const Count2Context = createContext<CountContextType>([
    0,
    () => {}
])

Context的值是一个由 count和其更新函数组成的元组。

之后,我们定义Counter1:

js 复制代码
const Counter1 = () => {
    const [count1, setCount1] = useContext(Count1Context);
    
    return (
        <div>
            Count1: {count1}
            <button onClick={() => setCount1((c) => c + 1)}>
                +1
           </buuton>
        </div>
    )
}

注意,Counter1只依赖Count1Context,它没有使用其他上下文。

Counter2的实现是类似的:

js 复制代码
const Counter2 = () => {
    const [count2, setCount2] = useContext(Count2Context);
    
    return (
        <div>
            Count1: {count1}
            <button onClick={() => setCount2((c) => c + 1)}>
                +1
           </buuton>
        </div>
    )
}

之后,实现Parent组件:

js 复制代码
const Parent = () => {
    <div>
        <Counter1 />
        <Counter1 />
        <Counter2 />
        <Counter2 />
    </div>
}

之后,我们定义Count1Context的provider Count1Provider:

js 复制代码
const Count1Provider =({
    children
}: {
    children: ReactNode
}) => {
    const [count1, setCount1] = useState(0);
    
    return (
        <Count1Contxt.Provider value ={[count1, setCount1]}>
            {children}
        </Count1Contxt.Provider>
    )
}

同理,我们定义Count2Provider:

js 复制代码
const Count2Provider =({
    children
}: {
    children: ReactNode
}) => {
    const [count2, setCount2] = useState(0);
    
    return (
        <Count2Contxt.Provider value ={[count2, setCount2]}>
            {children}
        </Count2Contxt.Provider>
    )
}

Count1ProviderCount2Provider类似,其不同在于传递的值。

最后,Parent组件,被这两个Provider所包裹:

js 复制代码
const App = () => {
    <Count1Provider>
        <Count2Provider>
            <Parent />
        </Count2Provider>
    </Count1Provider>
}

之后,这个App组件就会被嵌套在这两个provider组件里面。如果有更多的provider,就会造成更深的嵌套。

这个应用并不会遇到上文说的额外重新渲染。这是因为每个Context只接收原始值。所以Counter1Counter2之后在其对应的count变化时,进行重新渲染。为每一个状态创建provider是有必要的;否则,就要把多个状态整合在一个状态里,就会引起很多重新渲染。

如果你确定一个对象只会被修改一次,那为它单独创建一个Context也是可以的。比如这种:

js 复制代码
const [user, setUser] = useState({
    firstName: 'react',
    lastName: 'hooks'
})

这种情况下,就没必要为每一个name各建立一个Context了。

接下来,我们看看另一个方法。

用useReducer建立状态,并使用多个Context传播状态

第二种解决方案是创建一个单一的状态,并使用多个 Context 来分发状态的不同部分。在这种情况下,分发更新状态的函数应该使用一个单独的 Context。

以下示例基于 useReducer,它有三个 Context:两个用于状态的不同部分,最后一个用于 dispatch 函数。

首先,我们为两个counts变量创建Context,再创建一个Context用于分发对应的操作:

js 复制代码
type Action = { type: "INC1" } | { type: "INC2" };

const Count1Context = createContext<number>(0);
const Count2Context = createContext<number>(0);

const DispatchContext = createContext<Dispatch<Action>>(
    () => {}
)

在这个例子中,我们有两个count变量,为其各自创建了一个Context。但是,它们的都操作行为都由同一个Context进行分发。

为了更好的分发操作,我们需要定义一个reducer

接下来,我们要定义一个Counter1组件。这个组件使用了两个Contexts,一个Context用来存值,另一个用来分发函数:

js 复制代码
const Counter1 = () => {
    const count1 = useContext(Count1Context);
    const dispatch = useContext(DispatchContext);
    
    return (
        <div>
            Count1: {count1}
            <button onClick={() => dispatch({ type: "INC1"})}>
             +1
            </button>
        </div>
    )
}

同理,我们可以定义一个Counter2组件:

js 复制代码
const Counter2 = () => {
    const count2 = useContext(Count2Context);
    const dispatch = useContext(DispatchContext);
    
    return (
        <div>
            Count2: {count2}
            <button onClick={() => dispatch({ type: "INC2"})}>
             +1
            </button>
        </div>
    )
}

Counter1组件 和 Counter2组都使用了 DispatchContext

Parent组件如下:

js 复制代码
const Parent = () => {
    <div>
        <Counter1 />
        <Counter1 />
        <Counter2 />
        <Counter2 />
    </div>
}

现在,我们为其定义一个唯一的Provider组件。这个Provider组件会使用useReducer。这个reducer会处理两种操作INC1INC2。这个Provider组件还使用了之前定义的三个provider:

js 复制代码
const Provider = ({ children }: { children: ReactNode }) => {
    const [state, dispatch] = useReducer(
        (
            prev: { count1: number, coount2: number },
            action: Action
        ) => {
            if (action.type ===  "INC1") {
                return { ...prev, count1: prev.count1 +1 }
            }
            if (action.type ===  "INC2") {
                return { ...prev, count2: prev.count2 +1 }
            }
            throw new Error("no matching action")
            },
        {
            count1: 0,
            count2: 0
        }
    )
    return (
        <DisptachContext.Provider value={disptach}>
                <Count1Context.Provider value={state.count1}>   
                                <Count2Context.Provider value={state.count2}>
                                    {children}
                                                </Count2Context.Provider>
                </Count1Context.Provider>
        </DisptachContext.Provider value={disptach}>
    )
}

最后,再把Parent组件放在Provider组件里面:

js 复制代码
const App = () => {
    <Provider>
        <Parent />
    </Provider>
}

这个应用并不会遭遇额外的重新渲染;count1的变动之后触发Counter1组件的重新渲染,Counter2组件并不会因此重新渲染.

把状态拆分成更细的Context的好处是,更新时可以同时更细多个状态。比如说,你可以在reducer里面添加这个操作:

js 复制代码
    if (action.type === "INC_BOTH") {
        return {
            ...prev,
            count1: prev.count1 + 1,
            count2: prev.count2 +1,
        };
    }

正如我们在第一种解决方案中讨论的那样,创建一个用于对象(如用户对象)的 Context 在这种解决方案中也是可以接受的。

在本节中,我们学习了两种使用 Context 实现全局状态的解决方案。这些是典型的解决方案,但可能会有很多变体。关键点是使用多个 Context 来避免不必要的重新渲染。在下一节中,我们将学习一些基于多个 Context 的全局状态的最佳实践。

使用Context的最佳实践

在下面的部分,我们要讨论下面三个模式,以处理全局状态问题:

  • 自定义钩子和provider组件
  • 自定义钩子的工厂模式
  • 使用reducerRight以避免provider嵌套。

让我们挨个看。

自定义钩子和provider组件

在之前的例子中,我们直接使用useContext来获取Context的值。现在,我们可以直接显示地定义相关的钩子和provider组件。这样,我们就可以隐藏Context,并限制其使用。

下面的例子里,我们要创建自定义钩子和provider组件。我们要创建一个默认的Context 值 null,再检查 这个 值 在自定义 钩子 中是否为 null。

首先,我们要创建一个Context:

js 复制代码
type CountContextType = [
    number,
    Dispatch<SetStateAction<number>>
]

const Count1Context = createContext<
    CountContextType | null
>(null);

然后,我们定义Count1Provider,在Count1Provider中,我们用useState创建一个状态,再把它传递给 Count1Context.Provider:

js 复制代码
export const Count1Provider = ({
    chilren
}: {
    children: ReactNode
}) => (
    <Count1Context.Provider value={useState(0)}>
        {children}
    </Count1Context.Provider>
)

注意,useState(0)这个写法。它是合法的,它是const [count, setCount] = useState(0) 的简写。

之后,我们可以定义 useCount1钩子,来返回Count1Context的值。在这里,我们要检查Context的值是否为null。如果为null,我们要抛出一个 Error:

js 复制代码
export const useCount1 = () => {
    const value = useContext(Count1Context);
    
    if (value === null) throw new Error("Provider missing");
    return value;
}

同样的代码逻辑,用到count2上:

js 复制代码
const Count2Context = createContext<
    CountContextType | null
>(null);

export const Count2Provider = ({
    chilren
}: {
    children: ReactNode
}) => (
    <Count2Context.Provider value={useState(0)}>
        {children}
    </Count2Context.Provider>
)

export const useCount2 = () => {
    const value = useContext(Count2Context);
    
    if (value === null) throw new Error("Provider missing");
    return value;
}

接下来,我们就可以定义Counter1组件了:

js 复制代码
const Counter1 = () => {
    const [count1, setCount1] = useCount1();
    return (
        <div>
            Count1: {count1}
            <button onClick={() => setCount1((c) => c + 1 )}
             +1
            </button>
        </div>
    )
}

同理,这是Counter2组件:

js 复制代码
const Counter2 = () => {
    const [count2, setCount2] = useCount2();
    return (
        <div>
            Count1: {count2}
            <button onClick={() => setCount2((c) => c + 1 )}
             +1
            </button>
        </div>
    )
}

注意,Counter2 组件 和 Counter1 组件非常相似。其主要不同在于,Counter2组件使用了 useCount2钩子,而Counter1使用了useCount1钩子。

我们定义了一个Parent组件,这个组件调用了Counter1 组件 和 Counter2组件:

js 复制代码
const Parent = () => {
    <div>
        <Counter1 />
        <Counter1 />
        <Counter2 />
        <Counter2 />
    </div>
}

最后,就是定义App组件,在App组件里,用两个provider来包裹着Parent组件:

js 复制代码
const App = () => {
    <Count1Provider>
        <Count2Provider>
            <Parent />
        </Count2Provider>
    </Count1Provider>
}

通常,我们会为每个 Context 创建一个单独的文件,例如 contexts/count1.jsx。在这个文件中,我们只导出自定义钩子(如 useCount1)和提供者组件(如 Count1Provider),而不导出 Count1Context 本身。这种做法有助于保持代码的组织性和可维护性。

用自定义钩子实现工厂模式

为一个状态创建钩子和provider组件,其实是一个重复性的工作;好在,我们可以为这个任务创建一个函数。

下面这个例子展示了一个createStateContext函数的具体实现。

createStateContext函数接受一个自定义钩子useValueuseValue会接收一个初始值,并返回一个状态。如果你使用useState,它会返回一个由 state 和 setState 组件的元组。而这个 createStateContext函数 会返回一个 由 provider组件和 自定义钩子组件的元组。

此外,它还有一个特性:provider组件 可以 接收一个 可选的,传递给 useValueinitialValue 属性。这允许你在运行时设置状态的初始值,而不是在创建时定义一个固定的初始值。代码如下:

js 复制代码
const createStateContext = (
    useValue: (init) => State,
) => {
    const StateContext = createContext(null);
    const StateProvider = ({
        initialValue,
        children
    }) => {
        <StateContext.Provider value={useValue(initialValue)}>
         {children}
        </StateContext.Provider>
    };
    
    const useContextState = () => {
        const value = useContext(StateContext);
        if (value === null) throw new Error("Provider missing");
        
        return value;
    }
    
    return [StateProvider, useContextState] as const;
}

然后,我们看看如何使用createStateContext函数。我们可以定义一个自定义钩子useNumberState; 它会接收一个可选的 init 参数:

js 复制代码
const useNumberState = (init) => useState(init || 0);

通过传递useNumberStatecreateStateCount,我们可以创建很多Context。我们创建了两个Context。我们用useNumberState可以 创建自定义的 useCount1useCount2 钩子。代码如下:

js 复制代码
const [Count1Provider, useCount1] = 
    createStateCount(useNumberState);
const [Count2Provider, useCount2] = 
    createStateCount(useNumberState);

有了createStateCount后,我们就可以避免重复工作了。

之后,我们可以再次定义Counter1组件 和 Counter2组件。然后,它们使用useCount1useCount2 钩子 和之前是 一样的:

js 复制代码
const Counter1 = () => {
    const [count1, setCount1] = useCount1();
    return (
        <div>
            Count1: {count1}
            <button onClick={() => setCount1((c) => c + 1 )}
             +1
            </button>
        </div>
    )
}

const Counter2 = () => {
    const [count2, setCount2] = useCount2();
    return (
        <div>
            Count1: {count2}
            <button onClick={() => setCount2((c) => c + 1 )}
             +1
            </button>
        </div>
    )
}

然后,我们要写 ParentApp 组件。在使用 Count1ProviderCount2Provider 时,和之前一样:

js 复制代码
const Parent = () => {
    <div>
        <Counter1 />
        <Counter1 />
        <Counter2 />
        <Counter2 />
    </div>
}

const App = () => {
    <Count1Provider>
        <Count2Provider>
            <Parent />
        </Count2Provider>
    </Count1Provider>
}

注意,这样的代码可以减少代码量。createStateContext的要点是,在避免了重复代码的前提下,还保证提供了同样的功能。

除了用useState实现useNumberState外,我们可用useReducer实现useNumberState

js 复制代码
const useMyState = () => useReducer({}, (prev, action) => {
    if (action.type === 'SET_FOO') {
        return { ...prev, foo: action.foo };
    }
    // ...
};

我们甚至可以创建一个更复杂的钩子:

js 复制代码
const useMyState = (initialState = { count1: 0, count2: 0} ) => {
    const [state, setState] = useState(initialState);
    useEffect(() => {
        console.log('updated', state);
    });
    const inc1 = useCallback(() => {
        setState((prev) => {
         ...prev,
         count1: prev.count1 + 1
        })
    }, []);
    const inc2 = useCallback(() => {
        setState((prev) => {
         ...prev,
         count2: prev.count2 + 1
        })
    }, []);
    
    return [state, { inc1, inc2 }]
};

传入 useMyStatecreateStateContext, 依然是可行的。传其他自定义钩子也行。

值得注意的是,这个模式在使用TS的情况下,运行地非常好。TS可以进行类型检查,进而提升开发体验。

js 复制代码
const createStateContext = <Value, State>(
    useValue: (init?: Value) => State
) => {
    const StateContext = createContext<State | null>(null);
    const StateProvider = ({
        initialValue,
        children,
    }: {
        initialValue?: Value;
        children?: ReactNode;
    }) => (
        <StateContext.Provider value={useValue(initialValue)}>
        {children}
        </StateContext.Provider>
    );
    const useContextState = () => {
        const value = useContext(StateContext);
        if (value === null){
            throw new Error("Provider missing");
        }
        return value;
    };
    return [StateProvider, useContextState] as const;
};
    
const useNumberState = (init?: number) => useState(init || 0);

使用reduceRight来避免重复嵌套

有了createStateContext函数,创建状态变得很容易。假设我们创建了五个状态:

js 复制代码
const App = () => (
    <Count1Provider initialValue={10}>
        <Count2Provider initialValue={20}>
            <Count3Provider initialValue={30}>
                <Count4Provider initialValue={40}>
                    <Count5Provider initialValue={50}>
                        <Parent />
                    </Count5Provider>
                </Count4Provider>
            </Count3Provider>
        </Count2Provider>
    </Count1Provider>
    );

这段代码本身是没问题。但是这样的开发体验太糟糕了。为了避免这种风格,我们可以使用reduceRight:

js 复制代码
const App = () => {
    const providers = [
        [Count1Provider, { initialValue: 10 }],
        [Count2Provider, { initialValue: 20 }],
        [Count3Provider, { initialValue: 30 }],
        [Count4Provider, { initialValue: 40 }],
        [Count5Provider, { initialValue: 50 }],
    ]
    
    return providers.reduceRight(
        (children, [Component, props]) => 
            createElement(Component, props, children),
        <Parent />,
    );
}

当然,这个技巧也有变体。我们也可以使用 HOC 高阶组件。但其实核心都是一样,使用 reduceRight来构建provider 树。

这个技巧不仅对全局状态有用,对任何组件都有用。

在这个部分,我吗学习了用Context 构建全局状态的最佳实践。并不是说,你必须要遵守所有的最佳实践。只要你理解了Context的工作原理与局限,任何模式都可以有效的使用。

概要

在本章中,我们学习了如何使用 React Context 创建全局状态。Context 的传播机制可以避免在组件树中传递属性。如果正确理解 Context 的行为,使用 Context 实现全局状态就变得简单明了。基本上,我们应该为每个状态片段创建一个独立的 Context,以避免不必要的重渲染。一些最佳实践将有助于使用 Context 实现全局状态,特别是 createStateContext 的具体实现,这将帮助组织应用代码。

在下一章中,我们将学习另一种使用订阅模式实现全局状态的方法。

相关推荐
Freedom风间4 小时前
前端优秀编码技巧
前端·javascript·代码规范
萌萌哒草头将军5 小时前
🚀🚀🚀 Openapi:全栈开发神器,0代码写后端!
前端·javascript·next.js
萌萌哒草头将军5 小时前
🚀🚀🚀 Prisma 爱之初体验:一款非常棒的 ORM 工具库
前端·javascript·orm
拉不动的猪5 小时前
SDK与API简单对比
前端·javascript·面试
runnerdancer5 小时前
微信小程序蓝牙通信开发之分包传输通信协议开发
前端
BillKu5 小时前
Vue3后代组件多祖先通讯设计方案
开发语言·javascript·ecmascript
山海上的风5 小时前
Vue里面elementUi-aside 和el-main不垂直排列
前端·vue.js·elementui
电商api接口开发5 小时前
ASP.NET MVC 入门指南二
前端·c#·html·mvc
亭台烟雨中6 小时前
【前端记事】关于electron的入门使用
前端·javascript·electron