资讯详情 从物料注册到快照栈:可扩展拖拽建页内核设计与实践
📅 2026/10/27 21:34:23
简介这是一份围绕 CMS 拖拽生成页面思路的轻量源码示例面向希望理解可视化建站的前端初学者、课堂教学者和小型站点开发者重点解决从左侧组件库拖入画布、在右侧自由排版并把拖入内容替换为自定义内容的基础实现问题。压缩包共 9 个文件约 63KB包含 5 个 JavaScript 脚本、3 个 CSS 样式和 1 个 HTML 入口页分别用于拖拽与克隆逻辑、界面样式和示例页面组织并可见 dragula 相关依赖文件。已有 3705 人浏览学习具备较好的入门参考热度。读者可从中梳理 HTML5 拖拽事件的开始、移动与结束处理理解组件化设计、CSS 布局、数据绑定以及拖拽时的视觉提示和完成动画等交互反馈也能把这份简易原型用于个人博客、小型企业站或教学演示并在此基础上扩展更完整的 CMS 页面搭建与权限协作流程。1. 运营要改一个按钮文案不该再排一次版某内容平台的后台有个真实痛点活动页上「立即报名」要改成「立即预约」走完需求评审、排期、发版两天过去了热点早凉了。后来他们做了一套简易的 CMS 拖拽建页页面不再是代码而是一段 JSON运营在画布上把按钮拖进去、改文案、点保存线上立刻变。这套东西的核心其实不复杂物料注册表决定「能拖什么」拖拽克隆决定「拖进来的是原件还是副本」落点计算决定「插到第几个」快照栈决定「能不能后悔」。四件事拼起来就是一个能跑、能扩、能交付的拖拽建页内核。这篇适合两类人想给后台加可视化搭建的中级前端以及需要一个最小可用 demo 又要能继续往里加物料的开发者。2. 架构选型编辑器、Schema、渲染器三件套怎么分工2.1 为什么不直接存一段 HTML 片段见过不少简易版的第一版实现是直接把画布的innerHTML序列化扔进数据库前端v-html渲染回来。能跑demo 演示效果也不错但第二个需求就会翻车——运营说「把所有按钮的圆角统一改成 8px」你只能对着几千条 HTML 字符串跑正则。页面必须用结构化数据描述这是拖拽建页的地基。我在项目里用的是 JSON Schema好处和代价摆在一起看更清楚对比项HTML 片段JSON Schema批量改属性正则替换极易误伤遍历节点改props精确跨端渲染只能浏览器看同一份 JSON 可出小程序、出静态页版本 diff字符串 diff噪声大结构化 diff可做变更审计组件复用复制粘贴引用同一个物料类型实现成本低需要维护 schema 版本迁移代价里最难啃的是版本迁移。我一般会在根节点上钉一个version字段每次 schema 结构变更时写一个迁移函数读库的时候先跑一遍迁移链再交给渲染器。这一步现在不做等到线上有几千个页面的时候再补就是血泪经验了。// src/types/schema.ts export type NodeId string /** 一整个页面的根结构落库存的就是它 */ export interface PageSchema { version: string // schema 版本号用于读取时做迁移 root: ComponentNode // 根节点固定是一个 page 容器 } /** 画布上的每一个组件都是一棵子树 */ export interface ComponentNode { id: NodeId // 全局唯一选中、拖拽、撤销全靠它定位 type: string // 物料类型如 button / container props: Recordstring, any // 组件对外属性会序列化进 JSON style: Recordstring, string // 只存布局样式不存编辑器辅助样式 children: ComponentNode[] // 容器类物料才有叶子节点为 [] }id这一项看着不起眼实际上是整套拖拽的锚点。落点计算要拿它比对祖先关系撤销重做要靠它做 diff属性面板要拿它定位当前编辑的节点。生成方式用nanoid或者时间戳加自增都可以唯一要求是同一页面内不重复。style和props分开存是因为它们的编辑入口不同属性面板改props样式面板改style混在一起以后做「重置样式」会很别扭。2.2 技术栈与目录结构选型上我用Vue 3 TypeScript Vite Pinia。Vue 3 的component :is天然适合做递归渲染reactive的深层响应式也让 schema 改动后画布自动刷新省掉一轮手动 diff。TypeScript 在这个场景里不是可选项——schema 结构会随着物料增加不断变复杂没有类型约束改一个字段名要全局搜。拖拽实现上我用的是原生 HTML5 Drag and Drop API没有引 sortablejs 或 dnd-kit。原因很直接简易拖拽建页的交互边界是清晰的从物料面板拖到画布、画布内挪位置原生 API 的dragstart / dragover / drop三件套够用零依赖而且落点计算这段逻辑自己写一遍才能理解为什么那么多拖拽库在处理嵌套容器时会出问题。当然一旦需求扩展到多选拖拽、跨 iframe 拖拽、拖拽排序动画老老实实上库别硬扛。目录结构按「编辑器 / 物料 / 渲染器」三层切开边界很清楚src/ ├─ editor/ # 编辑器本身只在后台加载 │ ├─ Canvas.vue # 画布负责拖拽事件和落点高亮 │ ├─ MaterialPanel.vue # 左侧物料面板 │ ├─ PropsPanel.vue # 右侧属性面板按 propsSchema 自动生成表单 │ ├─ drag.ts # 拖拽协议payload 结构、MIME、全局 dragState │ ├─ tree.ts # 树的增删改查、祖先判定 │ ├─ useHistory.ts # 快照式撤销重做 │ └─ useSelection.ts # 选中态与悬浮态 ├─ materials/ # 物料库编辑器与渲染器共用 │ ├─ index.ts # 注册表导出 materialMap │ ├─ Button/index.ts │ └─ Container/index.ts ├─ renderer/ │ └─ NodeRenderer.vue # 递归渲染器预览态和发布态都用它 ├─ store/ │ └─ editor.ts # Piniaschema、选中 id、历史栈 └─ types/ └─ schema.ts关键点是materials/和renderer/不依赖editor/。这条依赖方向很重要编辑器可以按需加载后台才用而渲染器必须能被 C 端页面直接引用。如果渲染器里import了画布的选中框组件包体积会莫名其妙涨一大截。2.3 数据流一次拖拽在四个模块之间怎么走把一次完整拖拽拆开看链路是这样的物料面板上某个物料触发dragstart写入拖拽协议source: material鼠标进入画布dragover持续触发每次都用当前坐标算出落点父节点 插入下标 before/after/inside落点变化时更新高亮指示线同时preventDefault()允许放置松手触发drop读协议、深拷贝出带新id的节点、插入 schema提交一次快照画布重渲染属性面板自动切到新节点。第 2 步是整个链路里最容易写歪的地方。很多人会在dragover里直接操作 DOM 插入占位元素样式一多就乱正确做法是只维护一份「当前落点」状态让视图去响应它。落点状态大概长这样// src/editor/drag.ts export interface DropTarget { parentId: NodeId // 落进哪个容器 index: number // 插到 children 的第几位 position: before | after | inside rect: DOMRect | null // 用来画高亮线不参与逻辑判断 }parentId和index是逻辑rect纯粹是给 UI 用的。这个分离做对了后面加「拖拽到空白区域自动落到根容器末尾」这类规则只需要改计算函数不用碰视图。3. 物料系统与拖拽协议从注册一个组件到 dragstart3.1 物料描述文件长什么样物料不是「一个 Vue 组件」而是「一个组件 一份元信息」。元信息负责回答四个问题这物料叫什么、能不能装子节点、拖进来时默认属性是什么、属性面板该给它生成哪些表单控件。// src/materials/types.ts import type { Component } from vue export type PropField | { key: string; label: string; type: string | number } | { key: string; label: string; type: select; options: string[] } | { key: string; label: string; type: color } export interface MaterialConfig { type: string // 唯一标识写进 schema 的 node.type name: string // 面板上显示的中文名 icon: string // 面板图标标识 component: Component // 画布、预览、发布三处共用同一个组件 isContainer: boolean // 决定能不能接收子节点、能不能判定 inside defaultProps: Recordstring, any defaultStyle: Recordstring, string propsSchema: PropField[] // 属性面板据此自动渲染表单 maxChildren?: number // 容器类物料限制子节点数量防止拖出畸形页面 }component三处共用是这套设计里最值钱的一条。编辑器里看到的按钮、预览里看到的按钮、发布后线上看到的按钮是同一个组件实例的同一份代码。只要不走这条路就一定会出现「编辑器里好好的发布上去样式错了」这种玄学问题。注册表本身就是一个 Map顺便在开发环境做个重复注册的检查// src/materials/index.ts import { buttonMaterial } from ./Button import { containerMaterial } from ./Container import type { MaterialConfig } from ./types const all: MaterialConfig[] [buttonMaterial, containerMaterial] export const materialMap new Mapstring, MaterialConfig() for (const m of all) { if (materialMap.has(m.type)) { // 重复 type 会让 schema 里的节点渲染错组件必须尽早暴露 throw new Error(重复注册的物料类型: ${m.type}) } materialMap.set(m.type, m) } /** 左侧面板按分组展示时用这个列表顺序即展示顺序 */ export const materialList all一个具体物料长这样Button用h函数写避免为了一个按钮建一个 SFC// src/materials/Button/index.ts import { defineComponent, h } from vue import type { MaterialConfig } from ../types const ButtonView defineComponent({ name: ButtonView, props: { text: { type: String, default: 按钮 }, variant: { type: String, default: primary }, disabled: { type: Boolean, default: false } }, setup(props) { return () h( button, { class: [cms-btn, cms-btn--${props.variant}], disabled: props.disabled }, props.text ) } }) export const buttonMaterial: MaterialConfig { type: button, name: 按钮, icon: button, component: ButtonView, isContainer: false, defaultProps: { text: 按钮, variant: primary, disabled: false }, defaultStyle: { width: 120px, height: 40px }, propsSchema: [ { key: text, label: 文案, type: string }, { key: variant, label: 风格, type: select, options: [primary, default, text] }, { key: disabled, label: 禁用, type: select, options: [false, true] } ] }逻辑说明ButtonView的props声明就是 schema 里node.props的契约。defaultProps必须和组件props的默认值对齐否则拖进来第一次渲染和重置属性后的渲染会出现两种样子这种不一致排查起来非常费时间。style单独放是因为它由画布上的拖拽缩放直接写不经过属性面板。参数说明propsSchema里的key对应node.props的键名type决定属性面板生成输入框、下拉还是取色器。新增物料时只要实现MaterialConfig并在all数组里追加一行左侧面板、属性面板、渲染器三处都会自动认出来不需要改任何一处编辑器代码。这就是注册表模式在这个场景下的价值。3.2 拖拽协议dataTransfer 里到底放什么拖拽过程中浏览器只给了你一个通道传数据dataTransfer。协议设计得糙一点没关系但必须自描述否则将来加「从画布外部拖图片进来」会没法扩展。// src/editor/drag.ts export const DRAG_MIME application/x-cms-node /** 拖拽载荷区分「新拖入」和「画布内移动」两种情况 */ export interface DragPayload { source: material | canvas // material从物料面板新拖入canvas画布内挪位置 type: string // 物料类型canvas 场景下用于快速校验 nodeId?: NodeId // sourcecanvas 时才有指向被移动的那个节点 } /** dragover 阶段浏览器不让读 dataTransfer所以额外存一份内存状态 */ export let dragState: { payload: DragPayload | null; target: DropTarget | null } { payload: null, target: null } export function updateDragState(patch: Partialtypeof dragState) { dragState { ...dragState, ...patch } } export function startDrag(e: DragEvent, payload: DragPayload) { e.dataTransfer?.setData(DRAG_MIME, JSON.stringify(payload)) // copy新拖入move画布内挪动。光标会跟着变用户一眼能分清 e.dataTransfer!.effectAllowed payload.source material ? copy : move updateDragState({ payload, target: null }) } export function endDrag() { updateDragState({ payload: null, target: null }) }逻辑说明startDrag里同时做了两件事——写dataTransfer、更新内存里的dragState。写两遍不是冗余是必须的。drop事件里能读dataTransfer但dragover事件里读不到浏览器出于数据隔离的考虑只允许在drop时读取内容。而落点高亮恰恰发生在dragover阶段所以必须有一份内存副本兜底。这个坑几乎每个手写拖拽的人都会踩一次。参数说明source字段决定了drop时的分支逻辑——material走「新建节点」路径canvas走「移动节点」路径。这两条路径的差别不小新建要深拷贝并重新生成整棵子树的id移动要先把原节点从原位置摘掉再插到新位置顺序错了会插出一份幽灵节点。用一个字段区分比用两个不同的 MIME 类型更省事。3.3 落点计算before / after / inside 三态判定落点计算是拖拽建页里唯一有点算法味道的部分。核心是回答鼠标当前落在某个节点的哪一侧以及要不要落进它的内部。// src/editor/tree.ts export type DropPosition before | after | inside /** * 根据鼠标坐标和节点矩形判定插入位置 * param rect 目标节点的包围盒 * param clientX/Y 鼠标视口坐标 * param isContainer 目标节点是否是容器物料 */ export function resolveDropPosition( rect: DOMRect, clientX: number, clientY: number, isContainer: boolean ): DropPosition { const x clientX - rect.left const y clientY - rect.top if (isContainer) { // 容器内缩 20% 作为「内部区域」边缘留给前后插入 const padX rect.width * 0.2 const padY rect.height * 0.2 if (x padX x rect.width - padX y padY y rect.height - padY) { return inside } } // 非容器或者落在容器边缘按纵向中线判前后 return y rect.height / 2 ? before : after } /** 把落点换算成「父节点 插入下标」 */ export function resolveInsertIndex( node: ComponentNode, position: DropPosition, parentChildren: ComponentNode[] ): { parentId: NodeId; index: number } { if (position inside) { return { parentId: node.id, index: node.children.length } } const idx parentChildren.findIndex(c c.id node.id) return { parentId: , // 由调用方补上父 id index: position before ? idx : idx 1 } }逻辑说明inside的判定用 20% 内缩是为了给容器的四个边留出「插入到容器前后」的操作区。如果整块容器区域都判inside用户想把一个区块拖到另一个区块上方就永远做不到。20% 这个值不是定理容器很矮的时候比如一行高度 40px 的横条可以调到 30%避免前后区域太窄点不中。参数说明clientX/Y用视口坐标而不是pageX/Y配合getBoundingClientRect()才是一套混用会导致画布滚动后落点整体偏移表现为「越往下拖越不准」。parentChildren传的是目标节点的兄弟数组用来算before/after的下标inside分支的下标固定是children.length追加到末尾如果想插到容器头部把 index 改成 0 即可。4. 画布与组件树拖拽克隆、嵌套容器和撤销重做4.1 递归渲染器一份 schema 还原整页渲染器是全篇最短的代码之一也是最重要的一段。它做的事情只有一件拿着ComponentNode从materialMap里取出对应组件渲染自己然后递归渲染children。!-- src/renderer/NodeRenderer.vue -- script setup langts import { computed } from vue import type { ComponentNode } from /types/schema import { materialMap } from /materials const props defineProps{ node: ComponentNode }() const material computed(() { const m materialMap.get(props.node.type) if (!m) { // 物料被删掉但存量页面还在引用降级成一个占位块而不是整页白屏 return null } return m }) /script template component :ismaterial.component v-ifmaterial v-bindnode.props :stylenode.style :data-node-idnode.id template v-ifmaterial.isContainer NodeRenderer v-forchild in node.children :keychild.id :nodechild / /template /component div v-else classcms-missing :data-node-idnode.id 未知物料{{ node.type }} /div /template逻辑说明Vue SFC 里递归组件可以直接用文件名自引用不需要额外注册。>// src/editor/useDrop.ts import { nanoid } from nanoid import type { ComponentNode, NodeId } from /types/schema import { materialMap } from /materials /** 深拷贝一棵子树并为每个节点重新生成 id */ export function cloneSubtree(node: ComponentNode): ComponentNode { return { ...node, id: nanoid(), props: { ...node.props }, style: { ...node.style }, children: node.children.map(cloneSubtree) } } /** 按物料类型造一个新节点 */ export function createNode(type: string): ComponentNode { const m materialMap.get(type) if (!m) throw new Error(未注册的物料: ${type}) return { id: nanoid(), type, props: { ...m.defaultProps }, style: { ...m.defaultStyle }, children: [] } } /** 判断 id 是否为 ancestor 的后代防止把父节点拖进自己的子节点 */ export function isDescendant(ancestor: ComponentNode, id: NodeId): boolean { return ancestor.children.some(c c.id id || isDescendant(c, id)) }逻辑说明cloneSubtree里的三段浅拷贝一个都不能省。props和style如果是引用传递新节点和源节点会共享同一份对象改一个另一个跟着变——这在物料面板预览节点上特别容易发生因为预览节点通常是从createNode造出来的模板直接深拷贝就能规避。children用map(cloneSubtree)递归保证嵌套容器里的每个子孙节点都换上新 id否则撤销栈按 id 比对时会互相干扰。参数说明isDescendant是防止树成环的唯一防线。把父容器拖进它自己的子容器children会形成自引用渲染时递归到栈溢出直接白屏而且这个错误发生在运行时构建阶段完全看不出来。所以drop之前一定要做一次判定如果dragPayload.source canvas且目标父节点是被拖节点的后代直接取消这次放置。4.3 快照式撤销重做比命令模式省事撤销重做我一开始想用命令模式写了两天放弃了。原因很实际树的增删改查有十几种操作每个操作都要配一个精确的逆操作嵌套容器一深逆操作的下标算错一次就整棵树乱掉。改成快照式之后代码量少了三分之二。快照式的思路是每次操作完成后把整份 schema 深拷贝压进栈撤销就是把指针往回挪一格。代价是内存但一个页面的 schema 撑死几十 KB五十步历史也就几 MB完全可以接受。// src/editor/useHistory.ts import { ref, shallowRef } from vue import type { PageSchema } from /types/schema const MAX_STEPS 50 export function useHistory(initial: PageSchema) { const snapshots shallowRefPageSchema[]([structuredClone(initial)]) const cursor ref(0) function commit(next: PageSchema) { // 在历史中间做新操作旧的重做分支直接丢弃避免出现分叉历史 const kept snapshots.value.slice(0, cursor.value 1) kept.push(structuredClone(next)) if (kept.length MAX_STEPS) kept.shift() // 超上限丢最老的防止内存无限涨 snapshots.value kept cursor.value kept.length - 1 } function undo(): PageSchema | null { if (cursor.value 0) return null cursor.value - 1 return structuredClone(snapshots.value[cursor.value]) } function redo(): PageSchema | null { if (cursor.value snapshots.value.length - 1) return null cursor.value 1 return structuredClone(snapshots.value[cursor.value]) } const canUndo () cursor.value 0 const canRedo () cursor.value snapshots.value.length - 1 return { commit, undo, redo, canUndo, canRedo } }逻辑说明commit被调用的时机很关键——一次拖拽全程只 commit 一次在drop处理完之后。如果写成dragover里每次都 commit一次拖拽会压进几十个快照撤销一次等于往回退一像素。属性面板的输入同理输入框input事件不 commitchange或者失焦时才 commit否则用户改三个字要按三次撤销。参数说明structuredClone是浏览器原生 API比JSON.parse(JSON.stringify())快而且能正确处理undefined、Date这些 JSON 会丢的类型。如果目标浏览器版本比较老需要降级就用lodash.cloneDeep或者自己写递归。MAX_STEPS 50是个经验值页面节点特别多的时候可以降到 20你会在长按撤销的时候明显感觉到内存和速度的差别。5. 避坑与排查拖拽建页最容易翻车的五个位置5.1 拖拽本身的三类故障现象一松手之后什么都没发生drop永远不触发。原因是dragover里没有调用preventDefault()。HTML5 拖拽规范里dragover的默认行为是「拒绝放置」只有显式preventDefault()才会把它变成合法的放置目标。解决方式是在画布的dragover处理函数第一行就e.preventDefault()并且e.dataTransfer.dropEffect要跟dragstart里的effectAllowed对得上copy配copymove配move否则光标会显示禁止图标但事件照常触发很容易误判。现象二落点高亮一直在闪鼠标稍微一动就跳到别的节点。原因是dragover每秒触发几十次每次都直接setState更新落点视图跟着高频重渲染视觉上就是抖。解决方式有两条一是对坐标做 16ms 的节流二是只在落点的parentId index position真正变化时才更新状态坐标本身不参与响应式。我一般会写一个三元组做比较相等就直接 return。现象三把容器拖进它自己的子容器页面直接白屏。原因是树成了环递归渲染没有终止条件。上文isDescendant就是解决方案在drop里如果payload.source canvas先取出被拖节点判断目标父节点是否在它的后代里是就直接return。另外建议在NodeRenderer里加一个递归深度上限比如 20 层做兜底真出问题时至少能渲染出半页而不是整页崩溃这就是「黑匣子」的价值。5.2 数据与渲染不一致的两类问题现象四删掉中间一个组件后面组件的文案集体错位了一个。原因在v-for的:key用了数组下标。Vue 的 diff 算法按 key 复用实例下标不变但内容整体前移实例就被复用到错误的节点上。解决方法只有一个:key永远用node.id。同理v-for里不要再包一层v-if做过滤Vue 3 里v-if优先级高于v-for拿不到循环变量会直接报错。现象五编辑器里选中框、hover 描边发布到线上还在。原因是编辑器给组件加的辅助样式outline、box-shadow、position: relative写在了同一个 class 上而渲染器和编辑器共用组件。解决办法是给编辑器容器加一个根 class比如cms-editor-root所有辅助样式都写成.cms-editor-root [data-node-id]:hover { ... }这种后代选择器绝不污染组件自身的 class。这样预览和发布态天然干净不需要额外处理。额外一条经验属性面板改了值画布却不更新大概率是因为 schema 被整体替换时用的是ref而不是reactive或者赋值时用了Object.assign打平了一层丢了深层响应式。我一般统一走「深拷贝一份 → 修改 → 整份替换 → commit」这条路径虽然看起来重但行为可预测比跟响应式系统博弈划算得多。6. 二次开发加一个「倒计时」物料并让它走完整条链路真正的考验不是跑通 demo而是别人接手后能不能在半小时内加一个新物料。我把这条路径固化成一份清单加物料时照着走基本不会漏步骤改哪里容易漏的点1. 写组件src/materials/Countdown/index.ts组件 props 要和defaultProps完全对齐2. 声明属性同文件的propsSchemaselect 的 options 是字符串数组不是对象数组3. 注册src/materials/index.ts的all数组忘了加这一行面板里永远不出现4. 验证渲染拖进画布、切预览、看发布态三处样式必须一致第一步的代码最短可以写成这样// src/materials/Countdown/index.ts import { defineComponent, h, ref, onMounted, onUnmounted } from vue import type { MaterialConfig } from ../types const CountdownView defineComponent({ name: CountdownView, props: { seconds: { type: Number, default: 60 }, suffix: { type: String, default: 秒后结束 } }, setup(props) { const left ref(props.seconds) let timer: number | undefined onMounted(() { // 编辑器里也走真实倒计时方便运营确认效果预览态同理 timer window.setInterval(() { if (left.value 0) left.value - 1 }, 1000) }) onUnmounted(() window.clearInterval(timer)) return () h(span, { class: cms-countdown }, ${left.value}${props.suffix}) } }) export const countdownMaterial: MaterialConfig { type: countdown, name: 倒计时, icon: clock, component: CountdownView, isContainer: false, defaultProps: { seconds: 60, suffix: 秒后结束 }, defaultStyle: { fontSize: 20px, color: #ff4d4f }, propsSchema: [ { key: seconds, label: 总秒数, type: number }, { key: suffix, label: 后缀文案, type: string } ] }这里有个细节值得单拎出来倒计时组件带了定时器onUnmounted里必须清掉。编辑器频繁地拖入、删除、撤销组件实例的创建销毁次数远高于线上页面定时器不清会越积越多最后整个编辑器卡死。类似的还有轮播、动画类物料凡是有副作用的都要在卸载钩子里收尾。验证方式我一般按三步走先在画布上拖三个倒计时删中间那个撤销两次看数字和实例是不是都对得上再切预览态确认定时器还在跑、辅助样式没跟过来最后把页面 schema 导出成 JSON手动删掉version字段再导入看迁移函数是不是能兜住。这三步做完一个物料才算真接进来了。从那以后我每次加新物料都强制走一遍「拖入 → 删除 → 撤销 → 预览 → 导出重导」这条链路不再跳过任何一步。前面省下的五分钟后面往往要用两小时来查。希望帮到你。本文还有配套的精品资源点击获取