Micro State Management with React Hooks 的作者是Daishi Kato,在 React 社区属于非常活跃的大牛。我们在项目中使用的 jotai 就出自他。
花了差不多一星期的空闲时间读完了这本书,让我对 React 的状态管理有了全新的认知。
状态管理工具需要解决的问题
- 读状态
- 写状态
是的,要解决的直接问题是这俩。
另外的问题
当实现一个在 React 体系下的状态管理库时,不得不考虑这些问题:
* 状态放哪里:模块内 or 组件内
* 是否需要支持多实例
* 如何避免额外的渲染
* 如何支持异步
* 如何表达 Derived State
* 如何让语法更简单,用户使用心智最低
* …
实际上,绝大多数项目都不会同时面临如上所有的问题,所以社区也就出现了各式各样的状态管理库及辅助工具,解决特定场景或特定几个场景的问题。
本书的作者就产出了 zustant、jotai、valtio 等 GitHub star 很高,但完全不同类型的状态管理库,在书中也有一一被介绍到。
在此之前,我做状态管理库选型时往往只考虑了 api 的易用性,并未从项目实际需求出发,这对需要长期维护的项目来说是埋坑:引入一个很难移除而不能解决实际业务问题的包袱。
Component State vs Module State
- Component State 依托于 react 生命周期而存在的状态,随 React 组件树创建与销毁,其优点在于容易划分多实例,无需操心内存泄漏,而缺点在于无法方便在组件树之外读写数据,Jotai 是一个典型的 Component State 管理库
- Module State 是存在于 ES Module 内的状态,独立于 React,需要手动管理器生命周期,优缺点刚好与 Component State 反之,Redux 则是 Module State 的代表
使用 Context 管理复杂单体状态的问题
直接看例子:
interface People {
name: string;
age: number;
}
const StateContext = createContext<People | null>(null);
function usePeople() {
const people = useContext(StateContext);
if (!people) {
throw new Error("Cannot use usePeople outside <StateContext.Provider>.")
}
return people;
}
function Age () {
console.log('Age rendered');
const { age } = usePeople();
return <span>age: {age}</span>
}
function Name () {
console.log('Name rendered');
const { name } = usePeople();
return <span>name: {name}</span>
}
function App() {
const [people, setPeople] = useState({ name: '张三', age: 15 });
return <StateContext.Provider value={people}>
<Age />
<Name />
<button onClick={() => setState(p => ({ ...p, name: '李四' })) }>更新 name</button>
</StateContext.Provider>
}
上例中,当通过点击按钮更新 name
时,除了 Name
组件发生了 re-render,Age
组件也跟着发生了 re-render,这是预期之外的,因为在 Age
组件内仅消费了 age
。这里触发 re-render 的原因有两个:
1. 父组件 App
re-render 了,因为 people
更新了
2. Context 更新了
其中第一个问题很好解决,只要 Provider 的 children 使用 React.memo 包装了,即可阻止父组件 re-render 传递给子组件。
而由于 Context 的穿透性,第二点依旧会导致 Age
re-render。
使用 Subscription 管理 Module State
正常情况下,Module State 无法驱动 react 的 render,不过我们可借助 subscription 机制达成目的,先定义 Module
interface People {
name: string;
age: number;
}
function createStore(initState: People) {
let people: People = initState;
const cbs = new Set<() => void>();
// 获取状态快照
const getState = () => people;
// 更新状态
const setState = (nextState: People | (pre: People) => People) => {
people = typeof nextState === 'function' ? nextState(people) : nextState;
cbs.forEach(cb => cb());
}
// 订阅
const subscribe = (cb: () => void) => {
cbs.add(cb);
return () => {
cbs.delete(cb);
}
}
return { getState, setState }
}
再封装一个 hooks 使用 Store:
function useStore(store) {
// 设置初始状态
const [state, setState] = useState(store.getState());
useEffect(() => {
const unsubscribe = store.subscribe(() => {
// 监听并更新
setState(store.getState());
});
// 这里不能少,useEffect 被执行与 useState 初始化有时间间隔,此时 store 可能已经发生改变
setState(store.getState());
return unsubscribe;
}, [store]);
return [state, store.setState]
}
在组件中使用:
const store = createStore();
function Age() {
const [state, setState] = useStore(store);
return <>
<div>age: {state.age}</div>
<button onClick = {() => setState(p =>({ ...p, age: p.age + 1 }))}>inc age</button>
</>
}
function Name() {
const [state, setState] = useStore(store);
return <>
<div>name: {state.name}</div>
</>
}
可以正常 work 了,但依旧面临 age 变化时 Name
组件 re-render 的情况,如何解决呢?答案是:Selector
,手动告诉状态管理器关心的状态,改进下 useStore :
function useStore(store, selector) {
// 设置初始状态
const [state, setState] = useState(selector(store.getState()));
useEffect(() => {
const unsubscribe = store.subscribe(() => {
// 监听并更新
setState(selector(store.getState()));
});
// 这里不能少,useEffect 执行与 useState 初始化有时间间隔,此时 store 可能已经发生改变
setState(selectorstore.getState()));
return unsubscribe;
}, [store, selector]);
return [state, store.setState]
}
React Redux 的useSelector
亦是如此。
不过,原书上提到的 store 和 selector 如果发生变化, state 在下次 subscribe 被调用前是过期的,这里没看明白:
除了与 React 18 的 Concurrent Mode 有明确的关系:https://github.com/reactwg/react-18/discussions/86 目前没想到在其他情况下为何为存在问题。
自底向上的状态管理
自顶向下
像 redux 这样,全局状态维护在一个大对象里,组件树上的任意组件通过 Selector 的方式 pick 自己需要的状态,这样的状态管理方式从形式上叫:自顶向下。
其优势在于:
* 状态集中管理,能在一个地方管理所有的状态,清晰明了
缺点也很明显:
* 无法 tree-shaking 及按需加载
* 需要通过 selector 函数手动做性能优化,必要的情况下还需要处理 shallowEqual
或 deepEqual
自底向上
而像 recoil、 jotai 这样声明原子状态(Atom)并按所需消费的状态管理方式从形式上叫:自底向上。
其缺点在于:
* 应用的状态声明放于何处变更宽松了,如果分散存放则可能引入重复状态
优点:
* 天然拆分了状态,无需考虑 bundle size 问题
* 无需考虑 selector,默认按需渲染
使用 Proxy 监听状态变更
无论通过 Selector 亦或是直接使用 Atom,二者均能优化额外 render 的问题,但在某些情况下依然存在不必要的 re-render,以 jotai 为例:
const nameAtom = atom('张三');
const ageAtom = atom(15);
function App() {
const name = useAtom(nameAtom);
const age = useAtom(ageAtom);
return <>
<div> age: {age} </div>
{ age > 18 ? <div> name: {name} </div> : null }
</>
}
上例中,在age > 18
成立前,name
并不会用上,但实际情况下,无论age > 18
是否成立,当nameAtom
变化时,组件依然会 re-render,因为 useAtom 触发 render 的时机是对应的 atom 发生了变更。
而通过 Proxy 的方式在 render 阶段收集依赖,则可以控制到非常细粒度级别,以valtio为例:
const people = proxy({ age: 15, text: '张三' });
function App() {
const snap = useSnapshot(people)
return <>
<div> age: {snap.age} </div>
{ snap.age > 18 ? <div> name: {snap.name} </div> : null }
</>
}
上例则不一样,仅在 snap.age > 18
成立时才会触发 re-render,见 online example
当然, 上面这些例子是比较边缘的 case,而且对实际性能的影响也得综合项目的实际情况来看,而 Proxy 方式带来的更大的好处在于可不借助 immer 等三方库实现对数据 immutable 方式修改,为上述代码添加数据更新:
setInterval(() => {
people.age++;
}, 1000)
能分享这本书吗 大佬
善用搜索获取你想要的信息
书没搜索到,搜索到你的 blog 了,膜拜
相互学习