读《Micro State Management with React Hooks 》小记

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 被调用前是过期的,这里没看明白:
book

除了与 React 18 的 Concurrent Mode 有明确的关系:https://github.com/reactwg/react-18/discussions/86 目前没想到在其他情况下为何为存在问题。

自底向上的状态管理

自顶向下

像 redux 这样,全局状态维护在一个大对象里,组件树上的任意组件通过 Selector 的方式 pick 自己需要的状态,这样的状态管理方式从形式上叫:自顶向下。

其优势在于:
* 状态集中管理,能在一个地方管理所有的状态,清晰明了

缺点也很明显:
* 无法 tree-shaking 及按需加载
* 需要通过 selector 函数手动做性能优化,必要的情况下还需要处理 shallowEqualdeepEqual

自底向上

而像 recoiljotai 这样声明原子状态(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)