饥人谷技术博客项目实现小记

实现一个简单的 MVVM 框架

2019-07-28  本文已影响27人  fejavu

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 框架解析之双向绑定掘金用户牧云云
上一篇下一篇

猜你喜欢

热点阅读