Vue响应式数据基本讲解(2.0和3.0)

lxf2023-05-06 01:45:01

响应式数据

一、何为响应式

在我们的程序中,总是需要去修改JS变量中的数据,然后使得页面中的内容也跟着变化,如下面的代码

<body>
  <p>姓氏: <span class="lastName"></span></p>
  <p>名字: <span class="firstName"></span></p>
  <p>年龄: <span class="age"></span></p>
</body>
<script>
  var data = {
    name: "赵信",
    age: 18,
  }
  // 修改姓氏
  function getLastName() {
    let lastName = data.name.substring(1, 0);
    document.querySelector(".lastName").textContent = lastName;
  }
  // 修改名字
  function getFirstName() {
    let firstName = data.name.substring(1);
    document.querySelector(".firstName").textContent = firstName;
  }
  // 修改年龄
  function getAge() {
    document.querySelector(".age").textContent = data.age;
  }
  // 免喷卡:上述函数只是粗略写,各位大神不要那么认真说 获取姓氏名字代码不是这样写...
  getLastName();
  getFirstName();
  getAge();
  // 希望修改下方属性值后页面也会改变
  data.name = "杨过";
</script>

由上面代码我们可以看到页面,虽然我们改了data.name和data.age,但是页面中还是显示姓氏:赵;名字:信;年龄:18; ,并没有把我们想修改的姓名修改成姓氏:杨;名字:过

其实我们不难看出,下面虽然执行了data.name=杨过,但是却没让Js执行对应的修改dom的函数,页面又怎么可能会修改呢?

所以我们就是应该在data.name = 杨过下面执行getLastName();getFirstName()这两个方法

那么响应式数据就是这样了,只要数据有变化,页面也应该跟着变化

可以粗略的说:如果我们修改了data.name的值,那么只要执行了getLastName、getFirstName函数,那么就是响应式数据了

二、实现响应式

在我们前面的代码中,已经说明了什么是响应式,但是对于该代码,是非常繁琐的:“我每次修改name,我就要调用一下getLastName();getFirstName()这两个函数,如果我修改了10次name,那岂不是要调用10次函数,很显然这种实现是有问题的"

那是否有什么方法或者API让他在每一次修改name的时候就自动调用getLastName();getFirstName()这两个函数呢?

VUE2.0的实现

Object.defineProperty这个方法,正好可以实现我们想要的效果

我们先来看一下这个Object.defineProperty到底是干嘛的,为什么能实现这个方法

浅尝一下Object.defineProperty

官方定义

Object.defineProperty() 方法会直接作用在一个对象上,或者修改一个对象之后返回这个对象。

官方定义很难让我们看出这个方法能干嘛,不如我们看看他的参数,然后自己动手浅尝一下

  • obj要定义属性的对象。
  • prop要定义或修改的属性的名称或者Symbol。
  • descriptor要定义或修改的属性描述符。
尝试
var obj = {
    test: "测试"
};
Object.defineProperty(obj,"test",{
  // 修改当前属性的值,既"测试"改为"测试成功"
  value : "测试成功",
  // 该属性为布尔值。默认为true,当改为false时,就不能再修改这个属性的值了;
  writable : false, 
  // 该属性为布尔值。默认为true,当改为false时,就不能被for in遍历到或者Object.keys()中被枚举;
  enumerable : false,
  // 该属性为布尔值。默认为true,当改为false时,表示对象的属性是否可以被删除,以及除value和writable 特性外的其他特性是否可以被修改
  configurable : true,
  // get函数,该函数必须要有返回值,在获取监听的属性时自动调用该函数,并且返回值为获取属性
  get() {
      return 1;
  },
  // set函数,在修改该对象的属性值时,会自动调用该函数,参数为赋值的值
  set(value) {
    console.log(value);
  },
})

阅读上面代码,基本上大概就能明白这个api的使用,可能比较难理解的是get函数和set函数,下面我们具体讲解,这两个方法也是我们Vue2.0实现响应式的重点

var obj = {
  name: "赵信",
};
var name = "";
// 我们这里去监听对象的name属性
Object.defineProperty(obj,"name",{
  get(){
     console.log("获取了对象的name的属性,但我还是给他返回了赵信");
     return "赵信";
  },
  set(value){
     console.log("修改了name属性值,修改的值为:" + value)
     name = value;
  }
});
obj.name = "如来佛祖玉皇大帝观音菩萨指定取西经特派使者花果山水帘洞美猴王齐天大圣孙悟空";
console.log(obj.name);
console.log(name);

我们会发现,浏览器输出控制台居然显示出了四个输出,输出顺序为

  1. 修改了name属性值,修改的值为: 如来佛祖玉皇大帝观音菩萨指定取西经特派使者花果山水帘洞美猴王齐天大圣孙悟空
  2. 获取了对象的name的属性,但我还是给他返回了赵信
  3. 赵信
  4. 如来佛祖玉皇大帝观音菩萨指定取西经特派使者花果山水帘洞美猴王齐天大圣孙悟空
总结

看到这里大概就可以理解了,当去修改这个Object.defineProperty监听的对象的属性的时候,如果外部有人修改了这个属性,则会自动调用set函数;外部有人获取该对象的时候,则会自动调用get函数;

这不就是我们刚刚上面想要的响应式实现的效果吗?下面我们来实现一下响应式

实现

现在我们回到刚刚一开始的代码,并且使用Object.defineProperty试试,看看能不能达到我们想要的效果

<body>
  <p>姓氏: <span class="lastName"></span></p>
  <p>名字: <span class="firstName"></span></p>
  <p>年龄: <span class="age"></span></p>
</body>
<script>
  var data = {
    name: "赵信",
    age: 18,
  }
  // 修改姓氏
  function getLastName() {
    let lastName = data.name.substring(1, 0);
    document.querySelector(".lastName").textContent = lastName;
  }
  // 修改名字
  function getFirstName() {
    let firstName = data.name.substring(1);
    document.querySelector(".firstName").textContent = firstName;
  }
  // 修改年龄
  function getAge() {
    document.querySelector(".age").textContent = data.age;
  }
  // 免喷卡:上述函数只是粗略写,各位大神不要那么认真说 获取姓氏名字代码不是这样写...
  getLastName();
  getFirstName();
  getAge();
​
  // 以下为新增修改的代码;
  var copyName = data.name; // 备份set的时候的值,不然我在获取的时候怎么知道要给他什么值呢
  Object.defineProperty(data,"name",{
    get(){
      // 读取的时候我直接将备份的名字返回回去
      return copyName;
    },
    set(val) {
      // 在修改值的时候,我们把值备份一遍
      copyName = val;
      // 自动调用渲染姓氏和渲染名字的函数
      getLastName();
      getFirstName();
    }
  })
  // 希望修改下方属性值后页面也会改变
  data.name = "杨过";
</script>

我们在新增了值之后会发现,我只要修改了data.name,不管是在代码修改,还是在浏览器控制台上修改,都可以自动修改页面上的姓氏和名字了;那么我们想要的效果,不就达到了吗,这个时候只需要再给data.age也监听一下属性,那我在修改age的时候,页面上的年龄也会变化了

这就是Vue2的响应式数据实现原理了,但是这样的代码是非常冗余的,我要是有10个属性,那我是不是应该把10个属性都用Object.defineProperty,此时我们就应该把代码优化成一个通用的方法,让每个属性都可以自动的监听到,并且调用渲染的函数。

优化

既然上面说如果有多个属性,那就给多个属性都遍历一遍,然后批量注册,这样不就达到了我们上面说的代码冗余问题。那我们现在就写一个公用方法,直接把data传进来,然后给obj的属性批量注册。具体实现:

function observe(data) {
  // 遍历对象
  for (const key in data) {
    let copyValue = data[key]; // 备份值
    Object.defineProperty(data,key,{
      get() {
       return copyValue;
      },
      set(val){
       copyValue = val;
       // 执行修改dom的函数(执行依赖这个属性的函数)  ???这里的代码要怎么实现呢
      },
    })
  }
}

我们会发现,set的时候我们没法搞啊。我们要怎么拿到这个值需要修改dom的函数。有人会说,那把函数传进来呢?那我们遍历的对象,怎么知道哪个是对应的属性的函数呢?

通过get函数去收集依赖,收集哪个函数应该执行

那么Vue是怎么去收集依赖的呢?

首先我们先把需要调用的函数收集起来,然后在set的执行的时候批量调用,我们先一步一步来

function observe(data) {
  // 遍历对象
  for (const key in data) {
    let copyValue = data[key]; // 备份值
    let updateFuns = []; // 收集的函数
    Object.defineProperty(data,key,{
      get() {
       // fun怎么来???
       if(!updateFuns.includes(fun)){ // 判断是否存在,防止批量执行的时候重复调用
          updateFuns.push(fun);
       }
       return copyValue;
      },
      set(val){
       copyValue = val;
       // 遍历收集起来的函数,批量执行
       updateFuns.forEach((updateFun)=>{
           updateFun();
       })
      },
    })
  }
}

现在我们就差最后一步了,我们上面的代码中fun这个函数,应该从哪里来呢?

只要解决了这一点,那我们这个响应式就可以实现了,该如何收集?

下面我们直接给代码:

<body>
  <p>姓氏: <span class="lastName"></span></p>
  <p>名字: <span class="firstName"></span></p>
  <p>年龄: <span class="age"></span></p>
</body>
<script>
  // ...
  // 修改年龄
  function getAge() {
    document.querySelector(".age").textContent = data.age;
  }
  // 批量省代码,具体代码看上面
​
  observe(data); // 先批量监听对象的属性 
  // 我们在window上挂载这个函数
  window.__fun = getLastName;
  getLastName(); // 调用这个函数的时候,会用到data.name,这样在get的时候就可以收集到这个函数啦;
  window.__fun = null; // 收集到函数后把window.__fun还原
  
  // 每个都来一遍
  window.__fun = getFirstName;
  getFirstName();
  window.__fun = null;
​
  window.__fun = getAge;
  getAge();
  window.__fun = null;
​
  // 然后我们将observe函数改装一下
  function observe(data) {
    // 遍历对象
    for (const key in data) {
      let copyValue = data[key]; // 备份值
      let updateFuns = []; // 收集的函数
      Object.defineProperty(data,key,{
        get() {
         // fun怎么来??? 从window上获取
         if(!updateFuns.includes(window.__fun)){ // 判断是否存在,防止批量执行的时候重复调用
            updateFuns.push(window.__fun); // 收集起来
         }
         return copyValue;
        },
        set(val){
         copyValue = val;
         // 遍历收集起来的函数,批量执行
         updateFuns.forEach((updateFun)=>{
             updateFun();
         })
        },
      })
    }
  };   
 
  // 希望修改下方属性值后页面也会改变
  data.name = "杨过";
</script>

这样我们就实现了一个vue的数据响应式功能啦,最后只需要再来一个封装,把下面的代码再封装一遍基本上就完成了。

function runFun(fn) {
  window.__fun = fn;
  fn();
  window.__fun = null;
}

以上就是Vue实现响应式的基本过程啦,但是其实这样的实现是有缺陷的,具体下面会讲解

Vue3.0的实现

上面我们实现了响应式的基本过程,如果感兴趣的朋友可以通过阅读Vue2.0源码会发现,我们仅仅通过Object.defineProperty去实现其实是有很大缺陷的,下面列举几个常见的

1.我们如果对一个对象进行删除与添加属性操作,是无法劫持到的;因为我们是通过遍历对象每个属性去监听的,如果突然加了个属性或者少了个属性,这个对象是完全无感知的

const obj = {
    foo: "foo",
    bar: "bar"
}
observe(obj)
delete obj.foo // 这样页面是没反应的
obj.jar = 'xxx' // 如果页面用到了obj?.jar,但是一开始是没有,后续加上页面也是没响应的

2.当我们对一个数组进行监听的时候,并不那么好使

const arrData = [1,2,3,4,5];
observe(arrData);
arrData.push() // 没反应
arrData.pop()  // 没反应
arrDate[6] = 99 // 没反应

上面2个我们可以看到Object.defineProperty无法劫持到,从而无法实现数据响应式,

所以在Vue2源码中,增加了setdelete ,并且对数组api方法进行一个重写

大概代码如下,有兴趣的可以了解一下,没兴趣的直接看下面

// 数组重写
const originalProto = Array.prototype;
const arrayProto = Object.create(originalProto);
['push', 'pop', 'shift', 'unshift', 'splice', 'reverse', 'sort'].forEach(method => {
  arrayProto[method] = function () {
    originalProto[method].apply(this.arguments)
    dep.notice()
  }
});
​
// set、delete
Vue.set(obj,'bar','newbar')
Vue.delete(obj),'bar')

3.如果存在深层的嵌套对象关系,需要深层的进行监听,造成了性能的极大问题

所以在Vue3.0中就放弃了Object.defineProperty实现数据响应式,从而该为另外一种proxy

浅尝一下Proxy

官方定义

用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)

这个描述起码比Object.defineProperty好一点,起码知道他是劫持对象的,具体参数如下:

  • target:要使用 Proxy 包装的目标对象(可以是任何类型的对象,包括原生数组,函数,甚至另一个代理)
  • handler:一个通常以函数作为属性的对象,各属性中的函数分别定义了在执行各种操作时代理的行为。
尝试
var obj = {
    test: "测试"
};
const handler = {
    get: function(obj, prop) {
        return prop in obj ? obj[prop] : "没有这个属性哦";
    }
};
const proxyObj = new Proxy(obj, handler); // 代理了的对象
proxyObj.test = "测试成功";
proxyObj.a = "我新增了a";
​
console.log(proxyObj.test, proxyObj.a);      // "测试成功", "我新增了a"
console.log(proxyObj.c); // "没有这个属性哦"
// 然后我们输出obj看一下;
console.log(obj.test, obj.a);      // "测试成功", "我新增了a"
console.log(obj.c); // "没有这个属性哦"
// 我们会发现,我们操作的被代理对象也会自动修改

我们会发现,这样实现比我们在 Object.defineProperty上实现更加的方便,只需要直接代理一下当前这个对象就好了,不需要再去遍历代理了

Proxy还有很多截取对象的方法,这里就不说明了,有兴趣的可以去看一下官方文档

Proxy官方文档

下面我们来测试一下到底有没有我们想要的效果

function observe(obj) {
    if (typeof obj !== 'object' && obj != null) {
        return obj
    }
    // Proxy代理逻辑
    const observed = new Proxy(obj, {
        get(target, key, receiver) {
            const res = Reflect.get(target, key, receiver)
            console.log(`获取${key}:${res}`)
            return res
        },
        set(target, key, value, receiver) {
            const res = Reflect.set(target, key, value, receiver)
            console.log(`设置${key}:${value}`)
            return res
        },
        deleteProperty(target, key) {
            const res = Reflect.deleteProperty(target, key)
            console.log(`删除${key}:${res}`)
            return res
        }
    })
    return observed
}

测试一下

const obj = [1,2,3]
const proxyObj = observe(obj)
obj.psuh(4) // 控制台输出劫持到了

实现

其他实现方式VUE2就差不多了,有兴趣的可以自己去试一下

缺点

Proxy 不兼容IE,也没有 polyfill, defineProperty 能支持到IE9

总结

  • Object.defineProperty只能遍历对象属性进行劫持
  • Proxy直接可以劫持整个对象,并返回一个新对象,我们可以只操作新的对象达到响应式目的
  • Proxy可以直接监听数组的变化(pushshiftsplice

以上就是Vue实现响应式数据的基本理解啦,End

感谢大家耐心看到这里!