vue实现简单的MVVM框架


Posted in Javascript onAugust 05, 2018

不知不觉接触前端的时间已经过去半年了,越来越发觉对知识的学习不应该只停留在会用的层面,这在我学jQuery的一段时间后便有这样的体会。

虽然jQuery只是一个JS的代码库,只要会一些JS的基本操作学习一两天就能很快掌握jQuery的基本语法并熟练使用,但是如果不了解jQUery库背后的实现原理,相信只要你一段时间不再使用jQuery的话就会把jQuery忘得一干二净,这也许就是知其然不知其所以然的后果。

最近在学vue的时候又再一次经历了这样的困惑,虽然能够比较熟练的掌握vue的基本使用,也能够对MV*模式、数据劫持、双向数据绑定、数据代理侃上两句。但是要是稍微深入一点就有点吃力了。所以这几天痛下决心研究大量技术文章(起初尝试看早期源码,无奈vue与jQuery不是一个层级的,相比于jQuery,vue是真正意义上的前端框架。只能无奈弃坑转而看技术博客),对vue也算有了一个管中窥豹的认识。最后尝试实践一下自己学到的知识,基于数据代理、数据劫持、模板解析、双向绑定实现了一个小型的vue框架。

温馨提示:文章是按照每个模块的实现依赖关系来进行分析的,但是在阅读的时候可以按照vue的执行顺序来分析,这样对初学者更加的友好。推荐的阅读顺序为:实现VMVM、数据代理、实现Observe、实现Complie、实现Watcher。

源码:https://github.com/yuliangbin/MVVM

功能演示如下所示:

vue实现简单的MVVM框架

数据代理

以下面这个模板为例,要替换的根元素“#mvvm-app”内只有一个文本节点#text,#text的内容为{{name}}。我们就以下面这个模板详细了解一下VUE框架的大体实现流程。

<body>
 <div id="mvvm-app">
  {{name}}
 </div>
 <script src="./js/observer.js"></script>
 <script src="./js/watcher.js"></script>
 <script src="./js/compile.js"></script>
 <script src="./js/mvvm.js"></script>
 <script>
  let vm = new MVVM({
   el: "#mvvm-app",
   data: {
    name: "hello world"
   },  
  })

 </script>
</body>

数据代理

1、什么是数据代理

在vue里面,我们将数据写在data对象中。但是我们在访问data里的数据时,既可以通过vm.data.name访问,也可以通过vm.name访问。这就是数据代理:在一个对象中,可以动态的访问和设置另一个对象的属性。

2、实现原理

我们知道静态绑定(如vm.name = vm.data.name)可以一次性的将结果赋给变量,而使用Object.defineProperty()方法来绑定则可以通过set和get函数实现赋值的中间过程,从而实现数据的动态绑定。具体实现如下:

let obj = {};
let obj1 = {
 name: 'xiaoyu',
 age: 18,
}
//实现origin对象代理target对象
function proxyData(origin,target){
 Object.keys(target).forEach(function(key){
  Object.defineProperty(origin,key,{//定义origin对象的key属性
   enumerable: false,
   configurable: true,
   get: function getter(){
    return target[key];//origin[key] = target[key];
   },
   set: function setter(newValue){
    target[key] = newValue;
   }
  })
 })
}

vue中的数据代理也是通过这种方式来实现的。

function MVVM(options) {
 this.$options = options || {};
 var data = this._data = this.$options.data;
 var _this = this;//当前实例vm

 // 数据代理
 // 实现 vm._data.xxx -> vm.xxx 
 Object.keys(data).forEach(function(key) {
  _this._proxyData(key);
 });
 observe(data, this);
 this.$compile = new Compile(options.el || document.body, this);

}

MVVM.prototype = {
_proxyData: function(key) {
 var _this = this;
 if (typeof key == 'object' && !(key instanceof Array)){//这里只实现了对对象的监听,没有实现数组的
  this._proxyData(key);
 }
 Object.defineProperty(_this, key, {
  configurable: false,
  enumerable: true,
  get: function proxyGetter() {
   return _this._data[key];
  },
  set: function proxySetter(newVal) {
   _this._data[key] = newVal;
  }
 });
},
};

实现Observe

1、双向数据绑定

数据变动

--->

视图更新

视图更新

--->

数据变动

要想实现当数据变动时视图更新,首先要做的就是如何知道数据变动了,可以通过Object.defineProperty()函数监听data对象里的数据,当数据变动了就会触发set()方法。所以我们需要实现一个数据监听器Observe,来对数据对象中的所有属性进行监听,当某一属性数据发生变化时,拿到最新的数据通知绑定了该属性的订阅器,订阅器再执行相应的数据更新回调函数,从而实现视图的刷新。

当设置this.name = 'hello vue'时,就会执行set函数,通知订阅器里的订阅者执行相应的回调函数,实现数据变动,对应视图更新。

function observe(data){
 if (typeof data != 'object') {
  return ;
 }
 return new Observe(data);
}

function Observe(data){
 this.data = data;
 this.walk(data);
}

Observe.prototype = {
 walk: function(data){
  let _this = this;
  for (key in data) {
   if (data.hasOwnProperty(key)){
    let value = data[key];
    if (typeof value == 'object'){
     observe(value);
    }
    _this.defineReactive(data,key,data[key]);
   }
  }
 },
 defineReactive: function(data,key,value){
  Object.defineProperty(data,key,{
   enumerable: true,//可枚举
   configurable: false,//不能再define
   get: function(){
    console.log('你访问了' + key);return value;
   },
   set: function(newValue){
    console.log('你设置了' + key);
    if (newValue == value) return;
    value = newValue;
    observe(newValue);//监听新设置的值
   }
  })
 }
}

2、实现一个订阅器

要想通知订阅者,首先得要有一个订阅器(统一管理所有的订阅者)。为了方便管理,我们会为每一个data对象的属性都添加一个订阅器(new Dep)。

订阅器里存着的是订阅者Watcher(后面会讲到),由于订阅者可能会有多个,我们需要建立一个数组来维护。一旦数据变化,就会触发订阅器的notify()方法,订阅者就会调用自身的update方法实现视图更新。

function Dep(){
 this.subs = [];
}
Dep.prototype = {
 addSub: function(sub){this.subs.push(sub);
 },
 notify: function(){
  this.subs.forEach(function(sub) {
   sub.update();
  })
 }
}

每次响应属性的set()函数调用的时候,都会触发订阅器,所以代码补充完整。

Observe.prototype = {
 //省略的代码未作更改
 defineReactive: function(data,key,value){
  let dep = new Dep();//创建一个订阅器,会被闭包在key属性的get/set函数内,因此每个属性对应唯一一个订阅器dep实例
  Object.defineProperty(data,key,{
   enumerable: true,//可枚举
   configurable: false,//不能再define
   get: function(){
    console.log('你访问了' + key);
    return value;
   },
   set: function(newValue){
    console.log('你设置了' + key);
    if (newValue == value) return;
    value = newValue;
    observe(newValue);//监听新设置的值
    dep.notify();//通知所有的订阅者
   }
  })
 }
}

实现Complie

compile主要做的事情是解析模板指令,将模板中的data属性替换成data属性对应的值(比如将{{name}}替换成data.name值),然后初始化渲染页面视图,并且为每个data属性添加一个监听数据的订阅者(new Watcher),一旦数据有变动,收到通知,更新视图。

遍历解析需要替换的根元素el下的HTML标签必然会涉及到多次的DOM节点操作,因此不可避免的会引发页面的重排或重绘,为了提高性能和效率,我们把根元素el下的所有节点转换为文档碎片fragment进行解析编译操作,解析完成,再将fragment添加回原来的真实dom节点中。

注:文档碎片本身也是一个节点,但是当将该节点append进页面时,该节点标签作为根节点不会显示html文档中,其里面的子节点则可以完全显示。

Compile解析模板,将模板内的子元素#text添加进文档碎片节点fragment。

function Compile(el,vm){
 this.$vm = vm;//vm为当前实例
 this.$el = document.querySelector(el);//获得要解析的根元素 
 if (this.$el){
  this.$fragment = this.nodeToFragment(this.$el);
  this.init();
  this.$el.appendChild(this.$fragment);
 } 
}
Compile.prototype = {
 nodeToFragment: function(el){
  let fragment = document.createDocumentFragment();
  let child;
  while (child = el.firstChild){
   fragment.appendChild(child);//append相当于剪切的功能
  }
  return fragment;
  
 },
};

compileElement方法将遍历所有节点及其子节点,进行扫描解析编译,调用对应的指令渲染函数进行数据渲染,并调用对应的指令更新函数进行绑定,详看代码及注释说明:

因为我们的模板只含有一个文本节点#text,因此compileElement方法执行后会进入_this.compileText(node,reg.exec(node.textContent)[1]);//#text,'name'

Compile.prototype = {
 nodeToFragment: function(el){
  let fragment = document.createDocumentFragment();
  let child;
  while (child = el.firstChild){
   fragment.appendChild(child);//append相当于剪切的功能
  }
  return fragment;
  
 },
 
 init: function(){
  this.compileElement(this.$fragment);
 },
 
 compileElement: function(node){
  let childNodes = node.childNodes;
  const _this = this;
  let reg = /\{\{(.*)\}\}/g;
  [].slice.call(childNodes).forEach(function(node){
   
   if (_this.isElementNode(node)){//如果为元素节点,则进行相应操作
    _this.compile(node);
   } else if (_this.isTextNode(node) && reg.test(node.textContent)){
    //如果为文本节点,并且包含data属性(如{{name}}),则进行相应操作
    _this.compileText(node,reg.exec(node.textContent)[1]);//#text,'name'
   }
   
   if (node.childNodes && node.childNodes.length){
    //如果节点内还有子节点,则递归继续解析节点
    _this.compileElement(node);
    
   }
  })
 },
 compileText: function(node,exp){//#text,'name'
   compileUtil.text(node,this.$vm,exp);//#text,vm,'name'
 },};

CompileText()函数实现初始化渲染页面视图(将data.name的值通过#text.textContent = data.name显示在页面上),并且为每个DOM节点添加一个监听数据的订阅者(这里是为#text节点新增一个Wather)。

let updater = {
 textUpdater: function(node,value){ 
  node.textContent = typeof value == 'undefined' ? '' : value;
 },
}
 
let compileUtil = {
 text: function(node,vm,exp){//#text,vm,'name'
  this.bind(node,vm,exp,'text');
 },
 
 bind: function(node,vm,exp,dir){//#text,vm,'name','text'
  let updaterFn = updater[dir + 'Updater'];
  updaterFn && updaterFn(node,this._getVMVal(vm,exp));
  new Watcher(vm,exp,function(value){
   updaterFn && updaterFn(node,value)
  });
  console.log('加进去了');
 }
};

现在我们完成了一个能实现文本节点解析的Compile()函数,接下来我们实现一个Watcher()函数。

实现Watcher

我们前面讲过,Observe()函数实现data对象的属性劫持,并在属性值改变时触发订阅器的notify()通知订阅者Watcher,订阅者就会调用自身的update方法实现视图更新。

Compile()函数负责解析模板,初始化页面,并且为每个data属性新增一个监听数据的订阅者(new Watcher)。

Watcher订阅者作为Observer和Compile之间通信的桥梁,所以我们可以大致知道Watcher的作用是什么。

主要做的事情是:

在自身实例化时往订阅器(dep)里面添加自己。

自身必须有一个update()方法 。

待属性变动dep.notice()通知时,能调用自身的update()方法,并触发Compile中绑定的回调。

先给出全部代码,再分析具体的功能。

//Watcher
function Watcher(vm, exp, cb) {
 this.vm = vm;
 this.cb = cb;
 this.exp = exp;
 this.value = this.get();//初始化时将自己添加进订阅器
};

Watcher.prototype = {
 update: function(){
  this.run();
 },
 run: function(){
  const value = this.vm[this.exp];
  //console.log('me:'+value);
  if (value != this.value){
   this.value = value;
   this.cb.call(this.vm,value);
  }
 },
 get: function() { 
  Dep.target = this; // 缓存自己
  var value = this.vm[this.exp] // 访问自己,执行defineProperty里的get函数   
  Dep.target = null; // 释放自己
  return value;
 }
}

//这里列出Observe和Dep,方便理解
Observe.prototype = {
 defineReactive: function(data,key,value){
  let dep = new Dep();
  Object.defineProperty(data,key,{
   enumerable: true,//可枚举
   configurable: false,//不能再define
   get: function(){
    console.log('你访问了' + key);
    //说明这是实例化Watcher时引起的,则添加进订阅器
    if (Dep.target){
     //console.log('访问了Dep.target');
     dep.addSub(Dep.target);
    }
    return value;
   },
  })
 }
}

Dep.prototype = {
 addSub: function(sub){this.subs.push(sub);
 },
}

我们知道在Observe()函数执行时,我们为每个属性都添加了一个订阅器dep,而这个dep被闭包在属性的get/set函数内。所以,我们可以在实例化Watcher时调用this.get()函数访问data.name属性,这会触发defineProperty()函数内的get函数,get方法执行的时候,就会在属性的订阅器dep添加当前watcher实例,从而在属性值有变化的时候,watcher实例就能收到更新通知。

那么Watcher()函数中的get()函数内Dep.taeger = this又有什么特殊的含义呢?我们希望的是在实例化Watcher时将相应的Watcher实例添加一次进dep订阅器即可,而不希望在以后每次访问data.name属性时都加入一次dep订阅器。所以我们在实例化执行this.get()函数时用Dep.target = this来标识当前Watcher实例,当添加进dep订阅器后设置Dep.target=null。

实现VMVM

MVVM作为数据绑定的入口,整合Observer、Compile和Watcher三者,通过Observer来监听自己的model数据变化,通过Compile来解析编译模板指令,最终利用Watcher搭起Observer和Compile之间的通信桥梁,达到数据变化 -> 视图更新;视图交互变化(input) -> 数据model变更的双向绑定效果。

function MVVM(options) {
 this.$options = options || {};
 var data = this._data = this.$options.data;
 var _this = this;
 // 数据代理
 // 实现 vm._data.xxx -> vm.xxx 
 Object.keys(data).forEach(function(key) {
  _this._proxyData(key);
 });
 observe(data, this);
 this.$compile = new Compile(options.el || document.body, this);
}
Javascript 相关文章推荐
JavaScript监测ActiveX控件是否已经安装过的代码
Sep 02 Javascript
javascript实现上传图片并预览的效果实现代码
Apr 11 Javascript
JavaScript定义变量和变量优先级问题探讨
Oct 11 Javascript
JavaScript实现的多个图片广告交替显示效果代码
Sep 04 Javascript
详解Node.js中的事件机制
Sep 22 Javascript
微信小程序 使用picker封装省市区三级联动实例代码
Oct 28 Javascript
JS使用正则表达式验证身份证号码
Jun 23 Javascript
webpack项目调试以及独立打包配置文件的方法
Feb 28 Javascript
JavaScript函数式编程(Functional Programming)箭头函数(Arrow functions)用法分析
May 22 Javascript
微信小程序使用canvas自适应屏幕画海报并保存图片功能
Jul 25 Javascript
js实现图片3D轮播效果
Sep 21 Javascript
关于javascript中的promise的用法和注意事项(推荐)
Jan 15 Javascript
使用D3.js+Vue实现一个简单的柱形图
Aug 05 #Javascript
详解Require.js与Sea.js的区别
Aug 05 #Javascript
vue中关闭eslint的方法分析
Aug 04 #Javascript
详解Vue取消eslint语法限制
Aug 04 #Javascript
JavaScript原型对象、构造函数和实例对象功能与用法详解
Aug 04 #Javascript
JavaScript中变量、指针和引用功能与操作示例
Aug 04 #Javascript
webpack4.x开发环境配置详解
Aug 04 #Javascript
You might like
yii框架表单模型使用及以数组形式提交表单数据示例
2014/04/30 PHP
php数组索引的Key加引号和不加引号的区别
2014/08/19 PHP
PHP中读取照片exif信息的方法
2014/08/20 PHP
php实现Mongodb自定义方式生成自增ID的方法
2015/03/23 PHP
php中smarty变量修饰用法实例分析
2015/06/11 PHP
php生成0~1随机小数的方法(必看)
2017/04/05 PHP
js判断IE6/IE7/FF的代码[XMLHttpRequest]
2011/02/16 Javascript
jquery验证手机号码、邮箱格式是否正确示例代码
2013/07/28 Javascript
动态的创建一个元素createElement及删除一个元素
2014/01/24 Javascript
JS实现关键字搜索时的相关下拉字段效果
2014/08/05 Javascript
使用时间戳解决ie缓存的问题
2014/08/20 Javascript
js文件包含的几种方式介绍
2014/09/28 Javascript
node.js中的fs.fchown方法使用说明
2014/12/16 Javascript
JS实现常见的TAB、弹出层效果(TAB标签,斑马线,遮罩层等)
2015/10/08 Javascript
JS产生随机数的几个用法详解
2016/06/22 Javascript
JavaScript 继承详解(五)
2016/10/11 Javascript
javascript基本数据类型及类型检测常用方法小结
2016/12/14 Javascript
纯html+css+javascript实现楼层跳跃式的页面布局(实例代码)
2017/10/25 Javascript
js实现一个简易计算器
2020/03/30 Javascript
[46:16]2018DOTA2亚洲邀请赛3月30日 小组赛B组 iG VS VP
2018/03/31 DOTA
python使用点操作符访问字典(dict)数据的方法
2015/03/16 Python
Tensorflow简单验证码识别应用
2017/05/25 Python
在CentOS6上安装Python2.7的解决方法
2018/01/09 Python
简述Python2与Python3的不同点
2018/01/21 Python
Python基于FTP模块实现ftp文件上传操作示例
2018/04/23 Python
python 删除非空文件夹的实例
2018/04/26 Python
python多线程扫描端口(线程池)
2019/09/04 Python
Python 实现判断图片格式并转换,将转换的图像存到生成的文件夹中
2020/01/13 Python
澳大利亚墨尔本的在线时装店:LORETA
2018/09/14 全球购物
英国最大的独立玩具专卖店:The Entertainer
2019/09/06 全球购物
Marc O’Polo俄罗斯官方在线商店:德国高端时尚品牌
2019/12/26 全球购物
高级电工工作职责
2013/11/21 职场文书
群众路线个人自我剖析材料
2014/10/07 职场文书
小学英语复习计划
2015/01/19 职场文书
你对自己的信用报告有过了解吗?
2019/07/09 职场文书
tensorflow学习笔记之tfrecord文件的生成与读取
2021/03/31 Python