如果只保留一句话,我的判断是:

很多项目的问题,并不在于 ref、composable、provide/inject、Pinia、Query、localStorage 这些工具本身不好。
真正的问题在于:它们往往各自解决一段问题,最后整套状态系统却变成了散装拼接。
我这里说的“散装版”,不是在批评 Vue,也不是在否定 Vue 的灵活性,而是在批评一种极其常见的项目演化结果:
而 Zova/Cabloy 想做的,不是“换掉 Vue”,而是把 Vue 3 之上的状态组织方式做成更明确的套装版架构:
如果项目只是中小规模,“散装版”完全能跑,而且往往很快。
但当项目进入多人协作、长周期演进、SSR、缓存、持久化、表单、权限、资源复用并存的阶段,“套装版”会越来越像基础设施,而不再只是编码偏好。
这也是整篇文章真正想讨论的问题:
很多人一谈状态管理,第一反应是:
这些问题当然重要。
但对大型项目来说,它们都还只是表面。
更深一层的问题其实是:
如果这些问题分别由不同局部机制处理,那么项目迟早会进入一种熟悉的状态:
系统当然还在跑。
但越来越少的人能完整说清楚:
这才是大型项目真正会失控的地方。
因为一旦走到这一步,问题已经不再是“代码会不会写”,而是:
也就是说,大型项目真正缺的,往往不是“再多一个工具”,而是一套能让这些工具不互相打架的结构。
原因很简单:它的增长路径太自然了。
很多团队一开始都会这样写:
provide/inject这条路径每一步都合理。
问题在于,它默认是一条“局部优化优先”的增长路径,而不是“状态边界优先”的增长路径。
所以项目越大,越容易出现这些现象:
这就是“散装版”的本质:
换句话说,散装版真正危险的地方,不是它今天不能跑,而是它把复杂度的控制权长期交给了团队记忆、代码习惯和口头约定。
前期看起来很灵活,后期却很容易进入一种典型状态:
这就是为什么很多大型 Vue 项目不是突然崩掉,而是慢慢失去结构感。
它不是一下子写坏的,而是一点点长散的。
理解 Zova/Cabloy,最重要的一点不是“它和 Vue 不一样”,而是:
在常规 Vue 心智里,开发者更熟悉的是:
setup()ref() / reactive()computed()useRoute()provide/inject而在 Zova 里,被推到前台的是另一套组织重心:
__init__ 作为主要 wiring 点之一所以对大型项目来说,Zova/Cabloy 真正带来的,不是某个新 API,而是一条新的优先级:
这不是语法偏好,而是架构中心的转移。
说得更直白一点:
这一步顺序变化,看起来很小,却几乎决定了大型项目后续是越写越稳,还是越写越散。
因为大型项目一旦进入复杂阶段,真正稀缺的已经不是“会不会写一个功能”,而是能不能持续把边界写对。
在很多 Vue 项目里,状态最自然的宿主,是 setup() 里的局部变量:
复制代码const count = ref(0);
const count2 = computed(() => `=== ${count.value} ===`);
这当然没问题。
但项目一旦变大,这种写法很容易带来一个后果:状态先以“局部声明”的方式出现,之后再逐步外溢到 composable、store、父组件或别的抽象里。
Zova 则先把“状态宿主”架构化。可见状态通常直接落在 Controller 或 Bean 实例上:
复制代码count: number = 0;protected async __init__() {
this.count2 = this.$computed(() => {
return `=== ${this.count} ===`;
});
}increment() {
this.count++;
}
这里真正重要的,不是“少写 .value”。
而是:
这会直接改变大型项目的阅读方式:
大型项目最怕的,往往不是状态不够灵活,而是状态没有稳定归属地。
因为只要宿主不稳定,后面的共享、持久化、缓存、SSR、失效策略,几乎都会跟着漂移。
换句话说,状态一旦没有稳定宿主,后面所有“补救式抽象”都会越来越贵。
很多大型 Vue 项目之所以会越写越散,不只是因为状态多,而是因为共享边界的表达方式本身就是分裂的。
同样叫“共享”,常见做法却完全不同:
provide/inject结果就是:
Zova 把这个问题改写成同一个 IoC 模型下的几个 Scope:
| 共享范围 | 常见 Vue 3 做法 | Zova 的问题表达 |
|---|---|---|
| component-internal | 本地状态、组件内 composable | 这是不是 ctx 范围的 Bean? |
| between-components | props/emits、父级 composable、provide/inject | 这是不是 host / skipSelf 的层级注入? |
| app-global | store、app-level provided state | 这是不是 app 范围的共享状态? |
| system-level | 模块单例、长生命周期导入态 | 这是不是 sys 范围、跨请求不重建的状态? |
这张表真正重要的,不是术语,而是它背后的提问方式:
一个非常紧凑的心智模型就是:
复制代码class ControllerPage {
@Use()
$$localCounterState: CounterState; @Use({ injectionScope: 'host' })
$$hostCounterState: CounterState; @Use({ injectionScope: 'app' })
$$appCounterState: CounterState; @Use({ injectionScope: 'sys' })
$$sysCounterState: CounterState;
}
这段代码的价值,不只是“注入语法统一”。
更重要的是:
provide/inject、应用级共享、系统级共享,不再是三套平行世界对 SSR 来说,这种区分尤其关键。
因为很多项目里的“全局状态”,其实混着两种完全不同的东西:
这两种东西如果不分清,SSR 下就很容易出现污染、错位或错误共享。
而 Zova 把它直接结构化到 app 与 sys 的区分里,让它不再只是团队口头经验。
这就是套装版的一个关键特征:
这一步非常重要,因为大型项目最怕的,从来不是“机制不够多”,而是机制太多,但边界没有统一语法。
很多大型前端项目即便已经引入 store 和 query 库,状态体系依然只统一了一半。
最典型的切法通常是:
这仍然是“散装版”思路:
Zova 的 Model 把问题往前推了一步。
它不是只管 remote data,而是把几类常见状态都纳入统一的 model-state 层:
data:服务端 / query 风格状态mem:内存状态local:localStorage 状态cookie:Cookie 状态db:异步持久化状态这件事的意义非常大。
因为它意味着:
它们语义不同,但可以处在同一个更统一的 model ownership 下。
而且这不是空洞包装。
Zova 的 Model 建立在 TanStack Query 之上,但对业务开发者暴露的是更体系化的 model 表面:
所以更准确的说法,不是“Model 是另一个 store”,而是:
这也是为什么在 Zova 里,localStorage 不只是“顺手存一下”。
更准确的理解是:
看起来只是描述方式变了,实际上变化的是项目的组织方式:
在散装版里,这些问题常常被拆散到多个工具链里分别回答;而在套装版里,它们更倾向于被收回到同一个 model boundary 里统一回答。
这才是大型项目真正需要的状态抽象。
说到底,真正有价值的不是“又多了一层 API”,而是终于有了一层能够统一解释这些状态差异的边界。
很多团队即便用了 store 或 query 层,依然会在资源级复杂度上继续散掉。
比如一个典型的列表 / 表单 / 详情资源,页面通常还要分别知道这些事:
你会发现,到这里讨论的早就不只是“状态值”了。
它其实已经是一整组资源语义边界。
这也是 Zova ModelResource 最有价值的地方:
也就是说,模型可以拥有:
这会把很多项目里散在页面、表单、按钮、请求封装、schema 层、permission helper 层的东西,重新收回到一个稳定的资源边界里。
所以我更愿意用一句非常简洁的话概括它:
这句话的价值在于,它把“复杂状态管理”从几个响应式变量,提升成了对资源级边界的管理。
而资源级边界一旦稳定,页面、表单、权限、缓存、Schema 这些原本最容易四散的东西,也就更容易一起稳定下来。
这也是大型项目里最稀缺的能力之一:
| 维度 | 散装版 | 套装版 |
|---|---|---|
| 状态宿主 | 谁顺手拿着就先放哪 | 先确定 Bean / Controller / Model 的所有权 |
| 共享方式 | composable、provide/inject、store 并列增长 | 统一纳入 IoC Scope 讨论 |
| 服务端数据 | 单独交给请求层或 query 层 | 纳入 Model 的统一状态边界 |
| 本地持久化 | localStorage / cookie 各自包装 | local / cookie / db 都是 model-state family |
| 缓存 key | 往往是人为约定的全局字符串 | 由 model identity 与 selector 自动参与命名空间 |
| 失效规则 | 页面、按钮、mutation 各自处理 | 由 model 拥有 invalidation policy |
| SSR 语义 | 常靠额外约定补齐 | app / sys、hydrate/dehydrate 规则更结构化 |
| 代码阅读方式 | 先找变量和 hook,后猜边界 | 先看 owner、scope、model,再看实现 |
| 复杂度控制 | 主要靠团队自觉 | 更多靠框架结构把边界收住 |
这张表背后的核心判断,其实非常简单:
换句话说:
大型项目真正比拼的,往往不是第一周的开发速度,而是半年之后结构还能不能站得住。
如果只看“现在能不能写出来”,散装版并不吃亏。
但如果看“半年后还能不能解释、复用、修改、扩展”,两者差距会越来越明显。
在散装版里,一个列表页的筛选状态常见会这样演化:
ref这条路不是不能走。
问题在于,走到后面你会越来越难回答:
而在套装版里,第一步通常不是“抽不抽 composable”,而是先判断:
mem / local state?思路一变,状态归宿就先稳定了。
在散装版里,新增一条数据之后刷新列表,常见写法是:
invalidateQueries表面上能跑,但刷新规则很容易散落在多个调用点。
而在套装版里,这件事更适合由 Model 拥有:
这样页面消费的是“资源动作”,而不是自己去接触缓存细节。
散装版最容易失控的地方,经常不是“取数”,而是资源页面后期长出来的那些外围规则:
如果这些规则散在页面、表单、按钮、helper、store、query 之间,系统很快就会变得只剩“局部可解释”。
而 Resource Owner 的思路是:
这才是“复杂状态”真正被有效管理的样子。
这三个场景背后,其实是同一个判断:
也正因为如此,散装版更像“把问题一个个做掉”,而套装版更像“先把系统搭稳,再让问题进去”。
说到这里,也必须把代价讲清楚。
“套装版”并不意味着没有成本。
它的成本至少包括:
所以结论绝不是:
更准确的说法是:
前期节省的是抽象成本,后期付出的往往是边界成本。
也就是说,套装版并不是让你一开始写得最轻,而是让你在复杂度持续上升时,不至于越写越乱。
这也是它最现实的价值:
如果你只把状态管理理解成“选 Pinia 还是别的库”,那你看到的只是一层表面。
在大型前端项目里,更关键的问题通常是:
所以我更愿意把这件事总结成一句话:
“散装版”可以很快,“套装版”可以更稳。
前者依赖团队持续自觉,后者依赖结构先把边界收住。
而 Zova/Cabloy 真正有价值的地方,不在于它提供了某个单点能力,而在于它把那些原本最容易分散的问题——所有权、共享、缓存、持久化、SSR、资源语义——放进了一套更一致的系统里。
这才是它在大型 Vue 项目里最值得关注的地方。
说到底,状态管理真正决定上限的,不是你加了多少工具,而是:
这也是为什么“散装版 vs 套装版”看起来像是一个标题比喻,本质上讨论的却是大型前端工程最核心的一件事:
如果要把整篇文章再压成一句最短的结论,那就是:
如果你想继续顺着这篇文章往下看,最值得读的公开资料是:
ctx / app / sys / host 的边界data / mem / local / cookie / db 这些状态家族如果你正在做的正是一个越来越复杂的 Vue 项目,那么真正值得问的问题也许已经不是:
而是: