V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
• 请不要在回答技术问题时复制粘贴 AI 生成的内容
jaydenWang
V2EX  ›  程序员

React 缺失的“M”层:我开发了 Zenith,重塑完整的 Model

  •  1
     
  •   jaydenWang · 8 小时 55 分钟前 · 2105 次点击

    迷失的 Model

    我们在谈论 React 时常说 UI = f(State)。React 完美地解决了 View (视图) 层,但对于 Model (数据模型) 层,社区的探索从未停止。

    从 Redux 到 Hooks ,再到 Zustand ,我们越来越追求“原子化”和“碎片化”。这带来了极简的 API ,但也带来了一个严重的副作用:Model (模型)的破碎

    你是否遇到过这种情况:

    • 数据 (State) 定义在一个 create 函数里。
    • 计算 (Computed) 散落在组件的 useMemo 或各种 Selector 函数里。
    • 行为 (Action) 散落在 useEffect 或各个 Event Handler 里。

    “Model” 消失了,取而代之的是散落在各处的逻辑碎片。

    Zenith:重塑 Model 层

    Zenith 注重于高内聚( Co-location ) 的开发体验,可以把数据 (State)计算 (Computed)行为 (Action) 紧紧地封装在一起。

    Zenith = Zustand 的极简 + MobX 的组织力 + Immer 的不可变基石

    核心特性:“诚实”的 Model

    1. 完整的模型定义 (Co-location)

    在 Zenith 中,你不需要在闭包里用 get() 去“偷窥”状态,也不用担心 set 的黑盒逻辑。一个 Store 就是一个完整的、逻辑自洽的业务单元。

    class TodoStore extends ZenithStore<State> {
      // 1. 数据 (State)
      constructor() {
        super({ todos: [], filter: 'all' });
      }
    ​
      // 2. 自动计算属性 (Computed)
      // 告别手动写 Selector ,告别 useMemo
      // 像定义原生 getter 一样定义派生状态
      @memo((s) => [s.state.todos, s.state.filter])
      get filteredTodos() {
        const { todos, filter } = this.state;
        // ...逻辑
      }
    ​
      // 3. 行为 (Action)
      // 诚实地使用 this ,UI 层绝不能直接碰 State
      addTodo(text: string) {
        this.produce((draft) => {
          draft.todos.push({ text, completed: false });
        });
      }
    }
    

    2. 链式派生:自动化的数据流

    MobX 最让人着迷的是它的自动响应能力。Zenith 完美复刻了这一点,但底层依然是 Immutable Data

    你可以基于一个计算属性,派生出另一个计算属性( A -> B -> C )。当 A 变化时,C 会自动更新。我们不再需要手动维护依赖链,也不需要在组件里写一堆 useMemo一切计算逻辑都收敛在 Model 内部

    3. 组件即视图 (View):像 Zustand 一样简单

    定义 Model 虽然严谨,但在组件里使用必须极致简单。Zenith 提供了完全符合 React Hooks 习惯的 API 。

    你不需要高阶组件( HOC ),不需要 Connect ,只需要一个 Hook:

    const { useStore, useStoreApi } = createReactStore(TodoStore);
    ​
    function TodoList() {
      // ✅ 像 Zustand 一样选择状态
      // 只有当 filteredTodos 变化时,组件才会重渲染
      const todos = useStore((s) => s.filteredTodos);
      
      // ✅ 获取完整的 Model 实例 (Action)
      const store = useStoreApi();
    ​
      return (
        <div>
          {todos.map((todo) => (
             // UI 只负责触发意图,不负责实现逻辑
            <div onClick={() => store.toggle(todo.id)}>
              {todo.text}
            </div>
          ))}
        </div>
      );
    }
    

    4. 工程化的胜利

    Zenith 不仅仅是一个状态库,它内置了 History (撤销/重做)DevTools 中间件。

    我用它构建了 domd markdown WYSIWYG 编辑器,能够支撑 20000 行文档流畅编辑。

    结语

    Zenith 的出现不是为了争论 FP 好还是 OOP 好。

    它只是想告诉你:当你的项目逻辑日益复杂,当你受够了在几十个 Hook 文件中跳来跳去寻找业务逻辑时,你值得拥有一个完整的、诚实的 Model 层。

    让代码重归秩序。

    Github: https://github.com/do-md/zenith

    欢迎 Star 🌟 和 Issue 交流!

    第 1 条附言  ·  5 小时 27 分钟前

    附言:关于定位的说明

    发布后收到很多"为什么不用 Zustand"的反馈,我意识到可能造成了误解。

    Zenith 的能力对标是 MobX,而非 Zustand。

    如果用一句话概括:

    Zenith = MobX 的响应式 + Immer 的不可变 + 原生 React Hooks

    Zustand 专注于简单的全局状态管理,它做得很好,但它没有(也不打算有)计算属性、链式派生等 Model 层能力。

    Zenith 面向的是复杂状态场景(编辑器、设计器、游戏等),需要 MobX 级别的状态组织能力,但又希望保持不可变数据的开发者。

    如果你的项目用 Zustand 很舒服,请继续用 Zustand。 Zenith 不是要替代它,而是给需要更强 Model 层能力的场景提供另一种选择。

    对比参考:

    Zustand MobX Zenith
    适用场景 简单状态管理 复杂状态管理 复杂状态管理
    数据模型 不可变 可变 不可变(Immer)
    Store 内计算属性 ✅ @computed ✅ @memo
    React 集成 Hook HOC (observer) Hook
    学习曲线

    感谢所有反馈,特别是 @Ketteiron 提出的技术问题,我会在后续版本中改进 🙏

    43 条回复    2025-12-24 14:54:01 +08:00
    kingkongdog
        1
    kingkongdog  
       8 小时 40 分钟前 via Android
    恕我直言,99.99999% 的 React 项目都是贫血模型,Model 层毫无用处。
    uglyer
        2
    uglyer  
       8 小时 36 分钟前
    是个单例?
    jaydenWang
        3
    jaydenWang  
    OP
       8 小时 31 分钟前
    @uglyer 不是单例,也不推荐单例。使用 createReactStore 创建的都是局部状态,组件多实例彼此不影响。store 随着组件的生命周期销毁。直接 const todostore = new TodoStore()是单例,不推荐这么做,不过有些真正的全局状态可以这么干
    lanten
        4
    lanten  
       8 小时 30 分钟前   ❤️ 1
    redux 都已经扫进历史垃圾堆了,因为这完全脱离实践,是在跟空气斗智斗勇,过度设计的典范
    jaydenWang
        5
    jaydenWang  
    OP
       8 小时 25 分钟前
    @lanten 不需要写模版语言,组件层使用起来跟 zustand 是一样的。额外支持:1. 默认局部状态,支持多实例; 2. 计算属性。3.复杂状态性能优势明显 store 是纯粹的 class 写法,没有额外的约束
    shunia
        6
    shunia  
       8 小时 25 分钟前
    能不能不要设计得这么复杂。。。zustand 一个 object 定义完整 store 逻辑的方案显然更简洁清晰一些,又不是非要用 this ,用 get()又不犯法。
    而且你的实现好像也并没有解决你说得问题啊,看起来只是用 class 的形式重新实现了一遍 zustand 。
    rich1e
        7
    rich1e  
       8 小时 20 分钟前
    20000 行文档流畅编辑,跟 Model 有关系吗?🤔
    ltaoo1o
        8
    ltaoo1o  
       8 小时 20 分钟前   ❤️ 1
    理想是好的,现实不需要,开发者的水平 + 业务的快速迭代 or 复杂变更,注定了不会被接受。经历过好几个公司,历史代码都能看到类似的自研项目,当时的开发者走了,后面就不会再用了。包括 dva 这种之前流行的方案,新代码也不会用了
    mistsobscure
        9
    mistsobscure  
       8 小时 19 分钟前
    这对吗
    jaydenWang
        10
    jaydenWang  
    OP
       8 小时 18 分钟前
    @shunia 1. 解决了 zustand 没有计算属性的问题,有了计算属性,就不需要在组件层 selector ,可以把所有状态内聚的 store 中,计算属性 store 内以及各个组件都可以复用。可以做到没有 UI 的情况下,完成完整的业务逻辑
    2. 把“set”方法保护起来,组件中是无法 set 的,可以自由读取状态,set 状态必须调用 store 的 action
    jaydenWang
        11
    jaydenWang  
    OP
       8 小时 15 分钟前
    @rich1e 这是 Zenith 的性能优势。借助 immer 的不可变状态,共享引用以及 Zenith 的计算属性,可以实现更改一个深度状态,只渲染状态树的这条分支
    gkinxin
        12
    gkinxin  
       8 小时 0 分钟前
    你这案例一个 useState 都写完了。
    zzlove
        13
    zzlove  
       7 小时 57 分钟前
    简单业务我觉得这样更简单直观,Store 代码算是 ts 类型标注就几十行
    jaydenWang
        14
    jaydenWang  
    OP
       7 小时 54 分钟前
    @gkinxin 这一块示例不完整,github 有完整的示例。多个组件如何读取状态、set action
    novaline
        15
    novaline  
       7 小时 51 分钟前
    RTK 足矣,不要造轮子了
    mrwangjustsay
        16
    mrwangjustsay  
       7 小时 49 分钟前
    jaydenWang
        17
    jaydenWang  
    OP
       7 小时 48 分钟前
    @novaline 没有重复造轮子,核心是 immer 。github 有跟 RTK 的对比
    pakholeung372
        18
    pakholeung372  
       7 小时 43 分钟前
    export class Service implements IServiceWithStore<State> {

    store

    useState

    setState

    getState

    constructor() {
    this.store = create(
    () => ({
    current: undefined,
    }),
    )

    this.useState = this.store
    this.setState = this.store.setState
    this.getState = this.store.getState
    }

    getCurrent(state = this.getState()) { return state.current }

    useCurrent() {
    return this.store.useState(this.getCurrent)
    }
    }

    我一般是这样写的,就是会有一些样板代码
    jaydenWang
        19
    jaydenWang  
    OP
       7 小时 28 分钟前
    @pakholeung372 思路很像,一开始也写过 this.store = create(
    () => ({
    current: undefined,
    }),
    )
    BingoXuan
        20
    BingoXuan  
       7 小时 28 分钟前
    大部分情况下都是因为数据职责划分问题。不在于工具,而在于设计。
    shunia
        21
    shunia  
       7 小时 23 分钟前
    @jaydenWang #10
    @jaydenWang #11

    你这两个回复很重要,更清晰的说明了你这个工具的核心关注点,我觉得整个宣传物料里完全没有体现,给的 demo 也完全没有体现。

    另外那个 class 的整个的语法和结构非常难受,十分不统一,比如:
    - 构造函数里用 super 传入 initialState 但是又完全不体现出它是一个 state ,后面又有 .state
    - 莫名其妙蹦出来一个 @memo ,这个明显需要现代打包工具或者 TypeScript 的支持
    - fiteredTodos 和 addTodo 的实现是不是过于复杂了?收益又是什么?好像都是 one liner 可以做完的事情

    另外你这里还有一个非常蛋疼的点:多个 Store 之间如何交叉调用?必须实现在组件里,无法在 Store 内部实现?
    jaydenWang
        22
    jaydenWang  
    OP
       7 小时 23 分钟前
    @BingoXuan 是的,Zenith 要做的就是在设计好的基础上,保护好数据,优雅的更新数据,简单的获取数据。Zentith 对于复杂数据职责划分,保留了领域 store 的能力,可以一个 rootstore 组合多个领域 store 。可以参考 mobx 的这篇文章<https://zh.mobx.js.org/defining-data-stores.html>
    jsq2627
        23
    jsq2627  
       7 小时 17 分钟前 via iPhone
    rtk zustand jotai 不想给同事挖坑就老老实实使用这些广为人知的 library
    jsq2627
        24
    jsq2627  
       7 小时 16 分钟前 via iPhone
    抱歉,吐槽草率了
    看了一下,还是很优秀的设计
    jaydenWang
        25
    jaydenWang  
    OP
       7 小时 12 分钟前
    @shunia - 不好意思,没有保留 BaseStore 的细节。state 是继承自 ZenithStore
    - memo 是实现计算属性的核心,如果 filteredTodos 是通过 this.state.todos.filter 返回的值,组件层每次读区 filteredTodos ,都会返回一个新的索引,触发组件渲染。 @memo 显示声明了依赖项,当依赖项不变的时候,永远返回上一次的引用,组件不会额外渲染。当 this.state.todos 的索引改变的时候,可能是删除、增加、修改,filteredTodos 就会触发重新计算,因为索引变化,组件触发重新渲染
    - fiteredTodos 也就是这类派生状态,是鼓励写复杂的,响应式会更加友好,后续 setState 就不用考虑,todo 索引改变了,filter 的值改变了,fiteredTodos 自动计算,体现在 UI 层。把 setState 的复杂逻辑,转移到 get 中,后面业务逻辑复杂,setstate 的时候不需要考虑太多参数
    Chrisssss
        26
    Chrisssss  
       7 小时 4 分钟前   ❤️ 3
    我写了几十万行 react 的业务代码了,除了 setState 和 context 基本没用过其他的状态管理。恕我直言,99% 的业务代码都不用考虑单独搞个 model 层
    pakholeung372
        27
    pakholeung372  
       6 小时 59 分钟前
    @Chrisssss 这个倒是真的,主要是要做编辑器,设计器这类应用可能才需要用到 model 层
    jaydenWang
        28
    jaydenWang  
    OP
       6 小时 57 分钟前
    @shunia 补充一点,多个 store 的交互参考<https://zh.mobx.js.org/defining-data-stores.html>, Zenith 完整的支持这种模式
    ala2008
        29
    ala2008  
       6 小时 50 分钟前
    前端是不是故意的,越来越复杂了。。搞得门槛变高了,后端都看不懂了
    jackOff
        30
    jackOff  
       6 小时 47 分钟前
    大部分企业的业务都不需要 model 层
    onlxx123
        31
    onlxx123  
       6 小时 38 分钟前
    @Chrisssss 同意
    Ketteiron
        32
    Ketteiron  
       6 小时 34 分钟前
    const deps = getDeps.call(store, store);
    这样的实现必须手动在 getter 写一次,@memo 指定依赖列表,完全依赖约定,把 react 的糟粕带了过来。

    useStoreSelector 是通过猜测用户访问了什么属性调用 trackGetterAccess 增加引用计数,有多脆弱我就不说了,至少 StrictMode 会错误计数。此外没处理好竟态条件。

    另外 View 层反向控制 Model 的缓存过于反模式,只要没有 React 组件在查看属性,就会直接删掉缓存。
    Immer 混搭 weakMap 过于奇葩。

    一堆 any ,看一半就没耐心看下去了。
    codehz
        33
    codehz  
       6 小时 33 分钟前
    6202 年还在依赖实验性装饰器这点就已经输了()
    zustand 里想用 class 其实可以直接做一个中间件来做,以下是 ai 一秒生成的代码,可能有误,但大体思路明确

    https://grok.com/share/c2hhcmQtMi1jb3B5_424db85c-b856-4a85-a83e-d185fca2c8b7
    LiuJiang
        34
    LiuJiang  
       6 小时 30 分钟前
    @jaydenWang #10 你没认真看吧,zustand 有阿,而且你这个比 zustand 更复杂,居然引入装饰器模式
    jaydenWang
        35
    jaydenWang  
    OP
       6 小时 18 分钟前
    @Ketteiron 1. 没想过自动计算依赖
    2. useStoreSelector 计数不会出错,缓存不是目的,缓存是为了稳定的引用,是服务于 view 层。view 层用了缓存,不用了不缓存,不存在 view 层控制 model 层缓存,这个缓存就是服务于 view 层的
    3. 调用层有完整的 TS 类型推到,实现层还有一些 any 会修复
    jaydenWang
        36
    jaydenWang  
    OP
       6 小时 6 分钟前
    @Ketteiron trackGetterAcces 这种设计可能是有问题的,我想想有没有优雅的姿势自动清除缓存
    jaydenWang
        37
    jaydenWang  
    OP
       6 小时 3 分钟前
    @codehz 第一版就是基于 zustand 封装的,但是 zustand 不是核心。核心是 immer ,不可变状态,后续就移除了 zustand
    youyouzi
        38
    youyouzi  
       5 小时 54 分钟前
    “像 Zustand 一样简单”---那我为什么不直接用 Zustand ?

    通篇看下来,你这个并没有说非常大的亮点,反而更加复杂,上手难度更加高,而且还用装饰器这种模式,你所描述的东西它都有,你没有的它也有。

    还有一点,大家广为人知的 Zustand ,生态、社区,乃至各种坑都已经踩过了,ai 也已经收录了各种文档,为什么要用你这个呢?我在项目用 Zustand 也只是简单的管理一个普通的对象 store 也足以

    zustand/middleware/immer 也非常优秀的实践
    jja
        39
    jja  
       5 小时 39 分钟前 via iPhone
    不是很懂,等一千 star 了再来看看
    jaydenWang
        40
    jaydenWang  
    OP
       5 小时 37 分钟前
    @youyouzi View 层像 Zustand 一样简单。zustand 的 store 本身不支持计算属性,派生逻辑只能写在组件的 selector 里
    XCFOX
        41
    XCFOX  
       3 小时 30 分钟前
    已经用了好几年 Valtio 了。Valtio 和 Zustand 是同一个作者写的。
    Valtio 简洁到几乎只有 `proxy()`, `useSnapshot()` 两个函数。
    同样是用 class 组织数据 (State) 、计算 (Computed) 和 行为 (Action)。
    楼主的 Zenith 相比 Valtio 看不到优势。

    https://valtio.dev/docs/how-tos/how-to-organize-actions
    jaydenWang
        42
    jaydenWang  
    OP
       3 小时 21 分钟前
    @XCFOX 请教一下 Valtio 是如何实现计算属性的,get 方法如果 return 类似 this.todos.filter(t => true)或者 this.todos.map(t => t)是否存在性能陷阱
    XCFOX
        43
    XCFOX  
       3 小时 9 分钟前
    关于   ·   帮助文档   ·   自助推广系统   ·   博客   ·   API   ·   FAQ   ·   Solana   ·   4383 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 26ms · UTC 10:03 · PVG 18:03 · LAX 02:03 · JFK 05:03
    ♥ Do have faith in what you're doing.