Vue3 表单编辑器:左侧控件菜单 + 中间编辑区的拖拽全流程实现
基于 Vue3 + vuedraggable + Pinia 实现的表单编辑器,涵盖从左侧菜单拖拽添加控件、点击添加、中间区域排序调换、复制删除、切换题型等核心交互。本文只摘取代表性代码,梳理整体流程和设计思路。
目录
- 一、整体架构与数据流
- 二、技术选型
- 三、左侧控件菜单(form_menu_drag.vue)
- 四、中间编辑区(form_render_drag.vue)
- 五、Pinia Store 核心方法(editor.ts)
- 六、题目工具栏(drag_tool_bar.vue)
- 七、核心流程详解
- 八、性能优化:大列表按需渲染
- 九、踩坑总结
一、整体架构与数据流
1┌──────────────┐ 拖拽(clone) / 点击添加 ┌──────────────────┐ 2│ 左侧菜单 │ ──────────────────────────────▶ │ 中间编辑区 │ 3│ form_menu_ │ vuedraggable group │ form_render_ │ 4│ drag.vue │ name: 'componentsGroup' │ drag.vue │ 5└──────────────┘ └───────┬──────────┘ 6 │ 7 ┌────────────────────────────────┘ 8 ▼ 读写 formDataList 9 ┌───────────────┐ 10 │ Pinia Store │ 11 │ editor.ts │ 12 │ ┌───────────┐ │ 13 │ │formDataList│ │ ← 唯一数据源 14 │ │dragFormData│ │ ← 拖拽副本(排序用) 15 │ │List │ │ 16 │ │activeKey │ │ ← 当前选中项 17 │ │skipSyncDrag│ │ ← 拖拽同步锁 18 │ └───────────┘ │ 19 └───────────────┘ 20
核心数据流:
formDataList(Store)是唯一数据源,所有增删改都操作它dragFormDataList(组件局部)是 vuedraggable 的拖拽副本,仅用于排序,变更后同步回formDataListskipSyncDrag是一把锁,拖拽过程中阻止syncDragList重建拖拽副本
二、技术选型
| 技术 | 作用 |
|---|---|
| vuedraggable | Vue3 拖拽组件,基于 SortableJS 封装 |
| Pinia | 状态管理,维护 formDataList 等核心数据 |
| lodash-es | cloneDeep 深拷贝控件配置 |
| IntersectionObserver | 大列表按需渲染 |
1npm install vuedraggable sortablejs 2
三、左侧控件菜单(form_menu_drag.vue)
左侧菜单的核心是 vuedraggable + clone 模式:拖拽时不移动原数据,而是克隆一份新数据扔到中间区域。
模板部分
1<template> 2 <div class="form_menu_drag"> 3 <div v-for="(item, keyIndex) in formItemConf" :key="keyIndex"> 4 <h4 class="tool_bar_title">{{ item.groupTitle }}</h4> 5 <draggable 6 :list="item.groupList" 7 class="tool_item_list" 8 :group="{ name: 'componentsGroup', pull: 'clone', put: false }" 9 :clone="handleClone" 10 :sort="false" 11 :item-key="'renderKey'" 12 @start="onMenuDragStart" 13 @end="onMenuDragEnd" 14 > 15 <template #item="{ element }"> 16 <div class="tool_item"> 17 <div 18 class="tool_item_drag" 19 :class="{ disabled: checkItem(element?.customConf?.tagName || '') }" 20 @click.stop="handleAdd(element)" 21 > 22 <Icon :icon="[`svg-icon:${element.customConf.tagIcon}`](https://xplanc.org/primers/document/zh/03.HTML/EX.HTML%20%E5%85%83%E7%B4%A0/EX.svg.md)" /> 23 {{ element.customConf.label }} 24 </div> 25 </div> 26 </template> 27 </draggable> 28 </div> 29 </div> 30</template> 31
关键配置解读:
| 属性 | 值 | 作用 |
|---|---|---|
| group.name | 'componentsGroup' | 左右两侧 group name 相同,才能跨区域拖拽 |
| group.pull | 'clone' | 拖拽时克隆元素,不移动原数据 |
| group.put | false | 不允许从中间区域拖回左侧 |
| :clone | handleClone | 自定义克隆逻辑,生成唯一 key |
| :sort | false | 左侧菜单内部不排序 |
克隆逻辑 handleClone
1const handleClone = (e) => { 2 const isExist = EditorUtils.checkExist(e, unref(formDataList)); 3 if (!isExist) { 4 const cloned = cloneDeep(e); 5 // 生成唯一标识,避免多实例冲突 6 cloned.formItemKey = EditorUtils.getRandomId(); 7 cloned.renderKey = EditorUtils.getRandomKey(cloned.formItemKey); 8 cloned.baseConf.paginationNum = editorStore.activePageNum ?? 0; 9 10 // 关键:立即推入 formDataList,并锁定同步 11 editorStore.skipSyncDrag = true; 12 unref(formDataList).push(cloned); 13 return cloned; 14 } 15}; 16
为什么 clone 时就要 push 到 formDataList? 因为 vuedraggable 的 clone 机制会在拖拽完成后把克隆元素放进目标 list(即
dragFormDataList),但此时组件已经开始渲染。如果不提前 push 到 store,p_*.vue组件通过renderKey找不到数据就会报错。同时设置skipSyncDrag = true防止syncDragList重建dragFormDataList干扰拖拽。
点击添加 handleAdd
1const handleAdd = async (e) => { 2 const isExist = EditorUtils.checkExist(e, unref(formDataList)); 3 if (!isExist) { 4 const item = cloneDeep(e); 5 const tagName = item.customConf.tagName; 6 // 调用 store 方法添加,内部处理位置插入和激活 7 await editorStore.addFormItem(tagName, '', undefined, true); 8 editorStore.setFormSetting(false); 9 // 等两帧后滚动到新题目 10 nextTick(() => { 11 requestAnimationFrame(() => { 12 const newDom = document.querySelector('.drag_item.isActive'); 13 newDom?.scrollIntoView({ behavior: 'smooth', block: 'center' }); 14 }); 15 }); 16 } 17}; 18
拖拽开始/结束:防护措施
1const onMenuDragStart = () => { 2 // 1. 设置全局拖拽标志(编辑器组件内部通过此标志禁止写入) 3 (window as any).__isDragging = true; 4 5 // 2. 保存所有题目的标题/描述数据(拖拽后恢复用) 6 const preDragTitleData = new Map(); 7 unref(formDataList).forEach((item) => { 8 preDragTitleData.set(item.formItemKey, { 9 title: item?.customConf?.title || '', 10 titleHtml: item?.customConf?.titleHtml || '', 11 desc: item?.customConf?.desc || '', 12 }); 13 }); 14 (window as any).__preDragTitleData = preDragTitleData; 15 16 // 3. 禁用标题/描述/富文本区域的 pointer-events,阻止浏览器 drop 插入 17 const selectors = '.title_readonly, .desc_readonly, [contenteditable="true"], .w-e-text-container'; 18 document.querySelectorAll(selectors).forEach((el) => { 19 (el as HTMLElement).style.pointerEvents = 'none'; 20 }); 21}; 22 23const onMenuDragEnd = () => { 24 (window as any).__isDragging = false; 25 // 恢复 pointer-events 26 document.querySelectorAll(selectors).forEach((el) => { 27 (el as HTMLElement).style.pointerEvents = ''; 28 }); 29 // 通知画布恢复 DOM 30 window.dispatchEvent(new CustomEvent('editor-sync-after-drag')); 31}; 32
四、中间编辑区(form_render_drag.vue)
中间区域是 vuedraggable 的接收方和排序方,绑定局部 dragFormDataList,变更后同步回 Store。
模板部分
1<template> 2 <div class="form_render_drag"> 3 <div class="drag_content"> 4 <!-- 不可拖拽的特殊项(分页、说明等)单独渲染 --> 5 <div v-for="(item, index) in instructionsList" :key="item.formItemKey"> 6 <div class="drag_item" @click.stop="selectItem(item.renderKey)"> 7 <FormReducer :formItemData="item" /> 8 </div> 9 </div> 10 11 <!-- 可拖拽排序的区域 --> 12 <draggable 13 class="draggable_wrap" 14 :group="{ name: 'componentsGroup' }" 15 :list="dragFormDataList" 16 :handle="'.handle_bar'" 17 :item-key="'renderKey'" 18 :sort="true" 19 @start="onDragStart" 20 @end="onDragEnd" 21 @change="draggableChange" 22 > 23 <template #item="{ element, index }"> 24 <div 25 class="drag_item" 26 :class="{ isActive: activeKey == resolveFormItem(element).renderKey }" 27 @click.stop="selectItem(resolveFormItem(element).renderKey)" 28 v-show="element?.baseConf?.paginationNum == activePageNum" 29 > 30 <DragToolBar 31 v-if="activeKey == resolveFormItem(element).renderKey" 32 :tagName="resolveFormItem(element)?.customConf?.tagName || ''" 33 :renderKey="resolveFormItem(element).renderKey" 34 @trigger-copy="onCopyFn(element?.customConf?.tagName, element.formItemKey, index + 1)" 35 @trigger-del="onDeleteFn(element.formItemKey, index)" 36 @trigger-change="(type) => onChangeFn(type, element.formItemKey)" 37 /> 38 <div class="item_content"> 39 <FormReducer :formItemData="resolveFormItem(element)" /> 40 </div> 41 </div> 42 </template> 43 </draggable> 44 </div> 45 </div> 46</template> 47
关键配置解读:
| 属性 | 值 | 作用 |
|---|---|---|
| :list | dragFormDataList | 绑定局部拖拽副本,vuedraggable 直接操作它 |
| :handle | '.handle_bar' | 只有拖拽手柄能触发拖拽,避免误触输入框 |
| :sort | true | 允许内部排序 |
| @change | draggableChange | 拖拽变更回调,区分 added 和 moved |
拖拽副本同步机制
1const dragFormDataList = ref<any[]>([]); 2let lastListSig = ''; // 签名缓存,避免不必要的重建 3let dragCompleted = false; // 拖拽是否成功完成 4 5// 从 Store 同步到拖拽副本(仅在增删/顺序变化时) 6const syncDragList = () => { 7 if (unref(skipSyncDrag)) return; // 拖拽中不同步 8 const dataList = unref(formDataList).filter((item) => { 9 return item.formItemKey && !EditorUtils.excludeList.includes(item?.customConf?.tagName || ''); 10 }); 11 // 签名对比:如果 formItemKey 列表没变,跳过重建 12 const sig = dataList.map((item) => item.formItemKey).join(','); 13 if (sig === lastListSig) return; 14 lastListSig = sig; 15 dragFormDataList.value = dataList.slice(); 16}; 17 18// 渲染时始终从 Store 取最新数据,避免拖拽副本与 formDataList 脱节 19const resolveFormItem = (element: any) => { 20 if (!element?.formItemKey) return element; 21 return unref(formDataList).find((i) => i.formItemKey === element.formItemKey) ?? element; 22}; 23 24// 监听 formDataList 的 key 变化,自动同步 25watch( 26 () => unref(formDataList).map((item) => item.formItemKey).join(','), 27 () => syncDragList(), 28 { immediate: true }, 29); 30
为什么需要两个 list?
formDataList是 Store 中的唯一数据源,但 vuedraggable 需要直接操作 list 来实现拖拽排序。如果直接绑定formDataList,每次排序都会触发 Store 更新和响应式连锁反应。所以用一个局部dragFormDataList做缓冲,排序完成后统一同步回 Store。
五、Pinia Store 核心方法(editor.ts)
添加控件 addFormItem
1async addFormItem(tagName: string, formItemKey: string, index?: number, setActive = true) { 2 // 1. 获取默认配置(新建)或拷贝已有配置(复制) 3 let defaultItem: IFormModel.formItemConf; 4 if (formItemKey.length < 1) { 5 // 新建:从默认配置模板中取 6 defaultItem = EditorUtils.getDefaultItemConfig().filter( 7 (item) => item.customConf.tagName === tagName 8 )[0]; 9 } else { 10 // 复制:从已有数据中拷贝 11 defaultItem = this.formDataList.filter( 12 (item) => item.formItemKey === formItemKey 13 )[0]; 14 } 15 16 // 2. 生成唯一标识 17 const _formItemKey = EditorUtils.getRandomId(); 18 const _renderKey = EditorUtils.getRandomKey(_formItemKey); 19 const item = Object.assign(cloneDeep(defaultItem), { 20 formItemKey: _formItemKey, 21 renderKey: _renderKey, 22 }); 23 item.baseConf.paginationNum = this.activePageNum ?? 0; 24 25 // 3. 计算插入位置(当前选中项的下一个) 26 const currentIndex = this.formDataList.findIndex( 27 (item) => item.renderKey === this.activeKey 28 ); 29 if (index != undefined) { 30 this.formDataList.splice(Math.max(index, 0), 0, item); 31 } else { 32 index = currentIndex < 0 ? this.formDataList.length : currentIndex + 1; 33 this.formDataList.splice(Math.max(index, 0), 0, item); 34 } 35 36 // 4. 激活新项 37 if (setActive) this.setActiveKey(item.renderKey); 38 this.formItemKey = item.formItemKey; 39 40 return item; 41} 42
删除控件 delFormItem
1delFormItem(forItemKey: string, pos: number) { 2 this.formDataList = this.formDataList.filter((item) => { 3 return item.formItemKey != forItemKey; 4 }); 5 // 自动选中上一项 6 if (this.formDataList[0]) { 7 this.setActiveKey(this.formDataList[Math.max(pos - 1, 0)].renderKey); 8 } else { 9 this.settingForm = true; // 没有题目了,显示表单设置 10 } 11} 12
切换题型 changeTagType
1changeTagType(formItemKey: string, item: any) { 2 const allConfigs = EditorUtils.getDefaultItemConfig(); 3 4 this.formDataList = this.formDataList.map((obj) => { 5 if (obj.formItemKey === formItemKey) { 6 // 合并配置:只覆盖已有字段 7 const mergedConf = this.mergeCustomConf(obj.customConf, item); 8 9 // 标题处理:只有用户没自定义标题时,才更新为新题型默认标题 10 const oldDefaultTitle = allConfigs.find(c => c.customConf.tagName === obj.customConf.tagName)?.customConf?.title; 11 const newDefaultTitle = allConfigs.find(c => c.customConf.tagName === item.tagName)?.customConf?.title || item.label; 12 13 const currentTitle = (obj.customConf.title || '').trim(); 14 const isNotCustomized = !oldDefaultTitle || currentTitle === oldDefaultTitle; 15 16 if (isNotCustomized) { 17 mergedConf.title = newDefaultTitle; 18 mergedConf.titleHtml = [`<p>${newDefaultTitle}</p>`](https://xplanc.org/primers/document/zh/03.HTML/EX.HTML%20%E5%85%83%E7%B4%A0/EX.p.md); 19 } 20 21 return { ...obj, customConf: mergedConf }; 22 } 23 return obj; 24 }); 25} 26
六、题目工具栏(drag_tool_bar.vue)
每个题目选中后,顶部显示工具栏:题型切换、必填、复制、删除、拖拽手柄。
1<template> 2 <div class="tool_bar"> 3 <!-- 题型切换下拉框 --> 4 <div class="tag_select" v-show="activeKey == currentData.renderKey"> 5 <div class="switch_type" v-if="showTypeList.includes(tagName)"> 6 <el-select v-model="tagType" @change="selectTag"> 7 <el-option v-for="item in tagTypeList" :key="item.tagName" :label="item.label" :value="item.tagName" /> 8 </el-select> 9 </div> 10 <span v-else>{{ labelName }}</span> 11 </div> 12 13 <!-- 操作按钮 --> 14 <div class="edit_btns"> 15 <ElCheckbox v-model="currentData.baseConf.isRequired" label="必填" /> 16 <span @click.stop="triggerCopy"><Icon icon="svg-icon:drag_item_copy" />复制</span> 17 <span @click.stop="triggerDelect"><Icon icon="svg-icon:drag_item_del" />删除</span> 18 </div> 19 20 <!-- 拖拽手柄(只有拖这个位置才能拖拽) --> 21 <div class="handle_bar"> 22 <Icon icon="svg-icon:drag-handle-line" :size="30" /> 23 </div> 24 </div> 25</template> 26
注意:
:handle="'.handle_bar'"指定了只有.handle_bar元素能触发拖拽。这样用户点击输入框、按钮等交互元素时不会误触发拖拽。
七、核心流程详解
流程一:从左侧拖拽到中间
1用户拖拽控件 2 │ 3 ▼ 4handleClone() ──▶ 生成唯一 key + push 到 formDataList + skipSyncDrag=true 5 │ 6 ▼ 7vuedraggable 将克隆元素放入 dragFormDataList 8 │ 9 ▼ 10onDragStart() ──▶ 设置 __isDragging + 保存标题数据 + 禁用 pointer-events 11 │ 12 ▼ 13onDragEnd() ──▶ 恢复 pointer-events + 重置 skipSyncDrag 14 │ 如果 !dragCompleted(拖拽取消),清理孤立数据 15 ▼ 16draggableChange({ added }) ──▶ dragCompleted=true 17 │ 18 ▼ 19同步 dragFormDataList 顺序 → formDataList(排除项 + 普通项合并) 20 │ 21 ▼ 22nextTick: skipSyncDrag=false + syncDragList() 23
流程二:点击添加
1用户点击控件 2 │ 3 ▼ 4handleAdd() ──▶ editorStore.addFormItem(tagName) 5 │ Store 内部:生成 key + 计算位置 + splice 插入 + 激活 6 ▼ 7formDataList 变化触发 watch 8 │ 9 ▼ 10syncDragList() ──▶ 重建 dragFormDataList 11 │ 12 ▼ 13visibleCount +1 ──▶ 新题目立即渲染 14 │ 15 ▼ 16nextTick + rAF ──▶ scrollIntoView 滚动到新题目 17
流程三:中间区域排序
1用户拖拽手柄排序 2 │ 3 ▼ 4onDragStart() ──▶ blur 所有编辑器 + 保存标题数据 5 │ 6 ▼ 7vuedraggable 调整 dragFormDataList 顺序 8 │ 9 ▼ 10draggableChange({ moved }) ──▶ 检查逻辑规则 11 │ 12 ├─── 无冲突 ──▶ 同步顺序到 formDataList 13 │ 14 └─── 有冲突 ──▶ 弹出确认框 → 用户确认后同步 15 │ 16 ▼ 17onDragEnd() ──▶ 恢复 pointer-events + skipSyncDrag=false 18
draggableChange 核心实现
1const draggableChange = (res) => { 2 dragCompleted = true; 3 4 const saveAndProceed = async () => { 5 if (res.added) { 6 // 从左侧拖入的新元素 7 const element = res.added.element; 8 editorStore.setActiveKey(element.renderKey); 9 editorStore.setFormSetting(false); 10 11 // 同步 dragFormDataList 顺序到 formDataList 12 syncOrderToStore(); 13 } else if (res.moved) { 14 // 内部排序 15 const formItemKey = res.moved?.element?.formItemKey || ''; 16 // 检查逻辑规则冲突 17 const checkMsg = getPrevRuleItemIndex(formItemKey, res.moved.newIndex); 18 if (!checkMsg) { 19 syncOrderToStore(); 20 } else { 21 // 有冲突,弹确认框 22 dragRuleCheckContent.value = checkMsg; 23 showDragRuleCheck.value = true; 24 } 25 } 26 }; 27 28 // 延迟 50ms,确保 onDragStart 的 blur 已完成 29 setTimeout(saveAndProceed, 50); 30}; 31 32// 将 dragFormDataList 的顺序同步回 formDataList 33const syncOrderToStore = () => { 34 skipSyncDrag.value = true; 35 const dragList = unref(dragFormDataList); 36 const excludeList = EditorUtils.excludeList; 37 38 // 排除项(分页、说明等)保持原位 39 const excludedItems = unref(formDataList).filter((item) => 40 excludeList.includes(item?.customConf?.tagName || '') 41 ); 42 // 普通项按拖拽顺序排列 43 const normalItems = dragList.map( 44 (item) => unref(formDataList).find((i) => i.formItemKey === item.formItemKey) || item 45 ); 46 formDataList.value = [...excludedItems, ...normalItems]; 47 48 nextTick(() => { 49 skipSyncDrag.value = false; 50 syncDragList(); 51 }); 52}; 53
八、性能优化:大列表按需渲染
当表单题目超过 50 个时,一次性渲染所有题目组件会导致严重卡顿。项目采用 IntersectionObserver 按需渲染策略:
1const ESTIMATED_ITEM_HEIGHT = 160; 2const INITIAL_VISIBLE = 50; // 首屏渲染 50 个 3const VISIBLE_BUFFER = 15; // 上下缓冲 15 个 4const visibleCount = ref(INITIAL_VISIBLE); 5 6// 模板中:index < visibleCount 的才渲染真实组件,其余用占位 div 7// <template v-if="index < visibleCount"> 8// <FormReducer :formItemData="element" /> 9// </template> 10// <div v-else class="item_placeholder" :data-index="index" :style="{ minHeight: '160px' }"></div> 11 12// IntersectionObserver 监听占位 div 进入视口 13const observePlaceholders = () => { 14 const root = document.querySelector('.el-scrollbar__wrap'); 15 placeholderObserver = new IntersectionObserver( 16 (entries) => { 17 entries.forEach((entry) => { 18 if (!entry.isIntersecting) return; 19 const index = Number((entry.target as HTMLElement).dataset.index); 20 if (!Number.isNaN(index)) ensureVisibleUpTo(index); 21 }); 22 }, 23 { root, rootMargin: '400px 0px', threshold: 0 } 24 ); 25 root.querySelectorAll('.item_placeholder[data-index]').forEach((el) => { 26 placeholderObserver?.observe(el); 27 }); 28}; 29 30const ensureVisibleUpTo = (index: number) => { 31 const target = Math.min(index + VISIBLE_BUFFER + 1, dragFormDataList.value.length); 32 if (target > visibleCount.value) { 33 visibleCount.value = target; 34 } 35}; 36
策略说明:
- 首屏只渲染前 50 个题目,其余用固定高度的占位 div
- 滚动时 IntersectionObserver 检测占位 div 进入视口,动态扩大
visibleCount - 批量导入(一次增加 5+ 题)时重置为首屏渲染,避免一次性渲染过多
- 单题添加时
visibleCount + 1,新题立即渲染
九、踩坑总结
1. 拖拽时标题/描述被浏览器覆盖
问题:拖拽过程中,浏览器的原生 drop 行为会把拖拽数据插入到 contenteditable 的标题/描述区域,导致文字被覆盖。
解决方案:三层防护
1// 第一层:拖拽开始时禁用标题/描述/编辑器的 pointer-events 2const selectors = '.title_readonly, .desc_readonly, [contenteditable="true"], .w-e-text-container'; 3document.querySelectorAll(selectors).forEach((el) => { 4 (el as HTMLElement).style.pointerEvents = 'none'; 5}); 6 7// 第二层:设置全局 __isDragging 标志,编辑器组件内部通过此标志禁止写入 8(window as any).__isDragging = true; 9 10// 第三层:拖拽前保存标题数据,拖拽后恢复(兜底) 11const preDragTitleData = new Map(); 12formDataList.forEach((item) => { 13 preDragTitleData.set(item.formItemKey, { 14 title: item.customConf.title, 15 titleHtml: item.customConf.titleHtml, 16 desc: item.customConf.desc, 17 }); 18}); 19
2. 克隆元素 key 为空导致组件报错
问题:vuedraggable 的 clone 默认是浅拷贝,克隆出来的元素 formItemKey 和 renderKey 与原元素相同,导致组件找不到数据。
解决方案:在 handleClone 中立即生成唯一 key 并 push 到 Store:
1const handleClone = (e) => { 2 const cloned = cloneDeep(e); 3 cloned.formItemKey = EditorUtils.getRandomId(); // 唯一 ID 4 cloned.renderKey = EditorUtils.getRandomKey(cloned.formItemKey); // 唯一渲染 key 5 editorStore.skipSyncDrag = true; // 锁定同步 6 unref(formDataList).push(cloned); // 立即推入 Store 7 return cloned; 8}; 9
3. 拖拽取消后残留孤立数据
问题:用户拖拽到非目标区域取消时,handleClone 已经把数据 push 到了 formDataList,但 dragFormDataList 中没有,导致数据不一致。
解决方案:onDragEnd 中检查 dragCompleted 标志,如果未完成则清理:
1const onDragEnd = () => { 2 skipSyncDrag.value = false; 3 if (!dragCompleted) { 4 // 拖拽取消:清理 handleClone 推入但 dragFormDataList 中不存在的数据 5 const dragKeys = new Set(dragFormDataList.value.map((i) => i.formItemKey)); 6 formDataList.value = formDataList.value.filter( 7 (item) => 8 EditorUtils.excludeList.includes(item?.customConf?.tagName || '') || 9 dragKeys.has(item.formItemKey) 10 ); 11 } 12 dragCompleted = false; 13}; 14
4. 双 list 同步时机问题
问题:dragFormDataList 和 formDataList 是两个 list,排序时如果同步时机不对,会导致数据错乱。
解决方案:
- 拖拽中设置
skipSyncDrag = true,阻止syncDragList重建dragFormDataList draggableChange中先同步顺序到formDataList,再在nextTick中重置skipSyncDrag并调用syncDragList- 用
formItemKey签名对比避免不必要的重建
总结:整个拖拽编辑器的核心设计是双 list + 同步锁 + clone 即入栈。
formDataList是唯一数据源,dragFormDataList是拖拽操作缓冲区,skipSyncDrag是同步锁防止拖拽过程中互相干扰。配合handle_bar限定拖拽触发区域、IntersectionObserver 按需渲染、三层防护避免标题覆盖,实现了流畅的表单编辑体验。
《Vue3 表单编辑器:左侧控件菜单 + 中间编辑区的拖拽全流程实现》 是转载文章,点击查看原文。
