Vue双向绑定原理,教你一步一步实现双向绑定

当今前端天下以 Angular、React、vue 三足鼎立的局面,你不选择一个阵营基本上没法立足于前端,甚至是两个或者三个阵营都要选择,大势所趋。前端

因此咱们要时刻保持好奇心,拥抱变化,只有在不断的变化中你才能利于不败之地,保守只能等死。vue

最近在学习 Vue,一直以来对它的双向绑定只能算了解并不深刻,最近几天打算深刻学习下,经过几天的学习查阅资料,算是对它的原理有所认识,因此本身动手写了一个双向绑定的例子,下面咱们一步步看如何实现的。node

看完这篇文章以后我相信你会对 Vue 的双向绑定原理有一个清楚的认识。也能帮助咱们更好的认识 Vue。git

先看效果图 github

//代码:
<div id="app">
    <input v-model="name" type="text">
    <h1>{{name}}</h1>
</div>
<script src="./js/observer.js"></script>
<script src="./js/watcher.js"></script>
<script src="./js/compile.js"></script>
<script src="./js/index.js"></script>
<script>
const vm = new Mvue({
    el: "#app",
    data: {
        name: "我是摩登"
    }
});
</script>

数据绑定

在正式开始以前咱们先来讲说数据绑定的事情,数据绑定个人理解就是让数据M(model)展现到 视图V(view)上。咱们常见的架构模式有 MVC、MVP、MVVM模式,目前前端框架基本上都是采用 MVVM 模式实现双向绑定,Vue 天然也不例外。可是各个框架实现双向绑定的方法略有所不一样,目前大概有三种实现方式。浏览器

  • 发布订阅模式
  • Angular 的脏查机制
  • 数据劫持

而 Vue 则采用的是数据劫持与发布订阅相结合的方式实现双向绑定,数据劫持主要经过 Object.defineProperty 来实现。前端框架

Object.defineProperty

这篇文章咱们不详细讨论 Object.defineProperty 的用法,咱们主要看看它的存储属性 get 与 set。咱们来看看经过它设置的对象属性以后有何变化。微信

var people = {
    name: "Modeng",
    age: 18
}
people.age; //18
people.age = 20;

上述代码就是普通的获取/设置对象的属性,看不到什么奇怪的变化。架构

var modeng = {}
var age;
Object.defineProperty(modeng, 'age', {
  get: function () {
    console.log("获取年龄");
    return age;
  },
  set: function (newVal) {
    console.log("设置年龄");
    age = newVal;
  }
});
modeng.age = 18;
console.log(modeng.age);

你会发现经过上述操做以后,咱们访问 age 属性时会自动执行 get 函数,设置 age 属性时,会自动执行 set 函数,这就给咱们的双向绑定提供了很是大的方便。app

分析

咱们知道 MVVM 模式在于数据与视图的保持同步,意思是说数据改变时会自动更新视图,视图发生变化时会更新数据。

因此咱们须要作的就是如何检测到数据的变化而后通知咱们去更新视图,如何检测到视图的变化而后去更新数据。检测视图这个比较简单,无非就是咱们利用事件的监听便可。

那么如何才能知道数据属性发生变化呢?这个就是利用咱们上面说到的 Object.defineProperty 当咱们的属性发生变化时,它会自动触发 set 函数从而可以通知咱们去更新视图。

实现

经过上面的描述与分析咱们知道 Vue 是经过数据劫持结合发布订阅模式来实现双向绑定的。咱们也知道数据劫持是经过 Object.defineProperty 方法,当咱们知道这些以后,咱们就须要一个监听器 Observer 来监听属性的变化。得知属性发生变化以后咱们须要一个 Watcher 订阅者来更新视图,咱们还须要一个 compile 指令解析器,用于解析咱们的节点元素的指令与初始化视图。因此咱们须要以下:

  • Observer 监听器:用来监听属性的变化通知订阅者
  • Watcher 订阅者:收到属性的变化,而后更新视图
  • Compile 解析器:解析指令,初始化模版,绑定订阅者

顺着这条思路咱们一步一步去实现。

监听器 Observer

监听器的做用就是去监听数据的每个属性,咱们上面也说了使用 Object.defineProperty 方法,当咱们监听到属性发生变化以后咱们须要通知 Watcher 订阅者执行更新函数去更新视图,在这个过程当中咱们可能会有不少个订阅者 Watcher 因此咱们要建立一个容器 Dep 去作一个统一的管理。

function defineReactive(data, key, value) {
  //递归调用,监听全部属性
  observer(value);
  var dep = new Dep();
  Object.defineProperty(data, key, {
    get: function () {
      if (Dep.target) {
        dep.addSub(Dep.target);
      }
      return value;
    },
    set: function (newVal) {
      if (value !== newVal) {
        value = newVal;
        dep.notify(); //通知订阅器
      }
    }
  });
}

function observer(data) {
  if (!data || typeof data !== "object") {
    return;
  }
  Object.keys(data).forEach(key => {
    defineReactive(data, key, data[key]);
  });
}

function Dep() {
  this.subs = [];
}
Dep.prototype.addSub = function (sub) {
  this.subs.push(sub);
}
Dep.prototype.notify = function () {
  console.log('属性变化通知 Watcher 执行更新视图函数');
  this.subs.forEach(sub => {
    sub.update();
  })
}
Dep.target = null;

以上咱们就建立了一个监听器 Observer,咱们如今能够尝试一下给一个对象添加监听而后改变属性会有何变化。

var modeng = {
  age: 18
}
observer(modeng);
modeng.age = 20;

咱们能够看到浏览器控制台打印出 “属性变化通知 Watcher 执行更新视图函数” 说明咱们实现的监听器没毛病,既然监听器有了,咱们就能够通知属性变化了,那确定是须要 Watcher 的时候了。

订阅者 Watcher

Watcher 主要是接受属性变化的通知,而后去执行更新函数去更新视图,因此咱们作的主要是有两步:

  1. 把 Watcher 添加到 Dep 容器中,这里咱们用到了 监听器的 get 函数
  2. 接收到通知,执行更新函数。
function Watcher(vm, prop, callback) {
  this.vm = vm;
  this.prop = prop;
  this.callback = callback;
  this.value = this.get();
}
Watcher.prototype = {
  update: function () {
    const value = this.vm.$data[this.prop];
    const oldVal = this.value;
    if (value !== oldVal) {
      this.value = value;
      this.callback(value);
    }
  },
  get: function () {
    Dep.target = this; //储存订阅器
    const value = this.vm.$data[this.prop]; //由于属性被监听,这一步会执行监听器里的 get方法
    Dep.target = null;
    return value;
  }
}

这一步咱们把 Watcher 也给弄了出来,到这一步咱们已经实现了一个简单的双向绑定了,咱们能够尝试把二者结合起来看下效果。

function Mvue(options, prop) {
    this.$options = options;
    this.$data = options.data;
    this.$prop = prop;
    this.$el = document.querySelector(options.el);
    this.init();
}
Mvue.prototype.init = function () {
    observer(this.$data);
    this.$el.textContent = this.$data[this.$prop];
    new Watcher(this, this.$prop, value => {
        this.$el.textContent = value;
    });
}

这里咱们尝试利用一个实例来把数据与须要监听的属性传递进来,经过监听器监听数据,而后添加属性订阅,绑定更新函数。

<div id="app">{{name}}</div>
const vm = new Mvue({
    el: "#app",
    data: {
        name: "我是摩登"
    }
}, "name");

咱们能够看到数据已经正常的显示在页面上,那么咱们在经过控制台去修改数据,发生变化后视图也会跟着修改。

到这一步咱们咱们基本上已经实现了一个简单的双向绑定,可是不难发现咱们这里的属性都是写死的,也没有指令模板的解析,因此下一步咱们来实现一个模板解析器。

Compile 解析器

Compile 的主要做用一个是用来解析指令初始化模板,一个是用来添加添加订阅者,绑定更新函数。

由于在解析 DOM 节点的过程当中咱们会频繁的操做 DOM, 因此咱们利用文档片断(DocumentFragment)来帮助咱们去解析 DOM 优化性能。

function Compile(vm) {
  this.vm = vm;
  this.el = vm.$el;
  this.fragment = null;
  this.init();
}
Compile.prototype = {
  init: function () {
    this.fragment = this.nodeFragment(this.el);
  },
  nodeFragment: function (el) {
    const fragment = document.createDocumentFragment();
    let child = el.firstChild;
    //将子节点,所有移动文档片断里
    while (child) {
      fragment.appendChild(child);
      child = el.firstChild;
    }
    return fragment;
  }
}

而后咱们就须要对整个节点和指令进行处理编译,根据不一样的节点去调用不一样的渲染函数,绑定更新函数,编译完成以后,再把 DOM 片断添加到页面中。

Compile.prototype = {
  compileNode: function (fragment) {
    let childNodes = fragment.childNodes;
    [...childNodes].forEach(node => {
      let reg = /\{\{(.*)\}\}/;
      let text = node.textContent;
      if (this.isElementNode(node)) {
        this.compile(node); //渲染指令模板
      } else if (this.isTextNode(node) && reg.test(text)) {
        let prop = RegExp.$1;
        this.compileText(node, prop); //渲染{{}} 模板
      }

      //递归编译子节点
      if (node.childNodes && node.childNodes.length) {
        this.compileNode(node);
      }
    });
  },
  compile: function (node) {
    let nodeAttrs = node.attributes;
    [...nodeAttrs].forEach(attr => {
      let name = attr.name;
      if (this.isDirective(name)) {
        let value = attr.value;
        if (name === "v-model") {
          this.compileModel(node, value);
        }
        node.removeAttribute(name);
      }
    });
  },
  //省略。。。
}

由于代码比较长若是所有贴出来会影响阅读,咱们主要是讲整个过程实现的思路,文章结束我会把源码发出来,有兴趣的能够去查看所有代码。

到这里咱们的整个的模板编译也已经完成,不过这里咱们并无实现过多的指令,咱们只是简单的实现了 v-model 指令,本意是经过这篇文章让你们熟悉与认识 Vue 的双向绑定原理,并非去创造一个新的 MVVM 实例。因此并无考虑不少细节与设计。

如今咱们实现了 Observer、Watcher、Compile,接下来就是把三者给组织起来,成为一个完整的 MVVM。

建立 Mvue

这里咱们建立一个 Mvue 的类(构造函数)用来承载 Observer、Watcher、Compile 三者。

function Mvue(options) {
  this.$options = options;
  this.$data = options.data;
  this.$el = document.querySelector(options.el);
  this.init();
}
Mvue.prototype.init = function () {
  observer(this.$data);
  new Compile(this);
}

而后咱们就去测试一下结果,看看咱们实现的 Mvue 是否是真的能够运行。

<div id="app">
    <h1>{{name}}</h1>
</div>
<script src="./js/observer.js"></script>
<script src="./js/watcher.js"></script>
<script src="./js/compile.js"></script>
<script src="./js/index.js"></script>
<script>
    const vm = new Mvue({
        el: "#app",
        data: {
            name: "彻底没问题,看起来是否是很酷!"
        }
    });
</script>

咱们尝试去修改数据,也彻底没问题,可是有个问题就是咱们修改数据时时经过 vm.$data.name 去修改数据,而不是想 Vue 中直接用 vm.name 就能够去修改,那这个是怎么作到的呢?其实很简单,Vue 作了一步数据代理操做。

数据代理

咱们来改造下 Mvue 添加数据代理功能,咱们也是利用 Object.defineProperty 方法进行一步中间的转换操做,间接的去访问。

function Mvue(options) {
  this.$options = options;
  this.$data = options.data;
  this.$el = document.querySelector(options.el);
  //数据代理
  Object.keys(this.$data).forEach(key => {
    this.proxyData(key);
  });

  this.init();
}
Mvue.prototype.init = function () {
  observer(this.$data);
  new Compile(this);
}
Mvue.prototype.proxyData = function (key) {
  Object.defineProperty(this, key, {
    get: function () {
      return this.$data[key]
    },
    set: function (value) {
      this.$data[key] = value;
    }
  });
}

到这里咱们就能够像 Vue 同样去修改咱们的属性了,很是完美。彻底本身动手实现,你也来试试把,体验下本身动手写代码的乐趣。

总结

  1. 本文主要是对 Vue 双向绑定原理的学习与实现。
  2. 主要是对整个思路的学习,并无考虑到太多的实现与设计的细节,因此还存在不少问题,并不完美。
  3. 源码地址,整个过程的所有代码,但愿对你有所帮助。
  4. 若是你以为本文对你有帮助,欢迎转发,点赞。

关注微信公众号:六小登登。领取全套学习资源

相关文章
相关标签/搜索