阅读视图

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

从远程组件到极致性能:一次低代码架构的再思考

前段时间突然回顾了一下之前做过的一件事:上一份工作的核心任务之一,其实就是一个可视化 / 低代码平台。

当时受限于时间和复杂度,整体方案基本是基于 vue-sfc-playground 这一套思路,通过 iframe + 浏览器端编译的方式来实现远程组件的扩展能力。虽然最终把功能跑通了,但在真实使用过程中,这套方案逐渐暴露出不少问题。

比较典型的有:

  1. iframe 性能较差,通信成本高,调试也不友好
  2. 引入的模块必须支持 ESM,且兼容性受限
  3. 模块之间存在前置依赖,需要人工维护依赖关系
  4. 代码补全、Lint、插件能力受限,开发体验远不如本地 IDE
  5. 以及一系列零散但很消耗心智的问题

站在现在这个时间点回看,我觉得这个问题本身并不复杂,只是当时的实现方式并不优雅——它其实是有更好的解法的。

需求假设与目标拆解

为了方便后续讨论,我们先假定一个明确的需求:

低代码平台需要支持挂载任意自定义组件,用来组合实现业务功能,而不是只能使用平台内置的物料。

在这个前提下,我给自己定了几个明确的目标:

  • 开发阶段:本地 IDE 编写组件,修改后可以快速预览
  • 生产环境:极致的运行性能和尽可能小的包体积
  • 整体策略:放弃“纯在线编写组件”,全部基于本地开发来解决问题

原因也很现实:
在线写组件这条路,优化成本太高了,一旦引入复杂依赖、真实业务代码,维护难度会指数级上升。

开发阶段:追求极致的反馈速度

开发阶段整体的数据流和职责关系如下:

flowchart LR
  IDE[本地 IDE]
  IDE -->|文件变更| Rsbuild
  Rsbuild -->|HMR / WS| Renderer
  Renderer -->|defineAsyncComponent| VueRuntime

开发阶段的核心诉求其实很简单:

我在本地改代码,页面要立刻有反馈。

如果你看过 Vue 3 的官方文档,会发现它提供了一个非常关键的能力:defineAsyncComponent,用于异步加载组件。

import { defineAsyncComponent } from "vue";

const AsyncComp = defineAsyncComponent(() => {
  return new Promise((resolve, reject) => {
    // 从服务端获取组件
    resolve(/* 组件对象 */);
  });
});

推荐阅读: 给我 5 分钟,保证教会你在 Vue3 中动态加载远程组件 这篇文章可以帮助理解如何结合本地 Node 服务来挂载远程组件。

需要注意的一点是: resolve 返回的并不是字符串形式的源码,而是经过编译后的组件对象,本质上类似于 vue-loader 处理后的产物。

compiler-remote-comp

到这里,背景铺垫就结束了。

开发态整体架构设计

在开发阶段,我选择 Rsbuild 作为构建核心,整体思路大致如下:

  1. 维护一个模板工程(template) 用于提前约定好组件开发所需的基础配置。
  2. 编写 Rsbuild 插件
    • 插件会结合用户的 API KeyProject ID
    • 在本地开发阶段,通过 HMR / WebSocket 将组件的最新编译结果推送到平台面板
    • 在生产阶段,则负责将源码和构建产物上传到 OSS
  3. 渲染器(低代码核心)
    • 渲染器内部会固定一个 Vue 版本,作为所有组件的公共依赖
    • 接收由 Rsbuild 编译后的 JS 模块
    • 再通过 defineAsyncComponent 动态注入组件

约定与约束

除此之外,还需要提前定义一些结构和规范,例如:

  • global.css:用于声明全局样式
  • composes/:用于存放多个自定义组件

dev 模式启动后,插件会:

  • 开启 CORS
  • 向渲染器推送当前可用的组件列表及版本号
  • 文件变更后重新计算版本,并通知渲染器刷新

两个关键限制

这里有两个非常重要的点:

  1. 样式约束 在 Vue SFC 中禁止书写全局 style,一旦检测到直接报错,防止出现难以审查和回滚的样式污染。
  2. 依赖处理策略 在开发阶段,仅将 vue 本身 external 掉。 其他依赖(如 dayjs、UI 框架等)直接打进包里。

虽然这样会导致 JS 体积偏大,但这是开发态,为了效率和稳定性,这个代价是完全可以接受的。

为什么不用 vue3-sfc-loader

一个常见的问题是: 为什么不直接使用 vue3-sfc-loader 在浏览器端加载 SFC?

核心原因在于依赖管理。

vue3-sfc-loader 需要手动维护 moduleCache,而一旦涉及真实项目,就必然要引入大量第三方包。这就意味着你需要结合 importmap 来管理依赖关系和版本冲突。

例如:

<script type="importmap">
  {
    "imports": {
      "vue": "https://play.vuejs.org/vue.runtime.esm-browser.js",
      "vue/server-renderer": "https://play.vuejs.org/server-renderer.esm-browser.js"
    }
  }
</script>

这种方式在 Demo 场景下还可以接受,但在真实低代码平台中,维护成本会非常高,几乎不可控。

Build 阶段:为极致性能服务

flowchart TB
  subgraph Dev[开发阶段]
    IDE --> Rsbuild
    Rsbuild --> Renderer
    Renderer --> Browser
  end

  subgraph Prod[生产阶段]
    Config[JSON / DSL]
    Config --> Monorepo
    Monorepo --> Build
    Build --> OSS
    OSS --> Browser
  end

生产环境的目标只有一个:性能。

整体思路是结合 Monorepo,将低代码平台中的配置还原为一个真实可构建的工程。

工程还原策略

flowchart TB
  Root[monorepo]
  Root --> Composes[composes/* 组件包]
  Root --> Apps[apps/platform]
  Apps --> Pages[页面还原]

  • 每一个自定义组件,都被放置在 components/ 目录下,作为 Monorepo 的子包
  • apps/platform 中,根据平台生成的 JSON 配置:
    • 还原页面结构
    • 将对应的子包注册到 package.json 依赖中

随后直接执行 build

由于使用的是基于 Rust 的构建工具(如 Rsbuild / Rspack),即使是全量构建,耗时也基本控制在 30 秒以内

带来的收益

  • Tree Shaking:未使用代码会被自动移除
  • 更小的包体积
  • 更好的缓存命中率:结合分包策略,可最大化利用 HTTP 缓存

多页面与路由加载

对于支持多页面的低代码平台来说,结合这种拆分方式,每一个页面本质上只包含自己的业务代码。

const routes = [
  {
    path: "/remote-js",
    component: () => import("https://my-server.com/assets/RemoteComponent.js"),
  },
];

浏览器原生已经支持通过 import() 加载远程 ESM 模块,只要返回的是一个 Promise,Vue Router 就可以正常工作。

参考文档: Vue Router 路由懒加载

最后

这套方案更多是一次架构思路的延展,以及一些关键落地点的总结。

真正落地时,依然会有很多细节需要打磨,例如:

  • 沙箱与安全隔离
  • 组件版本管理
  • 发布与回滚策略

不过整体主线是清晰的:

开发态为体验让路,生产态为性能让路。

如果你对其中某些设计有不同的想法,或者有类似的实践经验,也欢迎交流一波。

❌