likes
comments
collection

react状态管理调研

作者站长头像
站长
· 阅读数 51

背景

在全民hooks的大背景下,dva虽然很🐮👃,但是也许可能maybe或者不再是一个最好的选择🙏,但直接使用hooks进行状态管理,可能在复杂应用中会掉入深坑(我暂时还没掉过,其他大哥文章里说的),而且刚好团队也需要进行一个状态管路库的调研。

统计

大致就统计了这些主流的。

名称github地址star是否调研备注
rxjsgithub.com/ReactiveX/r…27.6N老哥们&&学习成本太大
mobxgithub.com/mobxjs/mobx25.6N老哥们&&大家都会?
zustandgithub.com/pmndrs/zust…21Y
recoilgithub.com/facebookexp…17.5Y源码太多了,没看
dvagithub.com/dvajs/dva16.1N大家全会
jotaigithub.com/pmndrs/jota…9.8Y
redux-toolkitgithub.com/reduxjs/red…8.4N不想写redux
rematchgithub.com/rematch/rem…8.1N写法思路和dva一样
valtiogithub.com/pmndrs/valt…5.2Y
hoxgithub.com/umijs/hox1.3Y看了源码,暂时有点迷惑。一般来说都是存一个状态,hox把hook当作状态存起来,然后再当作一个容器,hook内部维护的状态(useState)作为状态输出。说的不对别喷我。

那么多库,听过的没听过的加起来十几个,但是看了看最后想要调研的那几个,

react状态管理调研

呵呵,虚假的繁荣罢了。

需要了解的背景知识

在了解这些库之前,需要先了解一下 react18 中新提供的api useSyncExternalStore,它用来帮助库的开发和维护者更好地管理外部状态。

正常情况下,我们写一个最简单的外部状态管理hook,是这样的,包含状态的获取、修改还有外部的订阅,是不是很熟悉,和redux 差不多。(代码都是视频里抄的,建议直接看视频)

import { useCallback, useEffect, useState } from 'react';

const createStore = initialState => {
  let state = initialState;
  const listeners = new Set();
  const getState = () => state;
  const setState = fn => {
    state = fn(state);
    listeners.forEach(l => l());
  }
  const subscribe = listener => {
    listeners.add(listener);
    return () => listeners.delete(listener);
  }

  return { getState, setState, subscribe };
}

const useStore = (store, selector) => {
  const [state, setState] = useState(() => selector(store.getState()));
  useEffect(() => {
    const callback = () => setState(selector(store.getState()));
    const unsubscribe = store.subscribe(callback);
    callback();
    return unsubscribe;
  }, [store, selector]);
  return state;
}

然后,创建一个外部状态,并且在组件里消费外部状态。

const store = createStore({ count: 0, text: 'hello' });

const Counter = () => {
  const count = useStore(
    store,
    useCallback(state => state.count, [])
  );

  const inc = () => {
    store.setState(state => ({
      ...state,
      count: state.count + 1
    }))
  }

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

const TextBox = () => {
  const text = useStore(
    store,
    useCallback(state => state.text, [])
  );

  const setText = e => {
    store.setState(state => ({
      ...state,
      text: e.target.value
    }))
  }

  return (
    <div>
      <input type="text" value={text} onChange={setText}/>
    </div>
  )
}

function App() {
  return (
    <div className="App">
      <Counter/>
      <Counter/>
      <TextBox/>
      <TextBox/>
    </div>
  );
}

大概呈现出来是这个样子,2个Counter和2个Input共享一个外部状态,会跟随另一个的改变而改变。

react状态管理调研

useSyncExternalStore可以简化useStore中我们的代码。

// 前面都一样,省略
import { useCallback, useSyncExternalStore } from 'react';

const useStore = (store, selector) => {
  return useSyncExternalStore(
    store.subscribe,
    useCallback(() => selector(store.getState()), [store, selector])
  )
}

效果完全一样,且我们不用操心内部的逻辑究竟是怎么样的。

在这个基础上,加上 shallow 和 middleware 的功能,基本就是 zustand 的源码了,用法也基本相似。

建议可以看看上面链接中的完整视频,就是zustand的作者发的,20分钟让我受益匪浅。

对比

春风得意马蹄疾,一日看尽长安花。

这周把主流数据流代表的源码翻了一遍。

本来都源码都看完的,结果recoil和jotai的代码实在太多了,直接放弃,但是我至少也翻了一遍,迅速把滚动条滑到底。

需求场景说明

对比场景就选用了大哥在大佬文章的评论里抛出的一个简化需求,如图。

react状态管理调研

简单来说,上面 +1 的按钮的操作,可以存档,并通过上面的undo和redo前进和回退,而下面的 +2 的按钮操作则无法存档。

各个库代码演示

第一个zustand的例子是为了更好看清楚UI层的组件里的一些操作,而且因为用了第三方的中间件,不放出来可能对其中的一些方法有些迷惑,后续的例子就不贴Component里的代码里,可以直接去sandBox里看,不然影响体验。

zustand

直接上大哥代码。

codesandbox.io/s/react-und…

import create from "zustand";
import { undoMiddleware } from "zundo";

// 将原来的部分拆到单独的 slice 中,添加 middleware
const createUndoSlice = undoMiddleware((set, get) => ({
  plus: () => {
    set((s) => ({
      ...s,
      data: s.data + 1
    }));
  },
  data: 3
}));

export const useStore = create((set, get) => ({
  tabs: "1",
  switchTabs: (key) => {
    set({ tabs: key });
  },
  plusWithoutHistory: () => {
    set((s) => ({ ...s, data: s.data + 2 }));
  },

  ...createUndoSlice(set, get)
}));
import "./styles.css";
import { Button, Space, Divider, Tabs } from "antd";
import "antd/dist/antd.css";

import { useStore } from "./store";

// application component
export default function App() {
  const store = useStore();
  const {
    data,
    plus,
    redo,
    undo,
    tabs,
    switchTabs,
    plusWithoutHistory
  } = store;

  return (
    <div style={{ padding: 24 }}>
      历史记录:
      <Space>
        <Button onClick={undo}>undo</Button>
        <Button onClick={redo}>redo</Button>
      </Space>
      <Divider />
      <div>下面是历史记录里的部分</div>
      <Space>
        <Button onClick={plus}>+1</Button>
      </Space>
      <div>data: {data}</div>
      <Divider />
      <div>不在历史记录里的部分</div>
      <div>
        <Tabs activeKey={tabs} onChange={switchTabs}>
          <Tabs.TabPane key={"1"} tab="数据"></Tabs.TabPane>
          <Tabs.TabPane key={"2"} tab="配置"></Tabs.TabPane>
        </Tabs>

        <div>下面的 +2 可使得 在历史记录外添加让 data +2 </div>
        <Button onClick={plusWithoutHistory}>+2</Button>
      </div>
    </div>
  );
}

我觉得这个看起来虽然看起来简单,但是是把复杂度转移到了第三方开发的middleware中,具有一定的特定场景迷惑性。

react hooks + useSyncExternalStore

直接用上文中react写的store进行开发的话,把前进和后退的实现写了一下,测了一下也基本满足要求,如果用zustand实现的话,可能会在api上的调用会更简洁一点,但是应该大差不差,但是确实比上面中间件的方式多了许多代码。

codesandbox.io/s/react-api…

import { createStore } from "./createStore";

let undo = [];
let redo = [];

export const store = createStore({
  tabs: "1",
  data: 3,
  plusWithoutHistory: () => {
    store.setState((state) => {
      return {
        ...state,
        data: state.data + 2
      };
    });
  },
  plus: () => {
    store.setState((state) => {
      const { data } = state;
      undo = [...undo, data];
      redo = [];
      return {
        ...state,
        data: state.data + 1
      };
    });
  },
  handleUndo: () => {
    if (!undo.length) {
      return;
    }
    store.setState((state) => {
      const { data } = state;
      const d = undo.pop();
      redo = [...redo, data];
      return {
        ...state,
        data: d
      };
    });
  },
  handleRedo: () => {
    if (!redo.length) {
      return;
    }
    store.setState((state) => {
      const { data } = state;
      const d = redo.pop();
      undo = [...undo, data];
      return {
        ...state,
        data: d
      };
    });
  }
});

valtio

proxy写起来有种写vue的感觉,挺舒服的,而且实现起这个场景起来感觉特别简洁。

codesandbox.io/s/valtio-tq…

import { proxy } from "valtio";

const state = proxy({
  data: 3,
  tabs: "1"
});

let undo = [];
let redo = [];

export const store = {
  state,
  plus: () => {
    undo = [...undo, state.data++];
    redo = [];
  },
  plusWithoutHistory: () => {
    state.data += 2;
  },
  handleUndo: () => {
    if (!undo.length) {
      return;
    }
    const d = undo.pop();
    redo = [...redo, state.data];
    state.data = d;
  },
  handleRedo: () => {
    if (!redo.length) {
      return;
    }
    const d = redo.pop();
    undo = [...undo, state.data];
    state.data = d;
  }
};

recoil

看了半天文档,实在不知道咋写,直接放弃。

jotai

codesandbox.io/s/jotai-gvr…

import { atom } from "jotai";

let undo = [];
let redo = [];

export const dataAtom = atom(3);
export const tabsAtom = atom("1");

export const plusDataAtom = atom(null, (get, set) => {
  const data = get(dataAtom);
  undo = [...undo, data];
  redo = [];
  set(dataAtom, data + 1);
});

export const plusWithoutHisDataAtom = atom(null, (get, set) =>
  set(dataAtom, get(dataAtom) + 2)
);

export const handleRedoAtom = atom(null, (get, set) => {
  if (!redo.length) {
    return;
  }
  const data = get(dataAtom);
  const d = redo.pop();
  undo = [...undo, data];
  set(dataAtom, d);
});

export const handleUndoAtom = atom(null, (get, set) => {
  if (!undo.length) {
    return;
  }
  const data = get(dataAtom);
  const d = undo.pop();
  redo = [...redo, data];
  set(dataAtom, d);
});

hox

总算是看着文档写下来了,总体感觉竟然还不错。

写完有两个2个思考的点

  1. 先得写个自定义hook,虽然自由度比较高,但是感觉又把皮球踢到了使用方这里,hook里代码的复杂程度,好坏程度太难控制了。
  2. 要自己去组件外面写个provider有点麻烦

codesandbox.io/s/hox-4j2gw…

import { useState, useRef } from "react";
import { createStore } from "hox";

export const [useHisStore, HisStoreProvider] = createStore(() => {
  const undo = useRef([]);
  const redo = useRef([]);

  const [data, setData] = useState(3);
  const [tabs, setTabs] = useState("1");

  const plus = () => {
    redo.current = [];
    undo.current = [...undo.current, data];
    setData(data + 1);
  };

  const plusWithoutHistory = () => {
    setData(data + 2);
  };

  const handleRedo = () => {
    if (!redo.current.length) {
      return;
    }

    const d = redo.current.pop();
    undo.current = [...undo.current, data];
    setData(d);
  };

  const handleUndo = () => {
    if (!undo.current.length) {
      return;
    }

    const d = undo.current.pop();
    redo.current = [...redo.current, data];
    setData(d);
  };

  return {
    data,
    tabs,
    plus,
    plusWithoutHistory,
    handleRedo,
    handleUndo
  };
});

总结

  1. 基本新的库都用上了useSyncExternalStore,跟原来老的几个库相比,代码量大大下降,以后的库应该都会用上这个api,毕竟官方说了,不用的话临界情况会有bug🐶,就差刀架脖子上了。
  2. 每个库试用下来的感觉就是,valtio写起来最舒服,个人推荐valtio。
  3. hox的写法,虽然看起来有疑惑,但是写起来还行
  4. 如果zustand的话,我觉得不如自己原地写一个,反正核心代码就这样了,既然大家都爱造轮子,不如都造一个看看谁的滚的更远,万一自己的写好了说不定直接起飞,退一百步,就算实在是写烂了,再迁移到zustand,成本也是低的一批。
  5. recoil的话看文档是真的没有想好怎么写,放弃了。。
  6. jotai我感觉比较适合细粒度的组件使用,因为每一个action(setter)都需要在组件里useAtom一次,如果一个组件里包含了多个action,写起来就会很繁琐,相反,如果按照action把组件拆分得足够细,感觉使用起来会比较适合。

由于挑选场景的限制,和对一些库不是那么了解,很多总结可能会有点片面。有可能是文档没读仔细,没找到更好的方法,或者理解有偏差等等,希望大哥们可以指正。

todo

基本整个篇幅都在代码使用的层面,可能对日后新项目的选型有所帮助,但是现在需要解决的还是老项目中状态管理问题的解决。

最粗暴的就是,用微应用拆分,新的模块用新的状态管理,老的用dva,就像出了hook,老的class组件维护还是保持原来的写法,我管这个叫做代码断层。到后来,维护一个项目,我可能需要熟悉并掌握react上下10年的生态(夸张一下)。

由于dva在项目中对代码的入侵性真的很强(有可能是我们项目代码写太差了),还是需要想一个风险成本较小的替换方案。

参考(可能点不进去)

  1. 数据流2022
  2. 谈谈复杂应用的状态管理(上):为什么是 Zustand
  3. react状态管理