個人博客始終都有一個特色,就是喜歡從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中的更新關聯的視圖,就能夠作到了。最後咱們刷新瀏覽器來產看彈出框:
圖中我用紅色圈標出了變化屬性的路徑,因爲咱們的路徑是數組標示的,因此看上去是以逗號「,」隔開的,如今,咱們就算完成了這個迷你庫,相信讀者也有本身的實現思路,筆者水平有限,若是哪裏有欠缺還但願你們指正,共同進步。