手写Vue中MVVM专题一

什么是MVVM

MVVM(模型-视图-视图模型)是一种基于MVC和MVP的架构模式。

MVVM最早是由微软提出的,它借鉴了桌面应用程序的MVC思想,在前端页面中,把Model用纯JavaScript对象表示,View负责显示,两者做到了最大限度的分离。
把Model和View关联起来的就是ViewModel。ViewModel负责把Model的数据同步到View显示出来,还负责把View的修改同步回Model。

目前Vue和Angular都使用了这种架构模式。Vue中使用MVVM实现了数据的双向绑定。

如下图所示:

mvvm.png

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文件。

mvvm1.png

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:将类数组转为真实数组。

因为可能存在嵌套元素,所以需要进行递归处理。

控制台打印如下:

mvvm2.png

现在我们已经选择出所有节点,并且已经根据类型进行打印。

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);
    }
  }
}

控制台打印如下:

mvvm3.png

现在我们已经解析出页面所需要绑定的元素属性值。

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-');
}