引言
可视化页面搭建器是低代码平台里最「看得见」的部分:用户从左侧组件面板拖一个按钮到画布,在右侧属性面板改文案,再绑定一个点击事件,一个页面就成型了。看起来简单,但要做得稳定、可维护、能承载复杂页面,工程复杂度并不低。
难点集中在三处:拖拽的落点计算(用户想放哪里、结构上应该放哪里,两者经常不一致)、数据结构的选型(树结构直观但操作昂贵,扁平结构高效但要额外维护关系)、设计器与运行时的复用(如果设计器和运行时用两套渲染逻辑,就必然出现「设计器里好好的,发布后变形」)。
本文按「数据结构 → 拖拽 → 布局 → 属性 → 绑定 → 事件 → 历史 → 发布 → 复用 → 性能」的顺序展开,每一节给出具体的实现思路与代码片段。读完你应当能判断:一个搭建器的复杂度究竟藏在哪,以及哪些设计决策会在页面变大后反噬。
目录
- 页面搭建器的定位
- 组件树与扁平化
- 拖拽交互与落点计算
- 布局系统:自由与流式
- 属性面板与 Schema
- 数据绑定与作用域
- 事件与动作编排
- 撤销重做与历史栈
- 预览、发布与多端
- 设计器与运行时的复用
- 大页面的性能
1. 页面搭建器的定位
先分清搭建器与表单引擎的边界。
页面搭建器:
组织「多个组件的排列与组合」
关注布局、层级、位置、样式
产物是页面元数据
表单引擎:
组织「一组字段的输入与校验」
关注显隐、联动、校验、提交
产物是表单元数据
关系:
表单本身可以是页面里的一个组件
页面提供容器,表单提供交互
搭建器是「容器级」的抽象,表单引擎是「字段级」的抽象。两者共享同一套组件协议与元数据理念,但操作粒度不同。
2. 组件树与扁平化
页面结构有两种表示,各有取舍。
树结构(嵌套):
{ "id": "root", "type": "Page", "children": [
{ "id": "a", "type": "Card", "children": [ { "id": "b", "type": "Button" } ] } ] }
扁平结构(归一化):
{ "rootId": "root", "nodes": {
"root": { "id": "root", "type": "Page", "children": ["a"] },
"a": { "id": "a", "type": "Card", "parent": "root", "children": ["b"] },
"b": { "id": "b", "type": "Button", "parent": "a", "children": [] } } }
| 维度 | 树结构 | 扁平结构 |
|---|---|---|
| 可读性 | 高 | 低 |
| 查找节点 | O(n) | O(1) |
| 移动节点 | 需递归 | 改指针 |
| 序列化 | 直接 | 需还原 |
| 撤销重做 | 深拷贝 | 结构共享 |
推荐「存储用扁平、渲染时还原树」:编辑操作在扁平结构上做(快),渲染时按需还原(直观)。
2.1 结构共享与不可变更新
// 移动节点:只改父节点与两个相关节点,其余引用不变
function moveNode(state: PageState, nodeId: string, newParentId: string, index: number) {
const nodes = { ...state.nodes };
const node = nodes[nodeId];
const oldParent = nodes[node.parent!];
nodes[node.parent!] = { ...oldParent, children: oldParent.children.filter((c) => c !== nodeId) };
const newParent = nodes[newParentId];
const children = [...newParent.children];
children.splice(index, 0, nodeId);
nodes[newParentId] = { ...newParent, children };
nodes[nodeId] = { ...node, parent: newParentId };
return { ...state, nodes };
}
结构共享让每次编辑只产生少量新对象,配合 React 的引用比较,能精确控制重渲染范围。
3. 拖拽交互与落点计算
拖拽是搭建器最核心的交互,也是最容易做糙的地方。关键是把「落点」算准。
拖拽三阶段:
1. dragstart:记录被拖组件,设置拖拽态
2. dragover:实时计算落点,显示插入指示线
3. drop:把组件插入落点,提交历史
落点计算:
遍历画布中所有「可放置容器」的矩形
找到鼠标位置命中的最深容器
在容器内按子元素中点计算插入索引
function findDropTarget(point: Point, nodes: PageState['nodes']): DropTarget | null {
let target: DropTarget | null = null;
for (const node of Object.values(nodes)) {
if (!isDroppable(node)) continue;
const rect = getRect(node.id);
if (!rect) continue;
if (contains(rect, point)) {
// 取最深(面积最小)的容器
if (!target || area(rect) < area(target.rect)) {
target = { parentId: node.id, index: computeIndex(node, point), rect };
}
}
}
return target;
}
3.1 插入指示线
指示线是用户体验的关键:它告诉用户「松手后会插到这里」。指示线的位置由落点容器的布局方向决定:流式布局画横线或竖线,自由布局则显示包围框。
3.2 拖拽的常见失败模式
落点抖动需要去抖或优先级规则;拖入自身子节点会形成环,必须在 drop 前检测并拒绝;自由布局若无吸附,组件位置会零散。
4. 布局系统:自由与流式
两种布局决定了搭建器的手感与能力上限。
| 布局 | 定位方式 | 优点 | 缺点 | 适用 |
|---|---|---|---|---|
| 流式 | Flex/Grid | 响应式好、稳定 | 自由度低 | 后台、表单页 |
| 自由 | absolute | 精确控制 | 不响应式、易乱 | 大屏、海报 |
| 混合 | 容器流式、叶子自由 | 兼顾 | 实现复杂 | 复杂落地页 |
{
"id": "card_1", "type": "Card",
"layout": { "mode": "flow", "direction": "row", "gap": 16, "padding": 24 },
"children": ["btn_1", "btn_2"]
}
{
"id": "banner_1", "type": "Banner",
"layout": { "mode": "free", "grid": 8, "snap": true },
"children": [
{ "id": "t_1", "type": "Text", "position": { "x": 40, "y": 24, "w": 320, "h": 48 } }
]
}
自由布局必须存绝对坐标与尺寸,并绑定基准分辨率(如 1920×1080),渲染时按比例缩放,否则换屏幕就乱。
5. 属性面板与 Schema
属性面板是「元数据的编辑器」,它的结构应该由组件自己的 Schema 生成,而不是每个组件手写面板。
interface ComponentSchema {
type: string;
name: string; // 显示名
group: string; // 面板分组
props: PropSchema[]; // 可配置属性
defaultProps?: Record<string, unknown>;
acceptsChildren?: boolean;
droppable?: boolean;
}
interface PropSchema {
name: string;
label: string;
type: 'string' | 'number' | 'boolean' | 'select' | 'color' | 'expr' | 'style';
options?: Option[];
defaultValue?: unknown;
group?: string; // 属性分组(基础/样式/事件)
}
{
"type": "Button", "name": "按钮", "group": "基础组件", "acceptsChildren": false,
"props": [
{ "name": "text", "label": "文案", "type": "string", "defaultValue": "按钮" },
{ "name": "variant", "label": "样式", "type": "select",
"options": [{"label":"主要","value":"primary"},{"label":"次要","value":"default"}] },
{ "name": "onClick", "label": "点击动作", "type": "expr" }
]
}
属性面板按 props 渲染,expr 类型渲染成表达式编辑器。这样新增组件只需提供 Schema,面板自动生成,无需改设计器代码。
6. 数据绑定与作用域
页面组件需要绑定数据(列表、详情、统计)。绑定模型决定了页面能表达多复杂的逻辑。
作用域链:
页面级变量(page.xxx)
→ 数据源(dataSource.users)
→ 组件级(item in list)
→ 局部变量(local.xxx)
表达式求值时从内向外查找
{
"dataSources": [
{ "id": "users", "type": "api", "url": "/api/users", "method": "GET" },
{ "id": "stats", "type": "api", "url": "/api/stats" }
],
"variables": [ { "name": "keyword", "type": "string", "default": "" } ]
}
{
"type": "Table",
"props": {
"data": "{{ users.data }}",
"columns": [
{ "title": "姓名", "field": "name" },
{ "title": "状态", "field": "status",
"render": "{{ item.status === 'active' ? '启用' : '停用' }}" }
],
"filter": "{{ variables.keyword }}"
}
}
6.1 数据源的依赖与刷新
数据源之间可能有依赖(列表详情依赖选中项)。设计器要能表达依赖,运行时才能按拓扑序刷新;刷新粒度也要可控,改一个变量不应刷新所有数据源。
7. 事件与动作编排
事件是页面从「静态」到「可交互」的桥梁。
事件源:onClick / onChange / onLoad / onSubmit
动作类型:
- 调用接口(api)
- 设置变量(setVariable)
- 导航(navigate)
- 弹窗/抽屉(openModal)
- 通知(notify)
- 自定义脚本(script,受限沙箱)
编排方式:
顺序执行 + 条件分支 + 错误处理
{
"onClick": {
"type": "sequence",
"steps": [
{ "type": "api", "source": "createUser", "params": { "name": "{{ form.name }}" } },
{ "type": "notify", "level": "success", "message": "创建成功" },
{ "type": "setVariable", "name": "refreshKey", "value": "{{ Date.now() }}" }
]
}
}
动作编排不宜做成图灵完备的编程语言——那会退化成「用 JSON 写代码」。保持有限的原子动作 + 顺序/条件组合,是可持续的边界。
8. 撤销重做与历史栈
没有撤销的搭建器是不可用的,但历史栈也是最容易做爆内存的地方。
interface HistoryState {
past: PageState[];
present: PageState;
future: PageState[];
}
function push(history: HistoryState, next: PageState, limit = 100): HistoryState {
const past = [...history.past, history.present].slice(-limit);
return { past, present: next, future: [] };
}
function undo(history: HistoryState): HistoryState {
if (!history.past.length) return history;
const previous = history.past[history.past.length - 1];
return {
past: history.past.slice(0, -1),
present: previous,
future: [history.present, ...history.future],
};
}
8.1 合并连续操作
拖拽与连续输入会产生大量历史条目,需按「操作类型 + 时间窗」合并:同一字段的连续输入在 500ms 内合并为一条。
9. 预览、发布与多端
搭建器的产物必须能脱离设计器运行。
三个阶段:
设计态(Design):可编辑,带辅助线与选中框
预览态(Preview):只读渲染,模拟真实数据
运行态(Runtime):部署后由应用壳加载
多端:
同一份元数据 → 不同端渲染器(PC / H5 / 小程序)
差异通过「端适配层」处理,而非复制元数据
预览必须走与运行时相同的渲染器,只把「编辑能力」关掉;若两者用两套代码,就会出现「预览正常、上线变形」。
10. 设计器与运行时的复用
这是搭建器架构里最重要的一条原则。
共享:组件实现、Schema 定义、数据绑定求值、布局计算
差异:设计态额外包一层「编辑外壳」
(选中框、拖拽手柄、hover 高亮、右键菜单)
实现:
<NodeRenderer mode="design" | "runtime" node={node} />
外壳只在 mode === "design" 时渲染
把编辑能力做成「外壳」而非「分支」,能让组件实现保持纯净,设计态与运行态共享同一份渲染逻辑。这与 渲染机制与性能优化 中讨论的渲染管线是同一套东西。
11. 大页面的性能
当页面组件超过几百个,设计器会明显卡顿。
优化手段:
1. 画布虚拟化:视口外的组件不渲染(或用低精度占位)
2. 选中态隔离:选中框用绝对定位覆盖层,不重渲染组件
3. 拖拽时冻结:拖拽中只更新指示线,不重算布局
4. 属性面板懒加载:只渲染当前选中组件的属性
5. 元数据不可变 + 结构共享:精确控制重渲染范围
其中「选中态隔离」收益最大:很多搭建器卡顿的根因是把选中状态放进了组件树,导致每次选中都全量重渲染。
权衡取舍
| 决策点 | 选项 A | 选项 B | 建议 |
|---|---|---|---|
| 数据结构 | 树 | 扁平 | 存储扁平、渲染还原 |
| 布局 | 自由 | 流式 | 后台选流式,大屏选自由 |
| 属性面板 | 手写 | Schema 生成 | Schema,便于扩展 |
| 动作编排 | 图灵完备 | 有限原子 | 有限,避免「JSON 编程」 |
| 设计/运行时 | 两套 | 复用渲染器 | 复用,外壳区分 |
常见坑清单
- 设计态与运行态两套渲染:必然出现预览正常、上线变形。
- 选中状态放进组件树:每次选中全量重渲染,页面一大就卡。
- 拖拽落入自身子节点:形成环导致渲染崩溃,drop 前必须检测。
- 自由布局不存基准分辨率:换屏幕后元素错位。
- 历史栈深拷贝全量状态:内存爆炸,需结构共享 + 条数上限。
- 连续输入不合并历史:撤销要按十几次才回到一步之前。
- 属性面板手写:新增组件要改设计器,无法插件化。
- 动作编排做成图灵完备:退化为「用 JSON 写代码」,难维护。
- 数据源无依赖分析:改一个变量刷新全部接口,浪费且抖动。
小结
可视化页面搭建器的骨架是「扁平结构 → 拖拽落点 → 布局模式 → Schema 面板 → 数据绑定 → 事件编排 → 历史栈 → 设计运行复用」。其中最容易被低估的是「设计态与运行态复用同一渲染器」这一条——它决定了搭建器的长期可维护性。
另一个反复出现的主题是「粒度控制」:结构共享控制重渲染粒度,依赖图控制联动粒度,历史合并控制撤销粒度,选中隔离控制性能粒度。搭建器的工程质量,很大程度上就是粒度控制的工程质量。
页面搭好之后,组件绑定的数据从哪来、如何建模、如何生成表结构,是 数据模型设计器 要回答的问题。而页面上的动作如何与后端流程打通,见 低代码与工作流引擎集成 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。