项目场景:
vue3+vite+elementPlus项目,使用vuedraggable实现拖拽生成问卷
一、问题描述
1、需求场景
左侧为题型,右侧为问卷,通过拖拽左侧题型组件到右侧生成对应题目,页面如下:
2、问题复现
目前遇到的问题是,同一题型拖拽到右侧后,修改其中某一个会影响所有的同一题型的组件,可见下图演示(gif转换有点问题,凑合看叭):
问题很显而易见了:
当修改某题的数据时,其同类型的题目的数据也会一并改变,不仅如此,新拖拽生成的同一题型的题目也变化了
二、问题代码:
先说一下我目前的实现方式,才好说明遇到的问题(仅截取部分代码)
1、左侧部分
<template>
<div class="element-select-container">
<el-row v-for="item in elementTypeList" :key="item.groupName">
<el-col class="element-group-title" :span="24">
<el-icon color="#3C5DE3" style="margin-right: 2px;">
<Minus />
</el-icon>
{{ item.groupName }}
</el-col>
<Draggable class="element-type-group" v-model="item.componentList" :group="dragGroup" item-key="label"
:sort="false" :clone="cloneRule">
<template #item="{ element }">
<el-card class="element-type-card alignCenter" shadow="hover" body-style="padding:5px;">
<component :is="element.typeIconSvg" /><span class="cursor-default-group">{{
element.label }}</span>
</el-card>
</template>
</Draggable>
</el-row>
</div>
</template>
<script lang="ts" setup>
import ElementSvgIcon from '@/components/edit/selectQuestionType/ElementSvgIcon';
import Draggable from 'vuedraggable';
const dragGroup = reactive({
name: 'elementTypeForm',
pull: 'clone',
put: false
})
const elementTypeList = reactive([
{
groupCode: 1,
groupName: '基础题型',
componentList: [
{
label: '文本',
elementId: 0,
typeIconSvg: ElementSvgIcon.SvgInput,
groupCode: 1,
typeCode: 1,
title: '文本框',
titleEdit: false,
required: false,
option: {
type: "textarea",
placeholder: "请输入"
},
},
{
label: '评分',
elementId: 0,
typeIconSvg: ElementSvgIcon.SvgRate,
groupCode: 1,
typeCode: 2,
title: '评分',
titleEdit: false,
required: true,
option: {
max: 10
// disabled: true,
},
},
// ......
// 以下省略,结构和上面差不多
]
},
{
groupCode: 2,
groupName: '其他',
componentList: [
{
label: '段落分割',
elementId: 0,
typeIconSvg: ElementSvgIcon.SvgDivider,
groupCode: 2,
typeCode: 1,
option: {
borderStyle: 'solid',
dividerColor: 'red',
content: '分割线',
contentColor: 'red',
contentPosition: 'left'
}
},
// ......
// 以下省略,结构和上面差不多
]
},
])
</script>
(2)右侧部分
<template>
<div style="height: 100%;">
<el-form style="height: fit-content;">
<Draggable v-model="evaluationForm.list" :style="`min-height:${styleStore.fullScreenTF ? '83vh' : '72vh'};`"
:group="dragGroup" item-key="elementId" :sort="true" handle=".dragIcon" @change="handleElementChange">
<template #item="{ element, index }">
<el-form-item style="background-color: #ffffff;margin: 1rem 0;">
<el-card
:class="{ 'question-card': true, 'active-question-card': index == currentQuestionIndex }"
@click="handleClick(index)">
<el-row v-if="element.groupCode == 1" class="question-title-group">
<span class="question-title-required">
{{ element.required ? '*' : '' }}
</span>
<span class="question-title-index">
{{ getQuestionIndex(index) + "、" }}
</span>
<span class="question-title">
<span v-if="!element.titleEdit" class="question-title-text"
@mouseenter="handleQuestionTitleEnter(index)">
{{ element.title }}
</span>
<span v-if="element.titleEdit" class="width-100-group"
@mouseleave="handleQuestionTitleLeave(index)">
<el-input v-model="element.title" class="question-title-input" type="textarea"
resize="none" :autosize="{ minRows: 1 }" placeholder="请输入问题标题"></el-input>
</span>
</span>
<span class="question-operate">
<el-tooltip content="长按拖动题目" placement="top" effect="light">
<el-icon class="dragIcon" size="large">
<Rank />
</el-icon>
</el-tooltip>
<el-tooltip content="复制题目" placement="top" effect="light">
<el-icon size="large" @click.stop="handleCopy(index)">
<DocumentCopy />
</el-icon>
</el-tooltip>
<el-tooltip content="删除题目" placement="top" effect="light">
<el-icon size="large" @click.stop="handleDelete(index)">
<Delete />
</el-icon>
</el-tooltip>
</span>
</el-row>
<el-row v-else-if="element.groupCode == 2" class="question-operate-group">
<!-- ...... -->
<!-- 省略,这里写的就是拖动、复制、删除 -->
</el-row>
<component :is="getElementByCode(element.groupCode,element.typeCode)"
v-bind="element.option">
</component>
</el-card>
</el-form-item>
</template>
</Draggable>
</el-form>
</div>
</template>
<script lang="ts" setup>
import BPElement from '@/components/edit/questionList/Element';
import Draggable from 'vuedraggable';
import { v4 as uuidv4 } from 'uuid';
import useStyleStore from '@/store/style';
const styleStore = useStyleStore();
const dragGroup = reactive({
name: 'evaluationFor',
pull: false,
put: true
})
const evaluationForm = ref([])
// 根据组别码及类型码获取封装好的组件
const getElementByCode = (groupCode: number, typeCode: any) => {
if (groupCode == 1) {
switch (typeCode) {
case 1:
return BPElement.BPInput
case 2:
return BPElement.BPRate
case 3:
return BPElement.BPRadioGroup
case 4:
return BPElement.BPCheckboxGroup
case 5:
return BPElement.BPMatrixRadioGroup
case 6:
return BPElement.BPMatrixScaleGroup
}
} else if (groupCode == 2) {
switch (typeCode) {
case 1:
return BPElement.BPDivider
case 2:
return BPElement.BPExplain
}
}
}
// 获取题目题号
const getQuestionIndex = (index: number) => {
let questionCount = 0;
for (let i = index; i >= 0; i--) {
if (evaluationForm.value[i].groupCode == 1) {
questionCount++;
}
}
return questionCount;
}
// 当前选择的题目
const currentQuestionIndex = ref()
// 进入题目区域,切换编辑模式
const handleQuestionTitleEnter = (questionIndex: number) => {
evaluationForm.value[questionIndex].titleEdit = true
}
// 离开题目区域,切换展示模式
const handleQuestionTitleLeave = (questionIndex: number) => {
evaluationForm.value[questionIndex].titleEdit = false
}
// 通过的change事件
const handleElementChange = (value: any) => {
console.log("handleElementChange");
console.log(value);
if (value.added != undefined) {
// 使用中间变量为新添加的元素重新设置elementId,并替换掉新数据,以保持elementId唯一
evaluationForm.list.splice(value.added.newIndex, 1);
let temp = value.added.element;
temp.elementId = uuidv4();
evaluationForm.list.splice(value.added.newIndex, 0, temp);
}
console.log(evaluationForm.list);
}
const handleCopy = (index: number) => {
let temp = evaluationForm.value[index];
// 设置新的Id
temp.elementId = uuidv4();
evaluationForm.value.splice(index + 1, 0, temp);
}
const handleDelete = (index: number) => {
evaluationForm.value.splice(index, 1);
}
</script>
3、实现说明
可以看到,我的实现方法,就是设置了两个Draggable组件,左侧Draggable 组件pull设置为clone,则当其拖拽到右侧时,对应的数据就会被添加到右侧Draggable组件所绑定的 evaluationForm数组中,从而呈现出来
三、问题排查:
遇到这个问题后,一开始只以为影响的是右侧部分,当时没有发现重新拖拽生成的同题型的组件也受到影响了,就以为是elementId的问题
因为本身拖拽的组件的elementId都是0,拖拽到右侧Draggable时,是自动添加到其所绑定的数组中的,而不是我处理添加进去的。而我为了保证elementId的唯一性,在右侧Draggable组件的change事件中,将新添加的元素的数据赋值给一个中间变量,并对其elementId进行了修改,最后用它替换了原数据
于是就猜想,难道是仅仅这一会就影响到了?
接下来就翻vuedraggable官方文档,看到了官方的一个自定义克隆的例子,发现可以通过Draggable组件的clone属性,拿到clone元素的属性,进行处理后并返回
于是就对左侧Draggable的代码进行修改
<Draggable class="element-type-group" v-model="item.componentList" :group="dragGroup" item-key="label"
:sort="false" :clone="cloneRule">
<template #item="{ element }">
<el-card class="element-type-card alignCenter" shadow="hover" body-style="padding:5px;">
<component :is="element.typeIconSvg" /><span class="cursor-default-group">{{
element.label }}</span>
</el-card>
</template>
</Draggable>
// ......省略
// 通过clone属性,重新设置elementId,这样就可以实现elementId不重复
// 这里要注意的是Draggable组件有一个是clone属性,有一个是clone事件,
// 前者可自定义返回clone的数据,后者则是clone的回调,不要搞错了
const cloneRule = (element:any) => {
let newElement=element;
newElement.elementId=uuidv4();
return newElement;
}
结果:
通过输出打印发现,added中element的elementId已经每次都是新值了,说明clone属性的效果已经生效了
但是将右侧Draggable组件所绑定的 evaluationForm数组打印出来发现,同类型的题目的数据莫名变成一样的了,第一次拖拽生成的题目莫名消失
不知道是覆盖还是什么原因,反正就是没了,全变成一样的了,并且仍然是引用地址,即修改其中一个,两个都会改变
解决思路:
既然仍然是引用地址,知晓了大概是浅拷贝导致的问题,那就用简单的深拷贝测试一下,对cloneRule方法进行修改
const cloneRule = (element:any) => {
let newElement=JSON.parse(JSON.stringify(element));
newElement.elementId=uuidv4();
return newElement;
}
使用JSON.parse()和JSON.stringify()能实现简单的深拷贝效果,即经过此番操作后,重新创建了对象
注意: 需要注意的是,使用JSON.parse()和JSON.stringify()能实现简单的深拷贝效果,但使用这种方法存在的问题就是不能复制函数、正则表达式等特殊对象,而且对于循环引用的处理也不友好
修改后结果如下:
解决方案:
既然是由浅拷贝引起的,那么只要找到合适的深拷贝方式就好了
1、简单对象
由于我需要传输的对象并不复杂,因此就自己写了一个深拷贝函数,如果没有函数、正则表达式啥的,直接用上面的JSON.parse()和JSON.stringify()就行了
const deepCopy = (obj: any): any => {
// 检查输入是否为对象或数组
if (typeof obj !== 'object' || obj === null) {
return obj; // 如果不是对象,直接返回值
}
// 如果是函数,直接返回原函数
if (typeof obj === 'function') {
return obj;
}
// 根据输入的类型(对象或数组)初始化结果
const result = Array.isArray(obj) ? [] : {};
// 递归地拷贝每个属性或元素
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
result[key] = deepCopy(obj[key]);
}
}
return result;
}
在对cloneRule方法进行修改
const cloneRule = (element:any) => {
let newElement=deepCopy(element);
newElement.elementId=uuidv4();
return newElement;
}
2、复杂对象
如果需要传输的对象比较复杂,可以使用第三方库lodash的 _.cloneDeep方法
lodash
3、最终实现效果
需要源码参考的话可以评论一下,人多的话我就抽空把源码贴一下