前言

今年大半年的时间,我都在参与公司内部低代码平台的搭建,从 Schema 协议设计、渲染器实现,到拖拽设计器、出码方案,基本把低代码前端这块完整趟了一遍。低代码在这两年经历了从”概念炒作”到”务实落地”的转变,我们平台目前已经支撑了内部几十个运营后台和活动页。这篇文章把最核心的两块内容——Schema 驱动和拖拽设计器——的实现思路整理出来,附上可参考的代码骨架和踩过的坑,作为今年的技术总结之一。

1. 低代码兴起与适用场景

先泼一盆冷水:低代码不是银弹。它兴起的原因很朴素——企业里存在大量逻辑简单、重复度高、对个性化要求低的页面需求:运营活动页、表单问卷、后台 CRUD 列表页、数据看板。这类需求占比其实很高(我们统计过内部需求池,超过 60% 的前端需求属于此类),但每一个都要走”提需求 → 排期 → 开发 → 测试 → 上线”的完整流程,成本严重不成比例。

低代码的解法是把这类页面的生产从”写代码”降级为”配置搭建”,把前端从重复劳动中解放出来去写真正有价值的复杂业务。同时它也让运营、产品这些不写代码的角色具备了有限的自助搭建能力。

对应的,低代码的不适用场景也很明确:强交互、复杂状态、极致性能要求的核心业务(比如编辑器、游戏、复杂中台业务),以及 UI 高度定制的设计驱动型页面。给低代码平台定义清楚边界,比堆功能更重要。这也是我今年最大的经验教训之一。

2. 核心思想:Schema 驱动 UI

2.1 用 JSON 描述页面

低代码平台整个体系的基石是一个约定好的 Schema 协议:**页面 = 一棵组件树,每个节点描述”用什么组件 + 传什么 props + 子节点是什么”**。一个简单的页面 Schema 长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
{
"version": "1.0",
"pageName": "活动报名页",
"components": [
{
"id": "comp_banner",
"componentName": "Image",
"props": {
"src": "https://cdn.example.com/banner.jpg",
"mode": "widthFix"
}
},
{
"id": "comp_form",
"componentName": "Form",
"props": { "labelWidth": 90 },
"children": [
{
"id": "comp_name",
"componentName": "Input",
"props": {
"label": "姓名",
"placeholder": "请输入姓名",
"required": true,
"model": "formData.name"
}
},
{
"id": "comp_city",
"componentName": "Select",
"props": {
"label": "城市",
"options": [
{ "label": "北京", "value": "bj" },
{ "label": "上海", "value": "sh" }
],
"model": "formData.city"
}
}
]
},
{
"id": "comp_submit",
"componentName": "Button",
"props": { "type": "primary", "text": "立即报名" },
"events": { "onClick": { "actionType": "submitForm", "api": "/api/signup" } }
}
]
}

设计 Schema 协议时我给自己定了三条原则:

  1. 协议先于实现。Schema 是平台资产,渲染器、设计器、出码器都是它的消费者。先把协议的字段、版本、扩展机制定义清楚,后面所有能力都长在这棵树上。
  2. 节点携带稳定 id。设计器里选中组件、属性回填、事件绑定全靠 id 定位,没有 id 的 Schema 是不可编辑的。
  3. 留好逃生舱。一定会有协议覆盖不了的需求,所以要留自定义组件、自定义代码块(事件里的 actionType 之外允许挂 JS 片段)这类逃生通道,否则平台会被”这个需求实现不了”耗死口碑。

2.2 渲染器:一个递归函数就够了

Schema 渲染器的实现思路简单到出乎意料:维护一个组件注册表,把组件名映射到真实组件,然后对 Schema 树做递归渲染。Vue 版核心代码不到 50 行:

1
2
3
4
5
6
7
8
9
// registry.js:物料注册表,物料包安装时注册进来
import { Image, Form, Input, Select, Button } from './materials';

export const registry = new Map();
registry.set('Image', Image);
registry.set('Form', Form);
registry.set('Input', Input);
registry.set('Select', Select);
registry.set('Button', Button);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<!-- Renderer.vue -->
<template>
<component :is="resolved" v-bind="node.props" v-on="boundEvents">
<!-- 有子节点就递归渲染自己 -->
<template v-if="node.children && node.children.length">
<Renderer v-for="child in node.children" :key="child.id" :node="child" />
</template>
</component>
</template>

<script>
export default {
name: 'Renderer',
props: { node: { type: Object, required: true } },
computed: {
resolved() {
const comp = registry.get(this.node.componentName);
if (!comp) {
console.warn(`物料未注册:${this.node.componentName}`);
return 'div'; // 兜底,避免整页崩掉
}
return comp;
},
boundEvents() {
// 把 Schema 里的 events 映射成真实事件回调
const handlers = {};
const events = this.node.events || {};
for (const [name, action] of Object.entries(events)) {
handlers[name] = (...args) => this.$emit('dispatch', action, args, this.node);
}
return handlers;
},
},
};
</script>

递归渲染器之外,还需要一套动作系统处理事件:Schema 里的 onClick: { actionType: 'submitForm' } 被派发到统一的 dispatcher,由它执行提交表单、跳转链接、打开弹窗、发送请求等预置动作。逻辑尽量收敛在动作协议里,实在复杂才允许逃生舱里的自定义代码。运行时渲染器加上动作系统,就是低代码平台的”内核”,我们平台的移动端和 PC 端渲染器共用同一套 Schema,只是物料实现不同。

3. 拖拽设计器:三区架构

低代码设计器的三区布局

3.1 物料面板、画布、属性面板

设计器(编辑器)是低代码平台体验的核心,业界成熟方案基本都收敛在”三区布局”:

  • 左:物料面板。按分类展示可拖入的组件(布局、表单、展示、媒体),本质是物料元信息的可拖拽列表;
  • 中:画布。渲染 Schema 的预览态,叠加选中、悬停、拖入位置提示等编辑态交互,内部跑的就是第 2 节的渲染器(加了编辑态开关);
  • 右:属性面板。根据选中组件的物料元信息(propsSchema)动态渲染出配置表单,改的是当前选中节点的 Schema。

三个区共享一个中心状态:当前页面的 Schema 树 + 当前选中的组件 id。所有交互最终都归结为对这棵树的操作(插入节点、移动节点、修改节点 props),这就是为什么 Schema 协议设计得好,设计器就好做。

3.2 HTML5 Drag & Drop API 实现拖拽

物料拖入画布,我选择原生 HTML5 Drag & Drop 实现(不依赖 dnd 库,可控性强)。完整链路分三段:拖起时携带组件标识 → 拖动时计算落点 → 落下时插入 Schema。

第一段,物料面板的每个组件设置 draggable,并在 dragstart 里把组件名放进 dataTransfer:

1
2
3
4
5
6
7
// 物料面板:开始拖拽时,声明要拖的组件类型
materialEl.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('application/x-material', 'Input');
e.dataTransfer.effectAllowed = 'copy';
// 只能挂 dataTransfer 能传的信息,复杂状态走全局 store
dragStore.setDragging({ componentName: 'Input', from: 'material' });
});

第二段,画布在 dragover 里阻止默认行为(不阻止则浏览器不允许 drop),并根据鼠标位置计算”应该插入到哪个节点的哪个位置”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 画布:拖动经过时计算落点
canvasEl.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
const target = findDropTarget(e.clientX, e.clientY);
if (!target) return;
// 计算:插入到 target 的 children 里,位于第几位
const index = calcInsertIndex(target.node, e.clientY);
dropHintStore.setHint({ parentId: target.node.id, index });
});

function calcInsertIndex(node, clientY) {
const rows = node.children.map((child) => child.el.getBoundingClientRect());
let index = rows.length;
for (let i = 0; i < rows.length; i++) {
const rect = rows[i];
// 鼠标落在某个子元素中线以上,就插到它前面
if (clientY < rect.top + rect.height / 2) {
index = i;
break;
}
}
return index;
}

第三段,drop 时把新组件节点插入 Schema 树:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
canvasEl.addEventListener('drop', (e) => {
e.preventDefault();
const { componentName } = dragStore.getDragging();
const hint = dropHintStore.getHint();
// 从物料元信息生成新节点:默认 props 来自物料的 defaults
const meta = materialMeta[componentName];
const newNode = {
id: genId(),
componentName,
props: { ...meta.defaults },
children: meta.container ? [] : undefined,
};
schemaStore.insertNode(hint.parentId, hint.index, newNode);
dropHintStore.clear();
});

画布内已有组件之间互相拖动换位(重排)复用同一套机制,只是 dragstart 来源是画布节点而不是物料面板,drop 时执行”删除旧位置 + 插入新位置”。

3.3 选中态与 outline 计算的坑

编辑态交互里,”选中组件的高亮框”看起来简单,坑却很多。最初我直接给每个组件容器加 outline: 1px solid,很快遇到三个问题:

  1. 组件根节点可能是内联元素或没有高度的空节点,outline 看不见或者形状诡异。解法是设计器约定”每个物料根节点必须是块级元素”,并在物料规范里作为 lint 规则检查;
  2. 高亮框遮挡子组件的 hover 和点击,导致嵌套组件只能选到外层。解法是把选中框从”组件自己的样式”改成独立覆盖层(overlay):一个绝对定位的图层浮在画布上方、pointer-events: none,选中节点变化时读取目标节点的 getBoundingClientRect(),把框画在覆盖层上。这样高亮永远不挡交互;
  3. 滚动和缩放导致覆盖层错位。getBoundingClientRect() 返回的是视口坐标,画布滚动、画布被缩放(我们支持 50%~150% 缩放)后必须换算:overlayX = (rect.left - canvasRect.left) / zoom。更稳的做法是监听画布 scroll 事件、用上 ResizeObserver,在任何布局变化后重算一次覆盖层。

覆盖层方案最终长这样:

1
2
3
4
5
6
7
8
9
10
11
12
function syncOverlay() {
const node = schemaStore.findNode(selectedId);
if (!node?.el) return hideOverlay();
const canvasRect = canvasEl.getBoundingClientRect();
const rect = node.el.getBoundingClientRect();
const zoom = canvasStore.zoom;
overlayEl.style.display = 'block';
overlayEl.style.left = `${(rect.left - canvasRect.left) / zoom}px`;
overlayEl.style.top = `${(rect.top - canvasRect.top) / zoom}px`;
overlayEl.style.width = `${rect.width / zoom}px`;
overlayEl.style.height = `${rect.height / zoom}px`;
}

另外还有一个细节值得记一笔:drop 目标容器的判定。嵌套布局里鼠标可能悬停在多层容器上,document.elementFromPoint 拿到的永远是最深元素,需要沿着 DOM 向上找最近的可容器化节点(有 children 能力的组件),否则会出现”拖进错误层级”的问题。

4. Schema 与出码

低代码平台绕不开的灵魂拷问:**Schema 存的是”配置”,但业务方常要”真代码”**——要么为了二次开发,要么为了平台解绑。出码(code generation)就是从 Schema 生成可维护的源码。

4.1 出码思路:AST 而非字符串拼接

出码的正确姿势是”数据 → AST → 代码”,而不是模板字符串拼接。以 Vue 代码为例,思路是把 Schema 树映射成 Vue 的模板 AST:每个组件节点对应一个元素节点,props 对应属性绑定,事件对应 @click 处理器声明,最后用 @vue/compiler 系列或 prettier 美化输出。

核心流程伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function generateVueCode(schema) {
// 1. 收集用到的物料 → 决定 import 语句
const usedComponents = collectComponents(schema.components);

// 2. 递归生成模板 AST
const templateAST = {
type: 'element',
tag: 'template',
children: schema.components.map(toVueElement),
};
// toVueElement(node): {
// tag: mapMaterialToTag(node.componentName), // Input → el-input
// props: buildVBind(node.props), // :label="姓名"
// events: buildVOn(node.events), // @click="handleSubmit"
// children: node.children?.map(toVueElement)
// }

// 3. 收集所有 model 绑定 → 生成 data() 初始结构
// 4. 收集所有事件 action → 生成 methods 骨架
// 5. 打印为源码字符串
return prettier.format(print(templateAST), { parser: 'vue' });
}

生成出来的代码里,data、methods 都带好了骨架和注释,工程师拿过去接手就是普通 Vue 项目的开发体验。我们踩过最大的坑是缩进和空行控制:字符串拼接出来的代码格式混乱不可读,后来全部换成 AST + prettier 输出,可读性问题一次性解决。

4.2 出码与渲染器的关系:一体两面

架构上我建议把出码器理解为”渲染器的孪生兄弟”:两者消费同一份 Schema,一个在运行时解释执行,一个在构建时翻译成代码。它们必须共享同一套物料元信息(props 类型、事件定义),否则会出现”设计器里好好的,出码后表现不一致”的经典 bug。我们后来把 props 类型校验抽成了独立包,渲染器和出码器共用,不一致问题就消失了。

5. 写在 2022 年末:一点趋势展望

今年明显感觉到低代码的”配置驱动”思想正在外溢:Serverless 的编排、自动化工作流工具、甚至许多 SaaS 的规则引擎,本质都是 Schema 驱动。

AI 那边有个值得记下的新闻:上月底 OpenAI 放出了 ChatGPT,一句需求描述就能吐出像样的代码片段,我身边的前端群刷屏了三天。它离直接生成整页 Schema 还很远,但”自然语言 → 页面”这条路头一次有了能摸得到的进展。低代码解决的是”不写代码也能搭页面”,用户却还要学”拖拽 + 配置”这套心智;如果这条路走通,平台的交互入口可能会从画布变成对话框,届时用户跳过的不只是代码,还有这套学习成本——而 Schema 仍然是那个不变的中心资产。

写在最后

回顾整个低代码平台的实现,最核心的判断只有一个:Schema 协议是资产,渲染器、设计器、出码器都是它的消费者。把协议设计好,三件套的实现反而是水到渠成的事:渲染器是 50 行的递归,设计器是”一棵树 + 三块面板”,出码器是 AST 翻译。

踩坑最多的地方都在编辑态交互上:overlay 错位、elementFromPoint 的层级问题、拖拽落点的插入判定。这类问题没有现成轮子,值得多花时间打磨体验,因为设计器好不好用直接决定平台的口碑和存活。

明年的计划是把 Schema 的逻辑编排部分做深(可视化事件流),并小范围验证 AI 生成 Schema 的可行性。如果你也在做低代码平台,欢迎留言交流,这一年踩的坑能聊的还有很多。