代码实现vue的双向绑定(二):解析器和MVVM

代码实现 vue 双向绑定系列

上文已经实现了双向绑定的数据劫持、订阅者和发布者,接下来要继续实现双向绑定中的解析器,以及 Vue 这样的 MVVM 框架如何搭建。

参考资料

剖析Vue原理&实现双向绑定MVVM
友情提示:本文可以伴着vue源码一起看,对源码的理解会更加深入

实现 Vue 框架

参照 Vue 的构造形式,生成一个数据绑定的入口,通过 Observer 监听自己的数据的变化,用 Compiler 去解析指定的模块,利用 Watcher 实现解析器和数据监听之间的通信。只要监听到数据变化,Watcher 收到通知就去更新视图。

首先声明一个 Vue 类,获取用户定义的options并且添加到vm.$options中。

// vue.js

export default class Vue {
  constructor(options) {
    const vm = this
    vm.$options = options
  }
}

<div id="app">
  <input type="text" id="input" v-model="msg"/>
  <p>{{ msg }}</p>
</div>
<script type="module" src="./vue.js"></script>
<script type="module">
  import Vue from './vue.js'

  var vm =  new Vue({
    el: '#app',
    data: {
      msg: 'Hello'
    }
  })

</script>

当我们在index.html中打印vm.msg可以看到结果是undefined,但是使用 Vue 的时候是能打印出值的。因为在 Vue 框架中,这里面还做了一个步骤,那就是代理data,将里面的值挂载到 Vue 实例里面。这里面主要的方法也是Object.defineProperty

// vue.js

export default class Vue {
  constructor(options) {
    const vm = this
    vm.$options = options
   
    initData(vm)
  }
}

function initData(vm) {
  var data = vm._data = vm.$options.data
  
  // 这里可以加上对 data props methods 中重复键值的判断
  // ...
  
  // 属性代理实现 vm[dataKey]
  Object.keys(data).forEach(key => {
    proxy(vm, key)
  })
}

// 此方法可以拓展一个参数,用来代理实例中不同属性的属性
// 比如 props methods computed 等等
function proxy(target, key) {
  Object.defineProperty(target, key, {
    configurable: false,
    enumerable: true,
    get: function() {
      return target._data[key]
    },
    set: function(newVal) {
      target._data[key] = newVal
    }
  })
}


这里不要忘记我们需要给data添加监听器Observer去监听data中每个属性的变化,便于我们在解析的添加Watcher

import observe from './observer.js'

export default class Vue {
  // ...
  
  function initData(vm) {
    var data = vm._data = vm.$options.data

    // 这里可以加上对 data props methods 中重复键值的判断

    // 属性代理实现 vm[dataKey]
    Object.keys(data).forEach(key => {
      proxy(vm, key)
    })

    observe(data, this)
  }
}

这里就实现了一个简易版的 Vue 框架,还要实现一个解析器(Compiler)去连接之前写好的 Watcher 和 Observer。

实现解析器

解析器需要大量的 DOM 操作,要求我们要熟悉 DOM 操作的一些 API,比如获取节点以及节点的信息,获取子节点,对文档碎片的操作等等,看到不懂的 API 自行查阅,这里不详细解释。

首先要先弄清楚解析器需要做什么:

  • 获取指定 HTML 文档中所有的节点
  • 根据指定的指令(v-bindv-onv-model{{ xxx }}等等),对所有节点进行遍历,根据指令的规则对节点中的变量替换成数据或者执行函数。
    • v-modal{{ xxx }},初始化的时候添加数据的订阅者,当数据变化的时候,收到通知去更新视图

从上面的功能点得出首先传入optionsel,编译器去获取 HTML 文档所有节点

import Compiler from './compile.js'

export default class Vue {
  constructor(options) {
    const vm = this
    
    // ...
    
    vm.$compile = new Compiler(options.el, vm)
  }
}
// compile.js

// 编译器 对文档里面的变量替换为数据,并且初始化的时候将每个节点绑定更新函数,添加监听数据的订阅者
// 数据变化的时候,收到通知去更新视图

export default class Compiler {
  constructor(el, vm) {
    this.$vm = vm
    // 获取节点
    this.$el = document.querySelector(el);
}

由于要进行大量的 DOM 操作,出于性能的考虑,我们先将节点转换为文档碎片document.createDocumentFragment()进行解析编译,完成之后再将其渲染到 DOM 节点中。

编译过程简单来说就是遍历每一个节点,根据节点类型和规定指令进行不同的编译操作。

// compile.js

export default class Compiler {
  constructor(el, vm) {
    this.$vm = vm
    // 获取节点
    this.$el = document.querySelector(el);

    if (this.$el) {
      // 为了性能,先将节点转换成文档碎片进行解析编译
      this.$fragment = this.nodeToFragment(this.$el);

      this.init();

      // 解析编译完成,再将碎片添加到 dom 节点中
      this.$el.appendChild(this.$fragment)
    }
  }

  init() {
    this.compileElement(this.$fragment);
  }

  nodeToFragment(el) {
    const fragment = document.createDocumentFragment();
    let child;

    // 把原生节点拷贝到 fragment
    while ((child = el.firstChild)) {
      fragment.appendChild(child);
    }

    return fragment;
  }

  // 遍历所有节点及子节点进行解析编译
  compileElement(el) {
    let childNodes = el.childNodes;

    [].slice.call(childNodes).forEach((node) => {
      // 根据节点类型 nodeType 进行区分编译
      // https://developer.mozilla.org/zh-CN/docs/Web/API/Node/nodeType
      
      // nodeType === 1
      if (this.isElementNode(node)) {
        this.compile(node);

        let text = node.textContent; // 针对 <p>{{ msg }}</p> 中的 content 进行编译解析
        const reg = /\{\{(.*)\}\}/; // 匹配双括号的正则表达式
        if (reg.test(text)) {
          const exp = text.match(reg)[1].trim()
          this.compileText(node, exp)
        }
      }
			
      // nodeType === 3
      if (this.isTextNode(node)) {
        // ...
      }
    });
  }

  compile(node) {
    const nodeAttrs = node.attributes;

    // 对节点属性进行遍历编译
    [].slice.call(nodeAttrs).forEach((attr) => {
      const attrName = attr.name;

      // 根据属性名对规定的指令进行编译
      // 比如 v-model v-bind v-on 等等
      // 这里只对 v-model 进行编译
      if (this.isDirective(attrName)) {
        const exp = attr.value; // 取出绑定的 key
        const dir = attrName.substring(2); // 获取对应的指令 bind model on等等
        

        if (this.isEventDirective(dir)) {
          // v-on 事件指令
          // ...
          
        } else {
          // 其余普通指令
          // compileUtil 自定义一个编译工具
          if(compileUtil[dir]) {
            compileUtil[dir] && compileUtil[dir](node, this.$vm, exp)
          }
          
        }
      }
    });
  }
  
  // utils
  isDirective(attr) {
    return attr.indexOf("v-") == 0;
  }

  isElementNode(node) {
    return node.nodeType === 1;
  }

  isTextNode(node) {
    return node.nodeType === 3;
  }

  isEventDirective(dir) {
    return dir.indexOf("on") === 0;
  }
}

// 自定义一个编译工具
const compileUtil = { 
	// v-model
  model(node, vm, exp) {
  	
  },
  
  // {{ exp }}
  text(node, vm, exp) {

  },
}

到这里我们就已经完成了对每个节点进行遍历,并且根据节点类型和规定指令进入到了不同的编译工具compileUtil里。不同的指令对应的编译流程不同,这里只针对{{ }}v-model进行编译,其余的指令可以参考这两个指令的思路自己编写。

不同指令的解析

解析工具的实现思路:

  • 写一个对于这个指令的更新函数(当数据发生变化的时候要对这个节点做什么)
  • 执行一次更新函数(相当于进行初始化)
  • new Watcher绑定一个 Wacther 添加到这个属性的Dep中,当数据发生改变的时候,Watcher触发更新函数对节点进行操作

不同指令还要对该节点做一些操作(比如绑定事件),具体的更新函数要针对不同的指令进行编写。

v-model

对于v-model来说,我们需要对该节点绑定一个输入事件(或者是选择事件,这里还可以根据节点来判断,这里暂不做判断),当用户输入的时候去更新data里面对应的属性。

// compile.js

// 指令解析
const compileUtil = {
  model(node, vm, exp) {    
    let val = this._getVal(vm, exp);
    // 对节点添加监听事件
    node.addEventListener("input", (e) => {
      const newVal = e.target.value;
      if (val === newVal) {
        return;
      }

      this._setVal(vm, exp, newVal);
      val = newVal;
    });
  },
  
  _getVal(vm, exp) {
    let val = vm;
    // 对象遍历取值
    exp = exp.split(".");
    exp.forEach((key) => {
      val = val[key];
    });

    return val;
  },

  _setVal(vm, exp, value) {
    let val = vm;
    // 对深层对象进行赋值
    exp = exp.split(".");
    exp.forEach((key, index) => {
      if (index !== exp.length - 1) {
        val = val[key];
      } else {
        val[key] = value;
      }
    });
  },
}

由于我们在initData()方法中已经添加了Observer,我们在控制台就已经能看到data.msg的订阅者数组,调用_getVal()方法时触发了getter,手动在 input 框输入内容导致data.msg改变的时候触发setter
在这里插入图片描述

上面实现了手动输入之后去改变对应data中属性的值,接下来要

  • 实现一个更新函数。
  • 初始化的时候执行一次更新函数,将data中属性的值渲染到 input 框中。
  • 给它添加一个Watcher,在 JS 中手动修改data的值,input 框的值会发生改变,这就完成了双向绑定。
//index.html

// ...
<script type="module" src="./vue.js"></script>
<script type="module">
  import Vue from './vue.js'
  
  var vm =  new Vue({
    el: '#app',
    data: {
      msg: 'Hello'
    }
  })
  
  // 模拟 JS 中修改 data 的数据
  setTimeout(() => {
    vm.msg = 'Hi'
  }, 2000)  
</script>
// compile.js

// 指令解析
const compileUtil = {
  model(node, vm, exp) {
    this.bind(node, vm, exp, 'model')
    
    let val = this._getVal(vm, exp); // 3 - 触发了一次 getter
    // ...
  },
  
  bind(node, vm, exp, dir) {
    // 获取指令对应的更新函数,在 Watcher 的 update() 中调用
    const update = updater[dir + 'Updater']
    
    // 执行一次更新函数,相当于初始化
    update && update(node, this._getVal(vm, exp))  // 1 - 触发了一次 getter
    // 绑定一个 Watcher
    var watcher = new Watcher(vm, exp, (value, oldValue) => {  // 2 - 触发了一次 getter
      update && update(node, value)
    })
  },
  
  // ...
};

// 更新函数集合
// 不同的指令进行不同的更新动作
const updater = {
  modelUpdater(node, value, oldValue) {
    node.value = value
  }
}

完成之后可以看到 input 框中初识显示的“Hello”,在2s后自动修改为“Hi”,控制台也能看到订阅者里面已经成功添加了一个Watcher。这样我们v-model指令就已经完成了。
在这里插入图片描述

模板字符串 {{ msg }}

这个实现起来比较简单,只需要将节点的textContent中有双括号的字符替换成data中的属性值就可以。直接上代码

// compile.js

export default class Compiler {
   // 遍历所有节点及子节点进行解析编译
  compileElement(el) {
    let childNodes = el.childNodes;

    [].slice.call(childNodes).forEach((node) => {
      if (this.isElementNode(node)) {
        this.compile(node);

        let text = node.textContent; // 针对 <p>{{ msg }}</p> 中的 content 进行编译解析
        const reg = /\{\{(.*)\}\}/; // 匹配双括号的正则表达式
        if (reg.test(text)) {
          const exp = text.match(reg)[1].trim()
          this.compileText(node, exp)
        }
        
      }
    });
  }
  
  compileText(node, exp) {
    compileUtil.text(node, this.$vm, exp);
  }

}

const compileUtil = {
  // ...
  
  text(node, vm, exp) {
    this.bind(node, vm, exp, 'text')
  },
}

const updater = {
  textUpdater(node, value, oldValue) {
    node.textContent = value
  }
}

在这里插入图片描述

好了现在两个指令都已经完成,双向绑定的编写也完成了,两篇文章实现了一个简易的 Vue 双向绑定架构,如果你也动手写完了这些代码,我相信你在理解 Vue 源码的时候也是有一定帮助的。

码字不易,若有收获,看完点个赞~

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值