可视化页面搭建器实现

拆解可视化页面搭建器的核心实现:组件树与扁平化、拖拽交互与落点计算、自由与流式两种布局、属性面板与 Schema 联动、数据绑定与作用域、事件与动作编排、撤销重做历史栈、预览发布与多端渲染、设计器与运行时复用,给出可运行的拖拽算法与数据结构,回答如何做出稳定可维护的搭建器。

引言

可视化页面搭建器是低代码平台里最「看得见」的部分:用户从左侧组件面板拖一个按钮到画布,在右侧属性面板改文案,再绑定一个点击事件,一个页面就成型了。看起来简单,但要做得稳定、可维护、能承载复杂页面,工程复杂度并不低。

难点集中在三处:拖拽的落点计算(用户想放哪里、结构上应该放哪里,两者经常不一致)、数据结构的选型(树结构直观但操作昂贵,扁平结构高效但要额外维护关系)、设计器与运行时的复用(如果设计器和运行时用两套渲染逻辑,就必然出现「设计器里好好的,发布后变形」)。

本文按「数据结构 → 拖拽 → 布局 → 属性 → 绑定 → 事件 → 历史 → 发布 → 复用 → 性能」的顺序展开,每一节给出具体的实现思路与代码片段。读完你应当能判断:一个搭建器的复杂度究竟藏在哪,以及哪些设计决策会在页面变大后反噬。

目录

  1. 页面搭建器的定位
  2. 组件树与扁平化
  3. 拖拽交互与落点计算
  4. 布局系统:自由与流式
  5. 属性面板与 Schema
  6. 数据绑定与作用域
  7. 事件与动作编排
  8. 撤销重做与历史栈
  9. 预览、发布与多端
  10. 设计器与运行时的复用
  11. 大页面的性能

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 编程」
设计/运行时两套复用渲染器复用,外壳区分

常见坑清单

  1. 设计态与运行态两套渲染:必然出现预览正常、上线变形。
  2. 选中状态放进组件树:每次选中全量重渲染,页面一大就卡。
  3. 拖拽落入自身子节点:形成环导致渲染崩溃,drop 前必须检测。
  4. 自由布局不存基准分辨率:换屏幕后元素错位。
  5. 历史栈深拷贝全量状态:内存爆炸,需结构共享 + 条数上限。
  6. 连续输入不合并历史:撤销要按十几次才回到一步之前。
  7. 属性面板手写:新增组件要改设计器,无法插件化。
  8. 动作编排做成图灵完备:退化为「用 JSON 写代码」,难维护。
  9. 数据源无依赖分析:改一个变量刷新全部接口,浪费且抖动。

小结

可视化页面搭建器的骨架是「扁平结构 → 拖拽落点 → 布局模式 → Schema 面板 → 数据绑定 → 事件编排 → 历史栈 → 设计运行复用」。其中最容易被低估的是「设计态与运行态复用同一渲染器」这一条——它决定了搭建器的长期可维护性。

另一个反复出现的主题是「粒度控制」:结构共享控制重渲染粒度,依赖图控制联动粒度,历史合并控制撤销粒度,选中隔离控制性能粒度。搭建器的工程质量,很大程度上就是粒度控制的工程质量。

页面搭好之后,组件绑定的数据从哪来、如何建模、如何生成表结构,是 数据模型设计器 要回答的问题。而页面上的动作如何与后端流程打通,见 低代码与工作流引擎集成 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「低代码」更多文章

  1. 自定义代码与逃生舱
  2. 低代码应用测试与质量
  3. 连接器与 API 编排