从远程组件到极致性能:一次低代码架构的再思考
前段时间突然回顾了一下之前做过的一件事:上一份工作的核心任务之一,其实就是一个可视化 / 低代码平台。
当时受限于时间和复杂度,整体方案基本是基于 vue-sfc-playground 这一套思路,通过 iframe + 浏览器端编译的方式来实现远程组件的扩展能力。虽然最终把功能跑通了,但在真实使用过程中,这套方案逐渐暴露出不少问题。
比较典型的有:
- iframe 性能较差,通信成本高,调试也不友好
- 引入的模块必须支持 ESM,且兼容性受限
- 模块之间存在前置依赖,需要人工维护依赖关系
- 代码补全、Lint、插件能力受限,开发体验远不如本地 IDE
- 以及一系列零散但很消耗心智的问题
站在现在这个时间点回看,我觉得这个问题本身并不复杂,只是当时的实现方式并不优雅——它其实是有更好的解法的。
需求假设与目标拆解
为了方便后续讨论,我们先假定一个明确的需求:
低代码平台需要支持挂载任意自定义组件,用来组合实现业务功能,而不是只能使用平台内置的物料。
在这个前提下,我给自己定了几个明确的目标:
- 开发阶段:本地 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 处理后的产物。
![]()
到这里,背景铺垫就结束了。
开发态整体架构设计
在开发阶段,我选择 Rsbuild 作为构建核心,整体思路大致如下:
- 维护一个模板工程(template) 用于提前约定好组件开发所需的基础配置。
-
编写 Rsbuild 插件
- 插件会结合用户的
API Key和Project ID - 在本地开发阶段,通过 HMR / WebSocket 将组件的最新编译结果推送到平台面板
- 在生产阶段,则负责将源码和构建产物上传到 OSS
- 插件会结合用户的
-
渲染器(低代码核心)
- 渲染器内部会固定一个 Vue 版本,作为所有组件的公共依赖
- 接收由 Rsbuild 编译后的 JS 模块
- 再通过
defineAsyncComponent动态注入组件
约定与约束
除此之外,还需要提前定义一些结构和规范,例如:
-
global.css:用于声明全局样式 -
composes/:用于存放多个自定义组件
在 dev 模式启动后,插件会:
- 开启 CORS
- 向渲染器推送当前可用的组件列表及版本号
- 文件变更后重新计算版本,并通知渲染器刷新
两个关键限制
这里有两个非常重要的点:
-
样式约束
在 Vue SFC 中禁止书写全局
style,一旦检测到直接报错,防止出现难以审查和回滚的样式污染。 -
依赖处理策略
在开发阶段,仅将
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 路由懒加载
最后
这套方案更多是一次架构思路的延展,以及一些关键落地点的总结。
真正落地时,依然会有很多细节需要打磨,例如:
- 沙箱与安全隔离
- 组件版本管理
- 发布与回滚策略
不过整体主线是清晰的:
开发态为体验让路,生产态为性能让路。
如果你对其中某些设计有不同的想法,或者有类似的实践经验,也欢迎交流一波。