随着浏览器的内核进步和 mvvm 概念在前端的普及,类似于 jQuery 这种围绕着 dom 发展的 js 库已经不再(至少不比以前)适用于前端开发。dom 操作的复杂性使得 jQuery 大放异彩,如今 dom 操作的复杂性大大降低。其实一张页面整体看来不仅仅单纯是 dom 的集合,页面不过是数据的人性化展示,页面其实是数据块的堆砌。

比如一个商城的分类和热销页面模块,对应的是数据库的分类表与热销表数据。又比如一个按钮的 on/off 状态,可能就是当前数据库中用户的隐私设置字段值。

数据与 DOM 状态的关系

可以说页面的几乎所有行为都和数据有关,数据的变化直接对应了页面状态或者 dom 的变化。在这种背景下,React、Vue、Angular、Knockout 数据绑定 dom 的框架(mvvm方式)更加适应前端开发的方式。这些框架在数据绑定 dom 方面做的很出色,提供的基于 dom 的指令方式,在我看来大大提高了前端开发的效率。

Vue.js 是我在这几种 mvvm 框架中用的比较多一种,Vue.js 相对来说简单容易上手,更加适合小团队开发。Vue.js 的文档在 这里 。在没有看过源码的情况下,本文就是我自己的一种实现类似于 Vue.js 的 demo 版 mvvm 方式。

一、开始预热

实现一个简易的 mvvm 框架,需要三个步骤。

  • 1、解析 dom 模板
  • 2、解析数据
  • 3、数据和事件与 dom 的绑定

一个类似与 Vue.js 的 dom 模板如下所示:

<div id="app">  
  <ol>
    <li>姓名:{{name}}</li>
    <li>年龄:<input type="text" v-model="age"> 输入的年龄为:{{age}}</li>
    <li>身高:<span v-text="stature"></span>cm</li>
    <li>属性:<span v-html="html"></span></li>
    <li>秘密:<button v-on:click="alert" v-bind:style="style">说出你的密码</button></li>
  </ol>
</div>  

着这段 dom 模板中,{{name}}、v-model="age"、{{age}}、v-text="stature"、v-html="html"、v-on:click="alert"、v-bind:style="style" 都是我们 处理的 dom 指令,实现 mvvm 的第一步就是解析这些 dom 指令,将指令转化成数据值或者绑定的事件。第二步就是将 dom 中的指令,转化成相对应的属性值。 比如:{{name}} 转化为 name: 'Tom',并且完成双向的属性绑定操作,当 name 属性的属性值发生变化的时候,对应的改变 dom 文本的更新。为了简单起见,本文的 demo 只对 text 和 input 的属性实现绑定。

二、实现步骤

1、查找所有的 dom 节点

<div id="app">  
  <ol>
    <li>姓名:{{name}}</li>
    <li>年龄:<input type="text" v-model="age"> 输入的年龄为:{{age}}</li>
    <li>身高:<span v-text="stature"></span>cm</li>
    <li>属性:<span v-html="html"></span></li>
    <li>秘密:<button v-on:click="alert" v-bind:style="style">说出你的密码</button></li>
  </ol>
</div>  

对于这样一份的 dom 模板,我们需要遍历这个模板里面的所有的 dom 节点,查找每一个 node 上是否有我们所需要的指令。如果存在了符合的指令,就需要进入下一步对当前这个 node 进行绑定或者解析的操作。 在这样的一个过程中需要注意的是,第二个 li 标签:<li>年龄:<input type="text" v-model="age"> 输入的年龄为:{{age}}</li> ,这样一个 dom 被切分为以下的节点是不合适的。

原DOM:
  <li>年龄:<input type="text" v-model="age"> 输入的年龄为:{{age}}</li>

不合适的解析:
  1、<li></li> // node 节点
  2、"年龄:<input type="text" v-model="age">输入的年龄为:{{age}}"  // innerHTML

应当将 “年龄”、“输入的年龄为:{{age}}” 单独解析成一个 text 节点,因为这些 text 节点中存在了 Vue.js 特殊的 mustache 语法。mustache 的语法问题会在之后的篇幅里面介绍。

原DOM:
  <li>年龄:<input type="text" v-model="age"> 输入的年龄为:{{age}}</li>

解析的DOM:
  1、<li></li> // node 节点
  2、"年龄:"  // text
  3、<input type="text" v-model="age"> // node 节点
  4、"输入的年龄为:{{age}}" // text

枚举所有 node 的方式为:如果发现 node 下面存在子 node 就递归循环查找。方法很简单,就不再描述了。

function iteratorNodes( node ){  
  Array.from(node.childNodes).forEach(function(v, i){
    console.log( v );
    if( v.childNodes.length >= 0 ) iteratorNodes( v );
  }, this);
}

2、处理文本节点的 mustache 语法问题

在上面的 dom 模板中,存在了这样的 “姓名:{{name}}” mustache 指令,需要将此处的 {{name}} 转化 data 对象上的 name 属性。在这里我用了一个简单的正则表达式 /{{(.+?)}}/g 获取符合 mustache 语法的子串,并将符合 mustache 语法的 text 解析成 data 对象上的属性值。需要注意的是,为了之后的二次更新模板,需要将一开始的 dom 模板给保留下来。

function mustache( node, data ){  
  var handleTextReg = /{{(.+?)}}/g, replaceCallback = function(match, p1, offset, string){ 
    return data[p1];
  };
  if( handleTextReg.test( node.textContent ) === false ) return;
  node.template = node.textContent; // 保留原始的 dom 模板
  node.textContent = node.textContent.replace(handleTextReg, replaceCallback.bind( this ));
}

3、处理简单的 dom 指令

v-text、v-html、v-show、v-if、v-model 等这些指令都是被认为是简单的指令,只是因为他们的结构满足 “v-xxx="xxx"” 的特征。处理简单指令的代码也很简单,这里就不再过多描述。

var simpleDomDirective = {  
  'textDirective': function( node, prop, value ){
    node.textContent = value;
  },
  'htmlDirective': function( node, prop, value ){
    node.innerHTML = value;
  },
  'showDirective': function( node, prop, value ){
    node.style.display = value === 'false' ? 'none' : '';
  },
  'ifDirective': function( node, prop, value ){
    if( value === 'false' ) node.parentNode.removeChild( node );
  },
  'modelDirective': function( node, prop, value ){
    var tagName = node.tagName.toLowerCase();
    if( !['input', 'textarea', 'select'].includes( tagName ) ) return;
    node.value = value;
  }
};

function getAttrValue( node, attr, data ){  
  var attr = node.getAttribute('v-' + attr);
  return data[attr];
}

function simpleDirective( node ){  
  var attrsMap = {
    text: 'textDirective',
    html: 'htmlDirective',
    show: 'showDirective',
    if  : 'ifDirective',
    model:'modelDirective'
  };
  Object.keys(attrsMap).forEach(function(v, i){
    var attr = node.hasAttribute( 'v-' + v ) ? ( 'v-' + v ) : '';
    if( attr.length === 0 ) return;
    simpleDomDirective[ attrsMap[v] ]( node, node.getAttribute(attr), getAttrValue( node, v ) );
  }, this);
}

4、处理一些复杂指令

复杂指令被认为是类似 v-bind:xxx="xxx"、v-on:xxx="xxx" 的指令。首先第一步是获取当前 node 的所有 attributes,因为 HTMLElement 的 attributes 返回的是一个 map 的数据格式,为了方便起见,使用 Object.keys( node.attributes ) 转化成数组。循环这个数组也就是查找当前这个节点的所有属性,如果查找到属性名是以 v-bind 或者 v-on 开始,则对当前的节点执行 setAttribute 或者 addEventListener 操作。复杂指令的属性名满足 v-bind:xxx、v-on:xxx 分割,在这里粗暴的使用分割 : 来获取 xxx 的属性值。下面这段代码只是为了演示,使用是有风险的。

function complexDirective( node, data, methods ){  
  Object.keys( node.attributes ).forEach(function(v){
    var name = node.attributes[v].name,
    event    = node.attributes[v].value;
    if( name.startsWith('v-bind') === true ) 
      node.setAttribute( name.split(':')[1], data[event] );
    if( name.startsWith('v-on') === true ) 
      node.addEventListener( name.split(':')[1], methods[event].bind( node, this ), false );
  }, this);
}

5、监听所有的绑定数据

监听所有绑定数据的部分是前端 mvvm 中最重要的一部分了,对于指令所需要的数据,在这一步应该对已经被需要的数据进行监听。监听主要的使用方式是 Object.defineProperty,Object.defineProperty 可以对一个对象的属性值进行监听,当对象的属性被访问或者被赋值的时候,能够触发本身的 getter、setter 事件。Vue.js 的双向事件绑定就是基于 defineProperty 来实现的。

Object.defineProperty 具体的使用方法可以参考 MDN 的文档,比如对一个 obj 对象监听他的 demo 属性。

var obj = {}, defaultValue = '我是 Object.defineProperty';  
Object.defineProperty( obj, 'demo', {  
  get: function(){ console.log('您访问了 demo 属性!'); return defaultValue; },
  set: function(val){ console.log('您赋值了 demo 属性!'); defaultValue = val; }
});

当获取 obj.demo 时,就会触发 getter 控制台会打印出 “您访问了 demo 属性!”,当对 obj.demo 赋值时,控制台就会打印 “您赋值了 demo 属性!”。monitorBindingData 方法对 Object.defineProperty 进行了一层简单包裹。

function monitorBindingData( data, prop, value ){  
  var initializtion = value;
  Object.defineProperty( data, prop, {
    get: function(){ return initializtion; },
    set: function( value ){ 
      initializtion = value; 
    }
  });
}

但是其实这样做还远远不够,数据类型并不仅仅是 key-value 那么简单。数据还包括了数组,对于数组的 push、pop、shift、unshift、slice 等操作。那么如何对数组的操作进行监听呢?

Object.prototype.__proto__ 其实是对数组的操作进行监听一种好的方式,数组的 push、pop、shift、unshift、slice 等操作都是来自于 Array.prototype 原型上挂载的实例方法。这个方法如果你 toString 出来,会发现其实是 [native code],也就是不能被修改了。但是我们可以修改数据的 __proto__ ,使他既继承 Array 又继承我们特定方法的对象。// Reflect.construct

function observeArray(array, callback) {  
  var methods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'],
  arrayProtptype = Array.prototype;
  customArray = Object.create( arrayProtptype );
  methods.forEach(function(v, i){
    Object.defineProperty(customArray, v, {
      writable: true,
      enumerable: true,
      configurable: true,
      value: function(...arg){
        // 保存一份副本,不修改原始数组
        callback( array.slice() );
        return arrayProtptype[v].call( this, ...arg );
      }
    });
  }, this);
  // Object.setPrototypeOf
  array.__proto__ = customArray;
}

6、生成数据与 dom 的关系

在第 5 点中,当被监控的属性发生改变的时候,我们已经能够知道。但是当数据改变的时候,如何修改页面的状态?这一部分的内容其实比较复杂,因为这里只需要对 input 和 text 实现双向数据绑定。我们就新建了一个 map 用来标记属性与 dom 的对应关系。方便在属性改变的时候及时更新 dom 状态。

var _ = {};  
function coupling( prop, node ){  
  if( _[ prop ] === undefined ) _[ prop ] = [];
  _[ prop ].push( node );
  return _;
}

7、model 引起 view 视图改变

当数据改变及时更新 dom,更新的 dom 只能是满足两种情况,要么是文本节点直接设置 textContent,要么是输入类型标签直接赋值。

function model2view( data, prop ){  
  if( _[ prop ] === undefined || _[ prop ].length === 0 ) return;
  _[ prop ].forEach(function(v, i){
    if( v.nodeType === 3 ) {
      var handleTextReg = /{{(.+?)}}/g, replaceCallback = function(match, p1, offset, string){ 
        return data[p1];
      };
      node.textContent = node.template.replace(handleTextReg, replaceCallback.bind( this ));
    } 
    if( v.nodeType === 1 && v.type !== undefined )
      v.value = data[prop];
  });
}

8、移除所有的 dom 指令

在所有的工作完成之后,专属的 dom 指令已经不再被需要,移除了 dom 模板中的 'text'、 'html'、 'show'、 'if'、 'model'、 'on'、 'bind' 指令。使用一个简单的正则表达式 /v-([^:]+)/g 来获取 node 所有属性中是否包含了符合 v-xxx 格式的属性名。

function removeDomDirective( node ) {  
  var attributes = Array.from(node.attributes), directives = ['text', 'html', 'show', 'if', 'model', 'on', 'bind'];
  attributes.forEach(function(v, i){
    if( v.name.startsWith( 'v-' ) && directives.includes( v.name.split(/v-([^:]+)/g)[1] ) )
      node.removeAttribute( v.name );
  });
}

三、放在最后

最后结合 jQuery 的包装方法,对上面的方法进行了一次包装实现了一个简单的 DEMO,DEMO 的位置在这里

MVVM DEMO

由于使用了很多低版本浏览器不兼容的方法,比如 Object.defineProperty、Object.keys、Array.prototype.includes 等。再加上本文代码不严谨也没有经过测试,错误良多,切勿用于生产环境!我自己都不用

以上。