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