Skip to content

Latest commit

 

History

History
238 lines (184 loc) · 14.6 KB

52. react 状态管理选择.md

File metadata and controls

238 lines (184 loc) · 14.6 KB

许久没有写大型的 react 应用了,在文档工作期间,不像常见的管理系统那样,react 只是用于渲染个别 ui 控件,比如点击弹窗,主体架构是自研的 canvas 渲染数据处理引擎。而这次又到了新公司,没有想到,隔了两三年,再次看到的 react 项目,有点怀念。翻开还是熟悉的味道,ant-design-pro 的结构加 dva 的数据流处理模式,想想以前也是这么写的,现在居然还是这么写,这几年前端变化如此大,这都没有变化,尤其是 dva 的写法还是和之前,如出一辙,难道是统一了江湖,方案深得人心?后面业务紧也没有细看。结果用了一两个月后发现,还是很多问题。

状态管理的困扰

react 状态管理应该用什么样的方式才能更好?是直接用 hook,还是第三方库 redux 这些?如何保证代码性能,又能提高可维护性?项目中一开始用的是 dva,写久了就发现不少问题,后来试着只用 hook 方式来管理,结果更是一团糟的,而且很容易导致 re-render。

dva 数据流

dva 是基于 react + redux + saga 的数据流方案, 显著的提高数据层的开发体验,在 model 里面就可以完成 state 初始化、reducer 和 effect 函数,包含了同步和异步的方式。api 也容易理解,上手快。dva 可以用来处理业务逻辑、状态变化,并负责 http 请求,这种组织数据的方式,将 view 和 model 分割开来,各司其责。不过用久了也就暴露出不少问题了。

  1. 臃肿复杂的 model。model 的直观感受,容易写的非常长,看看日常业务开发的 model 代码量

dva-model.png

其中大部分 ui 自身的状态都写在组件里面,model 代码量就接近 500 行。

model 里面缺少更细分的模块概念,所有状态、effect、reducer 都混在一起。如果要对某个业务组件单独开辟一个 model,只能通过建立新的命名看见方式。状态一多,维护很麻烦。相同状态的修改可能两个相隔很远的方法里面,每次修改都要 command + F 搜索下,看看有没有遗漏的。也可以通过定义模块细分,然后汇成一个 model 对象,不过这样操作更加奇怪了,会带来状态重复,操作混乱的问题。

后面试图降低 model 层的大小,只做联动状态处理,将数据管理放到各个 UI 组件里面,也就是分离状态管理和数据管理。不过这又会导致另外一个问题,交互复杂下的数据请求以及处理都放在了组件,无法统一处理,并且绑定在一起,会导致业务组件可维护性下降。如果状态不复杂,那也没有必要用 dva 了。

  1. 操作麻烦。不管是 dispatch 还是 put,都需要指定 type,无法简单调用函数。effect 里面获取状态需要调用 select,修改状态要通过 put,reducer 里面无法再次获取 state,只能修改。下面是一个简单的状态重置:
*reset({}: any, { put }: any): any {
  yield put({
    type: 'resetA',
  });
  yield put({
    type: 'resetB',
  });
  yield put({
    type: 'resetC',
  });
  yield put({
    type: 'resetD',
  });
}

一个简单的重置处理,涉及 A-D 的状态,原本 4 行代码就可以了,结果变成了 12 行,虽然 dva 的写法比 redux 体验好了很多,不过还是挺啰嗦的。

  1. 无法使用 async/await,dva 封装了 redux-saga,其采用的 generator 异步方式。毫无疑问 generator 对异步的控制方式更加细致,适用于复杂异步处理方面,async 函数可以用 generator 的自执行的方式来实现。不过在日常业务里面 async/await 更加通用方便,2023 年了,用 yield 在项目里面违和感太强了。

  2. typescript 提示不够完善。dva 没有类型导出,基本的 put、select 这些要自己实现,社区有对应的方案,比如 dva-model-enhance,umi4 里面也有做升级 commit

  3. connect 使用,提供了组件和 model 之间的关联,不过带来两个问题,为了状态传递,在原组件之上不仅有个 connect 高阶组件,还隐藏了 ReactRedux.Provider,虽然中间已经用了很多 useMemo 优化了,不过性能开销少不了。第二个则是,每次使用新的状态,都需要先在 connect 进行传入,如果遇到后面不需要了,忘记删除,状态更新还是会导致函数更新的。

  4. 最重要的 dva,已经被弃坑了,主要开发截止到 2019 年底,这两年主要是更新依赖版本,没有新的功能。目前 umi 4 里面文档介绍的包含三种数据流:useModel、valtio 和 dva。在最新讨论里面也是建议不用 dva。

no-Dva.png

极简数据流 useModal

umi 自身内置数据流管理插件,采用 useModel 就可以在全局范围里面使用,非常方便,一开始想要替换 dva 的时首选也是这个方案。只是问题来了,这种 model 是全局的,意味着下面这种写法

import { useRequest } from 'ahooks';
import { getUser } from '@/services/user';
 
export default () => {
  const { data: user, loading: loading } = useRequest(async () => {
    const res = await getUser();
    if (res) {
      return res;
    }
    return {};
  });
 
  return {
    user,
    loading,
  };
};

每次刷新任意一个页面都会执行生成 user 数据,对于通用全局状态,自然是没有问题,只是局部状态呢,完全不适用。不同页面涉及到的状态和数据,应该是各自维护,无需要暴露到全局。虽然 redux 在 connect 的时候会传入所有的命名空间状态,不过这里是初始化 state,并不会执行上面 useRequest 这样的方式。useModal 还是适用于全局变量,或者一些非常简单的系统。

状态管理回顾

状态管理初衷为了组件间通信,同时可以合理拆分逻辑,抽离状态,复用它们的能力。

react 本身的状态管理方式是 context,可以通过 Provider/Consumer 来传递使用,hook 方式则是 useContext。只是 react 的 context 实在过于简单,并且需要提供 Provider 才能使用,而且层层嵌套,容易导致 re-render。

状态管理在 class 里面主要是 redux 的方式,单向数据流,包括 dva 也是封装了 redux,其实现方式主要包含提供 createStore 以及 dispatch 方式,通过 action 执行对应的 reducer 来更新 state,简单实现如下:

// redux 
const createStore = (reducer) => {
  let state;
  return { dispatch: (action) => state = reducer(state, action) };
};
// react-redux 7.0
const connect = (initMapStateToProps) => {
  const contextValue = useContext(ContextToUse);
  const actualChildProps = initMapStateToProps(contextValue.store);
  return (WrappedComponent) => <ContextToUse.Provider value={overriddenContextValue}>
    <WrappedComponent {...actualChildProps} >
  </ContextToUse.Provider>
}

简化后结构还是很明了的。react-redux 连接 redux 以及 组件,将状态变化转递下去。此外还有其他状态管理的比如 MobX 这些。

hook 模式下,状态有了新的变化,从 class 的 setState 变化到 hook 方式,数据变得原子化、去中心化,不再是一个大一统的 state 里面维护,函数组件里面维护自身状态,redux 也推出了 useDispatch 以及 redux-toolkit,虽然没有以前那么模板化代码了,不过还是没有很简洁,感兴趣的可以看看 redux-toolkit。这里介绍几个轻量状态库:facebook 的 recoil、jotai 以及 zustand。这里看下它们的写法:

// recoil
const textState = atom({
  key: 'textState', 
  default: '', 
});
const [text, setText] = useRecoilState(textState);

// jotai
const countAtom = atom(0);
const [count, setCount] = useAtom(countAtom);

// zustand
const useBearStore = create((set) => ({
  bears: 0,
  increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
}));
const bears = useBearStore((state) => state.bears);

可以看出 recoil、jotai 两者风格很相似,都是分散的原子化状态管理模式,其中 jotai 介绍是受到 recoil 启发推出的,命名都是 atom。API 和原生 hook 方式很类似,尤其是 jotai。

jotai

jotai 也是需要在 APP 根部增加 Context.Provider 组件,然后 useAtom 的时候获取对应的 value。只是这里有个问题,Context 修改会导致父组件更新,从而导致子组件 re-render,这里又是如何避免的?

export function useAtomValue<Value>(atom: Atom<Value>, options?: Options) {
  const store = useStore(options);
  const [[valueFromReducer], rerender] = useReducer(
    (prev) => {
      const nextValue = store.get(atom)
      // ...省略,判断是否返回 pre
      return [nextValue]
    },
    undefined,
    () => [store.get(atom)]
  );
  useEffect(() => {
    const unsub = store.sub(atom, () => {
      rerender()
    })
    rerender()
    return unsub;
  }, [store, atom]);
  return valueFromReducer;
}

export const createStore = () => {
  const atomStateMap = new WeakMap<AnyAtom, AtomState>()
  const mountedMap = new WeakMap<AnyAtom, Mounted>()
  // ...省略
  return {
    get: readAtom,
    set: writeAtom,
    sub: subscribeAtom,
  }
}

为了避免更新 Context 导致全局更新,jotai 采用自己的一套发布订阅模式,在 useEffect 阶段收集 atom 和 rerender(也就是 dispatch),当 atom 的修改的时候,会执行所有订阅的 dispatch,从而触发组件更新,规避 context 更新问题。

zustand

zustand 可以说是 2022 年最热门的 react 状态管理库,看看其趋势,简直是遥遥邻先,一年下载量翻了四倍,比其他两个总和还多,目前大概是老大哥 redux 的 1/5。

zustand-npm-trend.png

zustand 相较前两个,特性非常明显,中心化的状态管理,从数据流考虑,分开 UI 组件和状态逻辑控制,可以说和 dev、redux 很类似,但是其 API 简单,并且支持 async 函数,同时不是单一的 store,可以按照功能模块组件划分创建 store,一个页面可能会有多个 store,解决了 dva 的 model 层过于臃肿问题。

zustand 提供了 selector,类似 redux 的 mapStateToProps,可以处理返回复杂的内容,并解构内容。如下面第一个例子

// 第一个例子
const { list, count } = useBearStore((state) => { 
    return {
      list: state.data.filter(item => item.type === state.type),
      count: state.count,
    }
  },
  shallow,
);
// 第二个例子
const { count } = useBearStore();
const count = useBearStore((state) => state.count);

上面第一个例子中,shallow 表示浅对比,可以用来优化性能,默认严格相等才不会更新,避免 re-render。

而对于第二个例子,由于 useBearStore() 返回了所有状态,当有任意一个 state 发生更新的时候,组件就会更新,所以应该改为下面的第二种写法。对于第一个例子的写法,如果 useBearStore 去除第二个参数,也就是 shallow,则每次返回的都是新的对象,会导致组件异常 re-render。关于 selector 的性能优化可以看这里的讨论

zustand 自身的实现还是比较容易理解,简化下如下所示

const createStoreImpl: CreateStoreImpl = (createState) => {
  let state: TState
  const listeners: Set<Listener> = new Set()
  const getState: () => TState = () => state
  const setState: SetStateInternal<TState> = (nextState, replace) => {
    if (!Object.is(nextState, state)) {
      const previousState = state
      state =
        replace ?? typeof nextState !== 'object'
          ? (nextState as TState)
          : Object.assign({}, state, nextState)
    }
  }
  const api = { setState, getState }
  state = createState(setState, getState, api)
  return api as any
}

const create = (createState) => (selector?: any, equalityFn?: any) => useStore(api, selector, equalityFn);

state 存储状态,setState 的时候修改 state,可以通过 getState 返回最新状态。上面代码省略了订阅处理,setState 会执行订阅的 listeners。

useStore 则是 react 自身的 useSyncExternalStoreWithSelector 方法,在 useSyncExternalStore 基础上多了 selector 和 isEqual 判断。useSyncExternalStore 让组件获取状态管理库的最新状态,避免渲染过程状态异常撕裂情况,现在第三方的状态管理库包括 redux 都用 useSyncExternalStore。

状态管理选择

不管是 recoil 还是 jotai、zustand 核心问题是解决状态通信,而随着 hook 带来的改变,状态不再是以前单一中心,自上而下更新,出现类 hook 操作的原子化趋势,组件自治,state 变化,则订阅该 state 组件会 re-render。这三者 api 也非常容易上手,支持 async 函数的异步请求,toolkit 稍微繁琐一点。另外 zustand 还有中间件 zustand/middleware,提供了 redux 转换以及 immer 等能力。

除了状态管理,其实还有远程状态库,比如 TanStack Query、SWR、use-request 这些,如果只是 CURD 业务,用这些远程库后,你就会发现没有剩多少状态了,连引入新的状态库都不用,不过远程状态库自己用的也不多,就不深入介绍了。

最后,选择哪个组件库要看业务。目前工作相关的业务其逻辑交互复杂,同一页面联动状态多,可以抽出可复用的逻辑,适合 zustand 来代替 dva。

ChatGPT 火得一塌糊涂,来一张 zustand 的:

chatGpt-zustand.png

参考

  1. 精读《zustand 源码》
  2. react tearing
  3. Best approach for selectors
  4. 数据流 2022
  5. What is the recommended way to load data for React 18?