不知道大家是否见过那种动辄几千行、逻辑像乱麻一样缠绕的 .vue 文件?
笔者在许多开源项目和企业级项目里都见过类似的现象:各种 watch 互相套娃、生命周期里塞满异步逻辑、父子组件传值传到怀疑人生。当项目进入中后期,Vue 的响应式系统仿佛从‘利器’变成了‘诅咒’,每一行代码的改动都像是在玩扫雷。
这种“面条代码”的泛滥让我开始反思:当下的前端开发范式,真的能支撑起当今逻辑爆炸的复杂应用吗?
起初,我以为这种混乱只是人为因素——觉得只要通过规范的 Code Review、靠着开发者的自觉,就能压制住代码的腐烂。但随着项目规模的膨胀,我推翻了自己的想法。我发现 Vue 的 API 仿佛自带一种传染性。
只要你的业务代码中还直接调用着 ref、watch、onMounted这些Vue最核心的功能,业务逻辑就不可避免地会向 UI 框架低头成为UI的附庸。今天为了省事顺手写下的每一个 watch和computed,都是为未来的“谁改了我的变量”埋下伏笔。Vue的这种‘响应式链路’在项目初期极度丝滑,但在项目后期就是噩梦的开始。
直到最后,我发现一个几乎无法避开的实事:只要 UI 框架还掌握着状态的‘修改权’,业务代码就几乎注定会退化成面条。 于是我开始意识到,我必须从物理层面给 Vue 的权力‘断供’。这便是我设计 Virid 的初衷:我要的不是更优雅地写 Vue 代码的方法,而是一套根本不属于 Vue 的全新世界。”
在这样的理念的推动下,我产生了一个极其激进的想法:**让逻辑彻底从 UI 中剥离,构建一套完全"无头"(Headless)的业务引擎。**当我将目光投向 Rust 的 Bevy ECS 架构 和 NestJS 的 IoC 依赖注入时,我发现了我自己的答案。
Bevy 是 Rust 圈子里最硬核的开源项目之一,它的 ECS 系统美得像艺术品。但可惜它为游戏而生,天然自带高频 Tick,直接挪到前端开发中会显得格格不入。NestJS 是 JS 领域里依赖注入最成熟的实践。我一直在思考,如果能用 NestJS 的手感去写一套 Bevy 式的解耦逻辑,会发生什么?@Virid/core 就是这个思考的答案。它剔除了多余的资源损耗,保留了最核心的架构美感。
站在巨人的肩膀上,我为前端量身定制了一套“带帧双缓冲与优先级调度的消息中心”。
它绝非简单的 Event Bus 或 Pub/Sub 模式所能比拟。它本质上是一个融合了 NestJS 依赖注入与Bevy调度核心的精密系统。通过帧双缓冲机制,它彻底消除了前端逻辑中常见的"竞态条件"与"状态踩踏";配合优先级调度,它确保了每一条业务指令都能在最合适的时间节拍里执行。
要使用@Virid/core,只需要简单的三步走。首先派生一个自己的消息。他可以携带任何你想要发送的数据。
// 初始化核心引擎
const app = createVirid();
// 派生一个自己的消息
class IncrementMessage extends SingleMessage {
constructor(public amount: number) {
super();
}
}
接着,定义自己的Component并注册他,这是“数据中心”,他只负责存储数据,除此之外没有任何逻辑。
@Component()
class CounterComponent {
public count = 0;
}
// 注册这个数据组件
app.bindComponent(CounterComponent);
最后,编写一个自己的system。他是纯静态的、不需要任何注册与调用,只需要编写他需要的参数。@Virid/core将会自己发现并在合适的时候调用它。
//定义系统
class CounterSystem {
//默认优先级
//无需任何操作,只要定义好后@Virid/core将会自动将system与对应的消息类型挂钩
//当接收到对应的消息之后,@Virid/core将会注入所有需要的参数,自动执行整个system
@System()
static onIncrement(
@Message(IncrementMessage) message: IncrementMessage,
count: CounterComponent,
) {
console.log("---------------------onIncrement----------------------");
console.log("message :>> ", message);
count.count += message.amount;
}
//设置一个很高的优先级
@System(100)
static onIncrementPriority(
@Message(IncrementMessage) message: IncrementMessage,
count: CounterComponent,
) {
console.log(
"---------------------onIncrementPriority----------------------",
);
console.log("message :>> ", message);
count.count += message.amount;
}
}
在任何地方,只要发送消息,onIncrement将会被自动调用。而且由于帧双缓冲机制,其天然自带防抖功能。
IncrementMessage.send(1);//这个消息将会被合并(如果使用EventMessage派生则不会被合并)
IncrementMessage.send(5);
//只需要发送上面的消息,CounterComponent将会被自动注入onIncrementPriority与onIncrement的调用中
//因为优先级的存在,控制台会先后显示onIncrementPriority与onIncrement的执行流程
//---------------------onIncrementPriority----------------------
//message :>> IncrementMessage {
// amount: 5
//}
//---------------------onIncrement----------------------
//message :>> IncrementMessage {
// amount: 5
//}
通过这种方式,业务逻辑、UI、数据三者能够彻底解耦,我们将不会再需要Vue做任何事情来介入业务,只要触发一个合适的信号,所有的系统将会自动合适的调用,并且调度系统将会严格保证执行的先后顺序。通过这样的设计,配合几个生命周期钩子。可以轻而易举的实现undo/redo与消息跟踪功能,这是在普通的Vue中难以做到的事。
由于 System 和 Component 都是纯粹的逻辑和数据,你可以在完全不启动浏览器、不渲染 Vue 组件的情况下,对业务逻辑进行 100% 的单元测试。
解决了业务逻辑放和数据在哪儿的问题,剩下的就是解决与Vue之间的黏合问题。如何利用Vue的响应式和各种API,优雅的让我们的核心数据投影到UI上?在这个过程中,我创造了@virid/vue和大量的核心概念。
要控制Vue,我们需要一个“代理人”(Controller)来做这件事。让他负责充当Virid与Vue之间的沟通人。他将会全权接管Vue的所有操作,并统一转发给System。于是,Vue文件中将会只剩下一行script代码(以一个音乐列表的播放为例)。
<template>
<div>
<div>This is a playlist page with many songs</div>
<div v-for="(item, index) in plct.playlist" :key="item.id">
<Song :index="index"></Song>
</div>
</div>
</template>
<script setup lang="ts">
import Song from "./Song.vue";
import { useController } from "@virid/vue";
import { PlaylistController } from "@/logic/controllers/playListController";
const plct = useController(PlaylistController, { id: "playlist" });
</script>
<style lang="scss" scoped></style>
在普通的Vue中,业务逻辑与UI逻辑往往掺杂在一起,但是在Virid的核心调度之下我们拥有了一个全新的选择:让Vue永远只负责UI的显示与绘制,将业务逻辑转交给@Virid/core。
为了兼容响应式,我引入了响应式装饰器@Responsive(),只要给任何变量打上这个装饰器,当我们访问的时候,其将会被Virid自动转换成Vue的响应式变量。这意味着我们可以直接告诉Virid,那些变量是需要响应式的。
@Component()
export class PlaylistComponent {
// 当前正在播放的歌,第一次访问时将会被Virid转化为响应式变量
@Responsive()
public currentSong: Song = null!
// 歌单列表,第一次访问时将会被Virid转化为响应式变量
@Responsive()
public playlist: Song[] = []
}
@Project()是一个非常强大的“桥梁”。使得Controller能够直接访问任何Component上的属性,同时将其转化为只读的。这意味着一个Controller能够任意观察Component中的数据,从而更新Vue组件,同时只读保证了Component数据的安全。
@Listener()装饰器用于“偷听”一个消息,但是与System不同的是,其只能偷听一种派生自ControllerMessage类型的消息,并且无法享受依赖注入的功能,这意味着一个Controller不能直接更改Component。
@OnHook('onSetup')装饰器告诉Virid,需要在Vue的什么生命周期自动调用下面这个方法。Virid将会在合适的时机自动调用被修饰的方法。
@Watch()是一个在Vue原版上,融合了Virid特点的更强大的功能,其不仅能够检测Controller自身响应式变量的变化。还能够监测任意一个Component上的变量。但是,因为**@Watch()**中只能更改Controller自身的变量,因此其仍然无法修改任何Component。
export class SongControllerMessage extends ControllerMessage {
//到底是哪一首歌发来的消息?索引
constructor(public readonly index: number) {
super()
}
}
@Controller()
export class PlaylistController {
//告诉Virid自动将playlist变为响应式的
@Responsive()
public playlist!: Song[]
//创建一个投影,从component中映射数据
@Project(PlaylistComponent, (i) => i.currentSong)
public currentSong!: Song
@Listener(SongControllerMessage)
onMessage(@Message(SongControllerMessage) message: SongControllerMessage) {
console.log('song', this.playlist[message.index])
//可以做一些操作统一拦截,或者直接调用播放器
PlaySongMesage.send(this.playlist[message.index])
}
@OnHook('onSetup')
async getPlaylist() {
//在这里可以获取数据,例如从服务器获取数据,这里模拟一下
await new Promise((resolve) => setTimeout(resolve, 1000))
this.playlist = [
new Song('歌曲1', '1'),
new Song('歌曲2', '2'),
new Song('歌曲3', '3'),
new Song('歌曲4', '4'),
new Song('歌曲5', '5'),
new Song('歌曲6', '6'),
new Song('歌曲7', '7')
]
}
//观测当前歌曲,如果变了就触发某些操作
@Watch(PlaylistComponent, (i) => i.currentSong, {})
watchCurrentSong() {
console.log('监听到当前歌曲改变PlaylistComponent:', this.currentSong)
}
}
对于每一首歌,我们同样需要创建一个对应的Controller来充当我们和Virid的代理人,但是与此同时,每一个Song组件也需要和父Playlist组件通讯。因此我创建了一些更强大工具。
在.Vue文件中,我们传递了这样的变量,但是!**我们只传递了Song组件的索引,并没有传递item本身。**因此,我们需要某种方式获得index,并且还要能够访问到父组件的属性。
<div v-for="(item, index) in plct.playlist" :key="item.id">
<Song :index="index"></Song>
</div>
@Env()是一个用于标记的标记装饰器。当你在子组件的Controller中标记一个属性为 @Env()时,Virid将会负责将其安装到这个属性上,这意味着你不需要自己定义props,按需声明取用即可。
@Inherit()是一个类似@Project()的工具,如果说@Project()是Controller与Component之间的只读桥梁。那么@Inherit()就是Controller与Controller之间的只读桥梁。@Inherit 彻底终结了前端组件通信中冗长的 Emit/Props 链路。它建立了一个虫洞,让子组件可以直接观察到远方父组件的状态的同时,无法对父组件产生任何副作用污染。
通过@Inherit()你可以从任意组件内“继承”任意Controller的状态,同时,他也是只读的,这保证了一个Controller永远无法偷偷修改另一个Controller中数据的权利,当另一个Controller因为组件卸载而销毁的时候,这样的连接将会自动断开,类似于一种WeakRef。
通过@Inherit()和@Project(),我们可以实现非常强大的功能,不需要父组件给我们提供任何数据,Song将会自己知道哪个数据才是自己应该得到的。
@Controller()
export class SongController {
@Env()
public index!: number
@OnHook('onSetup')
public onSetup() {
console.log('我的索引是:', this.index)
}
@Inherit(PlaylistController, 'playlist', (i) => i.playlist)
public playlist!: Song[]
@Project<SongController>((i) => i.playlist?.[i.index])
public song!: Song
playThisSong() {
//其实直接播放也行,但是这里我们模拟一下需要发送给父组件让父组件处理的情况
console.log('发送播放消息:', this.index)
SongControllerMessage.send(this.index)
}
}
最终,消息将在System中得到处理,从此整个Virid将得到完整的闭环。
//当Playlist调用 PlaySongMesage.send(this.playlist[message.index])时
//整个系统将被激活,从而更新正确的数据
@System()
static playThisSong(
@Message(PlaySongMesage) message: PlaySongMesage,
playlist: PlaylistComponent,
player: PlayerComponent
) {
//把这首歌添加到playlist里,如果没有的话
playlist.playlist.push(message.song)
//开始播放这首歌
playlist.currentSong = message.song
player.player.play(message.song)
//自动发送新消息,记录
return new IncreasePlayNumMessage()
}
Virid 不是为了消灭 Vue,而是为了解决业务逻辑被耦合在UI中的问题。它可能不适合所有的 Todo-list,但它一定适合那些让你夜不能寐的复杂系统。