个人博客始终都有一个特色,就是喜欢从0开始,努力让小白都能看的明白,即便看不明白,也能知道总体的前因后果,这篇博客依然秉承着这个风格。
以MVVM模式为主线去实现的JavaScript框架很是流行,诸如 angular、Ember、Polymer、vue 等等,它们的一个特色就是数据的双向绑定。这对于小白来讲就像变魔术同样,但不管对谁来说,当你看到一个令你感兴趣的魔术,那么揭秘它老是能吸引你的眼球。
这篇文章主要讲述MVVM实现中的一部分:如何监测数据的变化。html
注:本篇文章将生产出一个迷你库,代码托管在 https://github.com/HcySunYang/jsonob,因为本篇文章代码采用ES6编写,因此不能直接在浏览器下运行,读者在实践的时候能够采用该仓库的代码,clone仓库后:
一、安装依赖
npm install
二、构建项目
npm run build
三、使用浏览器打开 test/index.html 查看运行结果vue
那么接下来咱们要作什么呢?咱们会实现一个迷你库,这个库的做用是监测一个普通对象的变化,并做出相应的通知。库的使用方法大体以下:git
// 定义一个变化通知的回调 var callback = function(newVal, oldVal){ alert(newVal + '----' + oldVal); }; // 定义一个普通对象做为数据模型 var data = { a: 200, level1: { b: 'str', c: [1, 2, 3], level2: { d: 90 } } } // 实例化一个监测对象,去监测数据,并在数据发生改变的时候做出反应 var j = new Jsonob(data, callback);
上面代码中,咱们定义了一个 callback 回调函数,以及一个保存着普通json对象的变量 data ,最后实例化了一个 监测对象 ,对 data 进行变化监测,当变化发生的时候,执行给定的回调进行必要的变化通知,这样,咱们经过一些手段就能够达到数据绑定的效果。github
ES5 描述了属性的特征,提出对象的每一个属性都有特定的描述符,你也能够理解为那是属性的属性。。。。。npm
ES5把属性分红两种,一种是 数据属性, 一种是 访问器属性,咱们可使用 Object.defineProperty() 去定义一个数据属性或访问器属性。以下代码:json
var obj = {}; obj.name = 'hcy';
上面的代码咱们定义了一个对象,并给这个对象添加了一个属性 name,值为 ‘hcy’,咱们也可使用 Object.defineProperty() 来给对象定义属性,上面的代码等价于:数组
var obj = {}; Object.defineProperty(obj, 'name', { value: 'hcy', // 属性的值 writable: true, // 是否可写 enumerable: true, // 是否可以经过for in 枚举 configurable: true // 是否可以使用 delete删除 })
这样咱们就使用 Object.defineProperty 给对象定义了一个属性,这样的属性就是数据属性,咱们也能够定义访问器属性:浏览器
var obj = {}; Object.defineProperty(obj, 'age', { get: function(){ return 20; }, set: function(newVal){ this.age += 20; } })
访问器属性容许你定义一对儿 getter/setter ,当你读取属性值的时候底层会调用 get 方法,当你去设置属性值的时候,底层会调用 set 方法缓存
知道了这个就好办了,咱们再回到最初的问题上面,如何检测一个普通对象的变化,咱们能够这样作:数据结构
遍历对象的属性,把对象的属性都使用 Object.defineProperty 转为 getter/setter ,这样,当咱们修改一些值得时候,就会调用set方法,而后咱们在set方法里面,回调通知,不就能够了吗,来看下面的代码:
// index.js const OP = Object.prototype; export class Jsonob{ constructor(obj, callback){ if(OP.toString.call(obj) !== '[object Object]'){ console.error('This parameter must be an object:' + obj); } this.$callback = callback; this.observe(obj); } observe(obj){ Object.keys(obj).forEach(function(key, index, keyArray){ var val = obj[key]; Object.defineProperty(obj, key, { get: function(){ return val; }, set: (function(newVal){ this.$callback(newVal); }).bind(this) }); if(OP.toString.call(obj[key]) === '[object Object]'){ this.observe(obj[key]); } }, this); } }
上面代码采用ES6编写,index.js文件中导出了一个 Jsonob 类,constructor构造函数中,咱们保证了传入的对象是一个 {} 或 new Object() 生成的对象,接着缓存了回调函数,最后调用了原型下的 observe 方法。
observe方法是真正实现监测属性的方法,咱们使用 Object.keys(obj).forEach 循环obj全部可枚举的属性,使用 Object.defineProperty 将属性转换为访问器属性,而后判断属性的值是不是一个对象,若是是对象的话再进行递归调用,这样一来,咱们就能保证一个复杂的普通json对象中的属性以及值为对象的属性的属性都转换成访问器属性。
最后,在 Object.defineProperty 的 set 方法中,咱们调用了指定的回调,并将新值做为参数进行传递。
接下来咱们编写一个测试代码,去测试一下上面的代码是否能够正常使用,在index.html中(读者能够clone文章开始阶段给出的仓库),编写以下代码:
<html> <head> <meta charset="utf-8" /> </head> <body> <script src="../dist/jsonob.js"></script> <script> var Jsonob = Jsonob.Jsonob; var callback = function(newVal){ alert(newVal); }; var data = { a: 200, level1: { b: 'str', c: [1, 2, 3], level2: { d: 90 } } } var j = new Jsonob(data, callback); data.a = 250; data.level1.b = 'sss'; data.level1.level2.d = 'msn'; </script> </body> </html>
上面代码,很接近咱们文章开头要实现的目标。咱们定义了回调(callback)和数据模型(data),在回调中咱们使用 alert 函数弹出新值,而后建立了一个监测实例并把数据和回调做为参数传递过去,而后咱们试着修改data对象相面的属性以及子属性,看看代码是否按照咱们预期的工做,打开浏览器,以下图
能够看弹出三个对话框,这说明咱们的代码正常工做了,不管是data对象的属性,仍是子属性的改变,都可以监测到变化,并执行咱们指定的回调。
这样就结束了吗?可能细心的朋友可能已经意识到了,咱们在检测到变化并通知回调时,只传递了一个新值(newVal),但有的时候咱们也须要旧值,可是以如今的程序来看,咱们还没法传递旧值,因此咱们要想办法。你们仔细看上面 index.js 中forEach循环里面的代码,有这样一段:
var val = obj[key]; Object.defineProperty(obj, key, { get: function(){ return val; }, set: (function(newVal){ this.$callback(newVal); }).bind(this) });
实际上,val 变量所存储的,就是旧值,咱们不妨把上面的代码修改为下面这样:
var oldVal = obj[key]; Object.defineProperty(obj, key, { get: function(){ return oldVal; }, set: (function(newVal){ if(oldVal !== newVal){ if(OP.toString.call(newVal) === '[object Object]'){ this.observe(newVal); } this.$callback(newVal, oldVal); oldVal = newVal; } }).bind(this) });
咱们将原来的 val 变量名字修改为 oldVal ,并在set方法中进行了更改判断,仅在值有更改的状况下去作一些事,当值有修改的时候,咱们首先判断了新值是不是相似 {} 或 new Object() 形式的对象,若是是的话,咱们要调用 this.observe 方法去监听一下新设置的值,而后在把旧值传递给回调函数以后更新一下旧值。
接着修改 test/index.html 文件:
<html> <head> <meta charset="utf-8" /> </head> <body> <script src="../dist/jsonob.js"></script> <script> var Jsonob = Jsonob.Jsonob; var callback = function(newVal, oldVal){ alert('新值:' + newVal + '----' + '旧值:' + oldVal); }; var data = { a: 200, level1: { b: 'str', c: [1, 2, 3], level2: { d: 90 } } } var j = new Jsonob(data, callback); data.a = 250; data.a = 260; </script> </body> </html>
咱们在回调函数中接收了新值和旧值,在下面咱们修改了 data.a 的值为 250,而后运行代码,查看浏览器的反馈:
这样,咱们完成了最最基本的普通对象变化监测库,接着,咱们继续发现问题,咱们回过头来看一下数据模型:
var data = { a: 200, level1: { b: 'str', c: [1, 2, 3], level2: { d: 90 } } }
咱们能够发现, data.level1.c 的值为一个数组,数组在咱们工做中确定是一个很是常见的数据结构,当数组的元素发生改变的时候,也视为数据的改变,但遗憾的是,咱们如今库还不能监测数组的变化,好比:
data.level1.c.push(4);
咱们向数组中push了一个元素,可是并不会触发改变。操做数组的方法有不少,好比:’push’, ‘pop’, ‘shift’, ‘unshift’, ‘splice’, ‘sort’, ‘reverse’ 等等。那么咱们如何在使用这些方法操做数组的时候可以监听到变化呢?有这样一个思路,看图:
上图显示了,当你经过 var arr1 = [] 或者 var arr1 = new Array() 语句建立一个数组实例的时候,实例、实例的proto属性、Array构造函数以及Array原型四者之间的关系。咱们能够很容的发现,数组实例的proto属性,是Array.prototype的引用,当咱们使用 arr1.push() 语句操做数组的时候,是调用原型下的push方法,那么咱们可不能够重写原型的这些数组方法,在这些重写的方法里面去监听变化呢?答案是能够的,可是在实现以前,咱们先思考一个问题,咱们到底要怎么重写,好比咱们重写一个数组push方法,向数组栈中推入一个元素,难道咱们要这样去重写吗:
Array.prototype.push = function(){ // 你的实现方式 }
而后再一次实现其余的数组方法:
Array.prototype.pop = function(){ // 你的实现方式 } Array.prototype.shift = function(){ // 你的实现方式 } ...
这种实现是最不该该考虑的,暂且不说能不能所有实现的与原生无异,即便你实现的与原生方法在使用方式上如出一辙,而且不影响其余代码的运行,那么在性能上,可能就与原生差不少了,咱们能够在上面 数组实例以及数组构造函数和原型之间的关系图 中思考解决方案,咱们可不能够在原型链中加一层,以下:
如上图所示,咱们在 arr1.proto 与 Array.prototype 之间的链条中添加了一环 fakePrototype (假的原型),咱们的思路是,在使用 push 等数组方法的时候,调用的是 fakePrototype 上的push方法,而后在 fakePrototype 方法中简介再去调用真正的Array原型上的 push 方法,同时监听变化,这样,咱们很容易就能实现,完整代码以下:
/* * Object 原型 */ const OP = Object.prototype; /* * 须要重写的数组方法 OAR 是 overrideArrayMethod 的缩写 */ const OAM = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']; export class Jsonob{ constructor(obj, callback){ if(OP.toString.call(obj) !== '[object Object]'){ console.error('This parameter must be an object:' + obj); } this.$callback = callback; this.observe(obj); } observe(obj){ // 若是发现 监测的对象是数组的话就要调用 overrideArrayProto 方法 if(OP.toString.call(obj) === '[object Array]'){ this.overrideArrayProto(obj); } Object.keys(obj).forEach(function(key, index, keyArray){ var oldVal = obj[key]; Object.defineProperty(obj, key, { get: function(){ return oldVal; }, set: (function(newVal){ if(oldVal !== newVal){ if(OP.toString.call(newVal) === '[object Object]' || OP.toString.call(newVal) === '[object Array]'){ this.observe(newVal); } this.$callback(newVal, oldVal); oldVal = newVal; } }).bind(this) }); if(OP.toString.call(obj[key]) === '[object Object]' || OP.toString.call(obj[key]) === '[object Array]'){ this.observe(obj[key]); } }, this); } overrideArrayProto(array){ // 保存原始 Array 原型 var originalProto = Array.prototype, // 经过 Object.create 方法建立一个对象,该对象的原型就是Array.prototype overrideProto = Object.create(Array.prototype), self = this, result; // 遍历要重写的数组方法 Object.keys(OAM).forEach(function(key, index, array){ var method = OAM[index], oldArray = []; // 使用 Object.defineProperty 给 overrideProto 添加属性,属性的名称是对应的数组函数名,值是函数 Object.defineProperty(overrideProto, method, { value: function(){ oldArray = this.slice(0); var arg = [].slice.apply(arguments); // 调用原始 原型 的数组方法 result = originalProto[method].apply(this, arg); // 对新的数组进行监测 self.observe(this); // 执行回调 self.$callback(this, oldArray); return result; }, writable: true, enumerable: false, configurable: true }); }, this); // 最后 让该数组实例的 __proto__ 属性指向 假的原型 overrideProto array.__proto__ = overrideProto; } }
咱们新增长了 overrideArrayProto 方法,而且在程序的最上面定义了一个常量 OAM ,用来定义要重写的数组方法,同时在 observe 方法中添加了对数组的判断,咱们也容许了对数组的监听。接下来咱们详细介绍一下 overrideArrayProto 方法。
顾名思义,overrideArrayProto 这个方法是重写了 Array 的原型,在 overrideArrayProto 方法中,咱们首先保存了数组的原始原型,而后建立了一个假的原型,而后遍历须要从新的数组方法,并将这些方法挂载到 overrideProto 上,咱们能够看到,在挂载到 overrideProto 上的这些数组方法的里面,咱们调用了原始的数组原型上的数组方法,最后,咱们让数组实例的 proto 属性指向 overrideProto,这样,咱们就实现了上图中的思路。而且完成了想要达到的效果,接下来咱们可使用咱们已经重写了的数组方法去操做数组,查看能不能监测到变化:
var callback = function(newVal, oldVal){ alert('新值:' + newVal + '----' + '旧值:' + oldVal); }; var data = { a: 200, level1: { b: 'str', c: [{w: 90}, 2, 3], level2: { d: 90 } } } var j = new Jsonob(data, callback); data.level1.c.push(4);
在浏览器中能够看到,咱们的代码按照预期运行了:
直到如今,咱们能够几乎完美的监测到数据对象的变化了,而且可以知道变化先后的旧值与新值,那么这样就结束了吗?固然不是,咱们能够回顾一下当咱们修改数据对象的时候,咱们的确可以获取到新值和旧值,可是也仅此而已,咱们并不知道修改的是哪一个属性,可是可以知道修改的哪一个属性对于咱们是至关重要的。
好比MVVM中,当数据对象改变时,要去更新模板,而模板到数据之间的关系,是经过数据对象下的某个字段名称进行绑定的,举个简单的例子,好比咱们有以下模板:
<div id="box"> <div>{{name}}</div> <div>{{age}}</div> <div>{{sex}}</div> </div>
而后咱们有以下数据:
var data = { name : 'hcy', age : 20, sex : '男' }
最后咱们经过 viewModule 简历模板和数据的关系:
new Jsonob(document.getElementById('box'), data);
那么当咱们的数据模型data中的某个属性改变的时候,好比 data.name = ‘fuck’,如若咱们不知道改变的字段名称,那么咱们就没法得知要刷新哪部分模板,咱们只能对模板进行彻底更新,这并非一个好的设计,性能会不好,因此回到咱们最初的问题,当数据对象发生改变的时候,咱们得知变化的属性的名称是很必要的,可是如今咱们的 Jsonob 库还不能完成这样的任务,因此咱们要进一步完善。
在完善以前,咱们要提出一个路径的概念,所谓路径,就是变化的字段的路径,好比有以下数据模型:
var data = { a : { b : { c : 'hcy' } } }
那么字段 a 的路径就是用 data.a ,b 的路径就是 data.a.b,c 的路径就是 data.a.b.c。有的时候咱们也能够用数组或者字符串来表述路径,至于用什么来表述路径并不重要,重要的是咱们可以获取到路径,好比用数组表述路径能够这样:
一、 a 的路径是 [‘data’, ‘a’]
一、 b 的路径是 [‘data’, ‘a’, ‘b’]
一、 c 的路径是 [‘data’, ‘a’, ‘b’, ‘c’]
有了路径的概念后,咱们就能够继续完善 Jsonob 库了,咱们在存储路径的时候选择的是数组表示,用数组存储路径,咱们修改Jsonob库代码,修改了 observe 方法和 overrideArrayProto 方法,以下图,我作了全部修改的标注:
最后,让咱们再次尝试修改一切数组属性:
var callback = function(newVal, oldVal, path){ alert('新值:' + newVal + '----' + '旧值:' + oldVal + '----路径:' + path); }; var data = { a: 200, level1: { b: 'str', c: [{w: 90}, 2, 3], level2: { d: 90 } } } var j = new Jsonob(data, callback); data.level1.c.push(4); // 向数组 data.level1.c 中push一个元素 data.level1.c[0].w = 100; // 修改数组 data.level1.c[0].w 的值 data.level1.b = 'sss'; // 修改 data.level1.b 的值 data.level1.level2.d = 'msn'; // 修改 data.level1.level2.d 的值
咱们修改了四个属性的值,而后咱们在回调函数中接收了 path 参数,这样当数据模型变化的时候,咱们不只可以获取到新旧值,还可以知道是哪一个属性发生了变化,这样咱们就能够相应的作一些其余的事情,好比MVVM中的更新关联的视图,就能够作到了。最后咱们刷新浏览器来产看弹出框:
图中我用红色圈标出了变化属性的路径,因为咱们的路径是数组标示的,因此看上去是以逗号“,”隔开的,如今,咱们就算完成了这个迷你库,相信读者也有本身的实现思路,笔者水平有限,若是哪里有欠缺还但愿你们指正,共同进步。