学习vue实现双向绑定【附源码下载地址】

学习vue实现双向绑定【附源码下载地址】

嘉宝 web前端开发

问题:我怎么才能收到你们公众号平台的推送文章呢?
答案:只需要点击标题下面的蓝色字【web前端开发】关注即可。

写在前面

几乎是所有人都知道 Vue 是一个双向绑定的前端框架,但只有部分人知道实现 Vue-1 (下文中的Vue 均为Vue1 )是利用 defineProperty 来实现双向绑定的。

再次但是,只有部分的部分人,才知道 Vue 到底是如何利用 defineProperty 实现双向绑定的。

本文会带有一点解释,并用简单的例子实现一个 defineProperty 双向绑定。本文中可能会使用【可疑】这个字眼,代表这个函数很值得关注,别无他意。

先看看 Vue 是在哪里使用了defineProperty?

在源码中,发现了一个这样的函数,def()。这个函数里面包裹着我们最重要的api -- defineProperty。


/** * Define a property. * * @param {Object} obj * @param {String} key * @param {*} val * @param {Boolean} [enumerable] */function def(obj, key, val, enumerable) {
  Object.defineProperty(obj, key, {
    value: val,
    enumerable: !!enumerable,
    writable: true,
    configurable: true
  });}

defineProperty 的参数都是什么意思?

可是我们并看不懂源码里的defineProperty的参数是什么意思。所以我们去mdn上看看。


Object.defineProperty(obj, prop, descriptor)// obj: 需要定义的对象// prop: obj对象中,可能需要被定义(get)或修改(set)的属性名字// descriptor: 要定义(get)或修改(set)的obj的属性描述符// return : 这个方法 return 一个被传递给函数的对象,即 obj

展开说说 descriptor

什么是 descriptor 属性描述符?
属性描述符有两种主要形式:数据描述符和存取描述符。

数据描述符 是指一个具有值 (任意js的数据类型、数组或函数) 的属性,该值可能是可写的,也可能是不可写的。如何记忆呢?其实很简单,顾名思义,数据描述符就是通过 直接设定 value 的值,直接使得 obj 的某个属性有了值。

存取描述符 是指用 getter 或 setter 函数来定义的属性。如何记忆呢?其实很简单,顾名思义,存取描述符就是通过 存(set) 和取(get) ,使得 obj 的某个属性有了值。

描述符必须是这两种形式之一,但两者不能同时存在。

既属于数据描述符,又属于存取描述符的属性
学习vue实现双向绑定【附源码下载地址】

属于数据描述符的属性
学习vue实现双向绑定【附源码下载地址】

属于存取描述符的属性
学习vue实现双向绑定【附源码下载地址】

正确的使用数据描述符的例子


var obj = {  test: 'hi' };console.log(obj.test); //  'hi'// 在调用defineProperty的时候 'hi' 已经被 'hello' 覆盖Object.defineProperty(obj, 'test', {
    value: 'hello',
    writable : true,
    enumerable : true,
    configurable : true})console.log(obj.test); //  'hello'obj.test = 'ohYeah...';console.log(obj.test); //  'ohYeah...'

正确的使用存取描述符的例子


var obj = {  test: 'hi' };var Value = 'yoho';console.log(obj.test); //  'hi'// 在调用defineProperty的时候 'hi' 已经被 'yoho' 覆盖Object.defineProperty(obj, 'test', {
    get: function() {
        // 每次调用obj.test的时候,就会取到 Value 当前的值
        return Value;
    },
    set: function(newValue) {
        // 在每次 obj.test = newValue 赋值的时候,其实就是给全局变量 Value 赋值。
        // 以便下次调用 get 函数的时候能够取到当前最新的Value    
        Value = newValue;
    },})console.log(obj.test); //  'yoho'obj.test = 'ohYeah...';console.log(obj.test); //  'ohYeah...'

同时存在数据描述符和存取描述符的错误例子


var Value = 'yoho';Object.defineProperty(obj, 'test', {
    // 如果 value 属性同时和 get、set 使用,会报错如下
    value: 'hello',
    get: function() {
        return Value;
    },
    set: function(newValue) {
        Value = newValue;
    },})

Uncaught TypeError: Invalid property descriptor. Cannot both specify accessors and a value or writable attribute, #at Function.defineProperty (<anonymous>) at defineproperty.html?_ijt=n8sr3j2ihna97pcm6gsgj9kk1m:29
意思就是,descriptor不合法,不能同时指定存取描述符和value。

回到 def 源码,重新认识 defineProperty

在源码中,有一个这样的函数,def()。这个函数里面包裹着我们最重要的api -- defineProperty。


// 利用了数据描述符的方式来定义一个对象 obj 的 key 属性的值为 val// 并且明确知道这个属性是可以被赋值运算符改变,并且是可删除、可修改的function def(obj, key, val, enumerable) {
  Object.defineProperty(obj, key, {
    value: val,
    enumerable: !!enumerable,
    writable: true,
    configurable: true
  });}

还有哪里也有 Object.defineProperty

在搜索的过程中,还发现了一个 defineReactive 函数里也有使用到 defineProperty,明显这个函数很可疑,因为它的名字中也有define,这个函数如下。


function defineReactive(obj, key, val) {
   // 这里提到了一个 Dep 方法,他的实例 dep 在源码中频繁出现,注意点①
  var dep = new Dep();
  // .... 很多东西
  // 这里提到了一个 observe 方法,看上去也是一个重要的监听函数,注意点②
  var childOb = observe(val);

  // 在这里使用了 defineProperty
  Object.defineProperty(obj, key, {     
    // 定义了对象属性的可枚举,可修改或可删除的属性
    enumerable: true,
    configurable: true,
    // 定义了存取描述符 get 和 set 函数的实现
    get: function reactiveGetter() {
      var value = getter ? getter.call(obj) : val;
      // .... 一些判断后,最后得到了value
      return value;
    },
    set: function reactiveSetter(newVal) {
      var value = getter ? getter.call(obj) : val;
      // 如果新值没有改变,则return;
      if (newVal === value) {  return;  }
      if (setter) {
        setter.call(obj, newVal);
      } else {
        // 把新值赋值给 val    
        val = newVal;
      }

      // 调用了一个可以的名字为【观察】的可疑函数,并把新值传递出去 
      childOb = observe(newVal);

      // 这个可疑的实例,调用了一个看上去是通知的方法
      dep.notify();
    }
  });}

寻找注意点①,一个 Dep 构造函数

在源码中找到了 Dep 的实现过程:


var uid$1 = 0;// 每个 dep 实例都是可以显示观察到实例的变化的// 一个实例可以有多个订阅的指令function Dep() {
  this.id = uid$1++;
  // subs 用来记录订阅了这个实例的对象,
  // 也就是说某个被监听的对象一发生变化,subs 里面的所有订阅者都会收到变化
  this.subs = [];}// 当前这个的 target 是null,target 是全局的,而且是独一无二的// 可以通过 watcher 随时更新 target 的值Dep.target = null;// 接下来,这个实例有4个重要的方法,addSub  removeSub  depend  notify// 根据大神对方法的命名能够很容易猜测出方法的功能// 实现一个添加订阅者的方法 addSubDep.prototype.addSub = function (sub) {
  this.subs.push(sub);};// 实现一个移除订阅者的方法 removeSubDep.prototype.removeSub = function (sub) {
  this.subs.$remove(sub);};// 为target绑定 this 指向的方法 dependDep.prototype.depend = function () {
  Dep.target.addDep(this);};// 通知所有订阅者新值更新的方法 notifyDep.prototype.notify = function () {
  var subs = toArray(this.subs);
  for (var i = 0, l = subs.length; i < l; i++) {
    subs[i].update();
  }};

在这里,我注意到了 Dep.target 这个属性。在源码中的注释中,我们了解到“可以通过 watcher 随时更新 target 的值”。所以我们来看看什么是watcher。

线索延伸,寻找 watcher

通过全局搜索 watcher,我发现搜索结果实在是太多了。所以我搜索了function watcher。此时答案只有一个,那就是 Watcher 构造函数。粗略的看了看,大概有一些get、set、beforeGet、addDep、afterGet、update、run 等等方法,相当复杂。但确实发现了 Watcher 能够修改 Dep.target 的方法。


Watcher.prototype.beforeGet = function () {
  Dep.target = this;};

寻找观察点②,一个观察者的类 Observe


/** *  Observe 类会和每一个需要被观察的对象关联起来,  一旦产生关联,  被观察对象的属性值就会被 getter/setters 获取或更新 * 所以, 我们猜测这个类里一定会调用 Object.defineProperty */function Observer(value) {
  this.value = value;
  this.dep = new Dep();
  // 这里调用了 def 函数,应证了我们的猜测,确实调用了 Object.defineProperty
  def(value, '__ob__', this);
  // ... 更多处理}// 接下来,这个实例有3个重要的方法,walk  observeArray  convert// walk 遍历对象,并将对象的每个属性关联到 getter/setters,// 这个方法只有在参数是一个对象时才能被正确调用。Observer.prototype.walk = function (obj) {
  var keys = Object.keys(obj);
  for (var i = 0, l = keys.length; i < l; i++) {
    this.convert(keys[i], obj[keys[i]]);
  }};// observeArray 遍历数组,监听数组里的每一个元素。Observer.prototype.observeArray = function (items) {
  for (var i = 0, l = items.length; i < l; i++) {
    observe(items[i]);
  }};// convert  把传入的key value 和 getter/setter 关联起来,// 这样能在获取或更新属性的时候及时发送观察到的结果Observer.prototype.convert = function (key, val) {
  // 在这里看到似曾相识的函数,可以回去前2个小节看看
  defineReactive(this.value, key, val);};// ... more, 另外还有两个函数

但是全局搜索的时候,还发现了一个 observe 函数,也很可疑:


function observe(value, vm) {//....
    ob = new Observer(value);// .... 最后return了一个 Observer 类的值
  return ob;}

缺少一个导火索把一切线索串通起来

到现在为止,可以发现源码里的这些函数相互关联。线索就是按照下面的亮条路线串起来的。


observe方法 --- new ---> Observer类 --- 调用 ---> def方法 --- 使用了---> 描述符类型的 defineProperty

observe方法 --- new ---> Observer类,convert方法 --- 调用 ---> defineReactive方法 --- 使用了---> 存取描述符的defineProperty --- 同时实例化了dep ---> new Dep() ---> 可以被 Watcher 修改

到这里,就把刚刚解读的4段源码串了起来。他们的作用就是:① observe 负责监听数据的变化② 数据的获取和更新都使用 defineProperty③ Dep 负责管理订阅和发布

但还是少点什么,对,就是【数据从哪里来的?】,没有数据来源,有再完美的双向绑定也没用。

所以,我们来看看 Vue 的 data 部分会不会涉及到 observe我猜,就是 data --- 调用了---> observe方法

找到导火索 data

这里有一个小插曲,当你在 Vue 的文档中全局搜索 “data”, 或者 “ vue” 这样的关键字的时候,你会发现 data 有140个记录,vue 有203个记录。这么找下去,真是无从下手。

由于我们前面预测了,是 data 去引发了线索,所以我推测,data 调用了 observer。所以我决定把搜索条件改成 “observer”。就容易多了,很快发现了一个可疑的函数 initdata。源码如下:


/*** Initialize the data. data的初始化*/Vue.prototype._initData = function () {
    var dataFn = this.$options.data;
    var data = this._data = dataFn ? dataFn() : {};
    // ... 很多很多,对组件内外的prop、data做了各种规范和处理    
    // 重点出现了,调用了observe, 监听 data
    observe(data, this);};

这个 _initData 在 _initState 被使用:


  /**   * 给实例构造一个作用域,  其中包括:   * - observed data 监听data       * - .....   */Vue.prototype._initState = function () {
    // ...
    this._initData();
    // ...};
这个 _initState 在 _init 被使用:

Vue.prototype._init = function(options) {
    // ...
    // 初始化数据监听,并初始化作用域
    this._initState();    }

最后 _init 被 Vue 调用,


function Vue(options) {
  this._init(options);}

到此为止,我们得到了最终的结论


Vue实例 ---> data ---> observe方法 ---> Observer类 ---> def方法 ---> defineProperty

Vue实例 ---> data ---> observe方法 ---> Observer类-convert方法 ---> defineReactive方法 ---> defineProperty ---> new Dep() 订阅类 ---> 可以被 Watcher 修改

模仿思路,实现一个简陋的双向绑定

先模仿 Vue 创建一个构造函数

回忆一下 Vue 是如何实例化的?


var V = new Vue({
    // el 简化为所指定的id    
    el: 'app',
    data: { ... }})

由此可见,在实例化的时候,有两个重要的参数,el 和 data。所以,先虚拟一个构造函数。


function Vue(options) {
    this.data = options.data;
    var id = options.el;}

构造函数的参数有了,但是构造函数有什么功能呢?第一个功能应该能够解析指令,编译dom。回想一下平时写dom的时候,v-model,v-show,v-for。这些都是最常用的指令,并且直接写在dom上,但是实际渲染的html上并不会出现这些指令,为什么呢?因为被编译了。 谁编译了?Vue的构造函数负责编译。

给构造函数增加一个编译的方法


function Vue(options) {
    // ... 一些参数
    var id = options.el;
    // 利用 nodeToFragment 生成编译后的dom
    var dom = nodeToFragment(document.getElementById(id),  this);
    // 把生成好的 dom 插入到指定 id 的 dom 中去(这里简化id的处理)
    document.getElementById(id).appendChild(dom);}

上文中提到了一个 nodeToFragment 方法,这个方法其实是利用createDocumentFragment来创造一个代码片段。不了解 Fragment 的同学可以自行搜索了解一下。


 function nodeToFragment (node, vm) {
    var flag = document.createDocumentFragment();
    var child;
    while (child = node.firstChild) {
        compile(child, vm); // 调用 compile 解析 dom 属性
        flag.appendChild(child); // flag 不断填充新的 child 子节点
    }
    return flag;}function compile (node, vm) {
    if (node.nodeType === 1) {
        // 如果 node 是一个元素,解析他的所有属性
        var attr = node.attributes;
        for (var i = 0; i < attr.length; i++) {
            if (attr[i].nodeName == 'v-model') {
                // 简化,只对 v-model 属性做处理,对这个 dom 赋值
                var name = attr[i].nodeValue;
                node.value = vm.data[name];
                node.removeAttribute('v-model');
            }
        }
    }

    if (node.nodeType === 3) {
        // 如果 node 是文本节点,并且使用 {{...}} 赋值(简化操作),则对文本节点赋值
        if (/\{\{(.*)\}\}/.test(node.nodeValue)) {
            var name = RegExp.$1; // 获取到正则的第一个捕获组的值
            name = name.trim();
            node.nodeValue = vm.data[name]; // 将data 赋值给 该文本节点
        }
    }}

对比一下 dom 的编译前后。学习vue实现双向绑定【附源码下载地址】

根据之前的线索,构造一个 observe 方法

根据前面的结论,我们知道 observe 方法实际上就是一个监听函数。应该在data被确定后调用,所以在 Vue 的构造函数里。


  function Vue(options) {
    this.data = options.data;
    var data = this.data;
    // 调用 observe 方法来监听 data 里的数据
    observe(data, this);
    // ...}

observe 方法接受两个参数。遍历 data,获得属性,调用 defineReactive


function observe(objs, vm) {
    Object.keys(objs).forEach(function (key) {
        defineReactive(vm, key, objs[key]);
    })}

实现一个 defineReactive 方法

defineReactive 在本文的比较前面提到,这个方法是使用了defineProperty 这个方法的可疑函数。我们的 observe 中调用了它,所以现在也需要实现一下。


function defineReactive (obj, key, val) {
    // 这个函数就一个作用,调用了Object.defineProperty
    Object.defineProperty(obj, key, {
        get: function() {
            return val;
        },
        set: function (newVal) {
            if (newVal === val) return;
            val = newVal;
        }
    })}

我们知道,只要 obj 的 key 的值被赋值了,就会触发 set 方法。所以,当一个被 v-model 绑定了的 input 的值在变化时,应该就是出发 set 的最佳时机。那么在编译 dom 的时候,就需要提前给 dom 绑定事件。


function compile (node, vm) {
    if (node.nodeType === 1) {
        var attr = node.attributes;
        for (var i = 0; i < attr.length; i++) {
            if (attr[i].nodeName == 'v-model') {
                var name = attr[i].nodeValue;
                // 简化操作,明知只有 input 一个dom, 直接绑定
                // 给相应的data属性复制, 从而触发defineProperty的set
                node.addEventListener('input', function (e) {
                    vm[name] = e.target.value;
                })
                // 将data的值赋给该node
                node.value = vm[name];
                node.removeAttribute('v-model');
            }
        }
    }
    // ....}

根据之前的线索,需要一个订阅者的类 Dep


function Dep () {
    this.subs = [];}// 主要实现两个方法: 新增订阅者 & 通知订阅者Dep.prototype = {
    addSub: function(sub) {
        this.subs.push(sub);
    },
    notify: function() {  
        this.subs.forEach(function(sub) {
            sub.update();
        });
    },}

需要在 defineProperty 的时候设置订阅者。如果每次新增一个双向绑定的 get,都需要新增订阅者,每一次被双向绑定的 set 一次,就需要通知所有订阅者。所以需要修改一下 defineReactive 方法。


function defineReactive (obj, key, val) {
    var dep = new Dep();
    Object.defineProperty(obj, key, {
        get: function() {
            // 增加一个订阅者
            if (Dep.target) dep.addSub(Dep.target);
            return val;
        },
        set: function (newVal) {
            if (newVal === val) return;
            val = newVal;
            // 作为发布者发出通知
            dep.notify();
        }
    })}

此时,我们还需要补充一下 Watcher 类。专门用来改变 Dep.target 的指向。


function Watcher(vm, node, name, nodeType) {
    Dep.target = this;
    this.name = name;
    this.node = node;
    this.vm = vm;
    this.nodeType = nodeType;
    this.update();
    Dep.target = null;}Watcher.prototype = {
    get: function () {
        this.value = this.vm[this.name];
    },
    update: function () {
        this.get();
        // 简化操作,在编译函数中传入写死的参数
        if (this.nodeType == 'text') {
            this.node.nodeValue = this.value;
        }
        if (this.nodeType == 'input') {
            this.node.value = this.value;
        }
    }}

这个 Watcher 的作用就是,实际上实现 被订阅者的获取 和 订阅者的更新 的方法。


function compile (node, vm) {
    if (node.nodeType === 1) {
        //...            
        // vm: this 指向; node: dom节点; 
        // name: v-model绑定的属性名字; 'input': 简化操作,写死这个dom的类型
        new Watcher(vm, node, name, 'input');
    }

    if (node.nodeType === 3) {
        if (/\{\{(.*)\}\}/.test(node.nodeValue)) {
            //...        
            // 原本给文本节点赋值的方式是利用了 defineProperty 的 get
            // node.nodeValue = vm[name];  // 将data 赋值给 该文本节点    
            // 现在改为利用 Watcher,如果被订阅者变化了,直接update
            // 其中,name: {{}} 指定渲染绑定的属性; 'text': 简化操作,写死文本节点的类型
            new Watcher(vm, node, name, 'text');
        }
    }}

模仿后的总结

我们的模仿大约经历了以下几个过程第一步:创建一个构造函数Vue,并在构造函数中定义参数第二步:构建一个函数nodeToFragment, 能够把带指令的 dom 转化为 html5 的 dom第三步:nodeToFragment实际上是调用了compile, compile方法解析指令的属性并就进行赋值第四步:在构造函数Vue中增加一个监听方法observe,它接受构造函数Vue中的data作为参数,并为每个参数实现双向绑定。第五步:observe中调用了defineReactive,这个方法使用了 Object.defineProperty 来设置的数据的getter、setter。第六步:需要在compile触发setter,所以在compile中给输入框绑定事件第七步:虽然能够触发setter,但是显示的数据并没有触发getter。所以需要构造一个订阅类Dep,主要实现 增加订阅者 & 通知订阅者 两个方法。以便在 Object.defineProperty 的 setter 中触发通知函数 notify第八步:实现Dep的通知订阅者方法(notify),需要借助Watcher类,Watcher 中的 updata方法为每一个订阅者提供更新操作。第九步:需要在compile的时候为每一个订阅者实例化Watcher,所以,需要在compile中触发Watcher。传入相应的参数,让Watcher能够在update的时候正确赋值。

源码下载地址:链接: https://pan.baidu.com/s/1ggWkh3d 密码: 97kk

上一篇:分享非常有用的Java程序 (关键代码)(四)---动态改变数组的大小


下一篇:算法与数据结构基础 - 折半查找(Binary Search)