我们在谈论 React 时常说 UI = f(State)。React 完美地解决了 View (视图) 层,但对于 Model (数据模型) 层,社区的探索从未停止。
从 Redux 到 Hooks ,再到 Zustand ,我们越来越追求“原子化”和“碎片化”。这带来了极简的 API ,但也带来了一个严重的副作用:Model (模型)的破碎。
你是否遇到过这种情况:
create 函数里。useMemo 或各种 Selector 函数里。useEffect 或各个 Event Handler 里。“Model” 消失了,取而代之的是散落在各处的逻辑碎片。
Zenith 注重于高内聚( Co-location ) 的开发体验,可以把数据 (State) 、计算 (Computed) 和 行为 (Action) 紧紧地封装在一起。
Zenith = Zustand 的极简 + MobX 的组织力 + Immer 的不可变基石
在 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 });
});
}
}
MobX 最让人着迷的是它的自动响应能力。Zenith 完美复刻了这一点,但底层依然是 Immutable Data。
你可以基于一个计算属性,派生出另一个计算属性( A -> B -> C )。当 A 变化时,C 会自动更新。我们不再需要手动维护依赖链,也不需要在组件里写一堆 useMemo,一切计算逻辑都收敛在 Model 内部。
定义 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>
);
}
Zenith 不仅仅是一个状态库,它内置了 History (撤销/重做) 和 DevTools 中间件。
我用它构建了 domd markdown WYSIWYG 编辑器,能够支撑 20000 行文档流畅编辑。
Zenith 的出现不是为了争论 FP 好还是 OOP 好。
它只是想告诉你:当你的项目逻辑日益复杂,当你受够了在几十个 Hook 文件中跳来跳去寻找业务逻辑时,你值得拥有一个完整的、诚实的 Model 层。
让代码重归秩序。
Github: https://github.com/do-md/zenith
欢迎 Star 🌟 和 Issue 交流!
发布后收到很多"为什么不用 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 提出的技术问题,我会在后续版本中改进 🙏
1
kingkongdog 14 小时 4 分钟前 via Android
恕我直言,99.99999% 的 React 项目都是贫血模型,Model 层毫无用处。
|
2
uglyer 14 小时 0 分钟前
是个单例?
|
3
jaydenWang OP @uglyer 不是单例,也不推荐单例。使用 createReactStore 创建的都是局部状态,组件多实例彼此不影响。store 随着组件的生命周期销毁。直接 const todostore = new TodoStore()是单例,不推荐这么做,不过有些真正的全局状态可以这么干
|
4
lanten 13 小时 54 分钟前 redux 都已经扫进历史垃圾堆了,因为这完全脱离实践,是在跟空气斗智斗勇,过度设计的典范
|
5
jaydenWang OP @lanten 不需要写模版语言,组件层使用起来跟 zustand 是一样的。额外支持:1. 默认局部状态,支持多实例; 2. 计算属性。3.复杂状态性能优势明显 store 是纯粹的 class 写法,没有额外的约束
|
6
shunia 13 小时 49 分钟前
能不能不要设计得这么复杂。。。zustand 一个 object 定义完整 store 逻辑的方案显然更简洁清晰一些,又不是非要用 this ,用 get()又不犯法。
而且你的实现好像也并没有解决你说得问题啊,看起来只是用 class 的形式重新实现了一遍 zustand 。 |
7
rich1e 13 小时 45 分钟前
20000 行文档流畅编辑,跟 Model 有关系吗?🤔
|
8
ltaoo1o 13 小时 44 分钟前 理想是好的,现实不需要,开发者的水平 + 业务的快速迭代 or 复杂变更,注定了不会被接受。经历过好几个公司,历史代码都能看到类似的自研项目,当时的开发者走了,后面就不会再用了。包括 dva 这种之前流行的方案,新代码也不会用了
|
9
mistsobscure 13 小时 43 分钟前
这对吗
|
10
jaydenWang OP @shunia 1. 解决了 zustand 没有计算属性的问题,有了计算属性,就不需要在组件层 selector ,可以把所有状态内聚的 store 中,计算属性 store 内以及各个组件都可以复用。可以做到没有 UI 的情况下,完成完整的业务逻辑
2. 把“set”方法保护起来,组件中是无法 set 的,可以自由读取状态,set 状态必须调用 store 的 action |
11
jaydenWang OP @rich1e 这是 Zenith 的性能优势。借助 immer 的不可变状态,共享引用以及 Zenith 的计算属性,可以实现更改一个深度状态,只渲染状态树的这条分支
|
12
gkinxin 13 小时 24 分钟前
你这案例一个 useState 都写完了。
|
13
zzlove 13 小时 21 分钟前
|
14
jaydenWang OP @gkinxin 这一块示例不完整,github 有完整的示例。多个组件如何读取状态、set action
|
15
novaline 13 小时 15 分钟前
RTK 足矣,不要造轮子了
|
16
mrwangjustsay 13 小时 13 分钟前
|
17
jaydenWang OP @novaline 没有重复造轮子,核心是 immer 。github 有跟 RTK 的对比
|
18
pakholeung372 13 小时 7 分钟前
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) } } 我一般是这样写的,就是会有一些样板代码 |
19
jaydenWang OP |
20
BingoXuan 12 小时 52 分钟前
大部分情况下都是因为数据职责划分问题。不在于工具,而在于设计。
|
21
shunia 12 小时 47 分钟前
@jaydenWang #10
@jaydenWang #11 你这两个回复很重要,更清晰的说明了你这个工具的核心关注点,我觉得整个宣传物料里完全没有体现,给的 demo 也完全没有体现。 另外那个 class 的整个的语法和结构非常难受,十分不统一,比如: - 构造函数里用 super 传入 initialState 但是又完全不体现出它是一个 state ,后面又有 .state - 莫名其妙蹦出来一个 @memo ,这个明显需要现代打包工具或者 TypeScript 的支持 - fiteredTodos 和 addTodo 的实现是不是过于复杂了?收益又是什么?好像都是 one liner 可以做完的事情 另外你这里还有一个非常蛋疼的点:多个 Store 之间如何交叉调用?必须实现在组件里,无法在 Store 内部实现? |
22
jaydenWang OP @BingoXuan 是的,Zenith 要做的就是在设计好的基础上,保护好数据,优雅的更新数据,简单的获取数据。Zentith 对于复杂数据职责划分,保留了领域 store 的能力,可以一个 rootstore 组合多个领域 store 。可以参考 mobx 的这篇文章<https://zh.mobx.js.org/defining-data-stores.html>
|
23
jsq2627 12 小时 42 分钟前 via iPhone
rtk zustand jotai 不想给同事挖坑就老老实实使用这些广为人知的 library
|
24
jsq2627 12 小时 40 分钟前 via iPhone
抱歉,吐槽草率了
看了一下,还是很优秀的设计 |
25
jaydenWang OP @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 的时候不需要考虑太多参数 |
26
Chrisssss 12 小时 28 分钟前 我写了几十万行 react 的业务代码了,除了 setState 和 context 基本没用过其他的状态管理。恕我直言,99% 的业务代码都不用考虑单独搞个 model 层
|
27
pakholeung372 12 小时 23 分钟前
@Chrisssss 这个倒是真的,主要是要做编辑器,设计器这类应用可能才需要用到 model 层
|
28
jaydenWang OP @shunia 补充一点,多个 store 的交互参考<https://zh.mobx.js.org/defining-data-stores.html>, Zenith 完整的支持这种模式
|
29
ala2008 12 小时 15 分钟前
|
30
jackOff 12 小时 11 分钟前
大部分企业的业务都不需要 model 层
|
32
Ketteiron 11 小时 59 分钟前
const deps = getDeps.call(store, store);
这样的实现必须手动在 getter 写一次,@memo 指定依赖列表,完全依赖约定,把 react 的糟粕带了过来。 useStoreSelector 是通过猜测用户访问了什么属性调用 trackGetterAccess 增加引用计数,有多脆弱我就不说了,至少 StrictMode 会错误计数。此外没处理好竟态条件。 另外 View 层反向控制 Model 的缓存过于反模式,只要没有 React 组件在查看属性,就会直接删掉缓存。 Immer 混搭 weakMap 过于奇葩。 一堆 any ,看一半就没耐心看下去了。 |
33
codehz 11 小时 58 分钟前
6202 年还在依赖实验性装饰器这点就已经输了()
zustand 里想用 class 其实可以直接做一个中间件来做,以下是 ai 一秒生成的代码,可能有误,但大体思路明确 https://grok.com/share/c2hhcmQtMi1jb3B5_424db85c-b856-4a85-a83e-d185fca2c8b7 |
34
LiuJiang 11 小时 54 分钟前
@jaydenWang #10 你没认真看吧,zustand 有阿,而且你这个比 zustand 更复杂,居然引入装饰器模式
|
35
jaydenWang OP @Ketteiron 1. 没想过自动计算依赖
2. useStoreSelector 计数不会出错,缓存不是目的,缓存是为了稳定的引用,是服务于 view 层。view 层用了缓存,不用了不缓存,不存在 view 层控制 model 层缓存,这个缓存就是服务于 view 层的 3. 调用层有完整的 TS 类型推到,实现层还有一些 any 会修复 |
36
jaydenWang OP @Ketteiron trackGetterAcces 这种设计可能是有问题的,我想想有没有优雅的姿势自动清除缓存
|
37
jaydenWang OP @codehz 第一版就是基于 zustand 封装的,但是 zustand 不是核心。核心是 immer ,不可变状态,后续就移除了 zustand
|
38
youyouzi 11 小时 19 分钟前
“像 Zustand 一样简单”---那我为什么不直接用 Zustand ?
通篇看下来,你这个并没有说非常大的亮点,反而更加复杂,上手难度更加高,而且还用装饰器这种模式,你所描述的东西它都有,你没有的它也有。 还有一点,大家广为人知的 Zustand ,生态、社区,乃至各种坑都已经踩过了,ai 也已经收录了各种文档,为什么要用你这个呢?我在项目用 Zustand 也只是简单的管理一个普通的对象 store 也足以 zustand/middleware/immer 也非常优秀的实践 |
39
jja 11 小时 3 分钟前 via iPhone
不是很懂,等一千 star 了再来看看
|
40
jaydenWang OP @youyouzi View 层像 Zustand 一样简单。zustand 的 store 本身不支持计算属性,派生逻辑只能写在组件的 selector 里
|
41
XCFOX 8 小时 55 分钟前 已经用了好几年 Valtio 了。Valtio 和 Zustand 是同一个作者写的。
Valtio 简洁到几乎只有 `proxy()`, `useSnapshot()` 两个函数。 同样是用 class 组织数据 (State) 、计算 (Computed) 和 行为 (Action)。 楼主的 Zenith 相比 Valtio 看不到优势。 https://valtio.dev/docs/how-tos/how-to-organize-actions |
42
jaydenWang OP @XCFOX 请教一下 Valtio 是如何实现计算属性的,get 方法如果 return 类似 this.todos.filter(t => true)或者 this.todos.map(t => t)是否存在性能陷阱
|
43
XCFOX 8 小时 33 分钟前
|
45
ysmood 2 小时 21 分钟前 @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 。 |
46
jaydenWang OP @ysmood zenith 追求的不是简单,是工程化。他可以不用 context ,但是不推荐这么做,不推荐成为单例,不推荐成为全局状态。使用 context ,store 可以具备组件相同的生命周期,随着组件实例而实例,跟随组件销毁。至于 computed value ,这是响应式系统非常关键的点,有了 computed value ,你只需要关注派生状态依赖什么就可以了,这不是耦合,这是减少后续开发的负担。后续的 set 操作就可以足够轻量,不需要 set 一个状态的时候,考虑其它状态需要如何控制
|
47
Cbdy 1 小时 59 分钟前
proposal-decorators 还没进生产
|
48
horizon 1 小时 47 分钟前 很好,是我想要的
|
49
jaydenWang OP @XCFOX Zenith 和 Valtio 确是很相近,下面是整理的对比,Zenith 在封装和工程化上会有一些优势,使用确实没 Valtio 简单
 |
50
jaydenWang OP @horizon 谢谢,欢迎使用、交流
|
51
ysmood 39 分钟前
@jaydenWang redux 很工程化,为什么大家最后大家都宁愿 zustand 呢?因为你是强制要求别人工程化,而不是循序渐进的工程化,好的架构是能让大家能从 0 开始循序渐进的工程化,stalo 就是践行这样的设计,它并不强调只有一个 global state ,而是你可以先从简单的 global state 入手然后逐渐拆分系统,这个 example 里都有。你 provider 就是中心化的表现,只要有持久化的状态就肯定会存在类似 context 的概念,这是 gc 的架构就注定无法逃脱的。只是在于如果合理的引导大家工程化才是重点,而不是上来就 push 一堆大部分时候用不到的概念在框架里。
从我个人经验来讲不符合循序渐进的框架,最终都很难成功。这和乐高为什么能火这么多年是一个道理。 |
52
jaydenWang OP @ysmood zenith 强制工程化,在此基础上提供优雅的更新,便捷的使用。不会为了简单好用弱化对做真确事的最求
|
53
jaydenWang OP @ysmood Zenith 跟 Zustand 和 Stalo 做的事是不一样的,跟 Valtio 和 MobX 相似。就不要再讨论 zustand like 的方向了
|