JavaScript實現MVVM之我就是想監測一個普通對象的變化
我的博客始終都有一個特點,就是喜歡從0開始,努力讓小白都能看的明白,即使看不明白,也能知道整體的來龍去脈,這篇博客依然秉承著這個風格。
以MVVM模式為主線去實現的JavaScript框架非常流行,諸如 angular、Ember、Polymer、vue 等等,它們的一個特點就是數據的雙向綁定。這對于小白來說就像變魔術一樣,但無論對誰來講,當你看到一個令你感興趣的魔術,那么揭秘它總是能吸引你的眼球。
這篇文章主要講述MVVM實現中的一部分:如何監測數據的變化。
注:本篇文章將生產出一個迷你庫,代碼托管在 https://github.com/HcySunYang/jsonob,由于本篇文章代碼采用ES6編寫,所以不能直接在瀏覽器下運行,讀者在實踐的時候可以采用該倉庫的代碼,clone倉庫后 :
1、安裝依賴
npm install
2、構建項目
npm run build
3、使用瀏覽器打開 test/index.html 查看運行結果
那么接下來我們要做什么呢?我們會實現一個迷你庫,這個庫的作用是監測一個普通對象的變化,并作出相應的通知。庫的使用方法大致如下:
// 定義一個變化通知的回調
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 進行變化監測,當變化發生的時候,執行給定的回調進行必要的變化通知,這樣,我們通過一些手段就可以達到數據綁定的效果。
Object.defineProperty
ES5 描述了屬性的特征,提出對象的每個屬性都有特定的描述符,你也可以理解為那是屬性的屬性。。。。。ES5把屬性分成兩種,一種是 數據屬性, 一種是 訪問器屬性,我們可以使用 Object.defineProperty() 去定義一個數據屬性或訪問器屬性。如下代碼:
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方法里面,回調通知,不就可以了嗎,來看下面的代碼:
// 如果你使用chrome瀏覽器的話,則可以直接使用index.js,chrome在一定程度上支持ES6
// index.js
const OP = Object.prototype;
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中,編寫如下代碼:
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<script src="path/to/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="path/to/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中的更新關聯的視圖,就可以做到了。最后我們刷新瀏覽器來產看彈出框:
圖中我用紅色圈標出了變化屬性的路徑,由于我們的路徑是數組標示的,所以看上去是以逗號“,”隔開的,現在,我們就算完成了這個迷你庫,相信讀者也有自己的實現思路,筆者水平有限,如果哪里有欠缺還希望大家指正,共同進步。
來自: http://www.spotty.com.cn/archives/60/