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

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

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

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

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

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

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

探索模块状态的局限

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

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

js 复制代码
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:

js 复制代码
const store = createStore({ count: 0 })

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

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

js 复制代码
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 拆分开来,我们可以这样:

js 复制代码
const store2 = createStore({ count: 0 });

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

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

js 复制代码
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复用,将会非常好。这是伪代码:

js 复制代码
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的简单用例:

js 复制代码
const ThemeContext = createContext("light");


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

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

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

js 复制代码
<ThemeContext.Provider value="dark">
    <Component />
</ThemeContext.Provder>

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

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

js 复制代码
<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组件位于组件树最顶端:

js 复制代码
const Root = () => {
    <>
        <Component />
    </>
}

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

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

js 复制代码
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开始:

js 复制代码
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:

js 复制代码
type State = { count: number; text?: string };

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

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

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

js 复制代码
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:

js 复制代码
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的钩子:

js 复制代码
const useSetState = () => {
    const store = useContext(StoreContext);
    return store.setState;
}

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

js 复制代码
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,则享有不同的值:

js 复制代码
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:

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

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

概要

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

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

相关推荐
腾讯TNTWeb前端团队7 小时前
helux v5 发布了,像pinia一样优雅地管理你的react状态吧
前端·javascript·react.js
范文杰10 小时前
AI 时代如何更高效开发前端组件?21st.dev 给了一种答案
前端·ai编程
拉不动的猪10 小时前
刷刷题50(常见的js数据通信与渲染问题)
前端·javascript·面试
拉不动的猪10 小时前
JS多线程Webworks中的几种实战场景演示
前端·javascript·面试
FreeCultureBoy11 小时前
macOS 命令行 原生挂载 webdav 方法
前端
uhakadotcom12 小时前
Astro 框架:快速构建内容驱动型网站的利器
前端·javascript·面试
uhakadotcom12 小时前
了解Nest.js和Next.js:如何选择合适的框架
前端·javascript·面试
uhakadotcom12 小时前
React与Next.js:基础知识及应用场景
前端·面试·github
uhakadotcom12 小时前
Remix 框架:性能与易用性的完美结合
前端·javascript·面试
uhakadotcom12 小时前
Node.js 包管理器:npm vs pnpm
前端·javascript·面试