Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

52. react 状态管理选择 #53

Open
funfish opened this issue Feb 15, 2023 · 0 comments
Open

52. react 状态管理选择 #53

funfish opened this issue Feb 15, 2023 · 0 comments

Comments

@funfish
Copy link
Owner

funfish commented Feb 15, 2023

许久没有写大型的 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?
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant