返回

Vue3 子组件修改Props数组,父组件数据同步?问题分析及解决

vue.js

子组件修改Props数组导致父组件数据同步

当在Vue.js 3中开发时,有时会遇到一种棘手的情况:子组件接收来自父组件的数组Props,并且在子组件中对其进行修改时,父组件的原始数组也受到了影响。这种现象会让人感到困惑,也容易导致难以调试的问题。 本文分析产生该问题的原因并给出对应的解决方案。

问题分析:引用传递

问题的根源在于JavaScript的对象和数组是通过引用传递的。这意味着,当一个对象或数组被赋值给另一个变量时,两个变量都指向内存中的同一个数据。如果其中一个变量修改了数据,另一个变量也会看到相应的变化。

在上述代码中,App.vuelocations数组作为Props传递给UserLocations.vue。 子组件的 locations 变量通过 [...this.initalLocations] 创建了一个浅拷贝。然而,该浅拷贝仅仅拷贝了数组的外层结构 ,并没有对数组中存储的对象进行深拷贝 。因此 locations 中存储的对象与 父组件 locations 中存储的对象指向相同的内存地址。当子组件内部修改对象属性时,实际上直接修改了父组件数据中的对象。

简单来讲,子组件并没有“复制”props里的数组,而仅仅只是复制了引用,所以导致两边数据联动。

解决方案一:深拷贝

最直接的办法是在子组件内部创建一个数组的深拷贝。通过深拷贝,子组件拥有了一个独立于父组件的全新数组,任何修改都不会影响到父组件的数据。深拷贝可以解决这种引用带来的数据同步问题。

代码示例:

使用 JSON.parse(JSON.stringify(this.initialLocations)) 创建深拷贝:

<template>
    <ul>
        <li v-for="(location) in locations"
        :key="location.id"
        > 
        <span @click="decreaseOrder(location)">Up</span>
        {{ location.name}} {{location.order}}
        <span @click="increaseOrder(location)">down</span>
        </li>
    </ul>
</template>
<script>

export default {
    data: function() {
      return {
            locations: []
        }
    },
    props: {
        initalLocations: { 
            type: Array,
        },
    },
   watch: {
      initalLocations: {
        handler(newVal) {
          this.locations = JSON.parse(JSON.stringify(newVal));
        },
         immediate: true
       }
    },
    methods:{
        increaseOrder(location) {
            if (location.order != this.locations.length) {
                this.locations = this.locations.map(l => {
                    var returnLocation = {...l};
                    if (l.id == location.id) {
                         returnLocation.order += 1;
                    }
                    return returnLocation
                });
            }
        },
        decreaseOrder(location) {
            if (location.order != 1) {
               this.locations = this.locations.map(l => {
                    var returnLocation = {...l};
                   if (l.id == location.id) {
                       returnLocation.order -= 1;
                    }
                    return returnLocation
                });
            }
        },
    }
}
</script>

操作步骤:

  1. 将子组件中 data 属性下的 locations 初始化为空数组。
  2. 添加 watch 侦听器,当 initalLocations props 值发生更改时, 将新 props 深拷贝到locations中,
    使用 JSON.parse(JSON.stringify(newVal)) 创建 newVal的深拷贝。 immediate:true保证首次渲染组件,也能获得数据拷贝
  3. 修改methods中更改顺序的逻辑,确保所有逻辑操作,仅修改子组件本地的 locations 数据。

原理:
JSON.stringify() 方法将 JavaScript 对象转换为 JSON 字符串, JSON.parse() 方法将 JSON 字符串解析为 JavaScript 对象。 当一个对象被序列化再反序列化时,会创建一个全新的对象。因此可以有效的创建数据深拷贝,并保证数组中的每个对象也完成深拷贝。

安全建议: 这种方法对于简单对象和数组非常方便,但对于包含循环引用或者函数等特殊值的对象可能导致问题,不适合复杂对象深拷贝。

解决方案二:使用计算属性

计算属性可以创建一个基于响应式数据的新的值,它的优点在于具有缓存性,只有在相关依赖发生变化时才重新计算,能有效地避免不必要的性能损耗。通过将 initalLocations props 定义为计算属性的返回值,每次组件更新时, 可以实现创建一个新的拷贝,保证了子组件不会直接操作父组件的数据。

代码示例:

<template>
    <ul>
        <li v-for="(location) in locations"
        :key="location.id"
        > 
        <span @click="decreaseOrder(location)">Up</span>
        {{ location.name}} {{location.order}}
        <span @click="increaseOrder(location)">down</span>
        </li>
    </ul>
</template>
<script>

export default {
    props: {
        initalLocations: { 
            type: Array,
        },
    },
    computed: {
        locations() {
            return this.initalLocations.map(location => ({...location}));
            
        }
    },
    methods:{
        increaseOrder(location) {
             if (location.order != this.locations.length) {
                  this.$emit("update-location-order",location, location.order+1)
            }
        },
         decreaseOrder(location) {
             if (location.order != 1) {
                   this.$emit("update-location-order",location, location.order-1)
             }
        }
    }
}
</script>
<template>
  <user-locations :initalLocations="locations" @update-location-order="updateOrder"/>
</template>

<script>
import UserLocations from './components/UserLocations.vue'

export default {
  name: 'App',
  components: {
    UserLocations
  },
  data: function() {
    return {
      locations: [
        {"id": 121, name: "test 121", "order": 1},
        {"id": 122, name: "test 122", "order": 2},
        {"id": 123, name: "test 123", "order": 3},
        {"id": 124, name: "test 124", "order": 4}
      ]
    }
  },
  methods: {
    updateOrder(location, order){
      const index = this.locations.findIndex(l=> l.id == location.id)
      this.locations[index].order = order;
    }
  }
}
</script>

操作步骤:

  1. 移除 data选项中的 locations, 定义一个名为 locations 的计算属性,利用map函数遍历 props initalLocations 数组, 创建并返回一个新数组, 新数组的每一项,通过{...location} 创建对象的浅拷贝。
  2. 通过子组件通过 $emit 发射自定义事件 "update-location-order",并向父组件传递更改后的location以及新的 order值,实现子组件对 props 数组的操作逻辑外置到父组件。
  3. 在父组件中,通过监听 "update-location-order" 事件, 更新对应 location 对象的 order

原理:
使用计算属性保证子组件 locations属性总是获得 props 数据新拷贝。 使用 this.$emit和自定义事件的结合方式将 props数据的更改权限传递回父组件, 这种操作数据方式通常用于更严格的父子数据传递模式中, 子组件只应该发射修改事件, 负责数据的操作逻辑应该外置。

安全建议: 在使用这种方式操作数组的时候, 仍旧应该避免直接操作传递给子组件的数据的每一项(每一项数据还是通过浅拷贝过来的,深层嵌套的数据仍存在引用问题) ,在methods中需要确保不产生副作用。

结论

理解JavaScript引用传递的概念,并且在需要时使用正确的复制手段对确保Vue 组件间数据的正确性和稳定性非常重要。两种解决方案各有侧重,可根据项目场景灵活使用。 使用深拷贝更加便捷,而使用计算属性配合自定义事件操作props,更加安全和利于管理。开发者可以根据项目需求灵活选择,实现更可维护的 Vue 应用程序。