V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
nanxiaobei
V2EX  ›  React

resso,设计一个最简单的 React 状态管理器

  •  
  •   nanxiaobei ·
    nanxiaobei · 2022-02-17 10:52:17 +08:00 · 1777 次点击
    这是一个创建于 770 天前的主题,其中的信息可能已经有所发展或是发生改变。

    1. resso ,世界上最简单的状态管理器

    resso 是一个全新的 React 状态管理器,它的目的是提供世界上最简单的使用方式。

    同时,resso 还实现了按需更新,组件未用到的数据有变化,绝不触发组件更新。

    GitHub: https://github.com/nanxiaobei/resso

    import resso from 'resso';
    
    const store = resso({ count: 0, text: 'hello' });
    
    function App() {
      const { count } = store; // 先解构,再使用
      return (
        <>
          {count}
          <button onClick={() => store.count++}>+</button>
        </>
      );
    }
    

    只有一个 API resso,包裹一下 store 对象就行,再没别的了。

    如需更新,对 store 的 key 重新赋值即可。

    2. React 状态管理器是如何工作的?

    假设有一个 store ,注入到在不同的组件中:

    let store = {
      count: 0,
      text: 'hello',
    };
    
    // Component A
    const { count } = store;
    const [, setA] = useState();
    
    // Component B
    const { text } = store;
    const [, setB] = useState();
    
    // Component C
    const { text } = store;
    const [, setC] = useState();
    
    // 初始化
    const listeners = [setA, setB, setC];
    
    // 更新
    store = { ...store, count: 1 };
    listeners.forEach((setState) => setState(store));
    

    将各个组件的 setState 放到一个数组中,更新 store 时,把 listeners 都调用一遍,这样就可以触发所有组件的更新。

    如何监听 store 数据变化呢?可以提供一个公共更新函数(例如 Redux 的 dispatch),若调用即为更新。也可以利用 proxy 的 setter 来监听。

    是的,几乎所有的状态管理器都是这么工作的,就是这么简单。比如 Redux 的源码:https://github.com/reduxjs/redux/blob/master/src/createStore.ts#L265-L268

    3. 如何优化更新性能?

    每次更新 store 都会调用 listeners 中所有的 setState ,这会导致性能问题。

    例如更新 count 时,理论上只希望 A 更新,而此时 B 和 C 也跟着更新了,但它们根本没用到 count

    如何按需更新呢?可以使用 selector 的方式(例如 Redux 的 useSelector,或者 zustand 的实现):

    // Component A
    const { count } = store;
    const [, rawSetA] = useState();
    
    const selector = (store) => store.count;
    const setA = (newStore) => {
      if (count !== selector(newStore)) {
        rawSetA(newStore);
      }
    };
    

    其它组件同理,订阅新的 setAlisteners 中,即可实现组件的 "按需更新"。

    以上功能也可以利用 proxy 的 getter 来实现,通过 getter 来知晓组件 "用到" 的数据。

    4. resso 内部如何实现的?

    上面的实现中,是在每个组件中收集一个 setState 。更新 store 时,通过数据比对,确定是否更新组件。

    resso 使用了一种新的思路,其实更符合 Hooks 的元数据理念:

    let store = {
      count: 0,
      text: 'hello',
    };
    
    // Component A
    const [count, setACount] = useState(store.count);
    
    // Component B
    const [text, setBText] = useState(store.text);
    
    // Component C
    const [text, setCText] = useState(store.text);
    
    // 初始化
    const listenerMap = {
      count: [setACount],
      text: [setBText, setCText],
    };
    
    // 更新
    store = { ...store, count: 1 };
    listenerMap.count.forEach((setCount) => setCount(store.count));
    

    使用 useState 注入组件中用到的每一个 store 数据,同时维护一个针对 store 中每个 key 的更新列表。

    在每个组件中收集的 setState 数量,与用到的 store 数据一一对应。而非只收集一个 setState 用于组件更新。

    在更新时,就不需要再做数据比对,因为更新单元是基于 "数据" 级别,而非基于 "组件" 级别。

    更新某个数据,就是调用这个数据的更新列表,而非组件的更新列表。将整个 store 元数据化。

    5. resso 的 API 是如何设计的?

    设计 API 的秘诀是:先把最想要的用法写出来,然后再去想实现方式。这样做出来的东西一定是最符合直觉的。

    resso 一开始也想过以下几种 API 设计:

    1. 类似 valtio

    const store = resso({ count: 0, text: 'hello' });
    
    const snap = useStore(store);
    const { count, text } = snap; // get
    store.count++; // set
    

    这是标准的 Hooks 用法,缺点是得多加一个 API useStore。而且 get 时使用 snap ,set 时使用 store ,让人分裂,这肯定不是 "最简单" 的设计。

    2. 类似 valtio/macro

    const store = resso({ count: 0, text: 'hello' });
    
    useStore(store);
    const { count, text } = store; // get
    store.count++; // set
    

    这也是可以实现的,而且也是标准的 Hooks 用法。此时统一了 get 和 set 主体,但还是得多加一个 useStore API ,这玩意仅仅是为了调用 Hooks ,如果用户忘了写呢?

    而且实践中发现,在每个组件中使用 store ,都得 import 两个东西,store 和 useStore ,这肯定不如只 import 一个 store 简洁,尤其是用到的地方很多时会很麻烦。

    3. 为了只 import 一个 store

    const store = resso({ count: 0, text: 'hello' });
    
    store.useStore();
    const { count, text } = store; // get
    store.count++; // set
    

    这是最后一次 "合法" 使用 Hooks 的希望,只 import 一个 store ,但总归还是看起来很怪,无法接受。

    如果大家试着去设计这个 API ,会发现若想直接更新 store (需要 import store ),又想通过 Hooks 解构出 store 数据(需要多 import 一个 Hook ,同时 get 和 set 不同源),这个设计不管怎么都会看起来很别扭。

    为了终极简洁,为了最简单的使用方式,resso 最终还是踏上了这样的 API 设计:

    const store = resso({ count: 0, text: 'hello' });
    
    const { count } = store; // get
    store.count++; // set
    

    6. resso 的使用方式

    Get store

    因为 store 数据是以 useState 注入组件,所以需要先解构(解构即调用 useState),在组件的最顶层解构(即 Hooks 规则,不能写在 if 后),然后再使用,否则将会有 React warning 。

    Set store

    对 store 的第一层数据赋值,将触发更新,且仅对第一层数据的赋值触发更新。

    store.obj = { ...store.obj, num: 10 }; // ✅ 触发更新
    
    store.obj.num = 10; // ❌ 不触发更新(请注意 valtio 支持这种写法)
    

    resso 未支持 valtio 的写法,主要有以下考虑:

    1. 需深层遍历所有数据进行 proxy ,且更新数据时也需要先 proxy 化,会有一定的性能损耗。( resso 只在初始化时 proxy store 一次。)
    2. 因为所有数据都是 proxy ,在 Chrome console 打印时显示不友好,这是很大的问题。( resso 不会有这个问题,因为只有 store 是 proxy ,而一般是打印 store 内的数据。)
    3. 若解构出子数据,例如 objobj.num = 10 也可以触发更新,会造成数据来源不透明,是否来自 store 、赋值是否触发更新不确定。( resso 更新的主体永远是 store ,来源清晰。)

    7. Make simple, not chaos

    以上即是 resso 的设计理念,以及 React 状态管理器的一些实现方式。

    归根结底,React 状态管理器是工具,React 是工具,JS 是工具,编程是工具,工作本身也是工具。

    工具的目的,是为了创造,创造出作用于现实世界的作品,而非工具本身。

    所以,为什么不简单一些呢?

    jQuery 是为了简化原生 JS 的开发,React 是为了简化 jQuery 的开发,开发是为了简化现实世界的流程,互联网是为了简化人们的沟通路径、工作路径、消费路径,开发的意义是简化,互联网的意义是简化,互联网的价值也在于简化。

    所以,为什么不简单一些呢?

    Chic. Not geek.

    简单即是一切。

    try try resso: https://github.com/nanxiaobei/resso

    2 条回复    2022-02-18 02:43:23 +08:00
    lizhenda
        1
    lizhenda  
       2022-02-17 22:54:01 +08:00
    全部拜读了一遍,很不错的思考
    aaronlam
        2
    aaronlam  
       2022-02-18 02:43:23 +08:00
    整个库用法的确是很简单,很认同楼主的这种设计 API 的方式。
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   我们的愿景   ·   实用小工具   ·   5912 人在线   最高记录 6543   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 64ms · UTC 02:37 · PVG 10:37 · LAX 19:37 · JFK 22:37
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.