数据响应式原理

152 阅读2分钟

附:「这是我参与2022首次更文挑战的第3天,活动详情查看:2022首次更文挑战

深入理解 options .data

内存图

Vue对data做了什么?

重要议题,后面的代码会围绕这个问题进行扩展,看完所有代码后,再回来思考下这个问题。

1. 前 log 和 后 log (问题起源)

这个代码做了两个事情:

(1)改变了 n 的值

(2)输出log,实例前 和 实例后

// 引用完整版 Vue
import Vue from "vue/dist/vue.js";

Vue.config.productionTip = false;
const myData = {
  n: 0
};
console.log(myData); // 本节课精髓

new Vue({
  data: myData,
  template: `
    <div>
    {{n}}
    <button @click="add()">+10</button>
    </div>
    
  `,
  methods: {
    // 第二种方式
    add() {
      // myData.n += 10;
      this.n += 10;
    }
  }
}).$mount("#app");

// 第一种方式
setTimeout(() => {
  myData.n += 10;
  console.log(myData); // 本节课精髓
}, 3000);

代码:codesandbox.io/s/goofy-ban…

我们要做的就是分析这两个 log输出的结果到底是不是一样的?

先说在前面,我们主观认为它们都是一样的,感觉没有什么不同,但是实际前后的log是不同的。

前log:

后log:

跟前log一点都不一样,还多了这么多东西!

  • n:(...)是什么鬼?
  • get 和 set 又是什么?

带着这个疑问,继续看下面的代码 ↓ ↓ ↓

2. getter 和 setter

  • 这个语法是ES6中的
  • get 和 set 可以对一个属性进行读写(这个属性是虚拟的,我们暂且管他叫虚拟属性把)

我们写了一个 obj1 的对象,在这个 obj1对象中写了一个 xm 函数,函数中的代码指向外面的变量,最后调用这个函数输出结果

let obj1 = {
   x: "何",
   m: "顺昌",
   xm(){
      return this.x + this.m;
   }
}

console.log(obj1.xm()) // 何顺昌

需求1:能不能不加函数括号拿到值?

我:没问题!

let obj2 = {
 x: "何",
 m: "顺昌",
 get xm(){
  return this.x + this.m;
 }
}

console.log(obj2.xm) // 何顺昌

解析:只需要在函数前面加一个 get,这样我们获取这个值的时候就可以不用加括号,直接当属性来使用即可

需求2:你这个只能读么?给我加一个改的功能!

我:ok (有get 就有 set)

let obj3 = {
    x: "何",
    m: "顺昌",
    get xm() {
        return this.x + this.m;
    },
    set xm(xxx) {
        this.x = xxx[0];
        this.m = xxx.substring(1);
     }
    };
obj3.xm = "何小明";

console.log(`姓:${obj3.x},名:${obj3.m}`)  // 姓:何,名:小明

解析:

  • 在函数前加一个 set 就可以修改值了,当我们 obj3.xm = "何小明" 相当于就触发了这个 set 函数。

看完这个代码,你看懂 后log的问题了么?

我们把上面代码的 obj3 打印出来看下

console.log(obj3)

输出结果:

发现多了一个属性,我们的代码也没有这个属性呀?

再回头看下我们最开始的问题!===> 后log

发现它们很相似,有xm:(....) ,有 get 和 set

所以我初步判定:后log之所以多了这么多东西,应该是添加了 get 和 set

问题:什么时候被做的手脚?我咋不知道?!

带着什么时候做手脚的疑问,继续看下面的代码 ↓ ↓ ↓

3. Object.defineProperty

在定义一个对象之后,你还想在额外添加一个虚拟属性,就可以使用这个函数

代码

声明了一个 data0对象,这个对象里有一个 key:n,value:0

let data0 = {
 n:0
}

需求1: 用 Object.defineProperty 定义 n(实现上面代码的效果)

let data1 = {};

// 在data1上添加一个n属性,这个n属性的值是0,不是value0!
Object.defineProperty(data1, "n", {
  value: 0
});

console.log(`需求一:${data1.n}`);

吐槽:这煞笔语法把事情搞复杂了?(我直接用data0写法不可以么)非也,继续看。

需求2:n不能小于0 (即 data2.n = -1 无效 ,data2.n = 1有效 )

let data2 = {};

data2._n = 0; // _n 用来偷偷存储 n 的值

Object.defineProperty(data2, "n", {
  get() {
    return this._n; // 读,_n存着真实的值
  },
  set(value) {
    if (value < 0) return; // 小于0白写
    this._n = value; // 写
  }
});

console.log(`需求二:${data2.n}`);
data2.n = -1;
console.log(`需求二:${data2.n} 设置为 -1 失败`);
data2.n = 1;
console.log(`需求二:${data2.n} 设置为 1 成功`);

输出结果:

有人抬杠说:那如果对方直接使用 data2._n 呢?

我:算你狠!

需求3:使用代理

let data3 = proxy({ data: { n: 0 } }); // 括号里是匿名对象,无法访问

function proxy({ data }) {  // 这里的data就是上面代码的 n:0
  const obj = {};
  
  // 对上面这个 obj添加一个 n属性,此时对n的操作就是对data的操作
  Object.defineProperty(obj, "n", {
    get() {
      return data.n;
    },
    set(value) {
      if (value < 0) return;
      data.n = value;
    }
  });
  return obj; // obj 就是data的代理
}

// data3 就是 obj
console.log(`需求三:${data3.n}`);
data3.n = -1;
console.log(`需求三:${data3.n},设置为 -1 失败`);
data3.n = 1;
console.log(`需求三:${data3.n},设置为 1 成功`);

解析:只暴露了代理对象(房产中介),没有暴露真实对象(房东)。(就像你没有办法和房东直接说话,只能通过中介来把话传递)

我:杠精还有话说吗?

抬精说:有,你看下面代码!

需求4:

声明了一个变量,把变量当作引用

let myData = { n: 0 };
let data4 = proxy({ data: myData });  // 这里的myData 就是 n:0

console.log(`杠精:${data4.n}`);  // 0
myData.n = -1;
console.log(`杠精:${data4.n},设置为 -1 失败了吗!?`);  // -1

抬精说:我现在改 myData,是不是还能改?!你奈我何

我:艹,算你狠

我现在要解决的问题:

  • myData.n = -1; 的时候也能对它做一个限制
  • 就好像我跟房东说,只要有租客找你,你都要告诉我它准备干嘛,因为我们俩签了合同的,租客只能是我的,

需求5:就算用户擅自修改 myData,也要拦截它 (这个需求是100%拦截!)

  • 就好像我跟房东说,只要有租客找你私自签合同,你都要告诉我,因为我们俩是签了合同的,即使您跟租客签了合同,中介费你也要一样给我。
let myData5 = { n: 0 };
let data5 = proxy2({ data: myData5 }); // 括号里是匿名对象,无法访问

function proxy2({ data }) {
  
  // 1. 监听 myData5
  let value = data.n; // 保存最开始的n,也就是n:0
  Object.defineProperty(data, "n", {
    get() {
      return value;
    },
    set(newValue) {
      if (newValue < 0) return;  // 如果新值小于0,就不赋值
      value = newValue;
    }
  });
  // 就加了上面几句,这几句话会监听 data

  const obj = {};
  Object.defineProperty(obj, "n", {
    get() {
      return data.n;
    },
    set(value) {
      if (value < 0) return; 
      data.n = value;
    }
  });

  return obj; // obj 就是代理
}

// data3 就是 obj
console.log(`需求五:${data5.n}`);
myData5.n = -1;
console.log(`需求五:${data5.n},设置为 -1 失败了`);
myData5.n = 1;
console.log(`需求五:${data5.n},设置为 1 成功了`);

解析:

  • 这个100%拦截就可以得出我们做手脚的那个问题了!!(看一下这个代码是不是很眼熟?)

  • 所以基本判定是 new Vue 做的手脚,当我们把mydata传给 Vue,Vue就对其做了手脚,这也是为什么前后打印出的log有不同的原因!

小结

(1)Object.defineProperty

  • 可以给对象添加属性 value
  • 可以给对象添加 getter 和 setter
  • getter / setter 用于对属性的读写进行监控

(2)啥是代理?(设计模式)

  • 对 myData 对象的属性读写,全权由另一个对象 vm 负责
  • 那么 vm 就是 myData 的代理(类似房东的房子让房产中介来管,vm就是房产中介)
  • 比如 myData.n 不用,偏要用 vm.n 来操作 myData.n

(3) vm = new Vue({data:myData})

  • vm 会成为 myData的代理(proxy)
  • 会对 myData的所有属性进行监控

问题:

  1. 为什么要监控? ===> 是为了防止 myData 的属性变了,vm不知道
  2. vm知道了又如何? ===> 知道属性变了就可以调用render(data)呀!
  3. UI = render(data) ===> 视图发生变化

示意图(100%拦截)

  • 注意,全程都是在数据({n:0n:0})的上面进行get 和 set的,并没有直接覆盖删除数据({n:0n:0})
  • 对这个({n:0n:0})进行监听,监听后得到的是监听后的对象
  • 此时再对监听后的对象进行代理
  • 代理全程负责数据({n:0n:0})的读和写

数据响应式

1. 响应式

什么是数据响应式?

  • 我打你一拳,你会喊疼,那你就是响应式的
  • 即一个物体对外界的刺激做出反应,那它就是响应式的。
  • 事事有回应

Vue中的 data是响应式

  • const vm = new Vue({data:{n:0}})
  • 我如果修改 vm.n,那么 UI中的 n 就会响应我
  • Vue2是通过 Object.defineProperty 来实现数据响应式的

响应式网页

如果我改变网页窗口大小,网页内容会做出响应,这个就是响应式网页

2. Object.defineProperty有两个bug(无法监听,也就是无法做到数据响应式)

(1)对象中新增key(避免:提前写好)

Object.defineProperty(obj,"n",{...})

必须要由一个 "n",才会监听 & 代理 obj.n 对吧!

假设这个前端开发者很菜,没有给 n 怎么办?

情况一:开发者没有定义 n 直接使用

情况二:后添加

  • 代码:codesandbox.io/s/youthful-… (Vue只会检查第一层)
  • 如果我点击 set b,视图会显示 1吗? ==> 不会,因为Vue没法监听一开始不存在的 obj.b

解决办法

  1. 提前把 key 声明好
  2. 使用 Vue.set 或者 this.$set
new Vue({
  data: {
    obj: {
      a: 0 // obj.a 会被 Vue 监听 & 代理
    }
  },
  template: `
    <div>
      {{obj.b}}
      <button @click="setB">set b</button>
    </div>
  `,
  methods: {
    setB() {
      // this.$set(this.obj, "b", 1);
      Vue.set(this.obj, "b", 1);  // 使用Vue.set
    }
  }
}).$mount("#app");

输出结果:点击set b 后显示了数字1


  1. Vue.set做了什么?
  • 新增 key
  • 自动创建代理和监听(如果没有创建过)
  • 触发 UI 更新(但并不会立刻更新,因为是异步函数)

(2)数组中新增key(解决办法:使用尤雨溪的变更方法对其进行增删改查操作)

  • data中有数组怎么办?
  1. 你没办法提前声明所有 key
  • 示例1,数组的长度可以一直增加,下标就是 key
  • 所以我们没有办法提前把数组的key都声明出来
  • Vue也不能 检测对你新增了小标
  • 难道每次改数组都要用 Vue.set 或者 this.$set?
  1. 尤雨溪的做法(新增了一层原型)

  • 这 7个API都会被Vue篡改,调用后会更新UI
  • 篡改的代码如下:ES6 / ES5

ES5新增原型写法

const vueArrayPrototype = {
  push: function () {
    console.log(" push ");
    return Array.prototype.push.apply(this, arguments);
  }
};
vueArrayPrototype.__proto__ = Array.prototype;
const array = Object.create(vueArrayPrototype);
array.push(1);

ES6新增原型写法

class VueArray extends Array {
  push(...args) {
    const oldLength = this.length; // this就是当前数组
    super.push(...args);
    console.log(" push ");
    for (let i = oldLength; i < this.length; i++) {
      Vue.set(this, i, this[i]);
      // 将每个新增的 key 告诉 value
    }
  }
}
Vue;

3. 总结

对象中新增的key

  • Vue没有办法事先监听和代理
  • 所以建议是提前写好

数组中新增更多key

  • 尤雨溪篡改了7个API方便我们对数组进行增删

其他