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

2025 年 12 月 24 日
 jaydenWang

迷失的 Model

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

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

你是否遇到过这种情况:

“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 交流!

4719 次点击
所在节点    程序员
57 条回复
shunia
2025 年 12 月 24 日
@jaydenWang #10
@jaydenWang #11

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

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

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

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

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

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

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

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

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

zustand/middleware/immer 也非常优秀的实践
jja
2025 年 12 月 24 日
不是很懂,等一千 star 了再来看看
jaydenWang
2025 年 12 月 24 日
@youyouzi View 层像 Zustand 一样简单。zustand 的 store 本身不支持计算属性,派生逻辑只能写在组件的 selector 里

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/1180806

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX