vue双向绑定简单模拟

911 阅读4分钟

vue双向绑定原理都能说出来是基于Onject.defineProperty劫持对象,通过set方法通知视图更新。但是再再往深一点,比如何时劫持监听,如何通知对应的属性去更新对应的视图,相信有不少小伙伴不能完整的答出来,下面我么就一点一点简单模拟vue双向绑定的基本实现

可观测数据

要实现数据的双向绑定,首先我们需要具备对数据的的监控能能力,在数据的读取、修改操作的时候能接受到相应的信号,从而处理对应的逻辑,即我们需要得到可观测数据,实现可观测数据的核心api就是Object.defineProperty

let person = {
  name: 'jack',
  age: 25,
  height: '180cm'
}

Object.defineProperty(person, 'name', {
  get(val) {
    console.log('name属性被读取了')
    return 'jack'
  },
  set(newVal) {
    console.log('name属性被改变了->'newVal)
  }
})

person.name = 'lucy'  // name属性被读取了->lucy
var a = person.name   //name属性被读取了

从上面的一个简单的例子可能看出Object.defineProperty的主要功能,劫持指定对象下的指定属性,监听该属性的读、取操作,从而做不同的事情,接下来我们劫持对象下的所有属性

/**
 * 使一个对象转化成可观测对象
 * @param { Object } obj 对象
 * @param { String } key 对象的key
 * @param { Any } val 对象的某个key的值
 */

function defineReactive(obj, key, val) {
  Object.defineProperty(obj, key, {
    get(){
      console.log(`${key}属性被读取了`)
      return val
    },
    set(newVal){
      console.log(`${key}属性被修改了`)
      val = newVal
    }
  })
}

//遍历obj下的一层属性将obj下的所有一层属性都设置成可观测
function observable(obj){
  if(!obj || typeof obj !== 'object'){
    return null
  }
  let keys = Object.keys(obj)
  keys.forEach((key)=>{
    defineReactive(obj,key,obj[key])
  })
  return obj
}

let person2 = {
  name: '门哒哒',
  age: 24,
  height: '160cm'
}

let observablePerson = observable(person2)

observablePerson.name = 1 //name属性被修改了
var h = observablePerson.height //height属性被读取了

依赖收集

劫持了整个对象以后(这里可以吧劫持的对象理解成vm.$data),我们需要收集一个集合,对象中有哪些属性是被视图层所依赖的

//消息订阅器
class Dep{
  constructor(){
    this.subs = []
  }
  //增加订阅者
  addSub(){
  if(Dep.target){
      this.subs.push(Dep.target)
    }
  }
  //通知订阅者更新
  notify(){
    this.subs.forEach(sub => {
      sub.update()
    });
  }
}

Dep.target = null

//改造defineReactive,植入订阅器
function defineReactive(obj,key,val){
  let dep = new Dep()
  Object.defineProperty(obj,key,{
    get(){
    //属性被读取到的时候,将当前的订阅添加到订阅列表
      dep.addSub()
      console.log(`${key}属性被读取了`)
      return val
    },
    set(newVal){
      val = newVal
      console.log(`${key}熟悉修改`)
      dep.notify()
    }
  })
}


//添加监听器
// vm这里理解成vue的示例对象
//key是vue中$data下的属性名
//callback是key属性改变时候的回调函数
class Watcher {
  constructor(vm, key, callback) {
    Subscriber.current = this;
    vm.$data[key]; //触发新增订阅
    this.callback = callback;
    Subscriber.current = null;
  }

  update(nVal) {
    this.callback(nVal);
  }
}

梳理一下上面的代码,Dep类是一个订阅收集器,负责将新增的监听器收集到订阅列表然后每当劫持的对象中的属性发生变化的时候,将订阅列表遍历发送更新信号,然后对应的Watcher收到订阅信号(即update方法被调用)的时候,去更新视图,举个简单例子

首先我们有这样一段html

  <div id="app">
    <h1>{{title}}</h1>
    <p>{{message}}</p>
    <input type="text" v-model="message">
  </div>

然后我们实例化一个Vue

new Vue({
    el:"#app",
    data:{
        message:'hello Vue',
        title:'I am title'
    }
})

在这个实例化过程中经历了什么呢?首先是获取所有的dom树,正则匹配{{}}v-model中的字符,匹配到的时候,就实例化一个Watcher,实例化Watcher的时候会触发Dep收集订阅,初始化的时候会将data中的数据替换到dom中,当v-model绑定的时候监听change事件,然后改变data下对应message的值,而这个会触发可观测数据的set事件,进而触发Dep的notify来遍历分发更新信息,在更新的callback中将其他的message订阅的值替换(这里指的p标签下的message),这样,简单的双向绑定就完成了,我们可以吧匹配替换逻辑写到compile方法中


function compile(el) {
    var childNodes = Array.prototype.slice.call(el.childNodes);
    childNodes.forEach(node => {
      if (node.nodeType === 3) {
        //文本节点
        var nodeContent = node.textContent;
        var reg = /\{\{\s*(\S*)\s*\}\}/;
        if (reg.test(nodeContent)) {
          //将{{}}中变量替换成data中的变量值
          node.textContent = this.$data[RegExp.$1];
          //监听{{}}中变量名对应的data的属性的值的变化,并将变化后的值赋给dom
          new Watcher(this, RegExp.$1, nVal => {
            node.textContent = nVal;
          });
        }
      } else if (node.nodeType === 1) {
        var attrs = Array.prototype.slice.call(node.attributes);

        //遍历标签节点的所有属性
        attrs.forEach(attr => {
          let attrName = attr.name;
          let attrValue = attr.value;
          if (attrName.indexOf("v-") === 0) {
            attrName = attrName.substr(2);
            if (attrName === "model") {
              //匹配 "v-model"属性,并将该属性对应的data的值复制给该标签的value
              node.value = this.$data[attrValue];
              //监听该标签的input事件,并将当前的value赋值给data上的对应属性
              node.addEventListener("input", e => {
                this.$data[attrValue] = e.target.value;
              });
            }
            //监听v-model对应的data的变化,并将变化后的值赋值给dom
            new Watcher(this, attrValue, nVal => {
              node.value = nVal;
            });
          }
        });
      }

      // 递归查找所有文本节点
      if (node.childNodes.length > 0) {
        this.compile(node);
      }
    });
  }

compile的工作大致就是递归的去查找dom下所有绑定的{{}}v-model对应的字段跟data来匹配,匹配到之后实例化监听类Watcher在callback中实现替换,(当然,这只是一个简单的例子,额外的错误捕捉和优化都没有,指在理解双向绑定的流程)

最后只需要将这些模块简单的拼接起来就可以实现简单的双向绑定了,附上完整代码


//myvue class

class MyVue {
  constructor(options) {
    //将options的相关数据绑定到实例
    this.$data = options.data || {};
    this.$el = document.querySelector(options.el) ;
    this.$options = options;
    //初始化实例方法
    this.__init__();
  }

  __init__() {
    //观测数据
    this.observer(this.$data);
    //初始化视图
    this.compile(this.$el)
  }

  compile(el) {
    var childNodes = Array.prototype.slice.call(el.childNodes);
    childNodes.forEach(node => {
      if (node.nodeType === 3) {
        //文本节点
        var nodeContent = node.textContent;
        var reg = /\{\{\s*(\S*)\s*\}\}/;
        if (reg.test(nodeContent)) {
          //将{{}}中变量替换成data中的变量值
          node.textContent = this.$data[RegExp.$1];
          //监听{{}}中变量名对应的data的属性的值的变化,并将变化后的值赋给dom
          new Watcher(this, RegExp.$1, nVal => {
            node.textContent = nVal;
          });
        }
      } else if (node.nodeType === 1) {
        var attrs = Array.prototype.slice.call(node.attributes);

        //遍历标签节点的所有属性
        attrs.forEach(attr => {
          let attrName = attr.name;
          let attrValue = attr.value;
          if (attrName.indexOf("v-") === 0) {
            attrName = attrName.substr(2);
            if (attrName === "model") {
              //匹配 "v-model"属性,并将该属性对应的data的值复制给该标签的value
              node.value = this.$data[attrValue];
              //监听该标签的input事件,并将当前的value赋值给data上的对应属性
              node.addEventListener("input", e => {
                this.$data[attrValue] = e.target.value;
              });
            }
            //监听v-model对应的data的变化,并将变化后的值赋值给dom
            new Watcher(this, attrValue, nVal => {
              node.value = nVal;
            });
          }
        });
      }

      // 递归查找所有文本节点
      if (node.childNodes.length > 0) {
        this.compile(node);
      }
    });
  }

  //数据劫持(将data上的数据转化成可观测数据)
  observer(data) {
    const subscriber = new Subscriber();
    const keys = Object.keys(data);

    //遍历data下的所有一层属性,并且全部观测
    keys.forEach(key => {
      var val = data[key]
      Object.defineProperty(data, key, {
        get() {
          //监听器实例的时候会访问被劫持的数据属性,从而将该监听器加入监听列表
          if (Subscriber.current) {
            subscriber.addSubscription(Subscriber.current);
          }
          return val
        },
        set(nVal) {
          //监听数据发生改变时通知各监听器更新
          if(val!==nVal){
            subscriber.notify(nVal);
            val = nVal
          }
        }
      });
    });
  }
}

//订阅者
class Subscriber {
  constructor() {
    //监听列表
    this.subscriptions = [];
  }
  //新增监听器
  addSubscription(sub) {
    this.subscriptions.push(sub);
  }
  //通知各个监听器更新
  notify(nVal) {
    this.subscriptions.forEach(sub => {
      sub.update(nVal);
    });
  }
}

//监听器
class Watcher {
  constructor(vm, key, callback) {
    Subscriber.current = this;
    vm.$data[key]; //触发新增订阅
    this.callback = callback;
    Subscriber.current = null;
  }

  update(nVal) {
    this.callback(nVal);
  }
}


使用


<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
  <script src="./MyVue2.js"></script>
</head>

<body>
  <div id="app">
    <p>{{message}}</p>
    <input type="text" v-model="message">
  </div>
  <script>
    window.myvue = new MyVue({
      data:{
        message:'神秘的宝爷'
      },
      el:'#app'
    })
  </script>
</body>

</html>

以上.