vue双向绑定原理解析

一、前言

  Vue可以说是最近比较火的一个框架了,自己也用vue写过几个小项目了,所以在空余时间研究了一下vue双向绑定的原理,最后形成博客让自己印象更加深刻,也算给大家分享一些经验。

二、实现原理

  首先我们来说一下vue的双向绑定到底是如何实现的。其实vue是使用了数据劫持+订阅发布模式来实现的双向绑定。其中最主要的一个函数就是Object.definProperty(),如果不清楚这个函数的用法,可以查看MDN对于该函数的介绍。在这里,我们就是通过它的get/set来实现对数据的劫持。然后通过订阅发布模式实现双向绑定。我们来简单看一下如何实现数据劫持:

let demo = {
  name:""
}

let initValue = "init";
Object.defineProperty(demo, "name", {
  configurable:true,
  enumerable:true,
  get: function() {
    console.log("get 方法");
    return initValue;
  },
  set: function (value) {
    console.log("set 方法");
    initValue = value;
  }
})

console.log(demo.name);
//get 方法
//init
demo.name = "demo";
//set 方法

  这样我们每次在获取和设置demo.name的值的时候都会使用我们自定义的两个方法。那么,劫持了数据之后,我们该如何让数据和视图实现同步呢?此时我们应该从两个方面想:

  1. 视图改变,数据同步
  2. 数据改变,视图同步

  其中第一点通过事件监听很容易实现,例如当输入框里面的内容改变时,我们可以设置input事件,视图改变的时候会触发这个事件,我们在将数据与视图同步。

  那么第二点就是当数据改变的时候,我们如何同步到视图。此时我们应该有下面几个问题需要解决:

  1. 当数据改变时,视图如何知道数据发生了改变
  2. 数据改变时,我们如何知道应该更新哪个视图的数据
    这里我们通过订阅发布模式来解决这个问题 ,我们来看下面这张图
    在这里插入图片描述
    我们一个个来分析。
  • 我们需要一个监听器Observer来劫持data中的所有属性,这样我们就能知道数据何时发生了改变,
  • 我们需要一个编译器Compile,当解析html页面时判断哪些数据需要建立双向绑定,生成对应的订阅者Watcher加入到数组中
  • 我们将订阅者都存在一个数组内,数组是Dep类的一个属性,当数据改变时,调用Dep类的notify方法去通知每个订阅者,数据已经发生了改变
  • 订阅者接到通知后调用自身的方法去更新视图

  所以,总结起来就是,我们想要实现这个功能,需要实现一个监听器Observer,一个订阅者Watcher,一个Dep类存储订阅者,一个编译器Compile。下面我们一个个来实现,其中有的部分实现的很简单,但是可以帮助我们理解vue的原理。

三、实现监听器Observer

  因为definProperty只能监听某个对象的某个属性,所以我们借助递归来实现监听整个对象,这一步实现起来比较简单:

class Observer {
  constructor(data) {
    this.observerAll(data);
  }

  observer(data, key, value) {
    this.observerAll(value);
    Object.defineProperty(data, key, {
      configurable:true,
      enumerable:true,
      get: function () {
        console.log("get " + value);
        return value;
      },
      set: function (newVal) {
        console.log("set " + newVal);
        value = newVal;
      }
    })
  }

  observerAll(data) {
    if(Object.prototype.toString.call(data) !== '[object Object]') return ;

    Object.keys(data).forEach((key) => {
      this.observer(data, key, data[key]);
    })
  }
}

let obj = {
  name:"obj",
  m: {
    name:"m"
  }
}

new Observer(obj);

obj.m.name;
// get [object Object]
// get m
obj.name = "nihao";
// set nihao
console.log(obj.name);
// get nihao
// nihao

  同时我们简单的测试一下,证明我们的代码是ok的。

四、实现一个订阅者

  对于订阅者,他应该有四个属性:vm, exp, cb, value。其中vm是监听的对象,exp是监听的属性,有了这两个属性我们才能知道某个订阅者监听了哪个属性。cb是当数据改变时订阅者应该执行的回调函数。value保存的是旧的值,当订阅者接受到一个通知时,应该比较新值和旧值,如果数据发生了改变,则更新视图,否则不需要更新视图。订阅者还有三个方法,其中run和update是更新是要调用的方法。get是初始化时调用的方法。我们着重讲解一下get方法。我们前面提到,订阅者是存储在一个数组中的。而我们是在编译模版时将订阅者存储到数组中去的,那么我们通过什么方式将订阅者加入到数组中呢?因为我们每次获取和改变数据都会使用到set和get函数,而订阅者value的初值就是他监听数据的值,所以我们势必要通过get方法来获取到数据。所以我们可以把将watcher实例加入数组的代码写在get函数中,即Observer中。那么问题又来了,我们不仅仅第一次会调用get方法,后面也会调用get方法,如何避免多次加入同一个watcher?我们只在初始化watcher的时候才需要将watcher加入数组,所以我们在Watcher的get方法中获取数据的同时在Dep.target上将这个订阅者缓存,在Observer的get方法中我们只需要判断Dep.target中是否有缓存,即可判断是否需要执行add操作。下面我们来写代码:

class Watcher {
  constructor(vm, exp, cb) {
    this.vm = vm;
    this.exp = exp;
    this.cb = cb;
    this.value = this.get();
  }

  update() {
    this.run();
  }

  run() {
    let oldValue = this.value;
    if(oldValue !== this.vm[this.exp]) {
      this.value = this.vm[this.exp];

      this.cb(this.value);
    }
  }

  get() {
    Dep.target = this;
    let value = this.vm[this.exp];
    Dep.target = null;
    return value;
  }
}

  同时我们需要修改Observer中的代码

class Dep {
  constructor() {
    this.subs = [];
  }

  addSub(sub) {
    this.subs.push(sub);
  }

  notify() {
    this.subs.forEach((sub) => {
      sub.update();
    })
  }
}

class Observer {
  constructor(data) {
    this.dep = new Dep();
    this.observerAll(data);
  }

  observer(data, key, value) {
    this.observerAll(value);
    Object.defineProperty(data, key, {
      configurable:true,
      enumerable:true,
      //第二处更改
      get: () => {
        //第三处修改
        if(Dep.target) {
          this.dep.addSub(Dep.target);
        }
        return value;
      },
      set: (newVal) => {
        value = newVal;
        this.dep.notify();
      }
    })
  }

  observerAll(data) {
    if(Object.prototype.toString.call(data) !== '[object Object]') return ;

    Object.keys(data).forEach((key) => {
      this.observer(data, key, data[key]);
    })
  }
}

  我们的修改有三处,第一处是添加了Dep类,他有一个数组存储这订阅者,同时提供addSub方法添加新的订阅者。他还有一个方法可以通知数组中所有的订阅者,通过代码可以看到,一旦某个数据发生改变,他会通知所有的订阅者,而订阅者根据自身缓存的值和新值比较来判断是否应该更新视图。第二处更改我们把set和get改成了箭头函数,因为我们在函数中需要使用this值,而如果使用普通函数会导致this指向错误,所以我们通过箭头函数来修正this值。第三处修改就是我们添加订阅者的代码,我们判断Dep上是否有缓存,如果有则将其加入数组。至此我们已经实现了监听者和订阅者,其实此时我们已经实现了一个简单的双向绑定,我们来看看效果。测试代码如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <script src="Observer.js"></script>
    <script src="Watcher.js"></script>
    <script src="index.js"></script>
</head>
<body>
    <div id="root">
        emmm
    </div>
    <script>
        let data = {
          name:"msg"
        }
        new Observer(data);
        new Watcher(data, "name", (value) => {
          let root = document.getElementById("root");
          console.log(root)
          root.textContent = value;
        })
    </script>
</body>
</html>

  首先页面显示的是hello
在这里插入图片描述

  随后我们在代码中手动调用observer和watcher,并且为watcher传入回调函数:当数据改变时改变页面上div结点显示的内容。这里的各项数据都已经写死了,只是为了测试效果。当我们在控制台改变data的数据时,页面数据也发生了改变:
在这里插入图片描述

  好了,最后我们来写一个简单的编译器,自动的为页面中的元素建立订阅者。

五、实现Compile

  解析器需要获取到页面中的dom元素并且对其进行遍历,找出其中需要添加订阅者的地方,替换模版数据并且初始化一个订阅者。我们这里只实现vue中的{{}}。

class Compile {
  constructor(vm, el) {
    this.vm = vm;
    this.el = el;
    let root = document.querySelector("#root");
    this.compile(root);
  }

  compile(root) {
    let childNodes = root.childNodes;
    let reg = /\{\{(.*)\}\}/;
    [...childNodes].forEach((node) => {
      let text = node.textContent;
      if(node.nodeType === 3 && reg.test(text)) {
          let exp = reg.exec(text)[1];
          this.addWatcher(exp, node);
          this.updateText(node, this.vm.data[exp]);
      }
      if(node.childNodes && node.childNodes.length) {
        this.compile(node);
      }
    })
  }

  addWatcher(exp, node) {
    new Watcher(this.vm, exp, (value) => this.updateText(node, value));
  }

  updateText(node, value) {
    node.textContent = value;
  }
}

  我们获取到el结点之后,对其进行遍历,对于每个结点先判断他是否属于文本结点,即nodeType是否等于三。如果是文本结点,利用正则表达式判断其是否符合{{xxx}}格式将里面的内容。如果符合,将文本替换成对应的数据,并且初始化一个订阅者。否则不做任何事。最后,判断该结点是否还有子结点,继续递归。这样我们就能将页面中所有的{{xxx}}类的数据替换成我们想要的值。

六、整合

  最后,我们将这几个文件整合到index.js。我们创建一个MyVue类,接受一个对象作为参数。同时我们在构造函数中调用Observer和Compile。

class MyVue {
  constructor(option) {
    this.data = option.data;
    this.el = option.el;

    new Observer(this.data);
    new Compile(this, this.el);
  }
}

  最后我们来看一下效果:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <script src="Observer.js"></script>
    <script src="Watcher.js"></script>
    <script src="Compile.js"></script>
    <script src="index.js"></script>
</head>
<body>
    <div id="root">
        <div>
            <div>
                nihao
                <div>
                    {{name}}
                </div>
            </div>
        </div>
    </div>
    <script>
        new MyVue({
          el:"#root",
          data: {
            name: "data"
          }
        })
    </script>
</body>
</html>

  在页面上呈现的效果是这样的:
在这里插入图片描述

  至此,我们就完成了一个非常简单的效果,但是我想,通过这个简单的例子也能让我们对于vue双向绑定的原理有更深刻的认识。

七、结语

  最后,如果关心前端技术变化的同学可能会知道,vue3中使用了proxy来代替defineProperty。其实,proxy只是相当于一个defineProperty的升级版本,其最底层的思想还是不会改变的。当然,既然vue团队决定使用proxy来代替原来的方法,那么就证明后者肯定是优于前者的。那这两者到底有什么区别呢?我将会在下一篇博客中为大家介绍两者的区别。同时,因为自己水平有限,写的东西肯定还有很多错误,希望大家及时指出,我们共同进步。同时,文中涉及的代码已经上传到了github:https://github.com/klx-buct/myVue。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值