阅读视图

发现新文章,点击刷新页面。

从Vue到Bevy与Nestjs:我为 Vue 构建了一套“无头”业务引擎

不知道大家是否见过那种动辄几千行、逻辑像乱麻一样缠绕的 .vue 文件?

笔者在许多开源项目和企业级项目里都见过类似的现象:各种 watch 互相套娃、生命周期里塞满异步逻辑、父子组件传值传到怀疑人生。当项目进入中后期,Vue 的响应式系统仿佛从‘利器’变成了‘诅咒’,每一行代码的改动都像是在玩扫雷。

这种“面条代码”的泛滥让我开始反思:当下的前端开发范式,真的能支撑起当今逻辑爆炸的复杂应用吗?


起初,我以为这种混乱只是人为因素——觉得只要通过规范的 Code Review、靠着开发者的自觉,就能压制住代码的腐烂。但随着项目规模的膨胀,我推翻了自己的想法。我发现 Vue 的 API 仿佛自带一种传染性

只要你的业务代码中还直接调用着 refwatchonMounted这些Vue最核心的功能,业务逻辑就不可避免地会向 UI 框架低头成为UI的附庸。今天为了省事顺手写下的每一个 watchcomputed,都是为未来的“谁改了我的变量”埋下伏笔。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)来做这件事。让他负责充当ViridVue之间的沟通人。他将会全权接管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()ControllerComponent之间的只读桥梁。那么@Inherit()就是ControllerController之间的只读桥梁。@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,但它一定适合那些让你夜不能寐的复杂系统。

项目地址:github.com/ArisuYuki/v…

❌