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 DOM学习第四章 getElementByTagNames
Feb 19 Javascript
Jsonp 跨域的原理以及Jquery的解决方案
May 18 Javascript
javascript Event对象详解及使用示例
Nov 22 Javascript
基于jQuery倾斜打开侧边栏菜单特效代码
Sep 15 Javascript
JS添加删除DIV的简单实例
Jul 08 Javascript
微信小程序 radio单选框组件详解及实例代码
Jan 10 Javascript
Vue + Webpack + Vue-loader学习教程之相关配置篇
Mar 14 Javascript
jQuery列表检索功能实现代码
Jul 17 jQuery
JavaScript中发出HTTP请求最常用的方法
Jul 12 Javascript
webpack4 CSS Tree Shaking的使用
Sep 03 Javascript
在微信小程序中使用图表的方法示例
Apr 25 Javascript
浅谈Ant Design Pro 菜单自定义 icon
Nov 17 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 数组遍历顺序理解
2009/09/09 PHP
android上传图片到PHP的过程详解
2015/08/03 PHP
学习php设计模式 php实现策略模式(strategy)
2015/12/07 PHP
PHP的Laravel框架中使用AdminLTE模板来编写网站后台界面
2016/03/21 PHP
Yii2.0 模态弹出框+ajax提交表单
2016/05/22 PHP
PHP面相对象中的重载与重写
2017/02/13 PHP
利用PHP实现一个简单的用户登记表示例
2017/04/25 PHP
OAuth认证协议中的HMACSHA1加密算法(实例)
2017/10/25 PHP
出现“不能执行已释放的Script代码”错误的原因及解决办法
2007/08/29 Javascript
Jquery 获得服务器控件值的方法小结
2010/05/11 Javascript
js中widow.open()方法使用详解
2013/07/30 Javascript
JS中处理时间之setUTCMinutes()方法的使用
2015/06/12 Javascript
JavaScript几种数组去掉重复值的方法推荐
2016/04/12 Javascript
JS函数的定义与调用方法推荐
2016/05/12 Javascript
Angular实现响应式表单
2017/08/04 Javascript
带你快速理解javascript中的事件模型
2017/08/14 Javascript
vue动态路由配置及路由传参的方式
2018/05/23 Javascript
使用Vue中 v-for循环列表控制按钮隐藏显示功能
2019/04/23 Javascript
使用preload预加载页面资源时注意事项
2020/02/03 Javascript
手把手带你入门微信小程序新框架Kbone的使用
2020/02/25 Javascript
[05:36]DOTA2 2015国际邀请赛中国区预选赛第四日TOP10
2015/05/29 DOTA
python在windows下实现备份程序实例
2014/07/04 Python
Python AES加密模块用法分析
2017/05/22 Python
Windows 7下Python Web环境搭建图文教程
2018/03/20 Python
python使用socket实现的传输demo示例【基于TCP协议】
2019/09/24 Python
Python列表原理与用法详解【创建、元素增加、删除、访问、计数、切片、遍历等】
2019/10/30 Python
利用python控制Autocad:pyautocad方式
2020/06/01 Python
matplotlib相关系统目录获取方式小结
2021/02/03 Python
印度最大的网上花店:Ferns N Petals(鲜花、礼品和蛋糕)
2017/10/16 全球购物
Urban Outfitters德国官网:美国跨国生活方式零售公司
2018/05/21 全球购物
尼克松手表官网:Nixon手表
2019/03/17 全球购物
介绍一下木马病毒的种类
2015/07/26 面试题
轻金属冶金专业毕业生自荐信
2013/11/02 职场文书
机修工岗位职责
2013/11/24 职场文书
幼儿园评语大全
2014/04/17 职场文书
党的群众路线教育实践方案
2014/05/11 职场文书