区块树+拖拽克隆:CMS页面搭建编辑器简易实现
简介这是一份面向前端初学者与 CMS 页面搭建入门者的拖拽建站示例包聚焦“左侧组件库拖拽、右侧画布自由排版”的简易实现可用于理解拖拽克隆、组件替换与页面结构组织等基础流程。压缩包共 9 个文件约 63KB以 5 个 JavaScript 脚本承载拖拽交互与示例逻辑3 个 CSS 文件负责样式与布局呈现另有 1 个 HTML 页面作为入口整体轻量便于本地打开对照学习。该资源已有 3705 人学习或下载适合作为教学演示与小型站点搭建的参考样例。读者可从中梳理拖拽事件处理、组件化配置、样式排版和示例页组织方式快速建立对可视化页面生成器的直观认识并在此基础上扩展自定义组件与布局规则。1. 从一次改文案要排期三天说起运营要把活动页上的按钮文案从「立即参与」改成「马上报名」走完需求、排期、提测、发布三天。这个场景基本是所有中后台系统里做 CMS 拖拽生成页面的起点让非技术同学在物料面板里按住一个区块拖到画布上松手页面多出一块右边改文字改图片点发布就上线。这篇讲的是其中最「简易」的一条落地路线——区块树加拖拽克隆不做自由画布、不做像素级定位、不做旋转缩放两天能出可用版本后面再按物料往里加。它适合两类人要交毕设或课设、需要一个完整可演示的页面搭建模块的同学以及要给内部运营系统补一个页面配置能力的从业者。后面按「需求边界怎么划、Schema 怎么定、拖拽克隆怎么实现、物料和属性面板怎么接、哪些地方最容易失败、最后怎么验收」一路写下去代码是可以直接抄去改的。2. 总体架构设计三层结构与四个模块的职责划分先把「拖拽生成页面」这件事拆开看它其实是三个独立问题叠在一起页面用什么结构描述、这份结构怎么渲染成界面、界面上的拖拽动作怎么改这份结构。三层各自解耦任何一层出问题都能单独定位。真正容易出问题的地方在第三层前两层反而是要先定死的部分因为结构一旦定歪拖拽做得再顺也白搭。2.1 需求拆解先把「简易」的边界划清楚页面搭建有三种常见做法先摆在一起对比再决定选哪条。做法定位方式产出结构二次编辑成本响应式自由画布绝对定位 宽高一堆带 x/y 的图层高改一次尺寸全乱差需要另做移动端栅格拖拽12 列栅格 跨列行内列结构中受列宽约束较好区块树顺序 容器嵌套可读的树形 JSON低改顺序即改结构好交给 CSSCMS 场景里我一般推荐第三种。原因很实际运营改完的页面还要被下一次活动复用复用就意味着有人要读懂它、改它、在它上面加东西自由画布的产出是一堆坐标人读不懂机器也不好做响应式适配。区块树则天然对应 DOM 的嵌套一份 Schema 既能渲染成 PC 页也能靠 CSS 媒体查询塌成移动端单列。边界要写进方案文档里避免做着做着变成「什么都想支持」拖拽只改变区块的顺序和所属容器不改变坐标容器最多两层页面 → 区块 → 容器区块 → 子区块第三层禁止再放容器物料属性只走右侧面板不在画布上直接拖拽改宽高一个页面最多 200 个区块超过就提示拆分页面。这条「200 个区块」的上限不是随便定的它直接决定了后面 Schema 快照式撤销栈能不能用第 3 章会算这笔账。2.2 页面 Schema一份可读、可 diff 的 JSON 树整份方案的核心就是下面这段结构其它所有模块都围绕它转。{ version: 1.0, pageId: act_202410, title: 秋季活动落地页, root: { id: root, type: Page, props: { background: #ffffff, maxWidth: 750 }, children: [ { id: blk_8f2a, type: Banner, props: { image: /static/banner-01.png, height: 320, link: /act }, children: [] }, { id: blk_3c71, type: Container, props: { direction: row, gap: 12 }, children: [ { id: blk_9d05, type: Text, props: { content: 限时活动, fontSize: 28 }, children: [] } ] } ] } }字段设计上有几个刻意的取舍值得在方案里写清楚答辩时也是加分项。id用固定前缀加随机串不用数组下标。下标在拖拽排序后会变一旦拿下标当 keyReact 复用时会把状态错配到别的区块上表现是「改了这个输入框另一个跟着变」这类问题排查起来非常费时间。type只存物料标识不存组件路径这样物料可以换实现而不动存量数据。props一律是纯数据字符串、数字、布尔、数组不允许塞函数或 DOM 引用否则整份 Schema 就没法 JSON 序列化落库。children即使为空也保留成空数组渲染和遍历逻辑就不用到处判空。version字段现在看着多余等第三次改结构时就会庆幸有它。发布时把 Schema 快照和版本号一起存老页面用老渲染器、新页面用新渲染器就是最省事的兼容方案。2.3 渲染引擎同一份 Schema 跑编辑态和预览态编辑态和预览态必须共用同一套渲染逻辑否则「预览和编辑看到的不一样」会变成长期存在的投诉。差别只在最外层要不要包一个可交互的壳。// mode: edit 编辑态 | view 预览态 // 递归渲染同一份 schema两种外壳 function renderBlock(node, mode) { const meta materialMap[node.type]; if (!meta) return null; // 物料被下线时兜底避免整页白屏 const Comp meta.component; const children (node.children || []).map(c renderBlock(c, mode)); // 编辑态包一层带>// 全局唯一的拖拽会话按下时创建松开时销毁 const session { active: false, source: null, // palette 从物料面板新建 | canvas 画布内移动 material: null, // 物料类型如 Banner blockId: null, // source 为 canvas 时的原始块 id ghost: null, // 跟随指针的克隆节点 containerId: root, // 当前命中的容器 index: -1, // 预计插入下标-1 表示无有效落点 rafId: 0, lastEvent: null }; // 状态流转idle → dragging → dropping → idle // dragging 期间只更新 session 和指示线样式绝不改 schema // dropping 时才算一次正式提交进撤销栈关键约束是「拖拽过程中不改数据」。很多人图省事pointermove里直接把块插进数组、移出再插回视觉上是省了画指示线代价是每移动一像素就产生一次树变更撤销栈会被塞满而且组件反复 mount/unmount 会让内部状态比如输入框光标全丢。正确做法是移动阶段只算index和containerId用一个绝对定位的插入指示条表示落点pointerup时才真正执行一次插入。3.2 拖拽克隆幽灵节点怎么造、怎么跟着走从物料面板拖出来的时候画布上还没有任何真实节点用户需要有东西跟着手指走这就是克隆节点通常叫幽灵节点ghost。function createGhost(type, x, y) { const meta materialMap[type]; const el document.createElement(div); el.className drag-ghost; el.textContent meta.name; // 简易版只显示物料名想更真实可渲染缩略图 Object.assign(el.style, { position: fixed, left: 0, top: 0, pointerEvents: none, // 关键不让它挡住下面的命中检测 zIndex: 9999, opacity: 0.85, transform: translate3d(${x}px, ${y}px, 0) }); return el; } // 指针按下同时处理「从面板新建」和「画布内移动」两种来源 function onPointerDown(e, payload) { if (e.button ! 0) return; // 只响应左键 e.preventDefault(); session.active true; session.source payload.source; // palette | canvas session.material payload.type; session.blockId payload.blockId || null; // 指针捕获指针移出画布甚至移出窗口后move/up 仍然派发到这个元素 e.currentTarget.setPointerCapture(e.pointerId); if (payload.source palette) { session.ghost createGhost(payload.type, e.clientX, e.clientY); document.body.appendChild(session.ghost); } }三个参数值得解释。pointerEvents: none是幽灵节点的生命线去掉它document.elementFromPoint和所有命中检测都会返回幽灵节点自己插入位置永远算不出来表现为「指示线一直停在第一块前面」。用transform: translate3d而不是改left/top是因为 transform 走合成层不触发重排60fps 下明显更跟手left/top在长页面上会看到明显的延迟感。setPointerCapture解决的是「鼠标甩太快、离开画布后松开pointerup收不到页面卡在拖拽态」这个经典问题——不加它用户必须回到画布内松手体验非常奇怪。移动阶段要做节流把事件压到每帧一次function onPointerMove(e) { if (!session.active) return; session.lastEvent e; if (session.rafId) return; // 本帧已排过直接丢弃 session.rafId requestAnimationFrame(() { session.rafId 0; const { clientX, clientY } session.lastEvent; if (session.ghost) { session.ghost.style.transform translate3d(${clientX}px, ${clientY}px, 0); } // 命中检测 更新指示线见 3.3 updateDropTarget(clientX, clientY); }); }requestAnimationFrame节流的意义不只是省性能更重要的是让「最后一次移动」的处理顺序确定下来所有中间位置被丢弃只有每帧最后一次指针位置参与计算指示线不会因为处理顺序错乱而抖动。3.3 命中检测与插入指示线这是整个拖拽里最需要想清楚的一段。目标不是找「指针在哪个元素上」而是找「应该插到哪个容器的第几个位置」。做法分两步先用elementFromPoint找最近的容器再在容器内按子块中线算下标。// 在容器内计算插入下标以每个直接子块的垂直中线为分界 function calcInsertIndex(containerEl, clientY, clientX) { const isRow containerEl.dataset.direction row; const kids [...containerEl.querySelectorAll(:scope [data-block-id])]; for (let i 0; i kids.length; i) { const r kids[i].getBoundingClientRect(); // 横向排布的容器按 X 轴判断纵向按 Y 轴 const mid isRow ? r.left r.width / 2 : r.top r.height / 2; const pos isRow ? clientX : clientY; if (pos mid) return i; } return kids.length; // 落在最后追加 } // 找容器从指针位置向上冒泡找到第一个>const history { past: [], future: [], limit: 50 }; // 提交一次变更入栈、清空重做、写回、重渲染 function commit(nextSchema, label) { history.past.push({ schema: currentSchema, label }); if (history.past.length history.limit) history.past.shift(); // 超出上限丢最老的 history.future.length 0; // 新操作产生后原来的重做链作废 currentSchema nextSchema; markDirty(); render(); } function undo() { if (!history.past.length) return; const prev history.past.pop(); history.future.push({ schema: currentSchema, label: prev.label }); currentSchema prev.schema; render(); } function redo() { if (!history.future.length) return; const next history.future.pop(); history.past.push({ schema: currentSchema, label: next.label }); currentSchema next.schema; render(); }参数上limit: 50是按前面定的「单页最多 200 个区块」倒推的一个中型区块序列化后大约 1KB200 块整树约 200KB50 步快照最坏 10MB 内存浏览器完全扛得住。如果哪天要支持上千区块再换结构共享或 patch 方案现在没必要提前优化。label字段是给撤销按钮做提示用的「撤销删除横幅图」比只显示一个「撤销」好用得多实现成本却只是一行字符串。快照式撤销还有个隐藏好处Schema 天然不可变任何一步的历史状态都能直接拿去和当前状态做 diff做「版本对比」这类功能不用另写代码。拖拽落点提交时要额外判一次合法性目标容器是否是容器类型、层级是否超过两层、是否把容器拖进了自己内部。最后一条最容易漏把父容器拖进自己的子容器会造成循环引用渲染时直接栈溢出白屏。4. 物料体系与属性配置面板拖拽解决的是「怎么放」物料和属性面板解决的是「放什么、怎么改」。这一层的设计目标是新增一个物料只写一个配置文件加一个组件不改编辑器主流程。做不到这一点物料一多编辑器代码就会变成一团乱麻。4.1 物料注册协议一个对象描述一个组件registerMaterial({ type: Banner, // 唯一标识写进 schema 的 type 字段 name: 横幅图, // 物料面板和幽灵节点上显示的名字 group: 基础组件, container: false, // 是否可作为容器决定>function PropForm({ block, onChange }) { const meta materialMap[block.type]; return meta.form.map(field { const value block.props[field.key]; switch (field.type) { case number: return ( Field key{field.key} label{field.label} input typenumber value{value ?? 0} min{field.min} max{field.max} /* 用 onBlur 提交而不是 onChange避免每敲一个字符进一次撤销栈 */ onChange{e onChange(field.key, e.target.value, { defer: true })} onBlur{e onChange(field.key, Number(e.target.value))} / /Field ); case boolean: return Switch key{field.key} checked{!!value} onChange{v onChange(field.key, v)} label{field.label} /; default: return TextInput key{field.key} label{field.label} value{value ?? } placeholder{field.placeholder} onChange{v onChange(field.key, v, { defer: true })} onBlur{v onChange(field.key, v)} /; } }); }defer参数这里控制的是「只改本地显示、不进撤销栈」onBlur时才真正提交一次。不做这个区分在一个字号输入框里敲「2」「4」撤销栈里就多两步用户按一次撤销只回退一个字符体验很差这也是撤销栈被塞爆的常见来源。字段类型里没有做富文本是刻意的富文本编辑器会引入自己的 DOM 和选区块和画布的选中逻辑互相抢事件简易方案里先用多行文本加换行符渲染等确有需求再接。4.3 画布选中态与属性面板的双向同步选中状态和 Schema 是两份数据选中态不该进 Schema也不该进撤销栈——用「撤销」把选中态一起回退了是很怪的行为。const uiState { selectedId: null, hoverId: null, dragging: false }; // 面板改值找到节点、合并 props、整体提交 function updateProps(blockId, patch) { const next structuredClone(currentSchema); const node findNode(next, blockId); if (!node) return; Object.assign(node.props, patch); commit(next, 修改${materialMap[node.type].name}); } // 递归查找深度优先命中即返回 function findNode(node, id) { if (node.id id) return node; for (const child of node.children || []) { const hit findNode(child, id); if (hit) return hit; } return null; }structuredClone是现在的原生深拷贝比 JSON 那套快也更准但它同样不能处理函数Schema 里本来也不该有函数。老浏览器环境要兜底就直接退回JSON.parse(JSON.stringify())。画布点击选中要处理好冒泡点子块的时候不能同时把父容器也选中做法是在BlockShell的 click 里stopPropagation只在最内层生效。指针在子块上移动时hover 高亮应该给到最内层块但拖拽落点判定又要能冒泡到外层容器——这是两套事件前者用mouseover加closest后者用elementFromPoint独立算不要混在一起。4.4 发布Schema 落库与静态产物保存分两档草稿和发布。草稿每次改动自动存覆盖同一份记录发布生成一个带版本号的快照页面引用指向快照回滚就是把引用指回旧快照。表结构大概是这样。字段类型说明page_idvarchar页面标识同一页面的多个版本共用versionint自增版本号发布时 1schematext完整 Schema JSON 快照statustinyint0 草稿 / 1 已发布 / 2 已下线published_atdatetime发布时间草稿为 null发布前跑一遍校验把能想到的坑挡在入库之前Schema 能否通过物料注册表的类型检查有未注册的type就拦住、层级是否超过两层、必填的图片字段是否为空、单页区块数是否超限。校验不通过就列出具体是哪一块哪个字段别只弹一句「保存失败」运营根本不知道去哪改。对外输出走一条独立接口读取已发布快照、用mode: view渲染成 HTML 字符串缓存到 CDN。这条路径上不要有任何编辑相关代码包体小、也没有编辑态那些事件监听。5. 避坑与排查五个最容易失败的点下面五条基本覆盖了这类编辑器上线前后 80% 的报障。每条的写法都是「现象 → 原因 → 解决」遇到类似症状可以直接对号。5.1 指针甩出画布后松开页面卡在拖拽态现象快速拖动后移出浏览器窗口松手回来发现指示线还在、页面点不动只能刷新。原因pointerup只挂在画布或某个元素上指针离开该元素后事件根本派发不到session.active一直是true。解决按下时调用setPointerCapture(e.pointerId)把后续事件全部锁定到这个元素同时在window上补一个pointercancel和blur的兜底监听统一走resetSession()。resetSession要做全套清理移除幽灵节点、清空指示条、重置session、恢复document.body.style.userSelect。5.2 画布放在 iframe 里拖拽完全失效现象为了隔离样式把画布放进 iframe结果从物料面板往 iframe 里拖指示线不出现松手也没反应。原因跨文档后指针事件的坐标是相对各自文档的elementFromPoint在父文档里拿不到 iframe 内的元素。解决两种做法选一个。简易版干脆不用 iframe画布用 Shadow DOM 或前缀类名隔离样式成本最低必须用 iframe 的话父页面负责幽灵节点跟随pointermove时把「指针在 iframe 内的相对坐标」用postMessage传进去由 iframe 内部自己做命中检测并回传containerId index。后一种要多写一层消息协议非必要不上。5.3 拖出两个同样的块改一个另一个跟着变现象连续拖两个文本块到画布改第一个的字号第二个也变了。原因createBlockFromMaterial里直接引用了meta.defaults两个块共享同一个 props 对象或者updateProps里没做深拷贝直接改的是原树节点。解决克隆时深拷贝defaults每次修改前structuredClone(currentSchema)整树复制改副本再整体替换。后一步顺便保证了撤销栈里的历史快照不会被后续修改污染——这也是快照式撤销必须配深拷贝的原因。5.4 区块一多拖拽就开始掉帧现象页面区块超过 60 个后拖拽明显不跟手指示线一卡一卡。原因pointermove每次都遍历所有子块调getBoundingClientRect再加上整树重渲染一帧内做了几十次布局查询。解决三步。移动事件用requestAnimationFrame合并一帧只处理一次矩形缓存到拖拽开始、滚动、尺寸变化时才重算拖拽过程中只更新一个独立的指示条节点绝不触发画布重渲染。做完这三步200 块以内基本感觉不到卡顿。如果还卡就去看是不是BlockShell里有box-shadow或filter这类开销大的样式在 hover 时频繁切换。5.5 移动端能拖但拖完页面滑不动了现象手机上长按拖拽正常松手后整个画布区域上下滑动失灵。原因为了阻止拖拽时页面跟着滚给画布加了touch-action: none取消拖拽时没恢复或者pointerdown里preventDefault之后滚动被永久禁掉。解决touch-action: none只在session.active期间加到画布根节点上resetSession里移除。另外别在pointerdown就preventDefault改成在确认进入拖拽移动距离超过 5px 阈值之后再拦这样单纯的竖向滑动仍然能正常滚动页面。6. 验收与答辩把能力讲成一份可核对的清单这类项目验收最怕的就是演示时只拖了一个横幅图就说「做完了」。评审和答辩看的是边界不是顺路的那个主流程。我一般会准备一份清单逐条当场演示每条都能落到一个可观察的结果上。验收项操作预期结果新建区块从物料面板拖一个横幅图到空页面落点位置精确松手后块出现且自动选中排序把第三块拖到第一块上方指示线在两者之间松手后顺序正确撤销可回退跨容器把文本块拖进容器区块内部层级变化正确容器高度自适应变高非法落点把容器拖进自己的子容器指示线消失或变红松手无变化有提示文案属性联动在右侧改高度 320 → 480画布实时变化一次撤销回到 320不逐字符回退撤销重做连续 10 次操作后连按撤销每步都能正确回退50 步后不再回退且不报错刷新恢复拖完刷新页面布局完整保留选中态清空选中态不该持久化预览一致编辑态与预览态并排对比内容、间距、图片完全一致预览态无编辑痕迹移动端手机宽度打开预览页单列塌陷无横向滚动条演示顺序建议按「新建 → 排序 → 跨容器 → 撤销」走这四步能覆盖 Schema 结构、命中检测、命令栈三个核心设计。非法落点和撤销上限单独拿出来讲这两处最能体现「想过异常情况」比多拖十个物料有说服力。答辩被问到「为什么不用现成的拖拽库」时用第 2.4 节那套理由回答需要的是拖拽触发数据变更不是元素位移库的抽象在这里是负担。还有一个技巧提前准备一个「复刻真实活动页」的演示页用编辑器把某个常见活动页的结构完整搭一遍——横幅、倒计时、商品容器、三列图文。评审的人对活动页有直观印象看到能被复现出来比任何功能列表都有说服力顺带也验证了物料是否够用。从那次上线后我落下个习惯交付任何编辑器类项目验收前都强制拿一个真实页面从零复刻一遍中间卡住的每一处都记下来当成 bug 修。翻车最狠的一次是复刻到第三屏才发现容器不支持嵌套结构直接搭不出来——而这个坑在只拖动单个区块的演示里永远暴露不了。希望这套结构和这份清单能帮你少走一遍我走过的弯路。本文还有配套的精品资源点击获取