手写Vue中MVVM专题一
什么是MVVM
MVVM(模型-视图-视图模型)是一种基于MVC和MVP的架构模式。
MVVM最早是由微软提出的,它借鉴了桌面应用程序的MVC思想,在前端页面中,把Model用纯JavaScript对象表示,View负责显示,两者做到了最大限度的分离。
把Model和View关联起来的就是ViewModel。ViewModel负责把Model的数据同步到View显示出来,还负责把View的修改同步回Model。
目前Vue和Angular都使用了这种架构模式。Vue中使用MVVM实现了数据的双向绑定。
如下图所示:
View是视图展示部分,Model是原生JavaScript对象,Vue为我们实现了ViewModel。
什么是数据双向绑定
Vue.js采用数据劫持结合发布-订阅模式的方式,通过Object.defineProperty()来劫持各个属性的setter、getter,在数据变动时发布消息给订阅者,触发相应的监听回调。
具体分为3个部分:
1. 模板编译
2. 数据劫持
3. Wacther(观察者)
下面将使用ES5语法来实现模板编译部分。
模板编译
1. 建立页面结构
新建MVVM目录,目录包括js文件夹和utils文件夹以及index.html文件。
js文件下新建Vue.js文件和Compile.js文件。
util文件夹下新建tools.js文件。
html文件内容如下
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>手写MVVM-模板编译</title>
</head>
<body>
<div id="app">
<input type="text" v-model="message" />
<div>{{ message }}</div>
<ul><li></li></ul>
{{ message }}
</div>
<script type="text/javascript" src="./utils/tools.js"></script>
<script type="text/javascript" src="./js/Compile.js"></script>
<script type="text/javascript" src="./js/Vue.js"></script>
<script type="text/javascript">
new Vue({
el: '#app',
data: {
message: 'HelloWorld'
}
});
</script>
</body>
</html>
html文件负责引入JS脚本文件并且编写基本测试样例。
要达到的目的是如何将DOM节点转换为虚拟DOM节点以及如何解析DOM中需要被绑定的属性。
2. 编写Vue.js
定义一个Vue函数,接收的参数是一个对象,包括el和data属性。
并且在函数的原型上定义init方法,在其内部调用模板编译的方法Compile。
function Vue (opt) {
this.$el = opt.el;
this.$data = opt.data;
this.init();
}
Vue.prototype = {
init: function () {
new Compile(this.$el, this);
}
}
3. 编写Compile.js
大概流程如下:
1. 判断传入的el是否是node节点还是文本;
2. 将node节点转换为虚拟节点;
3. 编译节点。需要判断是元素节点还是文本节点;
4. 实现元素节点和文本节点的编译处理;
1. 创建Compile函数并在其原型上定义初始化方法init
function Compile (el, vm) {
}
Compile.prototype = {
init: function () {
}
}
2. 定义isElementNode工具方法
用于判断是都是元素节点。工具函数放入tool.js文件。
/**
* 判断是不是元素节点
*
* @param {*} node
*/
function isElementNode (node) {
return node.nodeType === 1;
}
nodeType是DOM节点的编号,与之对应的是节点的。
节点类型 | 节点编号 |
---|---|
元素节点 | 1 |
属性节点 | 2 |
文本节点 | 3 |
注释节点 | 8 |
document | 9 |
DocumentFragment | 11 |
3. 接受传递参数并且调用初始化方法
function Compile (el, vm) {
this.el = isElementNode(el) ? el : document.querySelector(el);
this.vm = vm;
this.init();
}
如果传入的el是元素节点就直接赋值。
如果不是,通过属性选择器选出对应的节点。
4. 定义节点转换为虚拟节点方法
使用DocumentFragment(文档碎片)作为存储的容器。
使用document.createDocumentFragment()来创建文档碎片。
文档碎片的好处
向页面添加节点时,可以动态创建div添加元素,然后添加到UI中,但是会存在外部包裹。
这时就可以使用DoucmentFragment,它不存在与DOM节点树中,没有外部包裹,可以有效提高性能。如果有动态创建列表的需求,不适用字符串拼接,同时存在大量属性,可以考虑使用DocumentFragment。当然,字符串拼接的性能相对较好。
tool.js里添加nodeToFragment方法
/**
* node节点转换为虚拟节点
*
* @param {*} el
*/
function nodeToFragment (el) {
var docFragment = document.createDocumentFragment(),
firstChild;
while (firstChild = el.firstChild) {
docFragment.appendChild(firstChild);
}
return docFragment;
}
函数中使用了appendChild方法。appendChild方法可以向当前元素内部添加子元素。
此方法存在于Node.prototype上,它不仅仅有增加的功能,还有剪切功能。
5. 定义compile方法并传入节点集合
Compile.prototype = {
init: function () {
if (this.el) {
this.compile(nodeToFragment(this.el));
}
},
/**
* 模板编译
*
* @param {*} docFragment
*/
compile (docFragment) {
}
}
6. 编写compile方法
compile主要负责负责区分元素节点和文本节点,以做后续处理。
/**
* 模板编译
*
* @param {*} docFragment
*/
compile (docFragment) {
var childNodes = docFragment.childNodes,
childNodeArr = Array.prototype.slice.call(childNodes),
childNodeLen = childNodeArr.length,
childNode;
if (!childNodeLen) {
return;
}
for (var i = 0; i < childNodeLen; i ++) {
childNode = childNodeArr[i];
if (isElementNode(childNode)) {
// 元素节点
console.log('元素节点:', childNode);
this.compile(childNode); // 存在嵌套元素时, 递归处理
} else {
// 文本节点
console.log('文本节点:', childNode);
}
}
}
childNodes:获取子节点集合(不仅仅是元素节点,包括其他节点)。
Array.prototype.slice:将类数组转为真实数组。
因为可能存在嵌套元素,所以需要进行递归处理。
控制台打印如下:
现在我们已经选择出所有节点,并且已经根据类型进行打印。
7. 处理元素节点
编写判断是否是vue指令的工具方法
tools.js增加isDirective方法。
/**
* 判断是不是vue指令
*
* @param {*} node
*/
function isDirective (node) {
return node.includes('v-');
}
编写处理元素节点的方法
/**
* 编译元素节点
*
* @param {*} node
*/
compileElement (node) {
var attrs = node.attributes, // 获取元素的属性节点集合
attrArr = Array.prototype.slice.call(attrs),
attrLen = attrArr.length,
attr;
for (var i = 0; i < attrLen; i++) {
attr = attrArr[i];
if (isDirective(attr.name)) {
var value = attr.value;
// this.vm.$data value
console.log(value);
}
}
}
attributes: 获取元素的属性节点集合
通过对属性节点集合遍历,过滤出包含v-*的指令并打印。
8. 处理文本节点
编写处理文本节点的方法
/**
* 编译文本节点
*
* @param {*} node
*/
compileText (node) {
var text = node.textContent,
reg = /\{\{([^}]+)\}\}/g;
if (reg.test(text)) {
// this.vm.$data text
console.log(text);
}
}
通过正则匹配{{}}之间的值,值为需要绑定的属性。
9. 编译调用两种处理方法
/**
* 编译
*
* @param {*} docFragment
*/
compile (docFragment) {
var childNodes = docFragment.childNodes,
childNodeArr = Array.prototype.slice.call(childNodes),
childNodeLen = childNodeArr.length,
childNode;
if (!childNodeLen) {
return;
}
for (var i = 0; i < childNodeLen; i ++) {
childNode = childNodeArr[i];
if (isElementNode(childNode)) {
// 元素节点
this.compileElement(childNode);
this.compile(childNode); // 存在嵌套元素时, 递归处理
} else {
// 文本节点
this.compileText(childNode);
}
}
}
控制台打印如下:
现在我们已经解析出页面所需要绑定的元素属性值。
4.总结
本文分析了如何用ES5进行vue模板的编译,当前实现方式是多种多样的。
如果通过这篇文章让你更加了解Vue模板编译的流程,那么这篇文章就是值得的。
完整文件如下:
Compile.js
function Compile (el, vm) {
this.el = isElementNode(el) ? el : document.querySelector(el);
this.vm = vm;
this.init();
}
Compile.prototype = {
init: function () {
if (this.el) {
this.compile(nodeToFragment(this.el));
}
},
/**
* 编译
*
* @param {*} docFragment
*/
compile (docFragment) {
var childNodes = docFragment.childNodes,
childNodeArr = Array.prototype.slice.call(childNodes),
childNodeLen = childNodeArr.length,
childNode;
if (!childNodeLen) {
return;
}
for (var i = 0; i < childNodeLen; i ++) {
childNode = childNodeArr[i];
if (isElementNode(childNode)) {
// 元素节点
this.compileElement(childNode);
this.compile(childNode); // 存在嵌套元素时, 递归处理
} else {
// 文本节点
this.compileText(childNode);
}
}
},
/**
* 编译元素节点
*
* @param {*} node
*/
compileElement (node) {
var attrs = node.attributes, // 获取元素的属性节点集合
attrArr = Array.prototype.slice.call(attrs),
attrLen = attrArr.length,
attr;
for (var i = 0; i < attrLen; i++) {
attr = attrArr[i];
if (isDirective(attr.name)) {
var value = attr.value;
// this.vm.$data value
console.log(value);
}
}
},
/**
* 编译文本节点
*
* @param {*} node
*/
compileText (node) {
var text = node.textContent,
// reg = /\{\{([^}]+)\}\}/g;
reg = /{{(.*?)}}/g;
if (reg.test(text)) {
// this.vm.$data text
console.log(text);
}
}
}
tools.js
/**
* 判断是不是元素节点
*
* @param {*} node
*/
function isElementNode (node) {
return node.nodeType === 1;
}
/**
* node节点转换为虚拟节点
*
* @param {*} el
*/
function nodeToFragment (el) {
var docFragment = document.createDocumentFragment(),
firstChild;
while (firstChild = el.firstChild) {
docFragment.appendChild(firstChild);
}
return docFragment;
}
/**
* 判断是不是vue指令
*
* @param {*} node
*/
function isDirective (node) {
return node.includes('v-');
}