拖拽式页面可视化搭建系统:从 JSON Schema 到 Vue3 实现

拖拽式页面可视化搭建系统:从 JSON Schema 到 Vue3 实现 “页面生成页”这种名字多半是项目负责人随手起的等真正动手做的时候才发现它既不只是一个页面也不只是“生成页面”的工具而是一整套页面可视化搭建的解决方案。我这次做的就是这样一个面向运营活动专题页的拖拽式页面生成器拖组件进画布、改右侧属性、实时预览效果、最后一键导出能在几分钟内拼出一个完整可上线的页面。不用写代码这是最关键的产品目标。这个项目适合谁参考一类是做内部运营系统的团队被大量同质化落地页的需求压得喘不过气另一类是接项目的外包或独立开发者希望用一套页面配置数据快速交付多个页面还有一类就是单纯想搞懂可视化搭建底层原理的前端开发。不管哪类读者这篇文章会把从选型、数据结构设计到拖拽交互实现一步步讲透把能直接抄的代码和踩过的坑都放出来。1. 先弄清楚“页面生成页”到底要做什么1.1 一句话定义这个项目用最直白的话说页面生成器等于左侧组件库物料池加中间画布加右侧属性面板。拖一个图片组件到画布右边能改图片地址和尺寸顶部能预览和发布。整个系统背后只做一件事——把用户在界面上的操作翻译成一份结构化的页面描述数据再由渲染引擎把这个描述数据还原成真实页面。这里要特别强调数据和视图分离这是整个项目的设计灵魂。很多人第一次接触这类项目时容易陷入“拖一个组件就写一个div”的误区结果越写越乱组件之间互相影响撤销重做也做不出来。正确做法是编辑器的所有交互都只修改一份JSON数据画布只是这份数据的可视化投影。理解了这个后面所有功能都会顺理成章。1.2 为什么不能直接用现成的开源搭建器有人会问市面上不是有现成的开源页面搭建器吗直接拿来用不就行了。我一开始也调研过几条路最后都放弃了。第一个原因是定制成本太高公司内部的视觉规范、组件交互逻辑和第三方开源工具很难完全匹配改起来比重新写还费劲。很多开源搭建器为了通用性把组件抽象得很重想加一个内部定制的营销组件要读懂它的一整套插件机制成本远高于自己维护一个轻量版本。第二个原因是交付场景问题。在项目制交付场景下客户要的是能独立部署、能导出自持的静态页面而不是依赖某个第三方平台运行时才能展示的东西。第三个原因是技术栈匹配我们团队主力是Vue而很多成熟的开源搭建器基于React强行混用会让前端维护变成灾难。所以这个项目最终选择自研一套轻量级页面可视化生成方案核心原则就是“够用、可控、好扩展”。1.3 功能列表与边界划分项目启动前明确边界非常关键否则这类工具会无限膨胀。我这次划定了三条线做拖拽式组件管理、属性配置、实时预览、导出页面JSON、基于JSON的运行时渲染。不做复杂的数据建模和后端联动需要后端的部分通过自定义属性和接口地址配置不涉及拖拽数据表这类高级低代码能力。演进方向预留了组件间交互的事件配置位但第一版先不实现避免功能膨胀导致延期。模块第一版目标说明组件拖拽支持基础容器和物料组件图片、文本、按钮、轮播、栅格容器属性配置每个组件的props可编辑动态生成配置表单实时预览桌面端和移动端切换用iframe隔离环境发布导出导出JSON加运行时渲染包可独立部署到任意静态服务器功能边界确定后团队讨论时就不会再有人过来问“能不能顺便加个表单设计器”每一条新需求都能快速判断是纳入本期、放到迭代计划还是直接拒绝。2. 技术方案是怎么选出来的2.1 为什么选择 Vue 3 TypeScript Pinia这类项目对这个技术栈非常契合。Vue 3的响应式系统非常适合实时预览修改某个组件的props画布中的组件会立刻更新不需要手动触发刷新也不需要在编辑器和画布之间来回同步状态。相比在React里频繁使用状态管理库做联动Vue 3的心智负担会小很多。组合式API在项目里也帮了大忙。自定义拖拽逻辑、历史记录逻辑、组件注册逻辑都可以抽成独立的 composables按功能切片使用不同成员写不同模块时几乎不会互相冲突。Pinia则是整个编辑器的状态中枢编辑状态、选中状态、历史栈都放在store里管理页面数据流非常直观。再加上TypeScript这个辅助角色物料协议和props规范有了类型约束团队成员调用接口时再也不会因为拼错字段名跑到运行时才报错。2.2 拖拽方案vuedraggable 还是自研先给结论如果画布上只有平铺的列表结构直接用 vuedraggableSortableJS 的 Vue 封装最快但只要组件开始在画布上做自由排版、嵌套、定位vuedraggable 的默认能力就不太够用了。这类编辑器最终都会走向嵌套容器的场景比如把一个轮播组件放进一个双栏栅格里这时候排序逻辑就复杂起来了。我这次选择的是“自研拖拽 按需复用排序思路”的方案。HTML5原生拖放事件dragstart、dragover、drop在编辑器中足够用性能也尚可而且可控性强。真正麻烦的不是“拖”而是“放下时算位置”。组件拖进画布后要判断它落在哪个容器里、落在第几个组件前后这个判断逻辑才是拖拽功能的难点后面我会详细讲。2.3 用 JSON Schema 串起整个项目这个项目第二阶段重构时我把“组件内部自己管理数据”改成了“一份全局JSON驱动所有渲染”这一步是整个项目能真正落地的关键。页面结构描述长这样{ version: 1.0.0, page: { title: 年中大促活动页, width: 750, backgroundColor: #f5f5f5 }, components: [ { id: c_001, type: banner, props: { imageUrl: https://example.com/banner.jpg, height: 320, link: /activities/sale }, children: [] }, { id: c_002, type: grid, props: { columns: 2, gap: 12 }, children: [ { id: c_002_1, type: image-card, props: { imageUrl: https://example.com/card.jpg, title: 爆款推荐 }, children: [] } ] } ] }所有组件都是同一套数据结构渲染引擎只需要读取type找到对应的动态组件把props传入即可。id为什么不用索引因为组件会被拖拽排序、删除、插入索引随时会变而id作为唯一标识能让历史记录、选中状态、渲染key都稳定下来。children递归字段则天然表达了容器的嵌套关系栅格套轮播、轮播套图片卡片都是这么组合出来的。version字段看似不起眼但运行时渲染包升级后老页面靠它做兼容判断能省去大量线上事故。3. 核心功能逐模块拆解3.1 组件注册中心物料池背后的协议组件注册中心是整套系统的插件化基础。每新增一个组件不用改动搭建器本身的代码只需要在注册表里加一项。注册表里的核心接口是ComponentMeta// material.ts export interface ComponentMeta { type: string; title: string; icon: string; group: layout | basic | media | marketing; props: Recordstring, PropMeta; defaultProps: Recordstring, unknown; render: Component; } export interface PropMeta { label: string; type: text | number | select | color | image | switch; options?: { label: string; value: string }[]; defaultValue?: unknown; }物料池就是注册了的一组meta数组左侧面板根据group分组渲染成可拖动的卡片画布渲染时按type查找render组件。属性面板同样依赖这份meta动态生成配置表单。可以说组件的可扩展性完全取决于这套协议设计得是否合理。类型定义参考通过TypeScript写清楚之后团队再新增调研组件时知道自己要补哪些字段出错率显著下降。3.2 画布渲染引擎从 JSON 到页面画布渲染引擎做的事情很纯粹遍历schema的components数组动态渲染出每个组件。核心代码简化后就这么一段template div classcanvas-root RecursiveRenderer v-foritem in pageData.components :keyitem.id :nodeitem :depth0 select-nodehandleSelect / /div /templateRecursiveRenderer组件内部做一个递归判断当前节点是普通物料组件就直接渲染它的render组件当前节点是容器组件就继续遍历children。这里有一个必须处理的细节组件需要展示一个选中边框也就是画布上显示的虚线外框。这个框不能影响导出页面的展示导出时必须过滤掉。我的方案是把它实现为一个装饰性的wrapper导出的运行时里不渲染这个wrapper。点击选中组件时要用stopPropagation阻止事件继续冒泡到父级容器否则永远选中的是根容器。这个坑我第一天就踩过后来给每个组件层的点击处理都统一加了指令封装。3.3 拖拽核心drop 位置的精确计算拖拽放置是编辑器里技术含量最高的部分。dragstart时只需要把物料类型记录下来难点集中在drop时如何确定插入位置。我的做法是在画布容器上统一监听dragover用原生API的document.elementFromPoint找出鼠标当前命中的最深层元素再向上查找最近的带有data-node-id属性的组件节点这样就拿到了目标容器。拿到容器后通过getBoundingClientRect拿到容器坐标和鼠标坐标结合容器内已有children的数量和每个子组件的位置用就近原则判断插入到哪个索引。原理不复杂但要处理边界情况鼠标在容器底部1/3处算插到尾部在某个子组件上半部分算插到它前面下半部分算插到它后面。这些阈值需要多调几轮直到手感顺滑为止。拖拽过程中的实时位置预览我并没有直接修改schema而是用一个暂存的placeholder节点标记插入位置这样画布只是多渲染了一个标识节点不会触发整个schema重排性能也不会崩。3.4 动态属性面板根据组件元信息生成表单属性面板的联动逻辑是选中组件后从注册表里读取对应type的propsMeta动态渲染出表单控件。属性面板本身也是一个递归渲染器根据PropMeta的类型渲染输入框、下拉框、颜色选择器、图片选择控件。修改表单值后把更新写入schema对应节点const selectedComponent computed(() findNode(store.schema.components, store.selectedId) ); function updateProp(propKey: string, value: unknown) { store.updateComponentProps(store.selectedId!, { [propKey]: value }); }这里有个经验属性面板的按钮、输入框这类控件要设置统一的防抖和失焦处理。否则用户在输入框连续输入时每个字符都会触发一次全局schema更新和画布重渲染低端机器上会明显卡顿。我实际测试下来输入类控件失焦再提交开关和颜色选择器即时更新体验最顺。3.5 实时预览与导出iframe 和运行时渲染实时预览我用的是iframe方案创建独立iframesrc指向一个独立的渲染入口页面通过postMessage把最新JSON数据传进去。iframe内部使用和线上发布完全相同的运行时渲染引擎渲染页面。好处是预览环境与线上发布环境完全一致还能有效隔离编辑器自身的样式影响。实测下来如果直接在编辑器页面里渲染预览效果编辑器的全局CSS很容易污染预览区域排查起来极其头痛。导出发布时系统会输出一份JSON配置和一个运行时渲染包。运行时渲染包就是一个200行左右的小渲染器读取JSON后查找内置组件注册表渲染对应组件。用户拿到这两个文件任选一个静态服务器就能跑起页面。我顺便做了一条命令一键生成一个自包含的HTML文件把运行时和JSON内联进去给客户的交付体验会好很多。4. 实操记录从零搭一个最小可用的版本4.1 初始化工程第一步先用Vite把工程搭起来这一步没什么特别选vue-ts模板即可npm create vitelatest page-builder -- --template vue-ts cd page-builder npm install npm install pinia目录结构我建议按功能切片组织而不是按页面组织。可以按这样的结构划分components目录放画布、属性面板、物料池materials目录放每个具体组件stores目录放编辑状态storeruntime目录放导出的运行时渲染器。这样划分的好处是后续写配置面板和运行时的时候找代码的位置不会乱。4.2 定义Pinia store和组件协议先把编辑器的状态store定义好。核心状态是schema、selectedId、历史栈以及几个操作函数// stores/editor.ts import { defineStore } from pinia; import { ref } from vue; export const useEditorStore defineStore(editor, () { const schema refPageSchema({ version: 1.0.0, page: { title: 未命名页面, width: 750, backgroundColor: #ffffff }, components: [], }); const selectedId refstring | null(null); function updateComponentProps(id: string, props: Recordstring, unknown) { const node findNode(schema.value.components, id); if (node) { node.props { ...node.props, ...props }; } } function addComponent(parentId: string, index: number, type: string) { const meta getMaterialMeta(type); const newNode: SchemaNode { id: genId(), type, props: JSON.parse(JSON.stringify(meta.defaultProps)), children: [], }; if (parentId) { const parent findNode(schema.value.components, parentId); parent?.children.splice(index, 0, newNode); } else { schema.value.components.splice(index, 0, newNode); } } return { schema, selectedId, updateComponentProps, addComponent }; });注意这里defaultProps存下来后每次创建新节点都必须深拷贝一次否则多个组件会共享同一个引用改一个全跟着变。这个坑我在早期版本踩过好几次后来一律改成JSON序列化深拷贝因为schema里的数据都是可序列化的简单对象不需要用structuredClone之外的特殊处理。4.3 物料池拖拽与画布放置物料池的每一项设置draggabledragstart时把组件类型写入dataTransfer。画布区域监听dragover和dropfunction handleDragStart(event: DragEvent, type: string) { event.dataTransfer?.setData(material-type, type); event.dataTransfer!.effectAllowed copy; } function handleDrop(event: DragEvent) { const type event.dataTransfer?.getData(material-type); if (!type) return; const canvasElement document.querySelector(.canvas-root); const dropPoint { x: event.clientX, y: event.clientY }; const targetContainer findClosestContainer(dropPoint, canvasElement); if (targetContainer) { store.addComponent(targetContainer.id, targetContainer.index, type); } event.preventDefault(); }findClosestContainer的实现就是前面说的elementFromPoint加就近索引判断。写好之后拖放会变得很顺滑。这个过程中有个容易被忽略的细节dragover事件里必须调用event.preventDefault()否则drop事件根本不会触发。4.4 配置面板的动态表单配置面板根据选中组件的meta动态渲染。我用一个通用表单渲染器组件FormRenderer遍历propsMeta数组生成对应的控件template div classprop-form div v-forprop in propsMeta :keyprop.key classprop-item label{{ prop.label }}/label component :isresolveControl(prop.type) :valuemodelValue[prop.key] update:modelValuehandlePropChange(prop.key, $event) / /div /div /template这种做法的好处是为系统新增一种属性控件时只需要实现一个新的Control组件并注册到resolveControl映射里。对多套表单来说所有物料组件共享同一套配置UI不用每个组件单独开发配置面板。4.5 导出与回显验证导出逻辑就是把schema序列化成JSON字符串再配上运行时渲染器。运行时渲染器单独打包成一个runtime.js文件它不依赖编辑器可以独立加载。运行时核心也是递归渲染代码和编辑器画布的RecursiveRenderer相似只是去掉了编辑态相关的装饰代码。我每次做完新功能都会做一遍回显验证导出页面然后新开一个空白HTML页面引入runtime.js和导出JSON看看渲染出来的页面和编辑器里是否完全一致。这个习惯帮我抓出过好几个问题比如编辑器里添加的某个样式类在导出时没有带全或者运行时缺少某个组件的样式文件导致布局错乱。5. 常见问题与排查技巧实录5.1 画布频繁闪烁和拖拽卡顿这个问题几乎每个做搭建器的人都会遇到根因是每次拖拽经过组件时整个schema都被误改了画布被迫整体重渲染。排查到最后发现是dragover处理逻辑里为了显示插入位置频繁地往schema里添加或者移动placeholder节点。解决思路分两步第一dragover期间只维护一个本地拖拽状态用local变量记录当前悬停的容器和索引画布顶部有一个占位插槽组件通过CSS定位显示插入线第二所有组件用唯一key渲染确保只有插入线附近的局部组件会重新计算。修改之后一个包含50个组件的页面拖拽时也能稳定在流畅范围。5.2 嵌套组件插入位置算不准栅格容器出现之后插入位置计算就开始出问题。因为子组件和父容器都响应命中elementFromPoint经常返回最深层的子组件而不是容器本身。我的方案是给容器的内容区额外包一层wrapper并且给wrapper也设置data-container-id和data-index属性。查找命中时先看有没有最近的data-container-id有就直接用没有再用data-node-id。嵌套越深越要依赖这种显式的数据标记而不是纯靠坐标计算。5.3 属性面板修改后画布不刷新出现过几次修改属性后画布没变化的情况排查下来都是组件内部props的引用问题。有的组件内部对接收的props做了解构导致响应式丢失。后来定了一个统一规范编辑器里所有组件接收props后内部继续用computed包一层禁止直接解构props对象。这个规范刻进团队约定里之后类似问题基本没再出现过。5.4 撤销重做历史栈的内存控制第一版实现撤销重做时每次操作都存一整个schema快照。结果用户连续拖拽10个组件后内存就能涨到几十MB在低端笔记本上明显卡顿。后来改成“防抖快照”策略连续修改操作在1000毫秒内只记录一次快照同时历史栈上限设为50条超过后丢弃最旧记录。实测下来普通编辑场景内存占用能稳在20MB以内。5.5 移动端适配的特殊处理页面生成器内置了移动端预览和设计稿宽度切换。默认设计稿宽度是750px导出页面时通过运行时做rem适配把设计稿里的px按比例转换成rem。编辑器中还提供了一组常用移动端屏幕尺寸的预览选项。有一个坑是iframe预览时必须根据当前选择的设备宽度同步更新iframe的宽度否则操作切到移动端设备时画布还是桌面宽度适配效果完全看不出来。6. 项目做完之后我的一些真实感受做这个项目的过程中我最深的体会是“数据驱动一切”这句话在可视化搭建领域真的是核心真理。所有编辑交互本质上都是对那份JSON的增删改查想清楚这一点后剩下的工作就是在数据层和视图层之间不断做映射和优化。编辑器里所有花哨的功能比如实时预览、拖拽排序、撤销重做最终都落回到如何高效地修改和同步那份数据上。最后再分享一个小技巧导出的运行时渲染包一定要把样式一起打包并做样式隔离避免页面宿主环境里的全局CSS污染生成页面的展示效果。我最初导出时只带了组件逻辑没带样式结果客户把页面嵌到官网子目录后发现按钮、字体全被官网样式带偏排查了大半天才定位到是全局CSS影响。后来改成用CSS Modules方案打包运行时样式才彻底解决这个问题。如果你也在做类似的项目提前把样式隔离纳入设计方案能少走不少弯路。