Vue 3 的响应式依然是底座;Zova 希望解决的,是复杂业务中状态、行为、依赖与生命周期如何拥有清晰归属的问题。
Vue 3 很成功。它足够轻、足够灵活,响应式也足够优雅;React、Angular 也都在持续演进。于是很多开发者会问:Vue 未来还会凭什么保持竞争力?
我认为,一个值得关注的方向并不只是继续讨论"状态管理库该选哪个",而是回到一个更基础的问题:
当一个真实业务逐渐复杂时,我们究竟应该如何组织状态、行为,以及它们之间的关系?
在大量 Vue 3 项目中,我反复看到一种熟悉的写法:
ts
const {
list,
loading,
fetchList,
createItem,
removeItem,
} = useTodo();
刚开始看,这很简洁。但页面大一点、业务复杂一点,问题就会慢慢浮现:
- 一个组件同时使用多个 composable,解构变量越堆越多;
list、loading、submit、reset来自哪里,要不停上下翻找;- 变量命名开始互相冲突,只能加前缀:
userList、orderList、todoList; - 为了避免解构后丢失响应式,又要记住
toRefs、storeToRefs、computed等规则; - Pinia、
provide/inject、composable、组件局部ref/reactive各自解决一部分问题,但开发者需要自行拼出整体架构。
这并不是说解构语法不能用,更不是说 Vue 3 的组合式 API 有问题。恰恰相反,它非常强大。
真正的问题是:当业务复杂度上升时,代码容易从"按对象协作"滑向"按变量拼装"。
而变量一旦散开,状态的归属、行为的边界、生命周期和依赖关系,也就容易跟着散开。
这正是 Cabloy/Zova 想解决的事情。
Vue 的响应式很好,但业务代码不该只剩一地变量
先澄清一个容易引发争论的观点:
Vue 并不是一个"必须面向对象"的框架;Vue 3 的组合式 API 也不是错误的架构方式。
Vue 3 给我们的核心能力,是一套出色的响应式系统。你可以用函数组织代码,也可以用对象组织代码;可以使用 ref、reactive,也可以把它们封装在 composable、store 或其他抽象中。
问题不在于"函数式还是面向对象"谁更先进,而在于:
你的业务状态和业务行为,是否有清晰、稳定、可理解的归属?
例如,一个待办事项页面通常有:
- 列表查询、加载状态、错误状态;
- 新建、修改、删除等操作;
- 查询缓存与操作后的刷新策略;
- 页面输入状态;
- 页面渲染逻辑;
- 可能还有权限、路由参数、跨页面共享状态。
如果这些内容只是不断从各个 composable 中解构出来,最终页面很容易变成这样:
ts
const { todos, loading, refresh } = useTodos();
const { createTodo, creating } = useCreateTodo();
const { removeTodo, removing } = useRemoveTodo();
const { keyword, resetKeyword } = useSearch();
const { currentUser } = useAuth();
每一行单独看都没问题;但放在一起时,页面真正的结构已经不明显了。
- 谁拥有待办列表?
- 创建成功后谁负责刷新列表?
loading是查询中的加载,还是创建中的加载?- 哪些状态应当跨页面保留,哪些只属于当前页面?
- 如果另一个页面也需要待办列表,是复制逻辑、抽公共 composable,还是建一个 store?
这些问题不是语法问题,而是状态与行为的架构问题。
换一个视角:让状态和行为重新回到对象中
Zova 并不试图替换 Vue 3 的响应式能力。
它做的是另一件事:
在 Vue 3 响应式之上,引入 Controller、Bean、Model 与依赖注入,让状态、行为、生命周期和依赖关系重新以"对象协作"的方式组织起来。
可以把它概括为三个核心特性:
Vue 3 响应式 + TSX 渲染 + Angular 风格依赖注入
注意,这不是把 Angular 或 React 简单搬进 Vue,而是让三者各自擅长的部分结合起来:
| 能力 | Zova 的选择 |
|---|---|
| 响应式底座 | Vue 3 |
| 渲染表达 | TSX |
| 对象组织与依赖协作 | IoC / Dependency Injection |
| 页面逻辑承载 | Controller |
| 可复用业务状态 | Model |
| 业务能力拆分 | Service / Bean |
这样,开发者不需要先问"我要把这段逻辑写进哪个 composable",而可以先问一个更自然的问题:
这份状态和行为,到底属于谁?
一个页面,就是一个有职责的对象
假设我们做一个最简单的计数器页面。
在 Zova 中,可以这样写:
tsx
@Controller()
export class ControllerPageCounter extends BeanControllerPageBase {
count = 0;
get countText() {
return `当前计数:${this.count}`;
}
increment() {
this.count++;
}
protected render() {
return (
<div>
<div>{this.countText}</div>
<button onClick={() => this.increment()}>
加 1
</button>
</div>
);
}
}
这段代码的关键不在于"用了 class",而在于它非常直接地表达了业务含义:
count是这个页面拥有的状态;increment()是这个页面提供的行为;countText是从状态推导出来的展示数据;render()描述页面如何呈现;- 所有内容都围绕同一个对象展开。
你不需要先创建 ref(0),不需要再返回一组值,也不需要在使用处解构:
ts
const { count, increment } = useCounter();
而是直接使用:
ts
this.count
this.increment()
这是一种很朴素、却很重要的差异:状态不再漂浮在函数返回值中,而是明确地属于一个对象。
当然,count 能响应式更新,并不是因为"类字段天然响应式"。
真实机制是:Zova 会把 Controller 作为容器管理的 Bean 创建,并在底层接入 Vue 的响应式能力。因此,当 this.count++ 执行后,依赖它的渲染会像普通 Vue 响应式代码一样更新。
换句话说:
面向对象负责组织代码,Vue 3 负责让对象中的状态具备响应式。
不要到处解构,让依赖关系保留在代码里
很多复杂页面的真正难点,不在 UI,而在多个业务能力之间的协作。
例如,待办页面需要一个"待办数据模型"。在传统 Vue 项目里,它可能是:
- 一个 composable;
- 一个 Pinia store;
- 一组 API 函数加若干
ref; - 或者以上几种方式的混合。
在 Zova 中,可以把它明确为一个 Model:
ts
@Model()
export class ModelTodo extends BeanModelBase {
findAll() {
return this.$useStateData({
queryKey: ['list'],
queryFn: async () => {
return await this.scope.api.todo.findAll();
},
});
}
create() {
return this.$useMutationData({
mutationKey: ['create'],
mutationFn: async body => {
return await this.scope.api.todo.create(body);
},
onSuccess: () => {
this.$invalidateQueries({ queryKey: ['list'] });
},
});
}
}
这个 ModelTodo 不只是"请求 API 的工具类"。
它拥有一组完整而内聚的职责:
- 待办事项查询;
- 查询缓存的身份;
- 创建操作;
- 创建过程的状态;
- 创建成功后的列表失效与刷新策略。
然后,在页面 Controller 中注入它:
tsx
@Controller()
export class ControllerPageTodo extends BeanControllerPageBase {
@Use()
$$modelTodo: ModelTodo;
get queryTodos() {
return this.$$modelTodo.findAll();
}
async addTodo() {
await this.$$modelTodo.create().mutateAsync({
title: '学习 Zova',
});
}
protected render() {
const todos = this.queryTodos.data ?? [];
return (
<div>
<button onClick={() => this.addTodo()}>
新建待办
</button>
<ul>
{todos.map(item => (
<li key={item.id}>{item.title}</li>
))}
</ul>
</div>
);
}
}
这里有一个很重要的阅读体验:
ts
this.$$modelTodo.findAll()
this.$$modelTodo.create()
你一眼就能知道:
- 这些能力来自
ModelTodo; - 页面依赖待办模型;
- 查询与写入逻辑没有散落在页面各处;
- 后续想查看缓存、请求、失效策略时,有明确的对象可以进入。
这就是依赖注入带来的价值:它不仅仅是"少写一个 import",而是让对象之间的依赖关系变得显式、稳定、可追踪。
Pinia、provide/inject、composable:不是三套"状态管理",而是同一个问题的不同碎片
Vue 社区里常见的状态组织方式包括:
- 组件内部的
ref/reactive; - composable;
- Pinia;
provide/inject;- 各类数据请求与缓存库。
它们都很有价值,也都适合特定场景。
但从初学者的角度看,它们容易形成一个困惑:
我到底该在什么时候用 composable,什么时候用 Pinia,什么时候 provide/inject,什么时候又该把状态放在组件里?
Zova 提供的思路是:先不急着从工具名称出发,而是先从状态归属与生命周期出发。
| 问题 | 更适合的归属 |
|---|---|
| 只在当前页面短暂存在的输入、开关、交互状态 | Page Controller |
| 可复用 UI 单元的状态与行为 | Component Controller |
| 跨多个页面复用的业务数据、查询、缓存、持久化状态 | Model |
| 可复用的业务能力或基础能力 | Service Bean |
| 某个组件树中的局部协作对象 | ctx 作用域 Bean |
| 应用或 SSR 请求范围内的对象 | app 作用域 Bean |
| 系统级、长生命周期对象 | sys 作用域 Bean |
于是,原来分散在多个生态工具中的概念,可以逐渐被统一到一个心智模型中:
状态不是先决定放进哪个库,而是先决定它属于哪个对象、活多久、被谁依赖。
这会让架构讨论从"技术选型"回到"业务建模"。
"对象化"不等于写 Java 式前端
看到 class、@Controller()、@Model()、@Use(),有些开发者会立刻担心:
这会不会变成过度设计?
会不会写成传统 Java 项目那种层层包装?
答案取决于我们怎么使用它。
Zova 并不要求一开始就拆出很多类。一个小页面完全可以只有一个 Controller:
tsx
@Controller()
export class ControllerPageProfile extends BeanControllerPageBase {
nickname = '';
save() {
// 保存昵称
}
protected render() {
return (
<input
value={this.nickname}
onInput={event => {
this.nickname = event.target.value;
}}
/>
);
}
}
只有当职责真的开始增长时,再逐步拆分:
- 页面变复杂:拆出 Render Bean;
- 样式变复杂:拆出 Style Bean;
- 业务能力可复用:拆出 Service Bean;
- 数据需要缓存、复用、持久化或 SSR 协调:拆出 Model。
这不是为了"面向对象而面向对象",而是让代码随着业务自然生长。
一个好的架构不应该要求开发者一开始就设计出一棵完美的类图;它应该允许开发者从简单开始,并在复杂度真正出现时,有一条清晰的演进路径。
TSX 让"渲染"和"行为"靠得更近
Zova 使用 TSX 作为主要渲染表达方式。
这并不意味着模板语法不好。Vue 模板对简单页面非常友好,也有很成熟的生态。
但在业务 UI 逐渐复杂时,TSX 有一个很实际的优势:渲染逻辑与 TypeScript 逻辑使用同一种语言表达。
例如条件、循环、局部变量、事件处理、组件组合,都可以自然地写在同一段代码中:
tsx
protected render() {
const query = this.$$modelTodo.findAll();
if (query.isPending) {
return <div>加载中......</div>;
}
if (query.isError) {
return <div>加载失败,请稍后重试。</div>;
}
return (
<ul>
{query.data?.map(todo => (
<li key={todo.id}>
<span>{todo.title}</span>
<button onClick={() => this.removeTodo(todo.id)}>
删除
</button>
</li>
))}
</ul>
);
}
在这里:
- 数据从哪个 Model 来,清楚;
- 点击后调用哪个行为,清楚;
- 页面状态如何分支,清楚;
- TypeScript 类型如何流动,也清楚。
对于习惯了业务逻辑复杂、组件组合较多的开发者来说,这种"逻辑---状态---渲染"在同一对象中的连续性,会带来很强的掌控感。
Zova 不是否定 Vue,而是给 Vue 增加一套更完整的工程语言
如果只看表面,Zova 像是在 Vue 3 上增加了 class、装饰器、IoC 容器和 Model。
但更准确地说,它提供的是一套更统一的前端工程语言:
- 用 Controller 表达页面或组件的交互职责;
- 用 Bean 表达可组合、可注入的能力;
- 用 Model 表达有生命周期、有缓存、有失效策略的数据状态;
- 用 IoC 表达对象创建、作用域与依赖关系;
- 用 Vue 3 响应式 保持状态变化与 UI 更新的自然联动;
- 用 TSX 让渲染与逻辑在同一种语言中协作。
所以,Zova 并不是在说:
"Vue 3 的组合式 API 不够好。"
它更像是在说:
"当项目从一个组件走向一套业务系统时,我们需要的不只是组合函数,还需要清晰的对象边界、依赖关系和状态归属。"
写在最后:从"怎么管理状态"到"状态属于谁"
前端开发这些年一直在讨论状态管理。
但很多时候,我们问错了问题。
我们经常问:
- 用 Pinia 还是 composable?
- 全局状态放哪里?
- 怎么避免 props drilling?
- 怎样让数据请求自动缓存?
这些问题当然重要,但它们背后其实是同一个更根本的问题:
这份状态属于谁?
谁应该修改它?
谁依赖它?
它应该活多久?
当它变化时,谁负责维护一致性?
当这些问题有了明确答案,技术工具的选择往往就不再困难。
这也是 Cabloy/Zova 想带来的体验:
不要让业务代码变成一堆被解构、被搬运、被拼装的变量。
让状态回到对象,让行为回到职责,让依赖回到结构。
如果你已经熟悉 Vue 3,不妨尝试用 Zova 写一个小业务页面:一个 Controller、一个 Model、几段 TSX。你可能会发现,Vue 3 的响应式依然熟悉,但组织复杂业务时,代码突然不再像"麻花"了。