第五章 使用Context和订阅来共享组件状态

48 阅读8分钟

在前面两章,我们学习了如何使用Context和订阅来实现全局状态。它们二者各有其利弊:Context允许我们在不同的子树注入不同的值,而订阅可以避免一些额外的重新渲染。

在这一章,我们会学习一个新的方法:把React Context 和 订阅 组合起来。这样组合的话,我们可以得到这两个方法各自的优点:

  • Context可以为一棵子树提供全局状态,而且Context的provider可以被嵌套。Context允许我们在React 组件生命周期内,通过类似 useState风格的钩子来获取全局状态。
  • 另一方面,订阅允许我们控制重新渲染。

同时享有这两个方法的优点,对于大型应用是很好的 - 因为,我们可以为不同的子树注入不同的值,同时还避免额外的重新渲染。

在这一章,我们会讨论这几个主题:

  • 探索模块状态的局限
  • 理解何时使用Context
  • 实现Context和订阅的组合

探索模块状态的局限

因为模块状态是定义在React 组件之外,它有一个局限:这个模块状态是走单例模式的,所以,你无法为不同的子树注入不同的值。

让我们复习一下在第四章实现的 createStore方法:

const createStore = (initialState) => {
    let state = initialState;
    const callback = new Set();
    const getState = () => state;
    const setState = (nextState) => {
        state = typeof nextState === 'function'
        ? nextState(state) : nextState;
        callbacks.forEach((callback) => callback());
    };
    const subscribe = (callback) => {
        callbacks.add(callback);
        return () => { callbacks.delete(callback)' };
    };
    return { getState, setState, subscribe };
}

我们可以使用createStore来定义一个store:

const store = createStore({ count: 0 })

注意,store是定义在React组件之外的。

为了在React组件中使用store,我们要借助 useStore。下面这个例子,是有两个组件展示共同的来找store的值。我们可以借助在第四章实现的 useStore来实现:

const Counter = () => {
    const [state, setState] = useStore(store);
    const inc = () => {
        setState((prev) => {
            ...prev,
            count: prev.count + 1
        });
    };
    
    return (
        <div>
            {state.count} <button onClick={inc}>+1</button>
        </div>
    )
}

const Component = () => {
    <>
        <Counter />
        <Counter />
    </>
}

我们有用来展示store对象内 count 的 Counter组件,还有一个用于 更新 count 值的 button。因为Counter组件是可复用的,Component组件有两个 Counter组件 实例。Component组件 会为 我们展示一对组件展示共同的状态。

现在,假设我们想展示另一对 Counter组件。我需要在 Component组件 中 展示 另外两个新的组件,但是这对组件 所展示的 counter 参数需要和原来不一样。

我们可以生成一个 新的 count值。我们可以 为 我们已经定义的 store 对象 添加一个新的值。但是如果我们想把不同的 sotre 拆分开来,我们可以这样:

const store2 = createStore({ count: 0 });

因为 createStore 是可复用的,创建 store2 因此很简单。

现在,我们可以创建Counter2 组件:

const Counter2 = () => {
    const [state, setState] = useStore(store2);
    const inc = () => {
        setState((prev) => {
            ...prev,
            count: prev.count + 1
        });
    };
    
    return (
        <div>
            {state.count} <button onClick={inc}>+1</button>
        </div>
    )
}

const Component2 = () => {
    <>
        <Counter2 />
        <Counter2 />
    </>
}

你也许发现了,CounterCounter2 组件的 相似性 - 它们都有14行,唯一的不同在于store的指向不同。我们也许还需要Counter3Counter4组件来展示更多store的值。理想情况而言,Counter应该是可复用的,但是因为 块级状态是定义在 React外的,所以它无法服用。这是块级状态的局限。

如果Counter可以给不同的store复用,将会非常好。这是伪代码:

const Component = () => (
     <StoreProvider>
         <Counter />
         <Counter />
     </StoreProvider>
);

const Component2 = () => (
     <Store2Provider>
         <Counter />
         <Counter />
     </Store2Provider>
);

const Component3 = () => (
     <Store3Provider>
         <Counter />
         <Counter />
     </Store3Provider>
);

观察这段代码,你会发现Component1,Component2, Component3, Component4几乎一样。唯一的不同在于Provider组件。这正是React的Context该登场的时候。

现在,你已经知道了 块级状态的局限了,也知道了多sotre的理想模式。接下来,我们要复习Context并探索其使用。

理解何时使用Context。

在我们学习如何 组合 Context 与 订阅之前,我们先复习一下 Context是如何运作的。

下面是一个Context的简单用例:

const ThemeContext = createContext("light");


const Component = () => {
    const theme = useContext(ThemeContext);
    return <div>Theme: {theme}</div>
}

useContext的返回值,取决于Context所以在的组件树。

如果要改变Context的值,我们可以使用Provider:

<ThemeContext.Provider value="dark">
    <Component />
</ThemeContext.Provder>

如此一来,Compoenent展示的是 dark。

Provider是可以被嵌套的。而useContext取值是遵从就近原则:

<ThemeContext.Provider value="this value is not used">
    <ThemeContext.Provider value="this value is not used">
        <ThemeContext.Provider value="this is the value used">
            <Component />
        </ThemeContext.Provider>
    </ThemeContext.Provider>
</ThemeContext.Provider>

如果组件树中没有provider,useContext取默认值。

比如说,我们假设Root组件位于组件树最顶端:

const Root = () => {
    <>
        <Component />
    </>
}

此时,Component组件展示的是light。

再看看provider提供的是默认值的情况:

const Root = () => {
    <ThemeContext.Provider value="light">
        <Component />
    <ThemeContext.Provider/>
}

一样,Component组件展示的还是light。

接下来,我们讨论一下何时该使用Context。首先,我们要回顾之前的例子,有provider和无provider有什么区别?我们可以说,没有。没有provider返回的是默认值。

对于一个Context来说,设置合适的默认值是有必要的。而Context的provider可以理解为针对默认值的重写函数。

ThemeContext的例子中,我们已经有一个可用的默认值了,那么设置provider的意义为何?因为,我们需要在不同的子树注入不同的值。否则,我们直接使用默认值即可。

使用Context来管理全局状态时,你可能只会在根节点处使用一个提供者(Provider)。这是一种合理的使用场景,但这种场景也可以通过第 4 章 中介绍的带订阅功能的模块状态来实现。鉴于模块状态能够涵盖在根节点使用一个上下文提供者的使用场景,那么只有当我们需要为不同的子树提供不同的值时,才需要使用上下文来管理全局状态。

在本节中,我们回顾了 React 上下文,并了解了何时使用它。接下来,我们将学习如何将Conetxt和订阅结合使用。

实现Context和订阅的组合

我们知道,使用Context来注入全局状态时,会有一个问题:它会产生不必要的 重新渲染。

模块状态的订阅没有额外 重新渲染的问题,但它有另一问题:它只能为整个组件树提供一个值。

我们现在要把这两者结合起来,来避免这两者的缺点。让我们来实现这个特性。我们先从createStore开始:

getState: () => T;
    setState: (action: T | ((prev: T) => T)) => void;
    subscribe: (callback: () => void) => () => void;
};

const createStore = <T extends unknown>(
    initialState: T
    ): Store<T> => {
    let state = initialState;
    const callbacks = new Set<() => void>();
    const getState = () => state;
    const setState = (nextState: T | ((prev: T) => T)) => {
        state =
            typeof nextState === "function"
            ? (nextState as (prev: T) => T)(state)
            : nextState;
        callbacks.forEach((callback) => callback());
    };
 
    const subscribe = (callback: () => void) => {
        callbacks.add(callback);
        return () => {
            callbacks.delete(callback);
        };
    };
    return{ getState, setState, subscribe };
};

在第四章,我们把createStore用在模块状态上。现在,我们要把createStore用在Context的值上。

下面这段代码用于创建Context。其默认值用于传给createContext,其指向为默认store:

type State = { count: number; text?: string };

const StoreConext = createContext<Store<State>>(
    createStore<State>({ count: 0, text: "hello" })
)

此时,这个store有两个属性: counttext

为了把这些值提供给不同的子树,我们要创建StoreContext:

const StoreProvider = ({
    initialState,
    children
} : {
    initialState: State,
    children: ReactNode
}) => {
    const storeRef = useRef<Store<State>>();
    if (!storeRef.current) {
        storeRef.current = createStore(initialState);
    }
    return (
        <StoreContext.Provider value={storeRef.current}>
            {children}
       </StoreContext.Provider>
    )
}

useRef用于确保store对象只会在第一次渲染时进行初始化。

为了使用store对象,我们要实现一个useSelector钩子。不像第四章的useStoreSelector以store为参数,useSelector的参数中没有store:

const useSelector = <S extends unknown>(
    selector: (state: State) => S
) => {
    const store = useContext(StoreContext);
    return useSubscription(
        useMemo(
            () => ({
                getCurrentValue: () => selector(store.getState()),
                subscribe: store.subscribe,
            }),
            [store, selector]
        )
    )
}

useContextuseSubscription,是这个模式的关键。

不像模块状态,我们需要提供一个用Context来更新状态的方法。useSetState是一个简单的可以返回setState的钩子:

const useSetState = () => {
    const store = useContext(StoreContext);
    return store.setState;
}

现在,我们可以使用我们实现的方法了。下面是一个用于展示store中count的组件。我们在组件外定义了一个selectCount,否则,我们要用useCallback来包裹它:

const selectCount = (state: State) => state.count;

const Component = () => {
    const count = useSelector(selectCount);
    const setState = useSetState();
    const inc = () => {
        setState((prev) => ({
            ...prev,
            count: prev.count + 1,
        }));
    };
    return (
        <div>
            count: {count} <button onClick={inc}> +1</button>
        </div>
    )
};

需要注意的是,这个Component组件并不被绑定到任何特定stor。这个组件可以用在不同的store上。

我们可以让这个Component组件在不同的地方:

  • 在任何provider之外
  • 在第一个provider之中
  • 在第二个provider之中

下面这个App组件,把Component组件放在了三个地方:1.provider之外;2.在第一个provider之中;3.在第二个provider之中。在不同provider中的Component,则享有不同的值:

const App = () => (
     <>
         <h1>Using default store</h1>
         <Component />
         <Component />
         <StoreProvider initialState={{ count: 10 }}>
             <h1>Using store provider</h1>
             <Component />
             <Component />
             <StoreProvider initialState={{ count: 20 }}>
                 <h1>Using inner store provider</h1>
                 <Component />
                 <Component />
            </StoreProvider>
        </StoreProvider>
    </>
);

消费了相同store对象的Component组件会享有相同的count值。在这个例子中,在不同组件树层级中的组件,会消费不同的store,所以在不同地方的组件会展示不同的count:

image.png

如果你在 “使用默认存储” 中点击 “+1” 按钮,你会看到 “使用默认存储” 中的两个计数会一起更新。如果你在 “使用存储提供者” 中点击 “+1” 按钮,你会看到 “使用存储提供者” 中的两个计数会一起更新。“使用内部存储提供者” 的情况也是如此。

在本节中,我们学习了如何利用Context和订阅的相关优势来实现全局状态。由于上下文的存在,我们能够将状态隔离在一个子树中;而由于订阅的作用,我们能够避免额外的重新渲染。

概要

在这一章,我们学习了一个新的模式,组合Context和订阅。它将两者的优点结合在了一起:为不同的子树注入独立的值,并且避免不必要的重新渲染。这个模式对中大型项目特别有用。在中大型应用中,时候会发生不同的子树有不同的值的问题,而使用这个模式可以解决这一问题,还避免了不必要的重新渲染。

从下一章开始,我们要深入一些全局状态库。我们将会学习这些库是如何基于我们现在学习的知识建立起来的。