300行代码实现Vue的MVVM响应式原理

前言

源码下载

Vue的响应式原理是面试老生常谈的问题了,而大多数人会选择直接背答案这样的形式去应付面试,一旦面试官继续追问,便什么也答不上来了,所以,我希望能通过参考Vue源码的形式,动手编写代码(大概300行左右)实现一个简单的Vue的MVVM框架,从而让我们更好的理解Vue的响应式原理。

我们会通过

  • 实现指令解析器Compile
  • 实现数据监听器Observer
  • 实现观察者Watcher

来实现整个Vue的MVVM的响应式原理

实现的功能不是很完善,如果大家有兴趣,可以自己补充,本次代码主要是完成整个响应式原理的流程,理解Observer,Compile,Watcher是什么,用来干什么,如何通过这三者,搭建起整个MVVM架构的桥梁,从而实现Vue的响应式原理。

image-20200720234220190

创建一个简单的html作为测试

<div id="app">
  <div>{{person.name}} -- {{person.age}}</div>
  <div>{{person.fav}}</div>
  <ul>
    <li>1</li>
    <li>2</li>
    <li>3</li>
  </ul>
  <div v-html="htmlStr" v-bind:style="{backgroundColor:red}"></div>
  <div v-text="person.fav"></div>
  <!-- <input type="text" v-bind:value='msg'> -->
  <input type="text" v-model='msg'>
  <div>{{msg}}</div>
  <h2 v-text="tip" v-on:click="clickMe"></h2>
</div>

和Vue一样在下面的script标签中创建一个Vue对象

// 为了避免跟Vue冲突 这里使用MVue作为类名
const app = new MVue({
    el: "#app",
    data: {
      person: {
        name: 'GHkmmm',
        age: '21',
        fav: '编程'
      },
      msg: '理解Vue的双向绑定原理',
      tip: '点我',
      htmlStr: 'hello world!'
    },
    methods: {
      clickMe(){
        console.log('click');
      }
    }
  })

创建MVue类

用于接收传入的参数

class MVue{
  constructor(options){
    this.$options = options;
    this.$el = options.el;
    this.$data = options.data;

    if(this.$el){
      // 实现一个指令解析器
      new Compile(this.$el, this);
    }
  }
}

实现一个指令解析器Compile

class Compile{
  constructor(el, vm){
    this.el = this.isElementNode(el) ? el : document.querySelector(el);
    this.vm = vm;
    /*
    	对页面内容进行编译的时候,因为需要遍历每个对象,所以不可能一一去更改
    	而是通过文档碎片的形式,统一修改完,再使用fragment去更新整个页面
    */
    // 1.获取文档碎片对象,放入内存中,减少页面的回流与重绘
    const fragment = this.nodeFragment(this.el);
    // 2.编译模版
    this.compile(fragment);
    // 3.追加子元素到根元素
    this.el.append(fragment);
  }
}

获取文档碎片

遍历子节点,添加到文档碎片对象中

nodeFragment(node){
  // 创建文档碎片对象
  const f = document.createDocumentFragment();
  let firstChild;
  //遍历<div id="app">下的子节点
  while(node.firstChild){
    firstChild = node.firstChild;
    // 将遍历得到的节点添加到文档碎片对象中
    f.append(firstChild);
  }
  // 返回文档碎片对象
  return f;
 }

编译模版

传入fragment之后,我们需要对fragment中的节点进行遍历,判断是元素节点还是文本节点,因为两者的处理方式不同,所以需要使用不同的方法进行编译

compile(fragment){
  	// 传入刚刚得到的文档碎片对象fragment
    // 1.获取子节点
    const childNodes = fragment.childNodes;
  	// 遍历childNodes数组
    [...childNodes].forEach(child => {
      /*
      	isElementNode(el){
          return el.nodeType === 1; 
        }
      */
      if(this.isElementNode(child)){
        // 元素节点
        // console.log('元素节点', child);
        this.compileElement(child);
      }else{
        // 非元素节点(文本节点...)
        // console.log('文本节点',child);
        this.compileText(child);
      }
      // 判断子节点下是否还有子节点,如果有则进行递归
      if(child.childNodes && child.childNodes.length){
        this.compile(child);
      }
    })
  }
编译元素节点
compileElement(node){
  	// 获取节点上的所有属性
    const attributes = node.attributes;
  	// 遍历属性数组
    [...attributes].forEach(attr => {
      // 使用解构赋值,将attr中的name属性和value属性,单独提取出来
      // 这里的name和value是和attr中的属性对应的,不能更改
      // name: v-text,v-model,v-bind....
      // vaule: v-text,v-model..后面跟的值
      const { name, value } = attr;
      /*
      	判断name是否以‘v-’开头,如果是,才进行编译,如果不是,则不进行处理
      	isDirective(attrName){
          return attrName.startsWith('v-');
        }
      */
      if(this.isDirective(name)){
        // 根据‘-’分割name,splite方法返回的是一个数组,第一个参数我们不用,第二个参数赋值给directive
        // 这里的directive和上面不一样,这里自己随意取个名字就好
        const [,directive] = name.split('-');
        // 因为指令不仅可能是v-text这种,还可能是v-on:click这种,所以需要进一步分割
        const [dirName,eventName] = directive.split(':');
        // 更新数据 数据驱动视图
        // 根据dirName的不同,调用complileUtil中不同的方法
        // dirName指的是text,html,bind,on...
        complileUtil[dirName](node, value, this.vm, eventName)

        // 删除有指令的标签上的属性
        node.removeAttribute(name);
      }
    })
  }
编译文本节点
compileText(node){
  // 获取文本内容
  const content = node.textContent;
  if(/\{\{(.+?)\}\}/.test(content)){
    complileUtil['text'](node,content,this.vm)
  }
}
complileUtil对象

内部实现了对不同指令的处理方法

const complileUtil = {
  getVal(expr, vm){
    /*
    	expr可能是msg 也可能是person.name,所以需要对expr再进行一次分割
    	
    	分割完得到一个数组,比如是['person', 'name'],使用reduce方法后
    	第一次返回vm.$data['person']
    	第二次返回vm.$data['person']['name']
    	
    	再比如是msg,则得到数组['msg'],所以直接返回vm.$data['msg']即可
    */
    return expr.split('.').reduce((data, currentVal) => {
      // console.log(data);
      // console.log(currentVal);
      return data[currentVal];
    }, vm.$data//初始值)
  },
  setVal(expr, vm, inputVal){
    return expr.split('.').reduce((data, currentVal) => {
      data[currentVal] = inputVal;
    }, vm.$data)
  },
  
	// 编译v-text指令以及文本节点的mustache语法{{msg}}
  text(node, expr, vm){
    let value;
    // 判断是否以‘{{’开头
    if(expr.indexOf("{{")!==-1){
      // 通过正则表达式 匹配双大括号,将大括号中的字符串提取出来
      value = expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
        // 传入getVal方法,在vm.$data中查找数据
        return this.getVal(args[1], vm);
      })
    }else{
      value = this.getVal(expr, vm);
    }
    // 更新视图
    this.updater.textUpdater(node, value);
  },
  // 编译v-html指令
  html(node, expr, vm){
    const value = this.getVal(expr, vm);
    this.updater.htmlUpdater(node, value);
  },
  // 编译v-model指令
  model(node, expr, vm){
    const value = this.getVal(expr, vm);
    // 视图=>数据=>视图
    // 监听input事件
    node.addEventListener('input', (e)=>{
      this.setVal(expr, vm, e.target.value);
    })
    this.updater.modelUpdater(node, value);
  },
  // 编译v-on指令
  on(node, expr, vm, eventName){
    let fn = vm.$options.methods && vm.$options.methods[expr];
    node.addEventListener(eventName, fn.bind(vm), false)
  },
  // 编译v-model指令
  bind(node, expr, vm, attrName){
    ...//这里大家可以自己动手去实现一下
  },
    
  //更新的函数
  updater: {
    textUpdater(node, value){
      node.textContent = value;
    },
    htmlUpdater(node, value){
      node.innerHTML = value;
    },
    modelUpdater(node, value){
      node.value = value;
    },
    bindUpdater(node, attrName, value){
      node[attrName] = value
    }
  }
}

实现一个数据监听器Observer

创建Observer类

作用

使用Object.defineProperty,为data中的所有属性设置getter和setter

class Observer{
  constructor(data){
    this.observer(data);
  }
  observer(data){
    if(data && typeof data === 'object'){
      // 遍历data对象
      Object.keys(data).forEach(key => {
        this.defineReactive(data, key, data[key]);
      })
    }
  }
  defineReactive(data, key, value){
    // 递归遍历
    this.observer(value);
    const dep = new Dep();
    // 监听并劫持所有的属性 
    // defineProperty传入data,key就是为了给data[key]添加对应的gettter和setter
    Object.defineProperty(data, key, {
      enumerable: true,
      configurable: false, //描述属性是否配置,以及可否删除
      get(){
        // 当访问属性时,会调用此函数
        // 初始化
        // 订阅数据发生变化时,往Dep中添加观察者
        Dep.target && dep.addSub(Dep.target);
        return value;
      },
      // 当属性值被修改时,会调用此函数
      // 这里使用箭头函数,是为了让this指向上层,而不是Object
      set: (newVal) => {
        this.observer(newVal)
        if(newVal !== value){
          value = newVal;
        }
        //告诉Dep通知变化
        dep.notify();
      }
    });
  }
}

在MVue类中实现

class MVue{
  constructor(options){
    this.$options = options;
    this.$el = options.el;
    this.$data = options.data;

    if(this.$el){
      // 实现一个数据观察者
      new Observer(this.$data);
      // 实现一个指令解析器
      new Compile(this.$el, this);
    }
  }
}

实现依赖收集器Dep

创建Dep类

作用
  • 收集观察者
  • 如果Observer中劫持的数据发生变化,会通知Dep去通知对应的观察者
class Dep{
  constructor() {
    this.subs = [];
  }
  // 收集观察者
  addSub(watcher){
    this.subs.push(watcher);
  }
  // 通知观察者更新
  notify(){
    this.subs.forEach(w => w.update())
  }
}

实现一个Watcher去更新视图

创建Watcher类

class Watcher{
  constructor(vm, expr, callback){
    this.vm = vm;
    this.expr = expr;
    this.callback = callback;
    this.oldVal = this.getOldVal()
  }
  getOldVal(){
    Dep.target = this;
    const oldVal = complileUtil.getVal(this.expr, this.vm)
    Dep.target = null;
    return oldVal;
  }
  update(){
    const newVal = complileUtil.getVal(this.expr, this.vm);
    if(newVal !== this.oldVal){
       this.callback(newVal);
    }
  }
}

在complileUtil的每个处理指令的方法中 实现Watcher

new Watcher(vm, expr, callback)

const complileUtil = {
  getContentVal(expr, vm){
    return expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
      return this.getVal(args[1], vm)
    })
  },
  text(node, expr, vm){
    let value;
    if(expr.indexOf("{{")!==-1){
      value = expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
        new Watcher(vm, args[1], ()=>{
          this.updater.textUpdater(node, this.getContentVal(expr,vm));
        })
        return this.getVal(args[1], vm);
      })
    }else{
      value = this.getVal(expr, vm);
    }
    this.updater.textUpdater(node, value);
  },
  html(node, expr, vm){
    const value = this.getVal(expr, vm);
    // 绑定观察者,将来数据发生变化,触发这里的回调,进行更新
    new Watcher(vm, expr, (newVal)=>{
      this.updater.htmlUpdater(node, newVal);
    })
    this.updater.htmlUpdater(node, value);
  },
  ...
}

展示

到这,我们就实现了整个Vue的MVVM响应式,也实现了数据的双向绑定

总结

vue是采用数据劫持配合发布者-订阅者模式的方式,通过
Object.defineProperty()来劫持各个属性的setter和getter,在数据变动时,发布消息给依赖收集器Dep,去通知观察者,做出对应的回调函数,去更新视图。

MVVM作为绑定的入口,整合了Observer,Compile和Watcher三者,通过Observer来监听model数据变化,通过Compile来解析编译模板指令,最终利用Watcher搭起
Observer,Compile之间的通信桥梁,达到数据变化=>视图更新;视图交互变化=>数据model变更的双向绑定效果

如果文章有问题欢迎大家指出

参考教程:参考教程

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值