实现一个简单的 MVVM 框架

MVVM 框架的全称是 Model-View-ViewModel,它是 MVC(Model-View-Controller)的变种。在 MVC 框架中,负责数据源的模型(Model)可以直接和视图进行交互,而根据软件工程的模块解耦的原则之一,需要将数据和视图分离,开发者只需关心数据,而将视图 DOM 封装,当数据变化时,可以及时地将表现层面对应的数据也同步更新,如下图:


Vue 的 MVVM 基本框架

要实现上述框架,首先将数据要封装起来。当 Model 中的数据更改的时候,需要将该数据改变反应出来,也就是——数据劫持。

第一步——实现数据劫持

实现数据劫持,我们需要用到一个 Object 对象的核心方法:Object.defineProperty ,在 MDN 的定义中,它是这样的:

Object.defineProperty 会在对象上定义一个新属性,或者修改一个对象的已有属性,并将这个对象返回。

它可以实现在一个对象(也就是数据 Model)上定义一个新属性,或修改已有属性(数据修改),并且将对象返回。这就实现了我们基本的对数据改变后返回通知的需求(通过相应方法)。

Object.defineProperty(obj, prop, descriptor)

三个参数中,分别是:需要修改的对象,需要修改对象中的某个属性,对该属性的描述。因此在实际应用中,如果需要对某个对象的全部属性进行劫持,则需要用类似for-in循环、Object.keys等枚举的方法。

// exp1
var dataObj = {
  name: 'fejv',
  age: 22,
  skill:['javascript', 'html', 'CSS', 'ES6']
}

// 劫持函数
function observe(data) {
  if(!data|| typeof data !== 'object') return ;
  for(let key in data) {
    let val = data[key];
    Object.defineProperty(data, key,{
      enumerable: true,
      configurable: true,
      get:function() {      
        console.log('get fun '+val);
        return val;
      },
      set:function(newVal) {
        console.log('new value: '+newVal);
        val = newVal;
      }
    });
    if(typeof val === 'object') {
      observe(val);
    }
  }
}

observe(dataObj);
/* 
// 绑定测试
>dataObj.name = 'jab';
>"new value: jab"
>"jab"
>dataObj.name
>"jab"
*/

上面代码中,设置了一个简单的数据模型 dataObj,它有3个属性,每一个属性的变动都需要被观察(劫持)。因此我们在观察函数 observe 中使用了 for-in 循环,将所有在dataObj中的属性都进行了观察。在修改了 dataObj.name后,调用了set函数,将对象dataObj的值修改为了新的值,实现了对象中属性值的绑定。
实现一个数据劫持

第二步——实现发布订阅模式

在实现了对数据源(Model)的数据劫持后,我们需要能够将变化通知到视图(View),因此运用到了 javaScript 设计模式中的“发布——订阅模式”。发布的角色——被订阅的频道,就是数据源(Model)中的数据,它将数据的变化发布出去;而订阅者的角色就是(View),它订阅数据源的变化,并且根据变化的数据改变自己的视图。
首先,我们要实现一个被订阅者,也就是一个频道。它需要具有发布消息;可以增加/删除订阅者到列表,并且在发布更新的时候需要将更新的内容发布出去。

// exp2
class Subject {
  constructor(){
    this.observers = [];  // 订阅者列表
  }
  addObserver(observer) {
    this.observers.push(observer);  // 增加订阅者
  }
  removeObserver(observer) {
    var index = this.observers.indexOf(observer);
    if(index > -1) {
      this.observers.splice(index, 1);  // 删除订阅者
    }
  }
  notify(msg){
    this.observers.forEach( observer => {
      observer.update(msg);  // 发布更新
    });
  }
}

该频道由一个基本的订阅者数组组成,具有增加/删除订阅者的功能,并且在发布新消息之后用 notify(msg) 函数发布到全部的订阅者(观察者)observer中。我们还需要一个观察者的原型:

// exp3
class Observer {
  constructor(name) {
    this.name = name;
  }
  update(msg) {
    console.log(this.name+' update: '+ msg);
  }
  subscribe(sub) {
   sub.addObserver(this);
  }
}

在上面的观察者(订阅者)中,使用了 ES6 的写法,观察者主要的更新函数和订阅函数写了出来,当使用Observer构造函数生成一个新的 Observer 对象之后,执行该对象中的订阅函数 subscribe 才会将其增加到生成的频道中,当该频道更新,会将更新发布到曾订阅过他的函数中。

// exp4
>var subA =  new Subject;
>var fejv = new Observer('fejv');
>fejv.subscribe(subA);
>subA.notify('subA initial version');
>"fejv update: subA initial version"
>subA.notify("version 2");
>"fejv update: version 2"

观察者模式或者发布订阅模式的两种写法
原型写法
ES6写法

第三步——实现数据的单向绑定

实现数据的观察者模式(发布——订阅模式)之后,我们需要结合数据劫持和发布订阅模式,将数据劫持中,劫持的数据变化发布到所有的对应的订阅者,在 MVVM 中就是将变化的数据劫持反应到 View 的网页模板中。

借鉴 Vue 的模板语法:

<div id="app" >
  <h1>{{name}} 's age is {{age}}</h1>
</div>

我们需要监控 nameage 作为变量的数据源(Model)中的变化,并且在即使反应到视图 View 上,因此我们要解析 html 模板,取得变量,将每个变量观察起来,当它产生变化的时候,将原本数据源的数据一并修改并且反应到视图中。

首先需要一个入口文件

// exp5
class MVVM {
  constructor(opts) {
    this.data = opts.data;
    this.node = document.querySelector(opts.node);
    this.observers = [];
    observe(this.data);
    this.compile(this.node);
  }
  
  compile(node) {
    console.log('compile fun in class MVVM');
    if(node.nodeType === 1) {  
        // 节点仍是 DOM 结构,继续解析子节点
      node.childNodes.forEach(childNode => {
        this.compile(childNode);
      });
    }else if(node.nodeType === 3) {
      this.renderText(node); // 已解析到文字
    }
  }
  
  // 匹配函数,匹配模板语言中的 name 和 age 变量
  renderText(node) {
    console.log('render fun in class MVVM');
    let reg = /{{(.+?)}}/g;
    let match;
    while(match = reg.exec(node.nodeValue)) {
      let sample = match[0];
      let key = match[1].trim();
      // console.log(sample,key);
      node.nodeValue = node.nodeValue.replace(sample, this.data[key]);
      new Observer(this, key, function(newVal, oldVal) {
        node.nodeValue = node.nodeValue.replace(oldVal, newVal);
      });
    }
  }
}
/*
let demoMVVM = new MVVM({
  node: '#app',
  data:{
    name: 'fejv',
    age: 23
  }
});
*/

该入口函数中,先将数据源观察起来(数据劫持),以便在数据有更改的时候即使通知到各数据视图,然后解析 HTML 中的节点,将解析后的节点换成相应的值,也就是demo.data.name/age中的值。

这是初始化的时候,将数据中的值换到 HTML 的 DOM 模板上,但是当数据源的值改变时我们,需要及时地将更改的值换到 HTML 页面中,就是:

// exp6
new Observer(this, key, function(newVal, oldVal) {
  node.nodeValue = node.nodeValue.replace(oldVal, newVal);
});

这需要配合在 Observer classupdata 函数中:

// exp7
class Observer {
  // ....
  update() {
    var oldVal = this.val;
    var newVal = this.getVal();
    if(oldVal !== newVal) {
      this.val = newVal;
      this.callback.bind(this.vm)(oldVal, newVal)
    }
  }
}

在新的 update 函数中,将上一次的值设置为旧值,最新的值需要调用 getVal 函数获取,然后将新旧值一并传入回调函数中,由回调函数执行将新知更换,getVal 函数就成了获取新值的关键。

// exp8
class Observer {
  //....
  getVal() {
    console.log('getVal fun in class Observer');
    currentObs = this;
    let val = this.vm.data[this.key];  
    // 在获取vm.data 的值的时候,会调用observe函数中的 get 函数
    currentObs = null;
    return val;
  }
}

由于数值的更改是在 sujects 对所有 observers 发出的,因此需要在调用 Observer 中的 get 函数时,将该观察者(observer)添加到sujects的列表中。但在observer函数中无法访问Observer 对象,因此上面代码中,将当前的Observer赋值给一个全局的currentObs,并在调用observe 函数中的get函数时,将这个全局的,也就是当前的Observe添加到subject频道中,当下次有值更新的时候,才能notify到相关的Observer

// exp9
function observe(data) {
  //...
  Object.defineProperty(data, key, {
    //...
    get:function() {
      if(currentObs) {
        console.log('get fun in observe fun,current observer is not null');
        currentObs.subscribeTo(subj);
      }
      return val;
    }
  });
}

配合相关的上述函数,加以修改,就实现了简单的单向绑定。
单向绑定的ES6写法

第四步——双向绑定的实现

双向绑定,就是在第三步单向绑定的基础上,数据流从 Model => ViewModel => View,增加到Model <=> ViewModel <=> View,也就是视图中的可以改变数据,改变可以反馈到数据源中,再从数据源反馈到表现的视图中。


双向绑定示意图

跟单向绑定的区别就是在于:

  1. 需要监控视图上(View)输入的值,作为数据源(Model)更改的来源;
  2. 实现初步的视图上的对事件进行绑定,例如 Vue 中的v-on: click等语法。

因此需要在模板语言上有一个输入框:

// exp10
  <div id="app">
    <input v-model="name" v-on:click="hello">
    <input v-model="age" >
    <h3>{{name}}'s age is: {{age}}</h3>
  </div>

上述代码中参考了 Vue 框架的 HTML 语言模板,用两个输入框作为nameage的数值的双向绑定,而v-on:click作为事件进行绑定,在 JS 中需要修改新的解析函数,判断是作为模型或者是指令,并且绑定该输入框。

// exp11
class MVVM {
  //...
  // 处理模板节点
  compileNode(node) {
    let attrsArr = Array.from(node.attributes);
    attrsArr.forEach(attr => {
      if(this.isModel(attr.name)) {
        this.bindModel(node, attr);  // 绑定数据
      }else if(this.isHandle(attr.name)) {
        this.bindHandle(node, attr);  // 绑定指令
      }
    });
  }
  // 初始化输入框的值
  bindModel(node, attr) {
    let key = attr.value;
    node.value = this.vm.$data[key];
    new Observer(this.vm, key, function(newVal){
      node.value= newVal;
    });
    
    // 绑定输入的值作为数据源
    node.oninput = (e) => {
      this.vm.$data[key] = e.target.value;
    };      
  }
  // 解析时间模板,绑定事件。
  bindHandle(node, attr) {
    let startIndex = attr.name.indexOf(':')+1;
    let endIndex = attr.name.length;
    let eventType = attr.name.substring(startIndex, attr.name.length);
    let method = attr.value;
    node.addEventListener(eventType, this.vm.methods[method]);
  }
  
  // 判断数据模板
  isModel(attrName) {
    return (attrName === 'v-model');
  }
  // 判断指令
  isHandle(attrName) {
    return (attrName.indexOf('v-on') > -1);
  }
}

上述代码分别对 HTML 中的两个input实现了数值和事件的绑定,并且第一步将初始化时候的值作为输入框的初始值,在每个输入框的值改变的时候绑定该事件,并且绑定到上述的 observeset函数中,实现了在视图上修改时,反馈到视图反应中,于是实现了简单的双向绑定。

双向绑定的实现源码和演示地址:
双向绑定的实现源码
MVVM 双向绑定的演示

参考阅读

  1. MVVM 框架解析之双向绑定掘金用户牧云云
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,723评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,080评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,604评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,440评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,431评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,499评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,893评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,541评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,751评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,547评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,619评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,320评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,890评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,896评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,137评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,796评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,335评论 2 342

推荐阅读更多精彩内容

  • MVC MVC是一种设计模式,它将应用划分为3个部分:数据(模型)、展示层(视图)和用户交互层。结合一下下图,更能...
    公子世无双ss阅读 11,463评论 1 12
  • vue理解浅谈 一 理解vue的核心理念 使用vue会让人感到身心愉悦,它同时具备angular和react的优点...
    ambeer阅读 24,100评论 2 18
  • 前言 使用vue也好有一段时间了,虽然对其双向绑定原理也有了解个大概,但也没好好探究下其原理实现,所以这次特意花了...
    指尖跳动阅读 7,976评论 0 16
  • 等与不等,我都等了,在与不在乎,我都已经在乎了,剩下的要么靠你,要么靠命运吧!…
    阿豆豆豆阅读 177评论 0 1
  • 这周来说说你的人生想要的到底是高度、深度、宽度还是温度? 最近基本上把所有的精力都投入在工作中,周末也很少休息,即...
    Carol在路上阅读 404评论 2 0