Vue3使用vuedraggable拖拽clone元素浅拷贝致对象属性覆盖问题

项目场景:

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、最终实现效果

在这里插入图片描述
需要源码参考的话可以评论一下,人多的话我就抽空把源码贴一下

  • 26
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值