第三章:使用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 的具体实现,这将帮助组织应用代码。

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

相关推荐
曲辒净29 分钟前
vue搭建一个树形菜单项目
前端·javascript·vue.js
喝拿铁写前端6 小时前
前端与 AI 结合的 10 个可能路径图谱
前端·人工智能
codingandsleeping6 小时前
浏览器的缓存机制
前端·后端
-代号95277 小时前
【JavaScript】十二、定时器
开发语言·javascript·ecmascript
灵感__idea8 小时前
JavaScript高级程序设计(第5版):扎实的基本功是唯一捷径
前端·javascript·程序员
摇滚侠8 小时前
Vue3 其它API toRow和markRow
前端·javascript
難釋懷8 小时前
JavaScript基础-history 对象
开发语言·前端·javascript
beibeibeiooo8 小时前
【CSS3】04-标准流 + 浮动 + flex布局
前端·html·css3
拉不动的猪8 小时前
刷刷题47(react常规面试题2)
前端·javascript·面试
浪遏8 小时前
场景题:大文件上传 ?| 过总字节一面😱
前端·javascript·面试