vue原生的模板编译功能的作用是将模板生成AST,然后通过AST生成渲染函数,再执行渲染函数生成vnode,最后根据vnode进行渲染
今天来实现一个简单版的模板编译功能,通过节点筛选和指令解析来完成渲染;
在实现模板编译功能之前首先得有一个模板,同时还得要一个Vue类作为基础
<!-- html --> <div id="app"> <p>姓名</p> <input type="text" v-model="user.name"> <p>年龄:{{user.age}}</p> <p>位置:{{location}}</p> <div v-html="user.sex"></div> </div> <!-- js --> new Vue({ el: '#app', data: { user: { name: '阿白Smile', age: 24, sex: `<p>性别:男</p>` }, location: '北京' } }) class Vue { constructor(options) { this.$el = options.el this.$data = options.data } } 复制代码
有了这个模板和基础类就可以开始进行模板编译了,接下来都将在这段代码上进行操作
因为模板编译是一个独立的功能,所以将它单独封装一个Compiler类,在Vue实例创建的时候调用它,并且将Vue实例作为参数传递给Compiler
class Compiler { constructor(vm) {} } 复制代码
现在有了一个提供模板编译功能的Compiler类,在Vue实例创建的时候调用它就可以开始对模板进行编译;
但是,我们还不能直接进行调用,因为Vue实例在创建的时候可能没有传递挂载元素,如果没有挂载元素也就不存在编译了,所以这里需要加一层判断,确保挂载元素存在
class Vue { constructor(options) { this.$el = options.el this.$data = options.data // 判断是否传递挂载元素 if (this.$el) { new Compiler(this) } } } 复制代码
Vue实例创建的时候挂载元素可能传的是一个字符串同时也可能是一只元素节点,如果是字符串,还需要获取到相应的元素节点才能进行下一步的操作;为了方便在之后的代码中更好的使用挂在元素和Vue实例,所以将他们都设置到Compiler实例上
class Compiler { constructor(vm) { this.el = this.isElement(vm.$el) ? vm.$el : document.querySelector(vm.$el) this.vm = vm } // 判断节点是否是元素节点 isElement(node) { return node.nodeType === 1 } } 复制代码
到这里已经获取到挂载节点的DOM和Vue实例,下面要开始考虑匹配页面中的各个绑定数据并替换他们;但是如果直接在DOM节点中进行替换的话会导致页面多次重绘和回流;
为了解决这个问题,所以我们将挂载节点下的所有DOM都获取到然后放入文档碎片中,在文档碎片中执行替换操作,全部替换完成后再将文档碎片塞回到挂载节点中,这样就避免了页面多次回流重绘;现在先创建一个fragment,并将挂载节点中的DOM节点移进去
class Compiler { constructor(vm) { this.el = this.isElement(vm.$el) ? vm.$el : document.querySelector(vm.$el) this.vm = vm let fragment = this.nodeToFragment(this.el) } // 判断节点是否是元素节点 // 创建文档碎片 nodeToFragment(node) { let fragment = document.createDocumentFragment() let firstChild while(firstChild = node.firstChild) { // appedChild具有移植性,使用appendChild将DOM移入到fragment之后,挂载节点下这个DOM将会被移除 fragment.appendChild(firstChild) } } } 复制代码
接下来可以开始对fragment中的节点进行编译了;在编译的时候需要注意将元素节点和文本节点进行区分,因为元素节点采用的是指令进行数据绑定,并且还需要进一步向下遍历;而文本节点采用的是小胡子语法,两种语法的解析方式不一样,所以分成两个方法单独处理
class Compiler { constructor(vm) { this.el = this.isElement(vm.$el) ? vm.$el : document.querySelector(vm.$el) this.vm = vm let fragment = this.nodeToFragment(this.el) // 用数据进行模板编译 this.compile(fragment) } // 判断节点是否是元素节点 // 创建文档碎片 // 模板编译 compile(node) { let childNodes = [...node.childNodes] childNodes.forEach(child => { if (this.isElement(child)) { this.compileElement(child) // 进一下向下遍历编译 this.compile(child) } else { this.compileText(child) } }) } // 元素节点编译 compileElement(node) { } // 文本节点编译 compileText(node) { } } 复制代码
通过上面的代码,我们将元素节点和文本节点进行了区分(这里暂不考虑注释节点),针对不同的节点可以做不同的处理;
需要注意的是在模板中有一些节点是没有做任何数据绑定的,他们不需要替换数据,我们将这些节点称为静态节点,所以在具体的编译过程中需要跳过这些静态节点;
那么,接下来就是通过元素节点的指令属性和文本节点的小胡子语法对静态节点进行过滤
// 元素节点编译 compileElement(node) { let attributs = [...node.attributes] attributs.forEach(attr => { let {name:attrName, value:attrVal} = attr // 利用“v-”过滤指令 if (attrName.startsWith('v-')) { } }) } // 文本节点编译 compileText(node) { let content = textContent // 利用正则过滤小胡子语法 if (/\{\{(.+?)\}\}/.test(content)) { } } 复制代码
到这里已经将数据绑定的节点全部筛选出来了,并且拿到了指令的key,value和小胡子语法字符串;接下来需要进行指令解析,先来看一个完整的vue指令,以v-model为例
v-model 复制代码
在这个指令中“v-”是指令的标识,model是指令的名称,我们需要通过名称找到相对应的方法来执行;
在指令解析之前我们还需要实现一个指令方法集directiveUtil,存入所有指令相对应的方法;并且指令方法应该接收当前操作的节点,绑定的字符串以及Vue实例作为参数
// 指令集 const directiveUtil = { // v-model处理方法 model(node, expr, vm) {}, // v-html处理方法 html(node, expr, vm) {}, // 小胡子语法处理方法 text(node, expr, vm) {} } 复制代码
好了,现在有了指令集,可以开始指令解析了
// 元素节点编译 compileElement(node) { let attributs = [...node.attributes] attributs.forEach(attr => { let {name:attrName, value:attrVal} = attr // 利用“v-”过滤指令 if (attrName.startsWith('v-')) { // 解析指令 let [,directive] = attrName.split('-') // 执行指令方法 directiveUtil[directive](node, attrVal, this.vm) } }) } // 文本节点编译 compileText(node) { let content = textContent // 利用正则过滤小胡子语法 if (/\{\{(.+?)\}\}/.test(content)) { directiveUtil['text'](node, content, this.vm) } } 复制代码
到这一步就已经找到相关指令的执行方法了,接下来就是要执行相关的指令方法,将绑定的参数替换为实例data中对应的值;
因为绑定参数的时候可能使用了对象语法(如:obj.key),所以需要添加一个获取value的方法
directiveUtil.getVal(expr, vm) { let exprs = expr.spilt('.') return exprs.reduce((current, item) => { return current[item] },vm.$data) } 复制代码
通过getVal方法已经可以获取到绑定参数的value了,所以接下来只需要进行替换就可以了;需要注意的是关于文本节点需要去除小胡子,并且还要考虑节点中绑定多个参数的可能
// v-model处理方法 model(node, expr, vm) { let val = this.getVal(expr, vm) node.value = val }, // v-html处理方法 html(node, expr, vm) { let val = this.getVal(expr, vm) node.innerHTML = val }, // 小胡子语法处理方法 text(node, expr, vm) { let content = expr.replace(/\{\{(.+?)\}\}/g, (...args) => { return this.getVal(args[1], vm) }) node.textContent = content } 复制代码
现在,fragment中所有绑定数据的节点都已经完成了相应内容的替换,所以,此时需要将fragment塞回到挂载节点中
class Compiler { constructor(vm) { this.el = this.isElement(vm.$el) ? vm.$el : document.querySelector(vm.$el) this.vm = vm let fragment = this.nodeToFragment(this.el) // 用数据进行模板编译 this.compile(fragment) // 将fragment塞回到挂载节点中 this.el.appendChild(fragment) } } 复制代码
到这里,一个简单的模板编译机制就实现成功了,打开页面看一看
各个数据都已经成功渲染了,但是由于还没有做响应式系统和依赖收集,所以数据还不能正常更新;并且当前的实现过程中只是阐明功能逻辑,所以很多容错机制,以及修饰符,事件等都没有进行处理;
源代码我已经提交到我的GitHub,欢迎大佬们拍砖
end