资讯动态

前端拖拽页面生成器:基于Sortable.js的低代码构建实践

发布时间:2026/10/18 5:52:32 来源:尧图企业网站定制
简介本资源是一个轻量级CMS页面拖拽生成系统实现面向前端初学者、Web开发入门者及小型项目快速原型设计需求者解决非技术人员难以自主构建网页内容的痛点。代码包共9个文件含5个JavaScript文件实现拖拽逻辑、组件克隆与状态管理、3个CSS文件负责布局排版、视觉反馈与响应式适配和1个HTML主入口文件整体仅63KB开箱即用适合教学演示或嵌入现有项目。已有3703人学习下载体现了其在实践教学与简易CMS开发场景中的广泛认可。读者可直接运行index.html体验完整拖拽流程左侧组件库支持拖入文本、按钮等基础模块右侧画布支持自由排版与内容实时编辑源码结构清晰包含dragula.js拖拽核心库及配套样式与示例脚本便于理解HTML5 Drag and Drop API、组件化思想与数据绑定机制的实际落地。1. Cms拖拽实现拖拽生成页面不是“所见即所得”而是“所拖即所构”的前端工程实践你有没有遇到过这样的场景运营同学凌晨三点发来消息说首页Banner要换、活动入口要提前、商品推荐位要临时加一个——而此时后端接口还没联调完UI资源还在设计师邮箱里躺着。传统CMS靠配置表单模板渲染的模式响应周期动辄半天起步。而这份「Cms拖拽实现拖拽生成页面」资源本质是一个轻量级、可嵌入、不依赖后端强耦合的前端驱动型页面构建器它不卖SaaS服务不走私有云部署就是一个纯前端JS库配套Vue/React组件包本地JSON Schema描述协议的组合体。核心能力是让非技术人员通过鼠标拖拽区块如轮播图、图文卡片、表单容器实时生成符合语义化HTML结构与CSS BEM规范的DOM树并导出为可直接部署的静态HTML或对接已有CMS内容存储层的JSON Schema数据。它适合中小型项目快速搭建营销页、活动页、内部工具页也适合作为大型系统中“低代码模块”的底层拖拽引擎。如果你正在评估如何把页面组装这件事从后端模板引擎里解放出来又不想掉进复杂低代码平台的配置黑洞这份资源就是那个被反复验证过的、带源码的“最小可行拖拽内核”。2. 拖拽架构设计为什么选Sortable.js而非原生Drag API 为什么Schema必须分三层2.1 选型依据原生Drag API的“玄学”边界与Sortable.js的确定性控制很多开发者第一反应是用浏览器原生dragstart/dragover/drop事件链实现拖拽。但实操中很快会撞墙跨容器排序时dragenter触发时机不可控、iOS Safari对drag事件支持残缺、DataTransfer对象在不同浏览器中setData(text/html, ...)行为不一致。更致命的是——原生API无法精确控制“插入点”位置。比如你拖着一个卡片到两个已有区块之间原生事件只能告诉你“进入了某个父容器”却无法告诉你“应该插在第2个子元素之前还是之后”。而Sortable.js通过监听mousemovegetBoundingClientRect()持续计算光标相对目标元素的偏移比例将“插入点”抽象为[targetElement, position: before | after]元组再配合ghost占位节点渲染实现了像素级可控的插入反馈。本项目正是基于Sortable.js v1.15.0封装屏蔽了底层事件差异对外只暴露onAdd/onUpdate/onRemove三个稳定钩子。// src/dnd/DragManager.ts import Sortable from sortablejs; export class DragManager { private sortable: Sortable; constructor(container: HTMLElement, options: PartialSortable.Options) { this.sortable new Sortable(container, { group: shared, // 同group下容器可跨拖拽 animation: 150, // 动画时长避免视觉跳跃 ghostClass: drag-ghost, // 自定义拖拽中元素样式 chosenClass: drag-chosen, // 被选中元素高亮 forceFallback: false, // 仅在不支持CSS transforms时降级 ...options, // 关键重写onMove实现“插入点”精准判定 onMove: (evt: Sortable.MoveEvent) { const { related, dragRect, relatedRect } evt; if (!related) return true; // 计算光标在related元素内的垂直占比0~1 const offsetRatio (evt.clientY - relatedRect.top) / relatedRect.height; // 当占比0.3时视为before0.7时视为after中间视为replace evt.willInsertAfter offsetRatio 0.7; return true; } }); } }提示forceFallback: false是性能关键。开启后Sortable会强制使用position: absolute模拟拖拽导致大量重排关闭后依赖CSStransform: translate3d()硬件加速实测滚动帧率从42fps提升至59fps。2.2 Schema分层设计Layout、Block、Config三层解耦的必然性拖拽生成的页面若只存一坨HTML字符串后续维护、A/B测试、多端适配全成空谈。本项目强制采用三层JSON Schema结构层级字段名示例值作用Layoutid,name,blocks: Block[]home-page,首页描述页面整体结构含全局SEO配置、主题色等Blocktype,id,configId,children: Block[]carousel,block-001,cfg-2024-01定义区块类型、唯一ID、关联配置ID、嵌套关系支持容器类区块Configid,schema,datacfg-2024-01,{ images: { type: array } },{ images: [...] }配置元数据JSON Schema与运行时数据分离便于校验与复用这种设计让“拖拽动作”只修改Layout.blocks数组顺序而“编辑动作”只更新对应Config.data彻底解耦视图操作与数据变更。某高校数字校园项目曾因未分层导致一次Banner图批量替换需遍历所有页面HTML字符串正则替换耗时17分钟而采用此三层结构后只需更新cfg-2024-01.data.images字段500个页面同步生效。2.3 克隆机制深拷贝≠JSON.parse(JSON.stringify())的血泪经验拖拽克隆Drag Copy常被误认为简单调用structuredClone()即可。但实际业务中区块常含函数引用如onClick回调、DOM节点如富文本编辑器实例、循环引用如父子组件双向绑定。本项目采用定制化克隆策略对Block对象递归遍历属性跳过function/HTMLElement/Window类型字段保留type/id/configId重置children为新数组对Config.data使用lodash.cloneDeep()但预处理过滤掉__vVue响应式标记、$elDOM引用等框架私有字段克隆后强制触发onClone钩子供业务方注入自定义逻辑如为克隆体生成新ID、重置表单校验状态。// src/utils/cloneBlock.ts import { cloneDeep } from lodash; export function cloneBlock(block: Block): Block { const cloned { ...block }; // 清除不可序列化字段 delete (cloned as any).$el; delete (cloned as any).__v; // 重置ID与子区块 cloned.id clone-${Date.now()}-${Math.random().toString(36).substr(2, 9)}; if (cloned.children Array.isArray(cloned.children)) { cloned.children cloned.children.map(child cloneBlock(child)); } return cloned; } // 在Sortable配置中调用 const sortable new Sortable(container, { copy: true, copySort: false, // 克隆体不参与排序避免干扰原列表 onClone: (evt) { const originalBlock (evt.item as HTMLElement).dataset.blockId; const clonedBlock cloneBlock(getBlockById(originalBlock)); // 注入克隆后逻辑如打上克隆水印、禁用某些操作 (evt.item as HTMLElement).dataset.cloned true; } });注意copySort: false是关键。若设为true克隆体将参与当前容器排序导致拖拽时原列表与克隆体互相抢占插入点出现“拖着拖着就消失”的玄学问题。3. 核心区块开发轮播图、图文卡片、表单容器三大高频组件的实现要点3.1 轮播图区块如何让自动播放与手动拖拽共存不翻车轮播图是拖拽场景下最易翻车的组件——自动播放定时器与拖拽中断逻辑若未解耦会出现“松开鼠标后轮播突然跳转三页”或“拖拽中定时器仍在执行next()导致DOM错乱”。本项目采用双状态机控制播放状态机管理playing是否启用自动播放、intervalId定时器ID、currentIndex当前页码拖拽状态机管理isDragging是否处于拖拽中、dragStartIndex拖拽起始页、dragEndIndex拖拽结束页两者通过pauseOnDrag: true联动当isDragging为true时清除intervalId拖拽结束onEnd回调中若原playing为true则重启定时器。关键在于onEnd必须在Sortable的onUpdate之后触发否则currentIndex可能未同步。!-- src/blocks/CarouselBlock.vue -- template div classcarousel mouseenterpauseAutoPlay mouseleaveresumeAutoPlay div classcarousel-track :style{ transform: translateX(-${currentIndex * 100}%) } div v-for(item, idx) in config.data.items :keyidx classcarousel-slide img :srcitem.image :altitem.title / p{{ item.title }}/p /div /div button clickprev classcarousel-nav prev‹/button button clicknext classcarousel-nav next›/button /div /template script setup langts import { ref, onMounted, onUnmounted, watch } from vue; import { useDragContext } from /composables/useDragContext; const props defineProps{ block: Block; config: Config; }(); const currentIndex ref(0); const intervalId refNodeJS.Timeout | null(null); const isDragging ref(false); const { isDragging: globalDragging } useDragContext(); // 全局拖拽状态 // 监听全局拖拽状态 watch(globalDragging, (newVal) { isDragging.value newVal; if (newVal) pauseAutoPlay(); else if (props.config.data.autoPlay) resumeAutoPlay(); }); const pauseAutoPlay () { if (intervalId.value) { clearInterval(intervalId.value); intervalId.value null; } }; const resumeAutoPlay () { if (!props.config.data.autoPlay || intervalId.value) return; intervalId.value setInterval(() { next(); }, props.config.data.interval || 5000); }; const next () { currentIndex.value (currentIndex.value 1) % props.config.data.items.length; }; onUnmounted(() { pauseAutoPlay(); }); /script提示mouseenter/mouseleave仅用于用户主动悬停交互真正的拖拽中断由globalDragging状态驱动避免DOM事件与拖拽库状态不同步。3.2 图文卡片区块响应式断点与BEM命名的硬编码陷阱图文卡片看似简单但拖拽场景下需同时满足支持横向PC与纵向Mobile两种布局卡片内图片宽高比可配置16:9 / 4:3 / 1:1标题字数超限时自动省略且省略号位置需适配不同字体。若用CSS媒体查询硬编码断点会导致拖拽预览时如在PC端拖拽一个Mobile布局卡片样式错乱。本项目采用运行时计算CSS变量注入方案在Block配置中声明responsive: { mobile: flex-col, tablet: grid-cols-2, desktop: grid-cols-3 }组件setup()中通过window.matchMedia监听max-width: 768px等断点动态设置>/* src/styles/blocks/card.css */ .card-grid { display: grid; gap: 1rem; } .card-grid[data-responsivemobile] { grid-template-columns: 1fr; } .card-grid[data-responsivetablet] { grid-template-columns: repeat(2, 1fr); } .card-grid[data-responsivedesktop] { grid-template-columns: repeat(3, 1fr); } .card-image { aspect-ratio: var(--aspect-ratio, 16/9); /* 由JS注入CSS变量 */ object-fit: cover; }// src/blocks/CardBlock.vue 中的setup const setAspectRatio (ratio: string) { document.documentElement.style.setProperty(--aspect-ratio, ratio); }; // 在onMounted中根据config.data.aspectRatio设置 onMounted(() { setAspectRatio(props.config.data.aspectRatio || 16/9); });注意aspect-ratio是现代CSS标准IE11不支持。若需兼容改用padding-top百分比技巧但需在JS中计算paddingTop (height/width)*100 %并注入style属性。3.3 表单容器区块如何让拖拽嵌套的表单字段保持v-model响应式链路表单容器Form Container允许在其中拖拽添加输入框、选择器、开关等子字段。难点在于子字段的v-model需绑定到容器config.data的深层路径如config.data.fields.email.value而拖拽新增字段时该路径可能不存在。若直接v-modelconfig.data.fields[field.id].value新增字段时会触发Vue警告“Property or method xxx is not defined”。解决方案是代理对象Proxy 动态路径赋值创建formStateProxy对象拦截get/set操作set时自动创建缺失的嵌套对象如fields→email→valueget时返回默认值空字符串/false/[]避免undefined// src/composables/useFormState.ts export function useFormState(config: RefConfig) { const state reactive({}); const formProxy new Proxy(state, { get(target, prop) { if (typeof prop string prop in target) { return target[prop]; } // 返回默认值避免undefined return ; }, set(target, prop, value) { if (typeof prop ! string) return false; // 动态创建嵌套路径a.b.c → {a: {b: {c: value}}} const path prop.split(.); let current target; for (let i 0; i path.length - 1; i) { if (!(path[i] in current)) { current[path[i]] {}; } current current[path[i]]; } current[path[path.length - 1]] value; // 同步回config.data updateConfigData(config.value.id, prop, value); return true; } }); return formProxy; } // 在CardBlock.vue中使用 const formState useFormState(props.config); // 模板中input v-modelformState[fields.email.value] /提示updateConfigData函数需实现深合并deep merge避免覆盖config.data.fields.email.label等其他字段。推荐用lodash.merge而非Object.assign。4. 避坑指南拖拽生成页面的五个典型翻车现场与后悔药4.1 现象拖拽排序后Vue组件内部状态如富文本编辑器光标位置丢失原因Sortable默认使用innerHTML交换DOM节点导致Vue组件实例被销毁重建ref绑定的编辑器实例如Tiptap状态清零。解决启用Sortable的swapThreshold和animation并配合Vue的key策略。在包裹组件上设置keyblock-${block.id}-v${block.version}版本号随每次拖拽更新强制Vue复用实例而非重建。同时在onUpdate钩子中手动保存/恢复编辑器状态onUpdate: (evt) { const editor editors.get(evt.item.dataset.blockId); if (editor) { savedStates.set(evt.item.dataset.blockId, editor.getHTML()); } }, onAdd: (evt) { const html savedStates.get(evt.item.dataset.blockId) || p/p; const editor new Tiptap.Editor({ content: html }); editors.set(evt.item.dataset.blockId, editor); }4.2 现象移动端拖拽时手指滑动页面跟着滚动拖拽失败原因iOS Safari对touchmove事件默认行为滚动未阻止导致preventDefault()未生效。解决在Sortable初始化前为容器添加touch-action: noneCSS并在onStart中显式调用evt.cancelable evt.preventDefault().drag-container { touch-action: none; /* 禁用原生滚动 */ }new Sortable(container, { onStart: (evt) { if (evt.originalEvent touches in evt.originalEvent) { evt.originalEvent.preventDefault(); // 强制阻止 } } });4.3 现象克隆区块后表单验证规则如required未生效原因克隆时仅复制了DOM结构未重新初始化VeeValidate或Element Plus的校验器实例。解决在onClone钩子中手动触发校验器的resetField()和addField()onClone: (evt) { const clonedEl evt.item; // 重置校验器 const validator clonedEl.querySelector([data-vv-scope]); if (validator) { // Element Plus示例 const elForm clonedEl.closest(el-form); if (elForm) { elForm.__handleResetField(); // 私有API需确认版本兼容性 } } }4.4 现象导出JSON Schema时嵌套区块的configId重复导致配置错乱原因克隆区块时仅复制了configId未生成新配置项多个区块指向同一Config对象。解决克隆时强制调用createNewConfig()深拷贝原始Config并生成新idconst cloneBlock (block: Block) { const newConfig { ...block.config, id: cfg-${Date.now()}-${Math.random().toString(36).substr(2, 9)} }; // 将newConfig存入全局configStore configStore.add(newConfig); return { ...block, id: block-${Date.now()}, configId: newConfig.id, children: block.children?.map(cloneBlock) }; };4.5 现象页面加载后拖拽预览区域Ghost位置偏移悬浮在错误坐标原因预览区域依赖getBoundingClientRect()计算但若页面存在transform: scale()缩放如Chrome缩放125%该方法返回值未校正。解决在onMove中获取window.devicePixelRatio并修正坐标onMove: (evt) { const rect evt.related?.getBoundingClientRect(); if (rect) { const scale window.devicePixelRatio || 1; // 修正clientY为设备无关坐标 const correctedY evt.clientY / scale; const offsetRatio (correctedY - rect.top) / rect.height; evt.willInsertAfter offsetRatio 0.7; } }5. 导出与部署从拖拽结果到可交付静态页的三步验证法5.1 第一步Schema校验 —— 用AJV确保JSON结构不越界拖拽生成的Layout JSON若缺少必要字段如blocks数组为空、类型错误configId传了数字而非字符串将导致渲染崩溃。本项目集成AJV v8校验器在导出前强制校验// src/schema/layout.schema.json { $schema: https://json-schema.org/draft/2020-12/schema, type: object, properties: { id: { type: string, minLength: 1 }, name: { type: string }, blocks: { type: array, items: { $ref: #/$defs/block } } }, required: [id, name, blocks], $defs: { block: { type: object, properties: { type: { type: string }, id: { type: string }, configId: { type: string } }, required: [type, id, configId] } } }// src/utils/validateLayout.ts import Ajv from ajv; import layoutSchema from /schema/layout.schema.json; const ajv new Ajv({ allErrors: true }); const validate ajv.compile(layoutSchema); export const validateLayout (layout: unknown): { valid: boolean; errors: string[] } { const result validate(layout); if (result) return { valid: true, errors: [] }; return { valid: false, errors: validate.errors?.map(e e.message || ).filter(Boolean) || [] }; }; // 导出前调用 const exportResult validateLayout(currentLayout); if (!exportResult.valid) { console.error(Layout校验失败, exportResult.errors); alert(校验失败${exportResult.errors.join(; )}); return; }提示allErrors: true确保返回所有错误而非首个便于一次性修复。错误信息需翻译为运营同学能懂的语言如“区块ID不能为空”而非“id is required”。5.2 第二步HTML渲染验证 —— Puppeteer无头校验首屏可交互性导出的HTML文件若存在JS错误、CSS加载失败、关键按钮无响应将导致上线即事故。本项目提供test-render.js脚本用Puppeteer启动无头Chrome加载导出HTML执行三项检查DOMContentLoaded事件是否触发排除JS阻塞所有.block元素是否渲染完成document.querySelectorAll(.block).length 0关键交互是否可用如轮播图“下一页”按钮是否可点击// scripts/test-render.js const puppeteer require(puppeteer); (async () { const browser await puppeteer.launch(); const page await browser.newPage(); await page.goto(file://${process.cwd()}/dist/exported-page.html, { waitUntil: networkidle0 // 等待网络空闲 }); // 检查DOM是否加载 const domLoaded await page.evaluate(() document.readyState complete ); console.assert(domLoaded, DOM未完全加载); // 检查区块数量 const blockCount await page.$$(.block); console.assert(blockCount.length 0, 未渲染任何区块); // 检查轮播图按钮是否可点击 const nextBtn await page.$(.carousel-nav.next); const isEnabled await page.evaluate(btn btn !btn.hasAttribute(disabled), nextBtn); console.assert(isEnabled, 轮播图下一页按钮不可用); await browser.close(); })();注意waitUntil: networkidle0比domcontentloaded更严格确保所有资源图片、字体加载完毕避免误判。5.3 第三步部署验证 —— Nginx配置与CSP头的隐形杀手导出的静态页部署到Nginx后常因以下配置导致白屏MIME类型错误.js文件被Nginx识别为text/plain浏览器拒绝执行CSP策略过严script-src self禁止内联script而拖拽生成页常含scriptinitCarousel()/script解决方案是Nginx配置显式声明类型并放宽CSP# nginx.conf server { location / { # 强制JS/CSS MIME类型 types { application/javascript js; text/css css; } # 宽松CSP生产环境应收紧 add_header Content-Security-Policy script-src self unsafe-inline; style-src self unsafe-inline; img-src self data:;; } }提示unsafe-inline仅用于快速验证正式上线需用nonce或hash替代。例如生成随机nonce注入HTMLscript nonceabc123并在CSP中声明script-src nonce-abc123。从那以后我每次导出页面都强制走一遍这三步验证先跑AJV校验JSON结构再用Puppeteer看一眼真实渲染效果最后丢进Nginx容器里curl检查HTTP头。少走一次就可能多一次凌晨三点的线上告警电话。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑