简介:这是一份面向前端初学者与 CMS 页面搭建入门者的拖拽建站示例包,聚焦“左侧组件库拖拽、右侧画布自由排版”的简易实现,可用于理解拖拽克隆、组件替换与页面结构组织等基础流程。压缩包共 9 个文件,约 63KB,以 5 个 JavaScript 脚本承载拖拽交互与示例逻辑,3 个 CSS 文件负责样式与布局呈现,另有 1 个 HTML 页面作为入口,整体轻量,便于本地打开对照学习。该资源已有 3705 人学习或下载,适合作为教学演示与小型站点搭建的参考样例。读者可从中梳理拖拽事件处理、组件化配置、样式排版和示例页组织方式,快速建立对可视化页面生成器的直观认识,并在此基础上扩展自定义组件与布局规则。
1. 从一次改文案要排期三天说起
运营要把活动页上的按钮文案从「立即参与」改成「马上报名」,走完需求、排期、提测、发布,三天。这个场景基本是所有中后台系统里做 CMS 拖拽生成页面的起点:让非技术同学在物料面板里按住一个区块,拖到画布上松手,页面多出一块,右边改文字改图片,点发布就上线。这篇讲的是其中最「简易」的一条落地路线——区块树加拖拽克隆,不做自由画布、不做像素级定位、不做旋转缩放,两天能出可用版本,后面再按物料往里加。它适合两类人:要交毕设或课设、需要一个完整可演示的页面搭建模块的同学;以及要给内部运营系统补一个页面配置能力的从业者。后面按「需求边界怎么划、Schema 怎么定、拖拽克隆怎么实现、物料和属性面板怎么接、哪些地方最容易失败、最后怎么验收」一路写下去,代码是可以直接抄去改的。
2. 总体架构设计:三层结构与四个模块的职责划分
先把「拖拽生成页面」这件事拆开看,它其实是三个独立问题叠在一起:页面用什么结构描述、这份结构怎么渲染成界面、界面上的拖拽动作怎么改这份结构。三层各自解耦,任何一层出问题都能单独定位。真正容易出问题的地方在第三层,前两层反而是要先定死的部分,因为结构一旦定歪,拖拽做得再顺也白搭。
2.1 需求拆解:先把「简易」的边界划清楚
页面搭建有三种常见做法,先摆在一起对比,再决定选哪条。
| 做法 | 定位方式 | 产出结构 | 二次编辑成本 | 响应式 |
|---|---|---|---|---|
| 自由画布 | 绝对定位 + 宽高 | 一堆带 x/y 的图层 | 高,改一次尺寸全乱 | 差,需要另做移动端 |
| 栅格拖拽 | 12 列栅格 + 跨列 | 行内列结构 | 中,受列宽约束 | 较好 |
| 区块树 | 顺序 + 容器嵌套 | 可读的树形 JSON | 低,改顺序即改结构 | 好,交给 CSS |
CMS 场景里我一般推荐第三种。原因很实际:运营改完的页面还要被下一次活动复用,复用就意味着有人要读懂它、改它、在它上面加东西;自由画布的产出是一堆坐标,人读不懂,机器也不好做响应式适配。区块树则天然对应 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用固定前缀加随机串,不用数组下标。下标在拖拽排序后会变,一旦拿下标当 key,React 复用时会把状态错配到别的区块上,表现是「改了这个输入框,另一个跟着变」,这类问题排查起来非常费时间。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 个区块」倒推的:一个中型区块序列化后大约 1KB,200 块整树约 200KB,50 步快照最坏 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 type="number" 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_id | varchar | 页面标识,同一页面的多个版本共用 |
| version | int | 自增版本号,发布时 +1 |
| schema | text | 完整 Schema JSON 快照 |
| status | tinyint | 0 草稿 / 1 已发布 / 2 已下线 |
| published_at | datetime | 发布时间,草稿为 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 修。翻车最狠的一次是复刻到第三屏才发现容器不支持嵌套,结构直接搭不出来——而这个坑在只拖动单个区块的演示里永远暴露不了。希望这套结构和这份清单,能帮你少走一遍我走过的弯路。
本文还有配套的精品资源,点击获取