vue实现简单的MVVM框架
不知不觉接触前端的时间已经过去半年了,越来越发觉对知识的学习不应该只停留在会用的层面,这在我学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
功能演示如下所示:
数据代理
以下面这个模板为例,要替换的根元素“#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); }