Vue2 数据劫持&双向绑定 简易实现和原理讲解

本文参考B站技术蛋老师的视频:

Vue.js 数据双向绑定的原理及实现_哔哩哔哩_bilibiliicon-default.png?t=N3I4https://www.bilibili.com/video/BV1934y1a7MN/?spm_id_from=333.999.0.0&vd_source=df2132c14ffaa0be85096c042d66c852自己跟着手敲了一遍代码,需要的读者自取:

DesignPattern/v-model at main · A164759920/DesignPattern (github.com)icon-default.png?t=N3I4https://github.com/A164759920/DesignPattern/tree/main/v-model

敲完一遍总感觉学的知识很琐碎,有的地方模棱两可,那就写篇博客复盘一下吧。🧐

一.数据劫持的实现

目标:

当被劫持的数据被修改时,自动将新值同步到template相应的插值表达式中(参考Vue2)

1.Object.defineProperty的运用

        1.1 实现关键

Object.defineProperty的getter和setter

对某个对象的若干属性使用Object.defineProperty后,可理解为这些属性已被【劫持】

  • 当再次访问该对象被劫持的属性时,将触发get()方法
  • 当再次修改该对象被劫持的属性时,将触发get()和set()方法

 上面提到,当一个对象的属性被劫持后,再次修改其值时,会调用其set函数,并且newValue就是新修改的值,但是【newValue】只能在set函数中访问到

        1.2闭包登场 

                1.2.1场景再现

   假设如下场景:

        ①.我劫持了一个对象中的属性

        ②.接着修改了对象中的属性值,即触发set函数

        ③.接着访问该属性,即触发get函数返回刚才新修改的值

现在问题在于,我们并未保存新修改的值,在get函数中并不能访问到刚才修改的新值。

                1.2.2 闭包登场 

        为解决1.2.1中的问题,这时我们就需要在get和set的外部声明一个变量value,并且使得被劫持的属性的get和set函数都可共同访问和修改这个value。

     在此基础上,使得每次触发set时将newValue赋值给value,每次触发get时将value的值return。这便是一种简单的闭包(Closure)


理论上访问函数作用域外部的变量会构成闭包,以get和set为例:

        可看到在get的作用域内【蓝色框】,并未声明任何变量,但其内部访问了key,value和dependency等外部变量

        同理,在set的作用域内【蓝色框】,只声明了形参newValue,但其内部还访问了value和dependency等外部变量

        因此,get的闭包中至少应该包含key,value和dependency,set的闭包中至少应该包含value和dependency。


理论上是这样,我们不妨实际劫持一个数据看看实际结果是否和我们预料的相同。

 以劫持"data"对象为例:

data = {
        name:"用户名",
        more:{
             yield1:"一号数据",
             yield2:"二号数据"
             }
    }

  通过使用Observer函数,我们可以分别对name和more属性调用defineProperty进行劫持,

劫持完成后,打开浏览器并在源代码的get和set函数中分别打上断点进行调试

        注:一个对象的若干属性被带有get函数和set函数的Objec.defineProperty处理过后,将会在原对象中挂载相应的方法。

我们这里将事先准备好的data对象,通过Observer函数进行处理,可看到data对象除了原属性name和more之外,确实多了两组get和set.

 

现在我们点开get name或者get more:

        不出意料,在get函数下出现了Scopes属性中,我们看到了和闭包相关的信息

可看到key、name和dependency均保存在此

现在我们来触发set函数进行同样的验证:

        不难看出set函数中同样存储了构成闭包的变量,同时由于set函数存在参数newValue,因此在其本地作用域内较get相比多了一个newValue。

至此,相信读者对闭包也有了一定的认识,在下面的代码中还有很多运用到闭包的场景,就不再赘述了。

     1.3递归劫持

        1.3.1 为什么需要递归劫持?

❗接下来考虑下一个问题:

        当存在嵌套对象时,如何保证嵌套对象中的每一个属性都能被正确的劫持?

观察之前的Oberver函数可知,我们采用Object.keys + forEach遍历的方法为对象的每一个属性使用defineProperty进行处理。

 我们不妨写两组数据试着劫持看看,看完相信读者就会明白为什么需要引入

// 当被劫持对象结构如下时,貌似没有什么问题:
const data = {
  name:"这是name",
  more:"这是more"
}

 ✅可看到data对象的name和more均有了各自的get和set

// 现在我们修改data,让其more属性的值为一个对象,并再次调用Observer函数
const data = {
  name: "这是name",
  more: {
    yield1: "这是yield1",
    yield2: "这是yield2"
  },
};

 ❌可看到more对象的yield1和yield2并没有被劫持成功(二者均没有get和set)。

        1.3.2 如何实现递归劫持?

        为解决1.3.1中遇到的问题,我们采用递归的思想对Observer函数进行如下修改


🔨我们先看改动①和②

为了解决前面提到的嵌套对象劫持失败的情况,我们可这样做:

  • 每取出一个对象的属性值时,将该值再次传入Oberver函数【对应改动②】,并在每次Oberver开始进行判断【对应改动②】
    • 若传入的值为空或非object类型时,直接return
    • 若传入的值为object类型,则遍历keys,为其每一个属性添加get和set

这样一来,无论嵌套几层的对象,我们都可确保每一层对象中的属性都被顺利劫持


🔨其次再看改动③

看之前我们先设想这样一个场景

问:若对象中某一属性的初始值为string类型的字符串现在我人为将属性值修改为对象,这个新的对象中的属性是否也能够被正常劫持?

// 场景再现
// 设置初始data对象
const  data = {
  name:"这是name",
  more:"这是more"
}

// 手动修改data的more属性
data.more = {
    yield1: "这是yield1",
    yield2: "这是yield2"
}
// data.more中yield1和yield2能成功被劫持吗?

答:肯定不能。但是在添加【改动③】后就可以。

 

【改动③】可以说是Observer函数中较精髓⭐⭐的一个地方,我们来不妨来分析它的作用:

  • 每当我们修改一个对象的属性时,会触发其set函数,并在set函数的newValue中拿到这个新修改的值,newValue可能是number、string 或object等各种类型
  • 因为之前我们对Observer函数进行了【①和②】,此处我们只需要将newValue再次传入Observer,若newValue为object类型,Observer则将自动为其每个属性添加get和set,若为非object,Observer自动return,不执行任何操作。

到目前为止,我们的Observer函数绝大多数情况下,已经可以正确的为被劫持对象中的各属性添加get和set,并且在属性值被修改时,也能及时为新属性添加get和set


基于当前Observer函数的功能,我们可以进一步实现数据到视图的更新

        使用过vue2.x的都知道,当我们在template模板中使用插值表达式时,vue在渲染界面时会自动将插值表达式的地方替换为this.data中对应的值,并且在this.data中相应的值被修改时,数据变化也能及时更新到视图,接下来我们也来实现一个类似的功能。

 思想如下:

  • 读取html模板,找到存在插值表达式的标签
  • 为每一个插值表达式中的属性添加一个Watcher
    • Watcher的功能,每当相应的属性被修改时,调用相应的方法将新属性值重新渲染到页面

  上述提到的功能,都将在Compile函数中实现,且该函数只在Vue实例被加载时执行一次。


至于如何定位到每一个插值表达式,不作为本文的重点讲解,请参考开头的原视频,本文重点讲解Compile中使用到的两个较为巧妙的类 Watcher和Dependency

2.Watcher类和Dependency类的运用

        2.1初步理解两者的关系 

        WatcherDependency的关系类似于订阅者发布者

每当Vue实例中this.$data的数据发生变化时,Dependency的实例就会notify出一个消息,通知订阅了该消息的各Watcher应该更新视图数据了。

        2.2如何理解Dependency类?

        它更像是对当前页面中所有插值表达式的Watcher实例的一种分类管理,为了更加有效管理众多的watcher,我们可以按以对象为单位对this.$data中的数据进行划分,一个对象拥有一个Dependency实例。

        到此我们先建立这样一种观念:

Dependency是以对象为单位建立的,它为了更好的管理众多的Watcher(这里参考了原视频,其实可以有更好的方法划分dependency,这里本文就采用和原视频的一样的划分方法)。

📄Constructor:

  • subscribers 数组:用于存放所有订阅者(这里每一个订阅者实际上就是一个Watcher实例)

📄addSub方法

        调用该方法可将新的Watcher实例添加到该Dependecy的subscribers中

📄notify方法

        遍历通知subscribers中的所有Watcher实例,该调用Watcher实例自身的update方法更新数据了

class Dependency {
  constructor() {
    this.subscribers = [];
  }
  // 添加的sub是每个Watcher
  addSub(sub) {
    this.subscribers.push(sub);
  }
  notify() {
    this.subscribers.forEach((sub) => {
      sub.update();
    });
  }

        2.3如何理解Watcher类?

一个插值表达式对应了一个Watcher实例,它拥有一个update方法,可以将this.$data中属性的值更新到页面中已渲染的插值表达式中。

⭐至于这个update方法何时触发,将由管理它的Dependency实例何时调用notify方法决定

📄Constructor中

  • vm Vue实例
  • key 需要被监视的属性名(即每个插值表达式中的属性名)
  • callback 用于更新视图的回调函数

我们可看到这里Watcher的构造函数里还有三行和Dependency相关的代码,我们将在稍后仔细分析这三行代码,这三行便是整个Watcher类的精髓所在⭐⭐⭐

📄update方法

调用该方法可将被监视属性的新数据重新渲染到界面上  

class Watcher {
  constructor(vm, key, callback) {
    this.vm = vm;
    // 插值表达式{{}}中的值
    this.key = key;
    // 回调函数即为更新{{key}}的方法
    this.callback = callback;
    Dependency.temp = this;
    // 触发getter函数
    key.split(".").reduce((prev, cur) => prev[cur], vm.$data);
    Dependency.temp = null; // 每次添加Watcher后将该temp清空,避免重复添加
  }
  // update方法为实例调用,访问相应的类内属性加this
  update() {
    // 获取新的值
    const newValue = this.key
      .split(".")
      .reduce((prev, cur) => prev[cur], this.vm.$data);
    // 调用相应的回调函数更新页面内容
    this.callback(newValue);
  }
}

        2.4 Watcher和Dependency的具体实现

 至此,我们已经对Watcher和Dependency有了一定的认识后,接下来我们不妨停下来思考如下两个问题:

        2.4.1在什么时候为每个插值表达式new一个对应的watcher?

 答:我们在Compile函数中不是可以匹配到html模板中的每个存在插值表达式的node吗,那我们就在每次匹配到插值表达式的时候new一个Watcher实例,这样html模板中的每个插值表达式便有了自己对应的Watcher。

        2.4.2如何建立【2.2】中所说的Watcher实例和Dependency实例的依赖管理关系?

我认为是整个程序中设计最为巧妙的地方,我们来仔细分析一下。为便于理解,我们将这个问题拆分为若干小问题,逐一讲解并实现

  • ①.以object类型为分类依据,创建Dependecy实例
  • ②.将每一个Watcher实例添加到其对应的Dependency实例中
  • ③.清除Dependency.temp
  • ④.编码测试

        ①.以object类型为分类依据,创建Dependecy实例

        前文提到,所有的Watcher实例都依赖于一个Dependecy实例管理,因此如何创建符合要求的dependency实例便是关键的第一步。

假设有如下data对象准备劫持,其属性more的属性值也为object类型,

那么我们希望:

        data对象拥有一个dependency实例,

        more对象也拥有一个dependecy实例.

         回想一下之前我们在哪个地方遇到过类似的情况,即以object类型为判断依据来处理一个可能含有嵌套对象的对象?

        没错,就是我们之前费了很大劲实现的带有递归功能Observer函数。如果我们在每次递归遍历对象属性进行defineProperty前,new一个Dependency类的实例dependency,然后再利用闭包的思想,就可以完美解决这个问题,于是我们只需在Observer函数中添加如下代码:

         ②.将每一个Watcher实例添加到其对应的Dependency实例中

                A.获取到Watcher实例

        要想实现这一功能,首先便是逮到watcher实例,其constructor中的this不正是指向watcher实例吗,于是我们在其构造函数中添加如下代码:

Dependency.temp = this;
// 这样Watcher实例就被暂存在到了Dependency类的temp下

                B.将Watcher实例送到可闭包访问Dependency实例的位置⭐⭐

回想一下:

  • 因为:我们的Dependency实例dependency是在Observer函数中创建
  • 所以:要想访问到它,我们得想办法在Watcher的Constructor中触发一个可以闭包访问dependency的函数
  • 提示:由于在这之前我们已对vue实例中的data对象的所有属性都添加了get和set,并且在Watcher的Constructor中我们还能拿到插值表达式中的属性名(key)和Vue实例(vm)。
  • 因此:我们在Watcher的constructor中只需访问Vue实例中插值表达式对应的属性触发其get函数,不就顺利将Watcher实例间接(当前存在Dependency.temp中)送到可以闭包访问的dependecy的地方(get函数)了吗

这里需要注意的是,插值表达式中的属性名是可能是链式的,因此我们在访问相应的属性值时,也需要链式访问,这里采用reduce方法。

 key.split(".").reduce((prev, cur) => prev[cur], vm.$data);

                C.🔥就是现在,动手

万事俱备,下一步我们只需在get函数中做两件事

  • 从Dependency.temp中取出当前Watcher实例
  • 闭包访问dependency,并调用其addSub方法,将Watcher实例添加到相应dependency的subscriber中

        ③.清除Dependency.temp

        由于我们每次new一个Watcher时,都将其暂存到Dependency的temp下,而Dependency是一个全局的类,我们应该在watcher被成功添加到dependency后就将其释放,否则会添加很多重复的watcher,释放的方式也很简单,将其指向null,由JS的GC自动回收就行。因此,我们还需在Watcher的Constructor中添加如下代码:

        ④.编码测试

 说明:为了能看到测试结果,我们事先在get函数中添加一个console.log来打印dependency实例,每次要查看watcher是否添加成功,访问对应的属性即可。

// 假设有如下插值表达式
<div> {{name}} </div>
<div> {{more.yield1}} </div>


// 有如下的Vue实例
// 且Vue实例已调用了Observer函数,data中的每个属性均有了get和set
  const vm = new Vue({
    el: "#app",
    data: {
      name: "用户名",
      more: {
        yield1: "一号数据",
        yield2: "二号数据",
      },
    },
  });
// 初始化后,先打印当前dependency看看
console.log(vm.$data.name)

 可看到当前subscribers为空

/*
接着我们模拟Compile函数的功能,为{{name}}手动添加一个Watcher
**/
const nameWatcher = new Watcher(vm,"name",(newValue)=>{
  console.log("假设这是可以把数据更新到视图的callback",newValue)
})

可看到Watcher已被成功添加到了dependency中了

/*
同理,为{{more.yield1}}手动添加一个Watcher
**/
const yield1_Watcher = new Watcher(vm,"more.yield1",(newValue)=>{
  console.log("假设这是可以把数据更新到视图的callback",newValue)
})

⭐⭐ 有意思的情况出现了,不同于为{{name}}添加watcher的执行结果,我们看到本次控制台打印了两次dependency,结果如下:


我们不妨仔细思考一下这是为什么?

其实造成这个结果一共有两个原因

  • 一个是reduce函数
  • 一个是dependency实例的闭包。

      之前就提到过,我们希望以对象为划分依据管理dependency,而出现这个结果恰恰说明了,我们之前【以object类型为分类依据,创建Dependecy实例】的部分成功,这么说有点抽象,下面同样用一个代码片段说明:

         为便于理解,我们不妨给每个dependency添加一个属性(create_obj):表示这是由哪对象创建的dependency,我们按如下方法修改其Constructor:

 

并对Observer函数进行如下修改:

 

// 修改完后,我们重新创建之前的两个Watcher
const nameWatcher = new Watcher(vm,"name",(newValue)=>{
  console.log("假设这是可以把数据更新到视图的callback",newValue)
})
const yield1_Watcher = new Watcher(vm,"more.yield1",(newValue)=>{
  console.log("假设这是可以把数据更新到视图的callback",newValue)
})

 现在再查看结果就一目了然了

        打印的第一个dependency实例属于data对象

        打印的第二个dependency实例属于more对象

        

        且对比以上两个dependency的subscribers可知,外层的denpendency(data对象)的subscribers中的watcher一定包含内层denpendency(more对象)的subscribers中的watcher,这是由于reduce函数的“链式效果”造成的。

上一张流程图来帮助我们理解:

         至此,我们已基本完成了Watcher类Dependency类知识点的讲解。

现在Observer函数和Compile函数已经可以为我们自动扫描html模板并根据插值表达式生成相应的Watcher,且Watcher都由相应的Dependency管理。

         2.4.3通知Watcher更新视图

我们还差最后一步:

在vm.$data中的数据发生变化时,通知页面更新HTML相应插值表达式中的数据

要实现这个也很简单,就是在set函数中添加如下代码:

每当vm.$data的数据变化时,触发相应的set,set中利用对应的dependencynotify方法,通知其下的所有订阅者(Watcher)调用自身的update方法更新视图


✅现在,我们已经完成了数据劫持的所有工作,现在只要插值表达式中的属性对应的value被改变时,就能及时将新的数据更新到视图。


二.双向绑定的实现

目标:

(如何从HTML模板中定位含有v-model的节点不作为本文重点讲解,请参考文章开头原视频)

 通过在input标签中添加属性 v-model="属性名"后

将【输入框中输入(显示)的值】与【vm.$data】中对应属性的值“双向绑定”在一起

1.【数据=>视图】的变化

        即:修改this.$data的值,引起输入框内的值同步变化

        和之前为插值表达式绑定Watcher类似,我们只需为每个v-model中的属性也绑定一个Watcher实例,便可以做到“修改this.$data的值,引起输入框内的值同步变化(数据=>视图)”的效果。

2.【视图=>数据】的变化 

      即:修改输入框的值,引起this.$data的值同步变化

        要实现视图=>数据的更新也很简单,我们只需为每个带有v-model的input框使用addEventListener监听其input事件,并将监听获取到的新值赋值给vm.$data对应中的属性即可。

        思想其实也很简单,还是通过赋值的方式触发set函数,set函数又触发dependency的notify方法通知可实现(数据=>视图)更新的Watcher,调用其update方法。

整个流程实际上就是视图=>数据=>视图。


三.小结

        本文花了较大的篇幅讲解如何实现简易的数据劫持及数据驱动视图修改的方法,在实现数据劫持后,再来看双向绑定就很简单了。

        在具体实现过程中闭包,发布订阅模式、reduce函数以及的defineProperty的使用,都值得我们学习。

前端小白第一次看完视频,不得不感叹Vue框架设计者的巧思,设计框架的不愧都是都是大神。

这也是我第一次写这么长的文章,若读者在阅读过程中发现错误,欢迎批评指正,互相学习。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值