React 缺失的“M”层:我开发了 Zenith,重塑完整的 Model - V2EX
V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
请不要在回答技术问题时复制粘贴 AI 生成的内容
jaydenWang
V2EX    程序员

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

  •  1
     
  •   jaydenWang 15 小时 23 分钟前 2541 次点击

    迷失的 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 条附言    11 小时 56 分钟前

    附言:关于定位的说明

    发布后收到很多"为什么不用 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 提出的技术问题,我会在后续版本中改进

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

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

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

    另外你这里还有一个非常蛋疼的点:多个 Store 之间如何交叉调用?必须实现在组件里,无法在 Store 内部实现?
    jaydenWang
        22
    jaydenWang  
    OP
       13 小时 51 分钟前
    @BingoXuan 是的,Zenith 要做的就是在设计好的基础上,保护好数据,优雅的更新数据,简单的获取数据。Zentith 对于复杂数据职责划分,保留了领域 store 的能力,可以一个 rootstore 组合多个领域 store 。可以参考 mobx 的这篇文章<https://zh.mobx.js.org/defining-data-stores.html>
    jsq2627
        23
    jsq2627  
       13 小时 45 分钟前 via iPhone
    rtk zustand jotai 不想给同事挖坑就老老实实使用这些广为人知的 library
    jsq2627
        24
    jsq2627  
       13 小时 44 分钟前 via iPhone
    抱歉,吐槽草率了
    看了一下,还是很优秀的设计
    jaydenWang
        25
    jaydenWang  
    OP
       13 小时 40 分钟前
    @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  
       13 小时 32 分钟前   3
    我写了几十万行 react 的业务代码了,除了 setState 和 context 基本没用过其他的状态管理。恕我直言,99% 的业务代码都不用考虑单独搞个 model 层
    pakholeung372
        27
    pakholeung372  
       13 小时 27 分钟前
    @Chrisssss 这个倒是真的,主要是要做编辑器,设计器这类应用可能才需要用到 model 层
    jaydenWang
        28
    jaydenWang  
    OP
       13 小时 25 分钟前
    @shunia 补充一点,多个 store 的交互参考<https://zh.mobx.js.org/defining-data-stores.html>, Zenith 完整的支持这种模式
    ala2008
        29
    ala2008  
       13 小时 18 分钟前
    前端是不是故意的,越来越复杂了。。搞得门槛变高了,后端都看不懂了
    jackOff
        30
    jackOff  
       13 小时 15 分钟前
    大部分企业的业务都不需要 model 层
    onlxx123
        31
    onlxx123  
       13 小时 6 分钟前
    @Chrisssss 同意
    Ketteiron
        32
    Ketteiron  
       13 小时 3 分钟前
    const deps = getDeps.call(store, store);
    这样的实现必须手动在 getter 写一次,@memo 指定依赖列表,完全依赖约定,把 react 的糟粕带了过来。

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

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

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

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

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

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

    zustand/middleware/immer 也非常优秀的实践
    jja
        39
    jja  
       12 小时 7 分钟前 via iPhone
    不是很懂,等一千 star 了再来看看
    jaydenWang
        40
    jaydenWang  
    OP
       12 小时 5 分钟前
    @youyouzi View 层像 Zustand 一样简单。zustand 的 store 本身不支持计算属性,派生逻辑只能写在组件的 selector 里
    XCFOX
        41
    XCFOX  
       9 小时 58 分钟前   1
    已经用了好几年 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
       9 小时 49 分钟前
    @XCFOX 请教一下 Valtio 是如何实现计算属性的,get 方法如果 return 类似 this.todos.filter(t => true)或者 this.todos.map(t => t)是否存在性能陷阱
    XCFOX
        43
    XCFOX  
       9 小时 37 分钟前
    luvsic
        44
    luvsic  
       5 小时 8 分钟前
    div class="reply_content">@lanten #4
    Redux 真是擅长把简单的问题复杂化,把前端圈都带到坑里了
    ysmood
        45
    ysmood  
       3 小时 25 分钟前   1
    @jaydenWang @XCFOX 我写了个更简单易用的 https://github.com/ysmood/stalo

    其他的库对于 typescript 的支持比我这个要差很多。尤其是 zustand ,这是我主要开发 stalo 的原因。

    这里有和 zustand 还有 valtio 等其他库的对比: https://github.com/ysmood/stalo/issues

    甚至开发专用的 devtools 插件,甚至可以玩 time travel ,视频演示性能比 redux 快几个数量级:

    可以自己开网页体验: https://stalo-examples.vercel.app/examples/Devtools.tsx

    computed value 就是个伪命题,复杂的项目都会尽量少用,因为 debugging 可能会非常复杂,不复杂的项目就更用不到了。关于重复渲染的问题,通常是简单的 redundant model 反而容易 fine tune 和优化,如果大量使用 computed value ,很可能加大耦合反而难以优化。

    你这个库还要依赖 context provider ,用起来非常麻烦,stalo 跨组建之间 share 状态更简单。

    你甚至可以基于我这个库开发,这样你就可以直接利用我写的 devtools 了。你这个项目代码连一行测试都没有,一般稍微懂一点的开发者是不敢用的,我这个项目至少测试都是 100% coverage 。
    jaydenWang
        46
    jaydenWang  
    OP
       3 小时 8 分钟前 via iPhone
    @ysmood zenith 追求的不是简单,是工程化。他可以不用 context ,但是不推荐这么做,不推荐成为单例,不推荐成为全局状态。使用 context ,store 可以具备组件相同的生命周期,随着组件实例而实例,跟随组件销毁。至于 computed value ,这是响应式系统非常关键的点,有了 computed value ,你只需要关注派生状态依赖什么就可以了,这不是耦合,这是减少后续开发的负担。后续的 set 操作就可以足够轻量,不需要 set 一个状态的时候,考虑其它状态需要如何控制
    Cbdy
        47
    Cbdy  
       3 小时 3 分钟前
    proposal-decorators 还没进生产
    horizon
        48
    horizon  
       2 小时 51 分钟前   1
    很好,是我想要的
    jaydenWang
        49
    jaydenWang  
    OP
       2 小时 42 分钟前   1
    @XCFOX Zenith 和 Valtio 确是很相近,下面是整理的对比,Zenith 在封装和工程化上会有一些优势,使用确实没 Valtio 简单
    ![Zenith vs Valtio]( https://ik.imagekit.io/g123/doraemon/c065d297-f4de-4eb5-86ad-19cd39d0957e.png)
    jaydenWang
        50
    jaydenWang  
    OP
       2 小时 20 分钟前
    @horizon 谢谢,欢迎使用、交流
    ysmood
        51
    ysmood  
       1 小时 43 分钟前
    @jaydenWang redux 很工程化,为什么大家最后大家都宁愿 zustand 呢?因为你是强制要求别人工程化,而不是循序渐进的工程化,好的架构是能让大家能从 0 开始循序渐进的工程化,stalo 就是践行这样的设计,它并不强调只有一个 global state ,而是你可以先从简单的 global state 入手然后逐渐拆分系统,这个 example 里都有。你 provider 就是中心化的表现,只要有持久化的状态就肯定会存在类似 context 的概念,这是 gc 的架构就注定无法逃脱的。只是在于如果合理的引导大家工程化才是重点,而不是上来就 push 一堆大部分时候用不到的概念在框架里。

    从我个人经验来讲不符合循序渐进的框架,最终都很难成功。这和乐高为什么能火这么多年是一个道理。
    jaydenWang
        52
    jaydenWang  
    OP
       1 小时 35 分钟前
    @ysmood zenith 强制工程化,在此基础上提供优雅的更新,便捷的使用。不会为了简单好用弱化对做真确事的最求
    jaydenWang
        53
    jaydenWang  
    OP
       1 小时 32 分钟前
    @ysmood Zenith 跟 Zustand 和 Stalo 做的事是不一样的,跟 Valtio 和 MobX 相似。就不要再讨论 zustand like 的方向了
    关于     帮助文档     自助推广系统     博客     API     FAQ     Solana     1542 人在线   最高记录 6679       Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 36ms UTC 16:31 PVG 00:31 LAX 08:31 JFK 11:31
    Do have faith in what you're doing.
    ubao msn snddm index pchome yahoo rakuten mypaper meadowduck bidyahoo youbao zxmzxm asda bnvcg cvbfg dfscv mmhjk xxddc yybgb zznbn ccubao uaitu acv GXCV ET GDG YH FG BCVB FJFH CBRE CBC GDG ET54 WRWR RWER WREW WRWER RWER SDG EW SF DSFSF fbbs ubao fhd dfg ewr dg df ewwr ewwr et ruyut utut dfg fgd gdfgt etg dfgt dfgd ert4 gd fgg wr 235 wer3 we vsdf sdf gdf ert xcv sdf rwer hfd dfg cvb rwf afb dfh jgh bmn lgh rty gfds cxv xcv xcs vdas fdf fgd cv sdf tert sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf shasha9178 shasha9178 shasha9178 shasha9178 shasha9178 liflif2 liflif2 liflif2 liflif2 liflif2 liblib3 liblib3 liblib3 liblib3 liblib3 zhazha444 zhazha444 zhazha444 zhazha444 zhazha444 dende5 dende denden denden2 denden21 fenfen9 fenf619 fen619 fenfe9 fe619 sdf sdf sdf sdf sdf zhazh90 zhazh0 zhaa50 zha90 zh590 zho zhoz zhozh zhozho zhozho2 lislis lls95 lili95 lils5 liss9 sdf0ty987 sdft876 sdft9876 sdf09876 sd0t9876 sdf0ty98 sdf0976 sdf0ty986 sdf0ty96 sdf0t76 sdf0876 df0ty98 sf0t876 sd0ty76 sdy76 sdf76 sdf0t76 sdf0ty9 sdf0ty98 sdf0ty987 sdf0ty98 sdf6676 sdf876 sd876 sd876 sdf6 sdf6 sdf9876 sdf0t sdf06 sdf0ty9776 sdf0ty9776 sdf0ty76 sdf8876 sdf0t sd6 sdf06 s688876 sd688 sdf86