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 相关文章推荐
xml 封装与解析(javascript和C#中)
Jul 26 Javascript
document.write()及其输出内容的样式、位置控制
Aug 12 Javascript
jQuery控制网页打印指定区域的方法
Apr 07 Javascript
JS创建事件的三种方法(实例代码)
May 12 Javascript
再谈Javascript中的异步以及如何异步
Aug 19 Javascript
使用prop解决一个checkbox选中后再次选中失效的问题
Jul 05 Javascript
jQuery Validate插件ajax方式验证输入值的实例
Dec 21 jQuery
小程序实现带年月选取效果的日历
Jun 27 Javascript
js中Object.defineProperty()方法的不详解
Jul 09 Javascript
微信小程序新手教程之页面打开数量限制
Mar 03 Javascript
记录一次完整的react hooks实践
Mar 11 Javascript
vue视频播放暂停代码
Nov 08 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
php实现数组按指定KEY排序的方法
2015/03/30 PHP
php基于自定义函数记录log日志方法
2017/07/21 PHP
解决php extension 加载顺序问题
2019/08/16 PHP
理解Javascript_10_对象模型
2010/10/16 Javascript
JavaScript中的函数嵌套使用
2015/06/04 Javascript
javascript判断网页是关闭还是刷新
2015/09/12 Javascript
微信小程序之picker日期和时间选择器
2017/02/09 Javascript
利用node.js制作命令行工具方法教程(一)
2017/06/22 Javascript
详解使用vue实现tab 切换操作
2017/07/03 Javascript
详解node+express+ejs+bootstrap构建项目
2017/09/27 Javascript
获取本机IP地址的实例(JavaScript / Node.js)
2017/11/24 Javascript
vue实现微信分享朋友圈,发送朋友的示例讲解
2018/02/10 Javascript
使用vuex解决刷新页面state数据消失的问题记录
2019/05/08 Javascript
es6函数之箭头函数用法实例详解
2020/04/25 Javascript
js实现飞机大战小游戏
2020/08/26 Javascript
vue Treeselect下拉树只能选择第N级元素实现代码
2020/08/31 Javascript
使用python实现knn算法
2017/12/20 Python
python实现监控某个服务 服务崩溃即发送邮件报告
2018/06/21 Python
详解用python写网络爬虫-爬取新浪微博评论
2019/05/10 Python
Python多个装饰器的调用顺序实例解析
2020/05/22 Python
python爬虫beautifulsoup解析html方法
2020/12/07 Python
[原创]赚疯了!转手立赚800+?大佬的python「抢茅台脚本」使用教程
2021/01/12 Python
如何给HTML标签中的文本设置修饰线
2019/11/18 HTML / CSS
天逸系统(武汉)有限公司Java笔试题
2015/12/29 面试题
TCP/IP中的TCP和IP分别承担什么责任
2012/04/21 面试题
四年大学生活的个人自我评价
2013/12/11 职场文书
保健品市场营销方案
2014/03/31 职场文书
大学教师师德师风演讲稿
2014/08/22 职场文书
党校学习心得体会范文
2014/09/09 职场文书
群众路线教育实践活动对照检查材料思想汇报(副处级领导)
2014/10/04 职场文书
2015年个人工作总结报告
2015/04/25 职场文书
老干部局2015年度工作总结
2015/10/22 职场文书
《分数乘法》教学反思
2016/02/24 职场文书
劳动合同变更协议书范本
2019/04/18 职场文书
只用40行Python代码就能写出pdf转word小工具
2021/05/31 Python
el-form每行显示两列底部按钮居中效果的实现
2022/08/05 HTML / CSS