React状态管理库

状态管理的实现机制

在React中我们是数据驱动视图,可以用一个公式来描述状态和UI的关系,即:

1
UI = f(state)

React中的一个核心概念就是幂等,即是在React Component中,输入相同的props & state & context 总是会得到相同的UI

通常状态管理库是可以不结合任何框架用来使用,即为vanilla,可以在(zustand、jotai)等库中清晰的看到vanila实现,这些包含了基本的JavaScript实现,而我们平常中使用的react框架,则是这些实现的积类 + Hooks,即(useMemo & useCallback & useState & useEffect) 中触发更新UI的hooks

状态管理库的整个变动流程基于如下的过程

其中订阅的含义是组件需要定于 Store 状态的变化,这样当状态发生改变时可以触发组件完成re-render,订阅和更新 UI 是结合React中的useState / useReducer / useSyncExternalStore 来完成的,订阅的过程其实就是收集这些Hooks调用过程中的dispatcher的过程,用来更新订阅的UI

  • useState: 返回setState调用setState来更新状态并使组件re-render

    1
    const [state, setState] = useState(initialState)
  • useReducer: 返回dispatch,调用dispatch 可以更新状态并使组件re-render

    1
    const [state,dispatch] = useReducer(reducer,initialArg)
  • useSyncExternalStore: 需要传入subscribe, useSyncExternalStore会传入一个函数,这个函数使组件去re-render

    1
    const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)

状态管理库的实习形式会把这些更新状态的函数保存起来,通过是一个Set的数据结构,这样更新Store时,会遍历整个Set中的函数来触发各个组件的re-render,如下

这样更新Store状态时才能正确的通知组件re-render,其实就是一种发布订阅的设计模式

在订阅了 Store 状态的组件中,当 Store 发生改变,所有订阅了该 Store 的事件都会得到更新去通知组件re-render

即全局状态管理库可以保证组件之间共享状态和维持状态的一致性

React Context的问题

在早期的React中,要使用全局状态管理一般是使用的context,确实是可以解决组件之间共享状态的问题,但是之所以会出现很多的状态管理库(zustand、mobx、jotai)就是为了解决context带来的麻烦和性能

产生性能问题

当在多个组件中传递同一个属性时,这个属性变化会导致所有组件发生re-render,即使某些组件没有真正使用到该值,会导致不必要的开销,造成性能上的问题

1
2
3
4
5
6
7
8
9
10
11
function updateContextProvider (
current: Fiber | null
workInProgress: Fiber
renderLanes: Lanes
) {
if(is(oldValue,newValue)) {
if(oldProps.children === newProps.children && !hasLegacyContextChanged()) {
return bailoutOnAlreadyFinishedWrok(currnt,workInProgress,renderLanes)
}
}
}

React会调用Object.is(oldValue, newValue)来对比context前后的状态是否一致,如果不一致 React 会沿着当前节点遍历Fiber树来寻找消费了当前 context 的组件,并对其进行标记代表这个组件应该被重现渲染

心智负担

要不断地写provider和context,且值很难跟踪到

全局状态管理库的特点

原子化/非原子化

一个前端应用由多个组件组合而成,组件之间有了共享状态的需求,状态管理库就出现了,但是对于管理数据的方式出现了两种case

  • Store:Redux | zustand为首的单一状态树
  • Atom:jotai 为首的原子化状态管理,原子化指的是整个应用外部状态被拆分为了一个个分片的状态片段,每个状态片段保存在各自的atom中,各自atom组合交错共同组成了整个应用的状态

Mutable/Immutable

根据全局状态库,状态的可变性可以分成Mutable(可变)Immutable(不可变)

  • Mutable: 指创建对象之后任何状态更新都是基于对原先对象的基础上进行的,比如mobx | valtio就是这种

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    import { proxy, useSnapshot } from 'valtio';

    const state = proxy({
    count: 0,
    });

    export default function App() {
    const snapshot = useSnapshot(state);

    return (
    <div>
    <div>{snapshot.count}</div>
    <button onClick={() => (state.count += 1)}>+1</button>
    </div>
    );
    }

    在上面例子中可以发现,点击按钮后会直接在原始状态上进行修改来完成状态的更新,即state.count+1

  • Immutable:指的是对象一旦创建就不能被修改,需要基于这个对象生成新的对象而保证原始对象不可变

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    // Zustand
    import { create } from "zustand";

    const useStore = create((set) => ({
    count: 0,
    increment: () => set((state) => ({ count: state.count + 1 })),
    }));

    export default function App() {
    const { count, increment } = useStore();

    return (
    <div>
    <div>{count}</div>
    <button onClick={increment}>+1</button>
    </div>
    );
    }

    对于Immutable模型的状态管理库而言,不能直接在原先的对象上修改,而需要生成一个新的对象,包含修改后的count

对于复杂的多层嵌套数据结构来说,Immutable方式处理比较麻烦,且容易出错,要写很多层的解构,而Mutable方式则更加的自然,符合代码的逻辑更改,且基于 Mutable 方案下的状态管理库内部基于 Proxy 来实现,会监听组件对于状态的使用情况,从而在更新状态时正确触发对应的组件完成 re-render,因此这种方案下我们可以认为性能默认就是最优的,不需要手动来优化。

Immutable的好处在于保证可预测性,而Mutable很难保证这点,状态每次发生变化时,这些变化可以被追踪以及最终状态的更新的结果是明确已知的

手动/半自动/自动优化

  • Zustand、React Redux等基于 selector 优化的状态管理库,背后会使用use-sync-external-store库,需要传入store使用selector选出组件关系的状态
  • Jotai 基于原子化模型的状态管理,每个原子维护自己的小状态片段,并通过原子之间的相互依赖关系阻止re-render,提高性能,不需要手动传递selector
  • MobX基于Mutable的状态管理库,内部采用Proxy来实现,自动监听组件使用了那些状态,只有状态变化才会触发组件re-render

全局状态管理/服务器状态管理

对于React中的状态我们可以粗略的分成三类

  • 局部状态: 指的是组件内使用和维护的状态,通过使用useState和useReducer钩子来创建和管理这些状态,控制组件内的行为,比如用户输入、表单控制等的
  • 全局状态: 当状态需要在多个组件中共享的时候,我们使用全局状态,这些状态不属于某个特定的组件,而是整个应用的共享资源,React中提供了React Context API,但是我们通常选择更加专业的zustand、jotai、mobx
  • 服务器状态: 这种状态涉及到与服务器的交互,服务器状态是从服务器获取的数据,并在前端中展示和管理,管理服务器状态的流行库比如SWR、TanStack Query 等,提供获取数据、缓存、更新等功能,简化了与后端数据交互的复杂性

通常我们在大型项目中,组合使用可以达到良好的效果

zustand实践

通常可以使用immer中间件配合使用,可以状态一套use、update、subscribe方法去实现 store的共享

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
import {type Draft} from 'immer'
import { useCallback } from "react";
type Use<S> = {
<R>(
selector: (state: S) => R,
deps?: unknown[],
isEqual?: (a: R, b: R) => boolean
): R;
(): S
}

type Get<S> = () => S;

export type UpdateFn<S> = (draft: Draft<S>) => S | void;

type Update<S> = (
reason: string,
updater: Partial<S> | UpdateFn<S>,
)=> void;

type Subscribe<S> = (
listener: (newState:S,oldState:S)=>void
) => () => void;

type Handler<S> = {
use: Use<S>;
get: Get<S>;
update: Update<S>;
subscribe: Subscribe<S>;
}

export const model = <S>(initial:S) => {
const $model = create(immer(() => initial))
/**
* 操作模型的方法
* */
const handler: Handler<S> = {
use(selector = (s: S) => s, deps:unknown[] = [], isEqual = Object.is) {
const callback = useCallback(selector, deps)
return $model(callback, isEqual)
},
get: $model.getState,
update(reason, updater) {
$model.setState(updater, false)
},
subscribe: $model.subscribe,
}
return handler
}

Jotai实践