Vue 模板编译:
Vue 是一个 MVVM 的框架,MVVM 就是 Model + View + ViewModel 即数据驱动视图的。
在使用 Vue 开发过程中,我们把写在 template 标签中的内容称之为模板。除去一些html原生的内容还有 solt、v-if、v-on、{{}} 这些原生html不存在的语法,但是浏览器仍然可以识别,其中最重要的一个原因就是 Vue 的模板编译了。Vue 会把用户在 template 标签中写的内容进行编译,找出原生的 html 和非原生的 html 内容,经过处理生成 Fragment ,而 Fragment 会经过patch过程从而得到将渲染的视图中的 Fragment ,最后根据 Fragment 创建真实的 DOM 节点并插入到视图中,最终完成视图的渲染更新。
Vue 模板编译的实现逻辑
- 需要获取到创建 Vue 实例传入的根元素
- 获取根元素,并使用 Fragment 创建一个文档片段
- 将根元素的所有子元素添加到 Fragment 中
- 循环这个 fragment 判断元素的类型,同时判断元素是否有子元素,如果有子元素,递归进行处理
- 如果是元素节点的话,调用对应的编译方法
1. 获取元素节点身上的所有自定义属性
2. 判断这些属性是否有 v- 开头的
3. 通过字符串切割的方法获取到具体的指令,执行对应的操作 - 如果是文本节点的话,调用对应的编译方法
1. 通过正则匹配元素内字符串是否是插值表达式(也就是{{数据}})
2. 根据插值表达式,到 Vue 实例的 data 属性中去查找这个属性,并将文本内容替换为这个属性
Vue 模板编译的具体实现
DOM 结构
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>compile</title>
</head>
<body>
<div id="app">
<div>
<p>AABB</p>
</div>
<div>
<span>{{list}}</span>
<p>{{name}}</p>
<p v-html="age">我使用了v-html的方法访问了age</p>
</div>
<input type="text" v-model="a.b.c.d">
</div>
<script src="./iCompile.js"></script>
<script>
let vue = new iCompile({
el: '#app',
data: {
a: {
b: {
c: {
d: 'abcd'
}
}
},
name: 'name',
age: '35',
obj: {
name: 1111
},
list: [1, 2, 3, 3, 4]
}
})
</script>
</body>
</html>
Compile 实现逻辑代码
class iCompile {
constructor(vue) {
this.$vue = vue // 获取Vue的实例
this.$el = document.querySelectorAll(vue.el)[0] // 获取根元素
// 当未查询到/未传入根元素时不执行编译操作
if (this.$el) {
let fragment = this.setFragment()
this.compileDom(fragment)
document.body.append(fragment)
}
}
/**
* 1. 创建一个 fragment 文档片段
* 2. 使用 while 循环将根元素的内容添加到 fragment 中
* 3. 返回这个 fragment
*/
setFragment() {
let fragment = document.createDocumentFragment()
let nodes = this.$el.childNodes
let currentNode
while (currentNode = nodes[0]) {
fragment.append(currentNode)
}
return fragment
}
/**
* 1. 获取 fragment 的所有子元素
* 2. 循环子元素判断元素类型, 如果子元素中有子集的话, 调用递归的操作对每一层处理
*
*/
compileDom(el) {
let childNodes = el.childNodes // 获取子元素
let reg = /\{\{(.+)\}\}/ // 查找插值表达式
childNodes.forEach(node => {
let textContent = node.textContent // 获取元素内容
let nodeType = node.nodeType // 元素元素类型
// 元素节点
if (nodeType == 1) {
this.compileEl(node)
// 文本节点, 当元素类型为 3 , 同时使用正则检测到文本节点的内容中有插值表达式,就对这个进行处理
} else if (nodeType == 3 && reg.test(textContent)) {
// 因为正则方法 match 方法返回的是一个数组, 他的第一项是原数据内容, 我们不需要这个内容, 所有要把这个切割掉
let express = textContent.match(reg).slice(1)
console.log('express: ', express);
this.compileText(node, express)
}
// 如果子集有子元素的话,传入当前子元素让它递归调用
if (node.childNodes.length) {
this.compileDom(node)
}
})
}
/**
* 根据元素的自定义属性去对元素执行相应的操作
* 1. 获取元素的自定义属性
* 2.
*
*/
compileEl(node) {
let attributesList = node.attributes; // 获取元素自定义属性
[...attributesList].forEach(attrs => {
const name = attrs.name; // 获取属性名
let attrValue = attrs.value; // 获取属性值
const provideName = name.substr(0, 2) // 保存属性名前 2 位字符, 用于判断是否是 Vue 的内置指令
const attrName = name.substr(2) // 保存属性名前 6 位字符, 用于判断这个是 Vue 的哪个指令
if (provideName == 'v-') {
// 通过 switch 匹配自定义属性的属性值, 执行不同的操作
switch (attrName) {
// 如果是 v-html 的话, 实际跟文本节点的处理方式类似, 调用 Vue 实例上的属性值, 用来替换元素本身的内容
case "html":
node.textContent = getVueData(attrValue, this.$vue.data)
break
case "model":
// Vue 的双向绑定, 当元素内容变化时, 需要同步将 data 中的数据更新
node.addEventListener('input', (e) => {
let newValue = e.target.value; // 获取当前输入的最新值
// 根据 v-model 上面绑定的属性值, 将 Vue 中实例对应的数据源变成最新的
setVueData(attrValue, this.$vue.data, newValue)
node.value = newValue
console.log(`修改后的内容为-${getVueData(attrValue, this.$vue.data)}`);
})
// 当元素初次加载的时候需要,调用 Vue 实例上的属性值, 用来替换元素本身的内容
node.value = getVueData(attrValue, this.$vue.data)
break
default:
break
}
}
})
}
/**
* 根据元素内容和插值表达式的值,从 Vue 的实例中查询对应的属性
* 并替换元素内容本身的内容
*/
compileText(node, exp) {
let textContent = node.textContent // 获取元素内容
exp.forEach(key => {
textContent = textContent.replace(`{{${key.trim()}}}`, getVueData(key, this.$vue.data))
})
node.textContent = textContent
}
}
/**
* 这里只考虑了, 在 Vue 中使用 obj.a 的方式
* 根据字符串column,将字符串切割成逐级的 key
* 根据 key 的列表逐级访问, 找到并返回对应的内容
*/
function getVueData(column, $data) {
let columnList = column.split('.'); // 切割字符串
let result = $data // 获取Vue 的实例
columnList.forEach((key) => {
result = result[key]
})
return result
}
/**
* 这里只考虑了, 在 Vue 中使用 obj.a 的方式
* 根据字符串column,将字符串切割成逐级的 key
* 根据 key 的列表逐级访问, 找到并返回对应的属性
* 将最新的属性值赋值给 这个属性
*/
function setVueData(column, $data, value) {
let columnList = column.split('.')
const len = columnList.length - 1 // 这里保存 len 的作用是判断当前循环是否是最后一项
let result = $data
columnList.forEach((key, index) => {
if (len == index) {
result[key] = value
} else {
result = result[key]
}
})
return $data
}
实现效果编译前 ( 后 )
编译前
编译后
从这两张图的对比上可以明显看到我们已经实现了使用 iCompile 方法根据 Vue 实例中 data 属性的值,去处理元素内容中插值表达式和指令去动态渲染元素的内容。
问题解答
Q: Fragment 是什么,主要解决了什么问题 ?
A: Fragment 是原生 JS 的一种创建文档片段的方法,如果我们对根元素中的每个元素做逐个处理的话,这会引起很大的性能问题,会多次触发浏览器的回流和重绘
当我们使用 fragment 相当于是在 JS 内存中创建了一个只存在于内存中的 DOM ,对 Fragment 中的元素内容做修改的话是不会引起 DOM 层面的变更的,对 fragment 中的元素都处理完成后再将这个 fragment 放到根元素中,这仅仅会引起一次的回流和重绘。
Q: 在 compileEl 方法中为什么要使用拓展运算符将元素的自定义属性展开?
A: 因为我们通过 DOM 元素获取到的自定义属性列表是一个伪数组,并不是一个真正的数组,它无法调用数组的一些方法,所以我们需要把它转换为真正的数组,再去逐个处理自定义属性。
Q: 在 setFragment 方法的 while 循环中,为什么要每次获取第一个元素?
A: while 也是 JS 的一种循环机制,他的终止条件是当()中的条件不满足时就会终止。
我们使用 currentNode 每次循环都会去获取一个元素,保存到 fragment 中。这个获取并不是引用,而是直接把子集拿过来,当根源素的子节点为空时,while 循环的条件也就不满足了,就会终止循环。