Vue 模板解释
如今的前端三大框架都有它们独特的模板,模板的作用就是让开发编码变得更加简单,然而我觉得 Vue 在这一点上是做得近乎完美的(当然,只是个人观点~~),Vue 模板解释的核心不外乎就是两个玩意儿,一个是双大括号表示式,另一个是模板指令,这两东西也是我们在 Vue 项目中都肯定会用到的,下面就来详细介绍他们是如何实现的。
(一)创建模板解释对象
function Vue(options) { // 将配置对象保存在实例对象上 this.$options = options // 将配置对象里面的data属性保存在实例对象上 let data = this._data = options.data // 保存实例对象,其实也可以用箭头函数~~ let me = this // 遍历data中的属性,逐一实现数据劫持 Object.keys(data).forEach(function (key) { me._proxy(key) }) // 模板解释 this.$compile = new Compile(options.el || document.body,this)}
可见,模板解释是在数据劫持之后实现的,在实现完数据劫持后,创建模板解释对象,并且保存到实例对象中,这里面有两个参数,第一个就是配置对象中的 el ,也就是挂载的 DOM ,第二个就是 vm 。
(二)通过 Fragment 容器实现初始化
function Compile(el, vm) { // 保存vm this.$vm = vm // 保存el,判断是否是元素节点,如果不是则尝试通过选择器字符来解释 this.$el = this.isElementNode(el) ? el : document.querySelector(el) // 确保$el存在 if(this.$el){ // 1. 取出el中所有子节点, 封装在一个fragment对象中 this.$fragment = this.node2Fragment(this.$el) // 2. 编译fragment中所有层次子节点,这个就是模板编译的核心方法~~~ this.init() // 3. 将fragment添加到el中 this.$el.appendChild(this.$fragment) }}
初始化的过程也是很容易理解,分三步,先将所有的元素转移到 fragment 容器中,然后在 fragment 容器中进行初始化,最后将这个 fragment 容器塞回原处。其实 fragment 容器并不进入页面,这里塞回去的仅仅是那些给初始化的节点而已。上面用到的三个定义在原型上的函数,isElementNode 用于判断是否是元素节点;node2Fragment 用于将节点中的所有子节点转移到 fragment 容器中,init 是初始化的核心函数,用于初始化模板数据:
Compile.prototype = { // 将节点中的所有子节点转移到fragment容器中 node2Fragment:function(node){ // 创建一个fragment对象 let fragment = document.createDocumentFragment() // 循环将元素节点中的所有子节点塞入fragment容器中,最终返回塞满子节点的fragment对象 let child while(child = node.firstChild){ fragment.appendChild(child) } return fragment }, // 判断是否是元素节点 isElementNode:function (node) { return node.nodeType === 1 }}
(三)初始化,详解 init 方法
Compile.prototype = { init:function(){ // 编译函数 this.compileElement(this.$fragment) }, compileElement:function(el){ // 获取所有子节点 const childNodes = el.childNodes // 保存compile对象 const me = this // 将类数组转化为真数组,遍历所有子节点 Array.prototype.slice.call(childNodes).forEach(function (node) { // 得到节点的文本内容 const text = node.textContent // 定义正则表达式,用于匹配大括号表达式 const reg = /\{\{(.*?)\}\}/ // 元素节点 if(me.isElementNode(node)){ // 编译元素节点的指令属性 me.compile(node) }else if(me.isTextNode(node) && reg.test(text)){ // 如果是一个大括号表达式的文本节点 me.compileText(node,RegExp.$1) } // 如果子节点还有子节点,递归调用 if(node.childNodes && node.childNodes.length){ me.compileElement(node) } }) },}
首先,init 方法去调用了compileElement 方法,该方法的主要作用就是处理之前准备好的 fragment 容器,将容器中所有子节点取出,然后进行分类处理,如果是一个元素节点,就去编译元素节点中的指令,如果是一个大括号表达式的文本节点,就去编译大括号表达式;如果节点里面还有子节点,则递归调用。顺着这个思路,先来研究比较简单的大括号表达式的情况(就是compileText这个方法):
Compile.prototype = { // 编译大括号表达式,参数node代表节点,exp代表表达式(就是正则匹配到的那个东西) compileText:function(node,exp){ compileUtil.text(node, this.$vm, exp) }}const compileUtil = { // 解释 v-text 和 双大括号表达式,由此也可以看出其实双大括号表达式跟v-text指令的实现原理是一致的! text:function (node, vm, exp) { this.bind(node,vm,exp,'text') }, // 真正用于解释指令的函数 bind:function (node, vm, exp, dir) { // 获取更新函数 const updaterFn = updater[dir + 'Updater'] updaterFn && updaterFn(node,this._getVMVal(vm,exp)) }, // 得到表达式对应的value _getVMVal:function (vm, exp) { let val = vm._data exp = exp.split('.') exp.forEach(function (key) { val = val[key] }) return val }}// 更新器const updater = { // 更新节点的textContent textUpdater:function (node, value) { node.textContent = typeof value === 'undefined' ? '' : value }}
从代码和注释上已经很好的说明了整个流程了,这里再简单的啰嗦一下吧,其实我们用的双大括号表达式也是一种指令,因为它跟v-text的处理是完全一致的,都是在操作节点的textContent属性。可能会让人迷糊的是 _getVMVal函数吧,这个函数的作用就是处理多层次对象的,因为表达式不会仅仅是一层的,也可能是两层或者多层次的,比如,data里面保存了一个person对象,里面还有name等其他属性,然而我们很可能会在表达式里面写person.name这样类似的多层次的属性(说句题外话,vue 不会监听到对象内部属性的变化,如果是简单的通过对象.属性名的方式去改变对象,那么vue是不知道的~~),这个函数也正是用于处理这种结构的。因为双大括号跟其他指令都很是类似的思想,都是在操作 DOM 的某个属性,具体的过程就不再细说了。