在前面的章节,我们学习了如何使用Context 来实现全局状态。我们可以知道,Context并不适合用于单例模式;它就是设计来避免单例模式,并为不同的子树提供不同的值的。对于一个类单例模式的全局状态来说,使用块级状态会更好,因为单例模式的全局状态只是内存里的一个值。本章的目标在于,是用React 实现 块级状态。这个模式虽然不如Context知名,但却常常被用来与现成的块级状态集成。
我们将学习如何在React把块级状态作为全局状态。为了在React组件使用块级状态,我们将使用订阅机制。
在这一章,我们会讨论这几个主题:
- 探索块级状态
- 在React中,把模块状态当作全局状态
- 添加订阅
- 使用selector 和 useSubscription
探索块级状态
块级状态是 指 定义在 块级 的变量。此处的 块 指的是,一个 ES 模块,或者 仅仅是 一个 文件。更简单的说,我们会把定义在函数外的状态,视作 块级 状态。
比如说,我们定义一个 count
变量:
js
let count = 0;
假设这个变量被定义在一个模块,那它是一个 块级状态。
通常情况下,在使用 React 时,我们希望拥有一个对象状态。下面定义了一个包含count属性的对象状态:
js
let state = {
count: 0,
}
还可以在state
里面添加其他属性,添加对象也是可以的。
然后,我们再定义这个对象的 getter 函数 和 setter 函数:
js
export const getState = () => state;
export const setState = (nexState) => {
state = nextState
}
注意,这两个函数都有 export 关键字,这意味着它们会在模块外被使用。
js
export const setState = (nextState) => {
state = typeof nextState === 'function'
? nextState(state) : nextState;
}
你可以用一个函数来更新:
js
setState((prevState) => {
...prevState,
count: prevState.count + 1
})
我们可以创建一个函数来生成一个包含状态和一些访问函数的容器,而不是直接定义模块状态。
下面的代码是一个具体实现:
js
export const createContainer = (initialState) => {
let state = initialState;
const getState = () => state;
const setState = (nextState) => {
state = typeof nextState === 'funciton'
? nextState(state) : nextState;
}
return { getState, setState }
}
然后,你可以这样使用:
js
import { createContainer } from '...';
const { getState, setState } = createContainer({
count: 0
})
目前为止,这个 块级状态 还和 React 没有关系。在下一部分,我们会学习如何 用 React 实现一个 块级状态。
在React中,把模块状态当作全局状态
正如我们在第三章 "使用上下文共享组件状态" 中所讨论的,React 上下文旨在为不同的子树提供不同的值。将 React 上下文用于单例全局状态是一种可行的操作,但这并没有充分发挥上下文的全部功能。
如果你想为整个渲染树设置全局状态,使用块级状态会更好。然而,在React组件中使用 块级状态,我们要自行解决重新渲染问题。
让我们看看这个简单的例子:
js
let count = 0;
const Component1 = () => {
const inc = () => {
count += 1;
}
return (
<div>{count} <button onClick={inc}> +1</button></div>
)
}
你会看见 count 的 值 为 0.但随着 按 button 次数的增加,页面并没有发生 重新渲染。
截止至写本书时,React 还只有两个钩子 useState, useReducer 可以 触发 重新渲染。我们需要用其中一个钩子,来为该组件与块级状态 建立 相应式。
我们可以这样调整刚刚的例子:
js
let count = 0;
const Component1 = () => {
const [state, setState] = useState(count);
const inc = () => {
count += 1;
setState(count);
}
return (
<div>{state} <button onClick={inc}> +1</button></div>
)
}
现在,如果你点击button,count变量会递增,同时重新渲染也得以触发。
让我们再看看这个例子:
js
const Component2 = () => {
const [state, setState] = useState(count);
const inc = () => {
count += 2;
setState(count);
}
return (
<div>{state} <button onClick={inc}> +2</button></div>
)
}
如果你点击Component1
的button,并不会触发Component2
组件的重新渲染。只有当你点击Component2
组件时,Component2
组件会更新最新的块级状态。这是Component1
组件与Component2
组件之间的不持续性,而我们希望的是Component1
组件和Component2
组件展示相同的值。这个不持续性,也会发生在有两个Component1
组件时。
有一个简单的方法,就是同时触发 Component1
组件和Component2
组件 的 setState
函数。这意味着,setState
函数也需要这块级。我们还需要考虑组件的生命周期,并使用useEfffect
钩子来修改位于React外的setState
函数 集合。
下面这个例子可以大致说明上面的思想,虽然这并不是很可行:
js
let count = 0;
const setStateFuncitons =
new Set<(count: number) => void>();
const Compent1 = () => {
const [state, setState] = useState(count);
useEffect(() => {
setStateFunctions.add(setState);
return () => { setStateFunctions.delete(setState); };
}, []);
const inc = () => {
count += 1;
setStateFunctions.forEach((fn) => {
fn(count);
});
}
return (
<div>{state} <button onClick={inc}> +1</button></div>
)
}
注意,我们会在useEffct
中设置一个用来清除副作用的清除函数。而在 inc
函数中,我们会调用setStateFunctions
中所有的setState
函数。
同理,Component2
组件会被做类似的改造:
js
const Compent2 = () => {
const [state, setState] = useState(count);
useEffect(() => {
setStateFunctions.add(setState);
return () => { setStateFunctions.delete(setState); };
}, []);
const inc2 = () => {
count += 2;
setStateFunctions.forEach((fn) => {
fn(count);
});
}
return (
<div>{state} <button onClick={inc2}> +2</button></div>
)
}
如大家所见,这个写法不是很具有实践性。我们要处理很多重复性的代码。
在下一个部分,我们会引入订阅模式,来减少重复性的代码。
添加订阅
现在,我们要学习订阅模式,并学习如何将 块级状态 与 React状态连接在一起。
订阅是获取更新通知的一种方式。常用的订阅会是这样的:
js
const unsubscribe = store.subscribe(() => {
console.log('store is updated');
});
在此,我们假设一个 store 变量有 以 callback
为 参数的 subscribe
方法,并返回一个 unsubscribe
方法。
我们期望的是,当store内的状态更新时,callback
函数被触发,并在控制台展示相关信息。
现在,让我们实现一个具有订阅功能的块级状态:
js
type Store<T> = {
getState: () => T;
setState: (action: T | (prev: T) => T) => void;
subsrcibe: (callback: () => void) => () => void;
}
const createStore = <T extends unknown> (
intialState: T
): Store<T> => {
let state = intialState;
const callbacks = new Set(() => void)();
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 }
}
我们可以这样使用 createStor
:
js
import { createStore } from '...';
const store = createStore({ count: 0 });
console.log(store.getState());
store.setState({ count: 1 });
store.subscribe(...);
之后,是如何在React 使用 store
变量。
我们可以定义一个钩子,useStore
。这个钩子会 返回一个由store
的状态 和 其 更新函数组成的 元祖:
js
const useStore = (store) => {
const [state, setState] = useState(store.getState());
useEffect(() => {
const unsubscribe = store.subscribe(() => {
setState(store.getState());
});
setState(store.getState()); // [1]
return unsubscribe;
}. [store]);
return [state, store.setState];
}
你会注意到 【1】 所在行的 代码。它是用于处理极端情况的。它在useEffect
内调用了 setState
函数一次。这是因为 useEffect
会延迟执行,而store
可能在 useEffect
执行前,已经有了值。
下面这个组件,使用了useStore
钩子:
js
const Component1 = () => {
const [state, useState] = useStore(store);
const inc = () => {
setState((prev) => {
...prev,
count: prev.count + 1
});
}
return (
<div>
{state.count} <button onClick={inc}> +1 </button>
</div>
)
}
下面是 Component2:
js
const Component2 = () => {
const [state, useState] = useStore(store);
const inc2 = () => {
setState((prev) => {
...prev,
count: prev.count + 2
});
}
return (
<div>
{state.count} <button onClick={inc2}> +2 </button>
</div>
)
}
这两个组件的按钮都会更新块级状态,而这个状态又被这两个组件所共享:
js
const App = () => (
<>
<Component1 />
<Component2 />
</>
)
当你运行这个应用时,会发生下图的事情。如果你把 +1 和 +2 按钮各点击一次,你会发现 两个 状态都变为了 3:
在这个部分,我们学习了如何使用订阅来连接 块级状态 和 React组件。
在下一个部分,我们要使用 selector 函数来 使用部分的状态,并学习如何使用 useSubscripition
.
使用selector 和 useSubscription
在上一个部分 实现的 useStore
钩子,返回的是 整个状态对象。这意味着,哪怕是很少部分的状态发生变化,系统会通知所有useStore
钩子,并导致不必要的重新渲染。
首先,我们开发一个useStoreSelector
。
我们先用之前的createStore
方法:
js
const store = createStore({ count1: 0, count2: 0 });
这个store
有两个变量: count1
和 count2
。
useStoreSelector
与 useStore
大体一样,除了useStoreSelector
接收了一个用于锚定状态范围的 selector 函数外。
js
const useStoreSelector = <T, S>(
store: Store<T>,
selector: (state: T) => S
) => {
const [state, setState] =
useState(() => selector(store.getState()));
useEffect(() => {
const unsubscribe = store.subscribe(() => {
setState(selector(store.getState()));
})
setState(selector(store.getState()));
return unsubscribe;
}, [store, selector]);
return state;
}
useStoreSelector
的 useState
钩子,接收的是 selector 函数 选定的值,而不是整个对象。
现在,我们定义个使用了useStoreSelector
的组件。useStoreSelector
返回的值是一个 数字。为了更新它,我们会 使用 store.setState()
:
js
const Component1 = () => {
const state = useStoreSelector(
store,
useCallback((state) => state.count1, []),
);
const inc = () => {
store.setState((prev) => {
...prev,
count1: prev.count1 + 1,
});
};
return (
<div>
count1: {state} <button onClick={inc}> +1 </button>
</div>
);
}
注意,我们需要使用 useCallback
来确保selector 函数的稳定性。否则,selector是作为useEffct
中的第二个依赖,Component1
组件会在每次重新渲染时重新订阅 store。
我们可以定义Component2
组件,用来展示count2
。这一次,我们在组件外定义 selector函数。
js
const selectCount2 = (
state: ReturnType<typeof store.getState>
) => state.count2
const Component2 = () => {
const state = useStoreSelector(store, selectCount2);
const inc = () => {
store.setState((prev) => {
...prev,
count1: prev.count2 + 1,
});
};
return (
<div>
count2: {state} <button onClick={inc}> +1 </button>
</div>
);
}
最后,实现 App
组件:
js
const App = () => {
<>
<Component1 />
<Component1 />
<Component2 />
<Component2 />
</>
}
下图是运行效果:
前两行是被 Component1
所渲染的。如果你点击其中一行的 button,count1会加一,Component1
组件会重新渲染。然而,Component2
不会重新渲染,因为 count2 并没有变动。
虽然 useStoreSelector
钩子运行良好,在生产环境也很稳定,但是在 store 或者 selector 变动时,还是有注意事项等。因为 useEffect
会晚一些之下,所以它可能在再次订阅已经完成时,返回一个已经过时的值。虽然我们可以自行修复这个问题,但是太费时间了。
幸运的是,React团队为我们提供了一个正式的钩子。它就是use-subscription
(www.npmjs.com/package/use...%25E3%2580%2582 "https://www.npmjs.com/package/use-subscription)%E3%80%82")
让我们使用useSubscription
来定义 useStoreSelector
。代码很简单:
js
const useStoreSelector = (store, selector) => useSubscription(
useMemo(() => {
getCurrentValue: () => selector(store.getState()),
subscribe: sotre.subscribe,
}, [store, selector])
);
我们可以避免使用useStoreSelector
,而使用 useSubscription
:
js
const Component1 = () => {
const state = useSubscription(useMemo(() => ({
getCurrentValue: () => store.getState().count1,
subscribe: store.subscribe,
}), []));
const inc = () => {
store.setState((prev) => ({
...prev,
count1: prev.count1 + 1,
}));
};
return (
<div>
count1: {state} <button onClick={inc}>+1</button>
</div>
);
};
在这个场景,useMemo
已经被使用了,不再需要使用useCallback
。
在这个部分,我们学习了使用 selector 函数来 选定 scope,以及更稳定的 useSubscription
来实现。
在本章中,我们学习了如何创建模块状态并将其集成到 React 中。利用这些知识,你可以将模块状态用作 React 的全局状态。订阅在集成中扮演着重要角色,因为它允许在模块状态改变时触发组件的重新渲染。除了基本的订阅实现来使用模块状态在 React 中,还有一个官方包。基本订阅和官方包都适用于生产环境的使用场景。
在下一章中,我们将学习第三种实现全局状态的模式,即将第一种模式和第二种模式结合起来。