不只懂 Vue 语法: 在 Vue 2 为何无法直接修改物件型别资料里的值?

问题回答

在 Vue 2,我们需要使用 .set() 等 Vue 语法来修改在 data 里的物件或阵列资料里的值。这是因为 Vue 2 是使用 Object.defineProperty() 实现响应式(reactivity)。在此机制下,Vue 只会为 data 里最外层的属性加上 gettersetter,因此只会侦测到最外层资料的变动,无法侦测到下一层的资料,并作出更新。

以下会再详细解说当中原理。

这篇文章会针对 Vue 2 作解释,下一篇才会讨论 Vue 3。这里的概念主要是参考官方教学影片和文件,并稍微修改例子便於说明。

Vue 2 无法实现响应式更新资料的情况

当我们在写 Options API 时,我们会把页面用到的资料通通放在data里,当资料有变动,并一并更新有用到该资料的画面。这是因为 Vue 2 和 Vue 3 分别使用了JavaScript 的 Object.defineProperty()Proxy 方法来完成。

在 Vue 2 里,之所以无法实现响应式来更新资料,原因通常有这两个:

  1. 资料没有建立在 Vue 的 data 属性里
  2. 更改物件或阵列时,使用.[]来改变物件的属性,以及用 .length改变阵列的长度、用[]指定阵列的索引来改变阵列中的某个值

第一种情况很易理解,就是建立 Vue 实体时,没有把资料写在data 属性里。详情见官方文件就很快能理解。
第二种情况,就是新手刚刚写 Vue 时所犯的错。而 Vue 官方文件也有说明,Vue 不能侦测阵列或物件的变化。

以下面的资料作例子,示范一些错误的写法:

data() {
    return {
          obj: {
            a: 1,
          },
          arr: [1, 2, 3],
    };
},

修改物件:

this.obj.b = 2 // 结果是 obj 不会新增 b 属性
delete this.obj.a; // 结果是 a 属性不会被删除

修改阵列:

this.arr[0] = 100 // 结果是 arr[0] 仍然是 1
this.arr.length = 1 // 结果是 arr 仍然是 [1,2,3]

对於以上情况,Vue官方提供了 Vue.set()this.$set() 等方法来解决。以下是正确的写法:

修改物件:

Vue.set(this.obj, 'b', 2) 
Vue.delete(this.obj, 'a')

修改阵列:

// 修改阵列中某个值
Vue.set(this.arr, 0, 100)
// 或
this.arr.splice(0, 1, 100);
// 截短阵列
this.arr.splice(1);

为什麽 Vue 无法监控物件或阵列的更动?

当我们平常在 data 物件里写上需要实现响应式的资料时,Vue 就会把 data 里的所有属性都跑一遍,透过Object.defineProperty(),在 data 里为这些属性逐一加上gettersetter

简单说明如下,例如有一件 T-shirt 商品的资料:

// 想像为 Vue 里面的 data 属性
  let data = {
    price: 100,
    quantity: 2,
    sizes: ["XS", "S", "M", "L", "XL"],
    info: {
      title: "纯色T-shirt",
      color: "白色",
    },
  };

  // 为每个属性加上getter、setter
  Object.keys(data).forEach((key) => {
    let internalValue = data[key];
    Object.defineProperty(data, key, {
      get() {
        console.log(`Get ${key}: ${internalValue}`);
        return internalValue;
      },

      set(newValue) {
        console.log(`Set ${key} from ${internalValue} to ${newValue}`);
        internalValue = newValue;
      },
    });
  });
  data.price = 200; 
  // Set price from 100 to 200

  console.log(data.price); 
  // Get price: 200
  // 200

以上只是一个简化版本,说明官方文件所指的「Vue 将遍历此对象所有的 property,并使用 Object.defineProperty 把这些 property 全部转为 getter/setter。」是什麽意思。

虽然以上例子还不完整,因为在 gettersetter 里,其实 Vue 还会执行其他函式来更新资料。虽然在这里还没作解释,但我们现在至少可以知道,Vue 只会为data 最外层的属性逐一加上gettersetter。当该值是一个阵列或物件,它们里面的值是不会被加上gettersetter

以下示范用之前错误的写法,就会发现这样写法只会触发getter,没有setter

// Get sizes: XS,S,M,L,XL
// 没触发 set 函式来更新 sizes
data.sizes[0] = 'XXS'

// Get info: [object Object]
// 没触发 set 函式来更新 info
data.info.color = '黑色'

以下写法才会触发set,原因刚才已提过,因为只有最外层的属性才会有set函式:

data.sizes = ['XSS', 'S', 'M', 'L', 'XL']
data.info = {
    title: '纯色T-shirt',
    color: '黑色',
}

为什麽一定要执行set才行?因为 Vue 是不只是依赖getter,还有在setter 里的其他程序来实现更新资料,实现响应式。所以如果没有正确透过 setter 来写入资料,就没法达成响应式。从下图可以见到setter的作用。

实现响应式的概念图

图片来源:https://cn.vuejs.org/v2/guide/reactivity.html
以上概念可见,一定要由setter去更新资料。

深入了解概念图

Vue 的官方影片有很详细示范如何用程序码实现上图的概念。以下会以官方例子稍作简化,并用我自己的了解去做总结。
当我们谈及响应式时,我们需要完成两项功能,才能真正达成响应式。举例说,我修改了price 这个值後,我就要做以下两件事:

  1. 更新在 data 里的 price 这个属性的值
  2. 重新渲染所有有涉及到 price 值的元件

第一点很简单,当我写data.price = 200时,我就会期望 data 里的 price 属性的值会由 100 改为 200。
第二点,举例说,我的元件某部分,以const total = data.price * data.quantity 来计算总额。这段程序码涉及了 price 这个值。因此total也照理需要被更新,换言之,total 需要被重新计算。

第一点:更新在 data 里的 price

要更新在 data 里的值,就是用上文提到的 setter 去处理。

第二点:重新渲染所有有涉及到 price 值的元件

在重新渲染所有有涉及到 price 值的元件前,我们需要知道哪些元件有用到 price 这个值。Vue 的做法就用 Watcher 函式记录下来。Vue 在建立元件的时候,会为这元件建立相应的 Watcher 函式,把此元件所有「依赖」(dependency) 的程序码都纪录下来。在我们的例子中,就是为根元件新增Watcher函式,并在里面记录此元件使用了total = data.price * data.quantity 这段程序码。

let total, target

// 先不用理解这个 watcher 函式的内容
function watcher(myFunc) {
    target = myFunc;
    target();
    target = null;
}

watcher(() => {
  total = data.price * data.quantity;
});

先不用理解这个 watcher 函式的内容,我们先知道 Vue 会为此元件建立 watcher 来记录依赖即可。
当我们执行最後那一段 watcher 函式时,里面的data.price 以及 data.quantity 就会分别触发 pricequantity 里的 getter 函式。因为上文提及过,每个 data 属性的值,都会有 getter 这个函式。

而在 getter 里,Vue 就会把那些依赖的程序码储存起来,示范如下:

let target, total;

// Dep 实体
class Dep {
    constructor() {
          this.subscribers = [];
    }

    // 4. 当触发 getter 时,就会执行 depend()
    depend() {
      if (target && !this.subscribers.includes(target)) {
        // 5. 把依赖的程序码储存起来
        this.subscribers.push(target);
      }
    }

}

Object.keys(data).forEach((key) => {
    let internalValue = data[key];

    const dep = new Dep();
    // 1. 使用 Object.definProperty 为 data 的每个值加上 get 和 set
    Object.defineProperty(data, key, {
      get() {
        // 3. 把依赖收集起来
        dep.depend();
        return internalValue;
      },

      set(newValue) {
        internalValue = newValue;
      },
    });
});

function watcher(myFunc) {
    target = myFunc;
    target();
    target = null;
}

watcher(() => {
    // 2. 触发 price 和 quantity 里的 getter
    total = data.price * data.quantity;
});

以上程序码中,加上了 Dep 这个实体。在这实体里,我们把传进来的依赖储存在 subscribers 里。为什麽我们要储存这个依赖?因为我们的初衷是,当 price 有变动时,total 就要被重新计算。做法就是把所有用到 price 的依赖都储存起来,当侦测到 price 有变动时,我们就会跑一次此值所用到的所有依赖,也就是概念图中提到的 "Notify" 步骤,示范如下:


  let data = {
      ...
  };
      
  let target, total;

  // Dep 实体
  class Dep {
    constructor() {
      this.subscribers = [];
    }

    // 4. 当触发 getter 时,就会执行 depend()
    depend() {
      if (target && !this.subscribers.includes(target)) {
        // 3. 把依赖收集起来
        this.subscribers.push(target);
      }
    }

    notify() {
      // 7. 跑一次之前储存在 subscribers 里的依赖,更新资料
      this.subscribers.forEach((sub) => sub());
    }
  }

  Object.keys(data).forEach((key) => {
    let internalValue = data[key];

    const dep = new Dep();
    
    // 1. 使用 Object.defineProperty 为 data 的每个值加上 getter 和 setter
    Object.defineProperty(data, key, {
      get() {
        // 3. 把依赖收集起来
        dep.depend();
        return internalValue;
      },

      set(newValue) {
        internalValue = newValue;
        // 6. 触发 notify 
        dep.notify();
      },
    });
  });

  function watcher(myFunc) {
    target = myFunc;
    target();
    target = null;
  }

  
  watcher(() => {
    // 2. 触发 price 和 quantity 里的 getter
    total = data.price * data.quantity;
  });


  console.log(total); // 200
  // 5. 修改 price 
  data.price = 200;
  console.log(total); // 400

当我们修改 price 时,关键就在於 Dep 里的 notify() 函式。透过执行 notify(),把之前储存在 Dep 实体的 subscribers 里的依赖,即是 total = data.price * data.quantity 跑一次,就能重新计算 total,达成响应式的效果。

完整程序码示范

https://codepen.io/alysachan/pen/MWoYvGG

总结

  • Vue 无法侦测列阵列和物件资料的变动,需要使用Vue.set()Vue.delete() 等语法。
  • Vue 2 是透过Object.defineProperty,把在 data 物件里最外层的属性逐一加上 gettersetter
  • 要实现响应式,Vue 利用getter/setterWatcher函式、Dep实体来处理。当修改一个值时,会触发setter来更新该值。
  • 同时,也要更新其他有使过这个值的部分。因此 Vue 在建立元件时,利用Watcher() 函式,把所有依赖(dependency)都记录起来,利用Dep实体里的depend()的方法,储存到对应的 Dep 实体里。当该值出现变化时,就会触发setter,以及执行在Dep里的 notify(),重新执行所有储存起来的「依赖」,从而得出所有需要更新的值,并重新渲染到画面,达成响应式的效果。

参考资料

JavaScript Reactivity Explained Visually


<<:  Day 01:什麽是演算法?

>>:  [Day14] CH09:寻寻觅觅——二元搜寻法

百度存档列印

没百度帐号,想下载百度档案,,请问可以帮我吗? 万分感谢 ...

Unity与Photon的新手相遇旅途 | Day1-环境安装

这是我第一次参加铁人赛,如果内容有不清楚或有误欢迎大家指正,影片皆是我自学Unity并且规划的教学内...

JS 属性列举与原型的关系 DAY70

属性列举与原型的关系 自订原型 与 原生原型 最大的不同是在可列举(enumerable)的部分 原...

Day 06 CSS <复合选择器>

CSS的选择器分为基础选择器以及复合选择器 本日将将继续说明复合选择器 复合选择器可以更准确更高效的...

创建App-联络客服

创建App-联络客服 由於本App的联络客服功能的延伸界面使用最简介的方式来设计,因为本App的原始...