Vue源码解析(二)Vue的双向绑定讲解及实现

文章中的代码时阶段,可以下载源码测试一下。
git项目地址:https://github.com/xubaodian/SimuVue

项目使用webpack构建,下载后先执行:

npm install

安装依赖后使用指令:

npm run dev

可以运行项目。

上篇文章,我们讲解了Vue的data属性映射和方法的重定义,链接地址如下:

Vue源码解析(一)data属性映射和methods函数引用的重定义

这篇文章给大家带来的是Vue的双向绑定讲解。

什么是双向绑定

我们看一张图:



可以看到,输入框上方的内同和输入框中的值是一致的。输入框的之变化,上方的值跟着一起变化。

这就是Vue的双向绑定。

对象属性监听实现

我们先不着急了解Vue时如何实现这一功能的,如果我们自己要实现这样的功能,如何实现呢?

我的思路是这样:



可以分为几个步骤,如下:

1、首先给输入框添加input事件,监视输入值,存放在变量value中。

2、监视value变量,确保value变化时,监视器可以发现。

3、若value发生变化,则重新渲染视图。

上面三个步骤,1(addEventListener)和3(操作dom)都很好实现,对于2的实现,可能有一下两个方案:

1、使用Object.defineProperty()重新定义对象set和get,在值发生变化时,通知订阅者。

2、使用定时器定时检查value的值,发生变化就通知订阅者。(这个方法不好,定时器不能实时反应value变化)。

Vue源码中采用了方案1,我们首先用方案1实现对对象值的监听,代码如下:

function defineReactive(obj, key, val, customSetter) {
  //获取对象给定属性的描述符
  let property = Object.getOwnPropertyDescriptor(obj, key);
  //对象该属性不可配置,直接返回
  if (property && property.configurable === false) {
    return;
  }

  //获取属性get和set属性,若此前该属性已经进行监听,则确保监听属性不会被覆盖
  let getter = property && property.get;
  let setter = property && property.set;
  
  if (arguments.length < 3) {
    val = obj[key];
  }

  //监听属性
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val;
      console.log(`读取了${key}属性`);
      return value;
    },
    set: function reactiveSetter (newVal) {
      const value = getter ? getter.call(obj) : val;
      //如果值没有变化,则不做改动
      if (newVal === value) {
        return;
      }
      //自定义响应函数
      if (customSetter) {
        customSetter(newVal);
      }
      if (setter) {
        setter.call(obj, newVal);
      } else {
        val = newVal;
      }
      console.log(`属性${key}发生变化:${value} => ${newValue}`);
    }
  })
}

下面我们测试下,测试代码如下:

let obj = {
    name: 'xxx',
    age: 20
};
defineReactive(obj, 'name');
let name = obj.name;
obj.name = '1111';

控制台输出为:

读取了name属性
test.html:51 属性name发生变化:xxx => 1111

可见,我们已经实现了对obj对象name属性读和写的监听。

实现了监听,这没问题,但是视图怎么知道这些属性发生了变化呢?可以使用发布订阅模式实现。

发布订阅模式

什么是发布订阅模式呢?

我画了一个示意图,如下:


发布订阅模式有几个部分构成:

1、订阅中心,管理订阅者列表,发布者发消息时,通知相应的订阅者。

2、订阅者,这个是订阅消息的主体,就像关注微信公众号一样,有文章就会通知关注者。

3、发布者,类似微信公众号的文章发布者。

订阅中心的代码如下:

export class Dep {
  constructor() {
    this.id = uid++;
    //订阅列表
    this.subs = [];
  }

  //添加订阅
  addSub(watcher) {
    this.subs.push(watcher);
  }

  //删除订阅者
  remove(watcher) {
    let index = this.subs.findIndex(item => item.id === watcher.id);
    if (index > -1) {
      this.subs.splice(index, 1);
    }
  }


  depend () {
    if (Dep.target) {
      Dep.target.addDep(this);
    }
  }

//通知订阅者
  notify() {
    this.subs.map(item => {
      item.update();
    });
  }
}

//订阅中心  静态变量,订阅时使用
Dep.target = null;
const targetStack = [];

export function pushTarget (target) {
  targetStack.push(target);
  Dep.target = target;
}

export function popTarget () {
  targetStack.pop();
  Dep.target = targetStack[targetStack.length - 1];
}

订阅中心已经实现,还有发布者和订阅者,先看下发布者,这里谁是发布者呢?

没错,就是defineReactive函数,这个函数实现了对data属性的监听,它可以检测到data属性的修改,发生修改时,通知订阅中心,所以defineReactive做一些修改,如下:

//属性监听
export function defineReactive(obj, key, val, customSetter) {
  //获取对象给定属性的描述符
  let property = Object.getOwnPropertyDescriptor(obj, key);
  //对象该属性不可配置,直接返回
  if (property && property.configurable === false) {
    return;
  }

  //订阅中心
  const dep = new Dep();

  //获取属性get和set属性,若此前该属性已经进行监听,则确保监听属性不会被覆盖
  let getter = property && property.get;
  let setter = property && property.set;
  
  if (arguments.length < 3) {
    val = obj[key];
  }

  //如果监听的是一个对象,继续深入监听
  let childOb = observe(val);
  //监听属性
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val;
      //这段代码时添加订阅时使用的
      if (Dep.target) {
        dep.depend();
        if (childOb) {
          childOb.dep.depend();
        }
      }
      return value;
    },
    set: function reactiveSetter (newVal) {
      const value = getter ? getter.call(obj) : val;
      //如果值没有变化,则不做改动
      if (newVal === value) {
        return;
      }
      //自定义响应函数
      if (customSetter) {
        customSetter(newVal);
      }
      if (setter) {
        setter.call(obj, newVal);
      } else {
        val = newVal;
      }
      //如果新的值为对象,重新监听
      childOb = observe(newVal);
      /**
       * 订阅中心通知所有订阅者
       **/
      dep.notify();
    }
  })
}

这里设计到闭包的概念,我们在函数里定义了:

 const dep = new Dep();

由于set和get函数一直都存在的,所有dep会一直存在,不会被回收。

当值发生变化后,利用下面的代码通知订阅者:

dep.notify();

订阅中心和发布者都有了,我们何时订阅呢?或者什么时间订阅合适呢?

我们是希望实现当读取data属性时候,实现订阅。所以在defineReactive函数的get监听中添加了如下代码:

    if (Dep.target) {
        dep.depend();
        if (childOb) {
          childOb.dep.depend();
        }
    }
    return value;

Dep.target是一个静态变量,用来存储订阅者的,每次订阅前指向订阅者,订阅者置为null。

订阅者代码如下:

let uid = 0;
//订阅者类
export class Watcher{
  //构造器,vm是vue实例
  constructor(vm, expOrFn, cb) {
    this.vm = vm;
    this.cb = cb;
    this.id = uid++;
    this.deps = [];
    if (typeof expOrFn === 'function') {
      this.getter = expOrFn
    } else {
      this.getter = parsePath(expOrFn)
    }
    this.value = this.get();
  }

  //将订阅这添加到订阅中心
  get() {
    //订阅前,设置Dep.target变量,指向自身
    pushTarget(this)
    let value;
    const vm = this.vm;
    /**
     * 这个地方读取data属性,触发下面的订阅代码,
     *  if (Dep.target) {
     *      dep.depend();
     *     if (childOb) {
     *       childOb.dep.depend();
     *     }
     *   }
     *   return value;
     **/
    value = this.getter.call(vm, vm);
    //订阅后,置Dep.target为null
    popTarget();
    return value
  }

  //值变化,调用回调函数
  update() {
    this.cb(this.value);
  }

  //添加依赖
  addDep(dep) {
    this.deps.push(dep);
    dep.addSub(this);
  }
}

//解析类属性的路径,例如obj.sub.name,返回实际的值
export function parsePath (path){
  const segments = path.split('.');
  return function (obj) {
    for (let i = 0; i < segments.length; i++) {
      if (!obj) return;
      obj = obj[segments[i]];
    }
    return obj;
  }
}

除了发布订阅以外,双向绑定还需要编译dom。

dom编译和input绑定

主要实现两个功能:

1、将dom中的{{key}}元素替换为Vue中的属性。

2、检测带有v-model属性的input元素,添加input事件,有修改时,修改Vue实例的属性。

检测v-model,绑定事件的代码如下:

export function initModelMixin(Vue) {
    Vue.prototype._initModel = function () {
        if (this._dom == undefined) {
            if (this.$options.el) {
                let el = this.$options.el;
                let dom = document.querySelector(el);
                if (dom) {
                    this._dom = dom;
                } else {
                    console.error(`未发现dom: ${el}`);
                }
           } else {
               console.error('vue实例未绑定dom');
           }
        } 
        bindModel(this._dom, this);
    } 
}

//input输入框有V-model属性,则绑定input事件
function bindModel(dom, vm) {
    if (dom) {
        if (dom.tagName === 'INPUT') {
            let attrs = Array.from(dom.attributes);
            attrs.map(item => {
                if (item.name === 'v-model') {
                    let value = item.value;
                    dom.value = getValue(vm, value);
                    //绑定事件,暂不考虑清除绑定,因此删除dom造成的内存泄露我们暂不考虑,这些问题后续解决
                    dom.addEventListener('input', (event) => {
                        setValue(vm, value, event.target.value);
                    });
                }
            })
        }
        let children = Array.from(dom.children);
        if (children) {
            children.map(item => {
                bindModel(item, vm);
            });
        }
    }
}

替换dom中{{key}}类似的属性代码:

export function renderMixin(Vue) {
    Vue.prototype._render = function () {
        if (this._dom == undefined) {
            if (this.$options.el) {
                let el = this.$options.el;
                let dom = document.querySelector(el);
                if (dom) {
                    this._dom = dom;
                } else {
                    console.error(`未发现dom: ${el}`);
                }
           } else {
               console.error('vue实例未绑定dom');
           }
        } 
        replaceText(this._dom, this);
    } 
}

//替换dom的innerText
function replaceText(dom, vm) {
    if (dom) {
        let children = Array.from(dom.childNodes);
        children.map(item => {
            if (item.nodeType === 3) {
                if (item.originStr === undefined) {
                    item.originStr = item.nodeValue;
                }
                let str = replaceValue(item.originStr, function(key){
                    return getValue(vm, key);
                });
                item.nodeValue = str;
            } else if (item.nodeType === 1) {
                replaceText(item, vm);
            }
        });
    }
}

到此位置,就实现了双向绑定。

测试代码如下,因为我用webpack构建的前端项目,html模板如下:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8" />
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <title>test
  </title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
  <div id="app">
    <div class="test">{{name}}</div>
    <input type="text" v-model="name">
  </div>
</body>
</html>

main.js代码:

import { Vue } from '../src/index';

let options = {
    el: '#app',
    data: {
        name: 'xxx',
        age: 18
    },
    methods: {
        sayName() {
            console.log(this.name);
        }
    }
}


let vm = new Vue(options);

效果如下:



可以下载源码尝试,git项目地址:https://github.com/xubaodian/SimuVue

项目使用webpack构建,下载后先执行:

npm install

安装依赖后使用指令:

npm run dev

可以运行项目。

如有疑问,欢迎留言或发送邮件至472784995@qq.com。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值