
简介这是一套基于Vue实现的可视化表单设计器源码面向企业级中后台与业务系统开发者旨在将繁琐的表单开发转变成直观的拖拽式配置。设计器内置可视化配置页面采用基于flex实现的栅格布局用户无需手写大量模板代码即可完成页面编排配置完成后可一键预览实际效果、一键生成结构化JSON数据也能直接生成立即可运行的代码显著提升交付效率。整个资源共437个文件其中以395个JavaScript脚本和12个Vue单文件组件为主体同时附有Markdown说明文档、HTML演示页面、CSS样式、字体图标等辅助文件压缩包大小仅2.51MB结构紧凑清晰便于阅读、调试与二次扩展。功能上覆盖自定义组件接入、远端数据接口加载、高级组件扩展、表单验证、快速获取表单数据以及国际化等能力能满足不同业务场景下的复杂表单需求。目前该资源已有578人学习适合想要提升表单开发效率或研究可视化设计器内部实现原理的前端开发者下载参考。1. 可视化表单设计器源码到底在解决什么问题拖拽只是表面很多人以为可视化表单设计器就是一个拖拽表格把输入框从左边拖到右边再保存成页面。真正做过中后台低代码平台的人会告诉你拖拽只是交互层设计器产出的核心是一份可序列化的 JSON Schema。这份描述组件类型、属性、校验规则和布局的数据才是能在多个业务系统之间复用的资产。这篇内容用 JavaScript 把这套方案的最小闭环讲清楚从数据结构、拖拽渲染、属性配置到运行时渲染最后给出自定义控件的扩展协议。适合正在做低代码平台、动态表单引擎或者想把手写表单改成可视化配置的团队。看完之后你会明白为什么源码包里最有价值的不是拖拽动画而是那份 Schema。2. JavaScript 表单设计器最小实现从组件描述对象到递归渲染2.1 表单设计器的核心数据结构组件描述对象与 JSON Schema先定义最小结构const widgetSchema { id: input_1, type: input, label: 姓名, props: { placeholder: 请输入姓名, maxLength: 20, allowClear: true }, rules: [ { required: true, message: 姓名不能为空 }, { min: 2, message: 姓名至少 2 个字符 } ], layout: { width: 100%, span: 12 } };这段代码说明设计器的每次拖拽、每次属性修改最终都落到widgetSchema上。type决定渲染成什么组件props对应组件入参rules在校验阶段使用layout控制栅格宽度。我一般会把props和rules分开因为两者的消费时机不同前者在设计器画布和运行时渲染都会用到后者只在提交校验和表单校验时读取。id要在整个表单内唯一删除组件后不回收防止重复 key 导致 Vue 或 React 调和出错。注意组件描述对象不能直接存组件实例否则JSON.stringify后变成{}整个设计器的保存导出都会失效。2.2 用 JavaScript 实现拖拽放置原生事件在画布上的插入逻辑拖拽不一定要引入 dragula 或 vuedraggable原生 HTML5 拖放足够撑起最小实现。给左侧组件库的每个条目设置draggabletrue拖拽开始后把type放进dataTransfer画布容器监听dragover和dropcanvas.addEventListener(dragover, (e) { e.preventDefault(); // 阻止默认行为否则 drop 不会触发 }); canvas.addEventListener(drop, (e) { e.preventDefault(); const type e.dataTransfer.getData(text/plain); const dropY e.clientY; const insertIndex findInsertIndex(dropY); insertWidget(type, insertIndex); });findInsertIndex用e.clientY和画布内每个组件的getBoundingClientRect()比较找到距离落点最近的组件下标再在它的前一个或后一个位置插入。这里有个坑不能直接用e.target判断插入位置因为落点很可能在一个输入框内部e.target是子元素需要向上找>function renderWidget(widget, renderProps) { const meta registry[widget.type]; if (!meta) return null; const element meta.render(widget.props, { ...renderProps, widgetId: widget.id, onValueChange: (value) updateValue(widget.id, value) }); if (Array.isArray(widget.children)) { const children widget.children.map((child) renderWidget(child, renderProps) ); element.children children; } return element; }这里的registry是组件注册表meta.render返回当前组件的虚拟节点或真实 DOM 节点。之所以不单独写一个巨大的 switch是为了让后面的自定义控件扩展不需要改渲染主流程。onValueChange回调把用户输入回写到组件数组里设计器画布才能实时反映出变化。注意这里传递的renderProps不能直接引用整个组件数组否则一旦更新会触发整棵树的递归渲染设计器里组件数量几十个还好过百之后需要使用按需更新后面讲性能时再展开。2.4 为什么要维护 JSON Schema 而不是直接操作 DOM对比维度直接操作 DOM 保存维护 JSON Schema 保存保存逻辑遍历 DOM 节点提取属性极易漏掉隐藏字段直接序列化组件数组天然可保存校验能力需要为每个 DOM 节点单独绑定校验状态rules 统一存在描述对象里运行时渲染器统一消费版本对比只能对比快照字符串难以定位差异可以做 JSON diff按字段级别追溯变更跨端复用只能在当前页面内使用同一份 Schema 可渲染成 Vue、React 或原生 JS 表单直接操作 DOM 的第一个痛点是反向写回。用户拖一个输入框你把它的 value、placeholder 都存进 DOM 属性下次回显时还要再遍历 DOM 找出来组件一嵌套就崩。第二个痛点是校验和联动逻辑没法表达你能存样式但存不了 required 和 visibleIf。JSON Schema 把数据和展示彻底分离保存的是状态而非结果这也是为什么绝大多数开源表单设计器都选择 Schema 驱动。有了这份 Schema设计器可以拆成两个独立包编辑器负责生成 Schema渲染器负责消费 Schema两边各自迭代这才是源码里真正值得复用的架构。3. JavaScript 表单设计器的属性配置、校验与联动实现3.1 属性面板与选中态更新组件配置时避免全局重绘画布点击组件时需要把当前选中组件的 id 存进设计器状态。属性面板监听这个状态读取对应描述对象并展示表单。更新属性时常见做法是直接改原对象function updateVisibleProps(widgetId, patch) { const widget flatWidgetMap[widgetId]; if (!widget) return; // 用 Object.assign 合并对象但要注意嵌套 props 的引用关系 widget.props { ...widget.props, ...patch }; renderCanvas(); }这里有三个问题要提醒。第一widget.props是嵌套对象直接用Object.assign只能合并第一层像style、labelCol这类需要深合并。第二每次修改都renderCanvas()会重绘整个画布用户输入一个字符就闪一下输入框还可能失焦。我一般会给需要连续输入的属性做本地缓冲onChange时只更新组件树里的值onBlur或防抖 300ms 后再触发整棵渲染。第三属性面板的数据要和画布数据同源不能为属性面板单独缓存一份副本否则切走再切回来就丢改动。3.2 校验规则生成从属性配置到运行时校验的映射校验不能只在提交时跑。设计器里应该提供规则配置常见做法是把每个规则类型映射成一个小函数const validators { required: (value, rule) value ! null value ! undefined String(value).trim() ! , pattern: (value, rule) new RegExp(rule.pattern).test(value), custom: (value, rule) rule.validator(value, rule) }; function validateWidget(widget, value) { const errors []; for (const rule of widget.rules || []) { const validator validators[rule.type]; if (validator !validator(value, rule)) { errors.push(rule.message || 校验不通过); } } return errors; }这个映射表的好处是规则类型是字符串可以安全地存在 JSON Schema 里。required的判定要特别注意空格和 0不能简单用!value。pattern场景要小心正则表达式来自用户输入时new RegExp不会抛错但可能造成灾难性回溯建议在属性面板里就让用户选择预置规则而不是直接填正则。custom类型的 validator 是函数不能直接序列化存储时需要改成函数名字符串运行时再查表这也是表单设计器源码里常见的函数注册表设计。3.3 表单项联动显隐与值计算的常见实现和坑联动是表单设计器最容易翻车的部分。最朴素的实现是给每个组件加一个visibleWhen字段条件是表达式字符串。渲染时递归判断function shouldVisible(widget, formValues) { const condition widget.visibleWhen; if (!condition) return true; const { field, op, value } condition; const current formValues[field]; switch (op) { case eq: return current value; case neq: return current ! value; case in: return Array.isArray(value) value.includes(current); default: return true; } }这里的坑在于字段值的类型。表单控件可能会返回字符串、数字、数组或者undefined一个「是」按钮的 value 是字符串yes一个数字输入返回的是字符串还是数字取决于组件实现。我一般会在属性面板强制要求用户通过下拉框选择字段类型并在运行时做一次normalizeValue把数字和布尔值统一转换后再比较。更深一层的联动是值计算比如数量乘以单价等于总价。这种不建议在渲染循环里同步计算否则每次输入都递归触发。正确姿势是把计算表达式注册到事件系统值变化时触发一次队列批量更新。3.4 动态执行脚本与存储型 XSS 的边界很多表单设计器会提供「动态执行脚本」能力用户填一段 JavaScript 来实现复杂联动。这个功能在若依这类后台框架里也有对应实现但直接eval脚本会有两个后果一是脚本在渲染器上下文里执行可能访问到全局变量和document二是脚本本身作为 JSON 内容存储回显时如果没做隔离等于给存储型 XSS 留了后门。安全做法是不要用eval改用new Function并把表单数据作为参数传入const fn new Function(values, return expression);但new Function仍然有全局作用域风险表达式里写this、window、document依然可用。更保险的做法是把动态脚本限制为白名单指令比如只支持values.field x这类描述式配置。如果你的产品真的需要用户自定义脚本就在 iframesandbox里运行去掉allow-scripts之外的能力。注意即使用new Function也必须在运行时渲染前对脚本内容做转义和长度限制否则恶意脚本 100% 会进入 DOM。4. 把表单设计器接进业务运行时渲染、数据回填与性能优化4.1 设计器与运行时渲染器分离同一份 Schema 两端共用设计器里的画布和业务系统里的实际表单应该共用同一份组件注册表和渲染函数。如果设计器输出一份 Schema到了业务端又手写一遍表单这个设计器没有真正解决问题。运行时渲染器可以精简成function createRuntimeForm(schema, options {}) { const formData options.initialData || {}; function setFieldValue(fieldId, value) { formData[fieldId] value; emit(change, { fieldId, value, formData }); } function validate() { const errors {}; for (const widget of schema.widgets) { const fieldErrors validateWidget(widget, formData[widget.id]); if (fieldErrors.length 0) errors[widget.id] fieldErrors; } return { valid: Object.keys(errors).length 0, errors }; } return { setFieldValue, validate, getValues: () ({ ...formData }) }; }这里的关键是schema.widgets不能直接存设计器里的组件数组必须经过一次 normalize把设计器的内部字段如isSelected、collapsed去掉只保留id、type、props、rules、children。很多团队会把设计器状态直接带给运行时结果表现层多出一堆无关字段校验逻辑也要绕开它们维护成本立刻上升。运行时渲染器只依赖这个 Schema不依赖任何编辑器状态设计器才能随时改版而不影响线上表单。4.2 数据回填与校验触发接入现有表单体系的代码姿势业务系统里最常见的接入方式是拿到 Schema 后立刻回填已有数据。数据回填的坑在于字段路径组件id默认就是数据字段名但用户可能把组件id命名为input_1而不是业务字段名。我一般会给组件对象增加一个单独的field字段保存提交时的字段名id只用于 UI 标识。回填时按field赋值function fillFormData(schema, record) { const initialData {}; const walk (widgets) { for (const widget of widgets) { const field widget.props.field || widget.id; if (record[field] ! undefined) initialData[field] record[field]; if (widget.children) walk(widget.children); } }; walk(schema.widgets); return initialData; }校验触发时机也有讲究。提交时调用validate是底线但更好的体验是在用户离开某个字段时触发单字段校验提交时触发全量校验。单字段校验需要校验器支持只跑一个 widget不在循环里重新构建所有错误对象。如果你接入的是 Element Plus 或 Ant Design最好包装成它们期望的rules格式让组件库自己的校验机制接管这样 UI 层面的错误展示不需要额外写。4.3 列表类表单的性能优化虚拟滚动与按需渲染当表单设计器被用来做商品规格、成员信息这类可重复列表时组件数量会从几十跳到几百继续整树重绘就会卡。常见的优化手段是列表容器组件内部做虚拟滚动只渲染可视区域的行每行的 Schema 结构不变但数据数组可以很长。设计器里的预览画布不需要真正虚拟滚动运行时渲染器才需要。另一个容易忽略的点是事件监听。setFieldValue每次更新都触发全量change事件如果表单里有个大图表在监听会同步抖动。给事件系统加一个flush队列批量更新完成后再向外派发一次事件效果会明显改善。还有组件级别的缓存父组件更新时用浅比较跳过子组件渲染这在 React 里尤其重要。4.4 画布缩放与可视化大屏适配的相同思路做可视化大屏的人都在处理一个问题不同分辨率下图表和页面如何缩放。表单设计器画布也有同样问题设计稿是 1440 宽度用户屏幕可能是 1920 或 1366。两种方案都有对应场景等比缩放和动态栅格。等比缩放是对画布容器设置transform: scale()所有组件按比例缩放优点是不影响内部布局缺点是小屏下看不清。动态栅格是给 Schema 里的layout.span做响应式换算断点变化时重新计算每行放几个组件这个方案更适合实际表单。我一般会在设计器状态里存一个canvasZoom值拖拽、点击的位置都要除以缩放系数再换算成坐标不然画布放大后 drop 的位置会偏移。5. 进阶给 JavaScript 可视化表单设计器写自定义控件扩展5.1 设计自定义控件注册协议扩展点要稳定才能让业务方不修改核心代码就加控件。定义一个registerWidgetfunction registerWidget(type, meta) { registry[type] { type, render: meta.render, propsSchema: meta.propsSchema, configure: meta.configure || (() {}) }; }propsSchema描述属性面板要渲染哪些配置项configure负责把属性值映射到组件 props。业务方只需调用registerWidget(staff-picker, {...})自定义控件就能出现在组件库列表、画布和属性面板里。实际项目建议把registerWidget的入参设计成与核心表单包独立的 npm 包避免业务代码和设计器源码互相依赖。5.2 Schema 版本化与导入导出表单 Schema 一定会演进老版本的配置需要平滑迁移。导出时在 Schema 顶层加versionconst schema { version: 3, widgets: [/* ... */] };每次升级写一个migrate(oldSchema) { ... }迁移函数按版本号串起来。导入时先判断版本再依次执行迁移最后再交给渲染器这样旧表单不会因为缺字段直接报错。5.3 从设计器导出代码把 Schema 编译成表单代码最后说一个实用技巧在设计器里一键导出 Vue 模板或 React JSX本质是遍历组件树拼字符串而不是用模板引擎。拼字符串时要注意缩进和属性引号组件props里的函数需要导出为onChange回调占位。这样能把可视化配置的结果沉淀为可提交的代码资产适合团队里不想引入运行时渲染器的场景。本文还有配套的精品资源点击获取