在前面两章,我们学习了如何使用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 />
</>
}
你也许发现了,Counter
和 Counter2
组件的 相似性 - 它们都有14行,唯一的不同在于store的指向不同。我们也许还需要Counter3
和 Counter4
组件来展示更多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有两个属性: count
和 text
。
为了把这些值提供给不同的子树,我们要创建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]
)
)
}
将useContext
与useSubscription
,是这个模式的关键。
不像模块状态,我们需要提供一个用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和订阅。它将两者的优点结合在了一起:为不同的子树注入独立的值,并且避免不必要的重新渲染。这个模式对中大型项目特别有用。在中大型应用中,时候会发生不同的子树有不同的值的问题,而使用这个模式可以解决这一问题,还避免了不必要的重新渲染。
从下一章开始,我们要深入一些全局状态库。我们将会学习这些库是如何基于我们现在学习的知识建立起来的。