# 前言
MVVM 是與 MVC 進化出來的,區(qū)別在與將view層的數(shù)據(jù)變動直接響應到viewModel層上而不是響應給model,其表現(xiàn)上最大的區(qū)別就在于雙向數(shù)據(jù)綁定功能
# 雙向數(shù)據(jù)綁定原理簡介
發(fā)布者-訂閱者模式(Backbone.js) 一般通過sub, pub的方式實現(xiàn)數(shù)據(jù)和視圖的綁定監(jiān)聽婿失,更新數(shù)據(jù)方式通常做法是 vm.set('property', value)
臟值檢查(Angular.js) 通過一定的事件觸發(fā)檢測數(shù)據(jù)是否有變更來決定是否需要更新視圖函卒。如用戶操作ng-click,XHR事件竿开,瀏覽器Location變更事件 ( [圖片上傳失敗...(image-aed435-1561973477623)]timeout , [圖片上傳失敗...(image-9ee74c-1561973477623)]
digest() 或 $apply()等
數(shù)據(jù)劫持(vue.js)
Vue 的雙向數(shù)據(jù)綁定的原理關心兩個要點
-
數(shù)據(jù)劫持: 通過
Object.definedProperty()
劫持并監(jiān)聽數(shù)據(jù)變動 -
觀察者模式: 通過
發(fā)布者-訂閱者
模式實現(xiàn)數(shù)據(jù)更新
# Object.definedProperty()
這是一個原生JavaScript標準庫中的一個方法焙畔,被稱作是對象屬性的精確添加和修改掸读。用于直接在一個對象上定義一個新屬性,或者修改一個對象的現(xiàn)有屬性宏多, 并返回這個對象儿惫。
Object.defineProperty(obj, prop, descriptor)
從圖上可以看得出來,這個“精確添加和修改”的方法最重要的是屬性操作符的配置伸但。需要提到的一點是肾请,屬性操作符分類:存取操作符
和 數(shù)據(jù)操作符
。上圖也有表示更胖,指定時兩中操作符只能選擇其一铛铁,不能混用,否者報錯却妨。
被操作的屬性如果不存在饵逐,則會創(chuàng)建這個屬性梢灭;如果已存在恰矩,則執(zhí)行更新。
添加屬性
常用的
var obj = {};
obj.a = 1;
這種添加屬性的辦法實質(zhì)上就是執(zhí)行的如下操作囱修。需要強調(diào)的是:直接賦值的操作符設置的都是true捞烟,而這些操作符他們本身的默認值都是false薄声,注意區(qū)分。
var obj = {};
// 在對象中添加一個屬性與數(shù)據(jù)描述符的示例
Object.defineProperty(o, "attr", {
value : 37,
writable : true,
enumerable : true,
configurable : true
});
【解讀】本例的意思是 給對象obj
添加屬性attr
题画,它的值為37默辨,并且設置屬性attr
的值可以被修改(writable),加入到可枚舉隊列中以便for-in / for-of
等操作可以遍歷到(enumerable)婴程,設置配置屬性可以修改廓奕,且該屬性可以被delete obj.attr
刪除(configurable)抱婉,所以有以下操作
obj.attr = 20;
console.log( obj.attr ); // 20
delete obj.attr;
console.log(obj.attr); // undefined
修改屬性(writable)
正常情況下屬性都需要修改功能档叔,需要設置操作符writable值為true桌粉。當設置為false時,并不會拋出錯誤衙四,但是值不會被修改铃肯。在嚴格模式下,會拋出錯誤
throw Error:xx is read-only
var o = {}; // Creates a new object
Object.defineProperty(o, 'a', {
value: 37,
writable: false
});
console.log(o.a); // logs 37
o.a = 25; // No error thrown
console.log(o.a); // logs 37\.
是否可被for...in和Object.keys()獲却浮(enumerable)
var o = {};
Object.defineProperty(o, "a", { value : 1, enumerable: true });
Object.defineProperty(o, "b", { value : 2, enumerable: false });
Object.defineProperty(o, "c", { value : 3 }); // enumerable 默認是false
o.d = 4; // 采用默認賦值的方式押逼,屬性操作符enumerable為true(configurable也是true)
for (var i of o) {
console.log(i); // 打印 'a' 和 'd'
}
Object.keys(o); // ["a", "d"]
o.propertyIsEnumerable('a'); // true
o.propertyIsEnumerable('b'); // false
設置屬性不可被刪除 (configurable)
configurable
特性表示對象的屬性是否可以被刪除,以及除writable
特性外的其他特性是否可以被修改惦界√舾瘢【這里并不是說設置configurable
之后就不能設置其他操作符,而是不能修改沾歪,如下enumerable
屬性】
var o = {};
Object.defineProperty(o, "a", {
get : function(){return 1;},
enumerable: true,
configurable: false
});
// 以下例子會拋出異常漂彤,因為屬性a的值不可被修改了
Object.defineProperty(o, "a", {value : 12});
console.log(o.a); // logs 1
delete o.a; // 不會拋出錯誤,但執(zhí)行不成功
console.log(o.a); // logs 1
當然灾搏,如果把configurable
設置為true
挫望,delete o.a
就能將a
屬性正確刪除
能實現(xiàn) 數(shù)據(jù)劫持
的存取操作符 get/set
get()
和set()
是Object.definedProperty()
中非常重要的兩個操作符,當屬性被訪問時狂窑,get
方法被執(zhí)行媳板,傳入的參數(shù)列表為空。當屬性被修改時泉哈,只有一個參數(shù)蛉幸,即新值。注意參數(shù)列表雖然沒體現(xiàn)但默認都帶有this
對象丛晦,并且需要注意this
有可能是本身屬性巨缘,也有可能是繼承屬性。另外采呐,訪問器屬性的會"覆蓋"同名的普通屬性若锁,因為訪問器屬性會被優(yōu)先訪問,與其同名的普通屬性則會被忽略斧吐。
| obj.attr = 10
這種直接賦值操作對應的 getter 和 setter 邏輯如下
var obj = { attr: 10 }
Object.definedProperty(o, 'attr', {
enumerable : true,
configurable : true,
get: function() {
console.log('get方法被調(diào)用了');
return this.attr
},
set: function(newValue) {
console.log('set方法被調(diào)用了又固,參數(shù)是: ' + newVal);
this.attr = newValue
}
})
obj.attr; // get方法被調(diào)用了
obj.attr = 3; // set方法被調(diào)用了,參數(shù)是: 3
| 數(shù)據(jù)劫持的實現(xiàn)
? 正因為get
和set
的存在煤率,我們可以在其中自行添加一些處理邏輯
// 屬性操作符配置
var pattern = {
enumerable : true,
configurable : true,
get: function () {
return 'I alway return this string,whatever you have assigned';
},
set: function (newVal) {
this.myname = 'this is my name string';
}
};
function TestDefineSetAndGet() {
Object.defineProperty(this, 'attr', pattern);
};
var instance = new TestDefineSetAndGet();
instance.attr = 'baby';
console.log(instance.attr); // I alway return this string,whatever you have assigned
console.log(instance.myname); // this is my name string
# 極簡的雙向數(shù)據(jù)綁定
此例實現(xiàn)的效果是:隨文本框輸入文字的變化仰冠,span
中會同步顯示相同的文字內(nèi)容;在js或控制臺顯式的修改 obj.hello
的值蝶糯,視圖會相應更新洋只。這樣就實現(xiàn)了 model => view
以及 view => model
的雙向綁定。
# Vue 實現(xiàn)雙向數(shù)據(jù)綁定
整理了一下,要實現(xiàn)mvvm的雙向綁定识虚,就必須要實現(xiàn)以下幾點:
1肢扯、實現(xiàn)一個數(shù)據(jù)監(jiān)聽器Observer,能夠?qū)?shù)據(jù)對象的所有屬性進行監(jiān)聽担锤,如有變動可拿到最新值并通知訂閱者
2蔚晨、實現(xiàn)一個指令解析器Compile,對每個元素節(jié)點的指令進行掃描和解析肛循,根據(jù)指令模板替換數(shù)據(jù)铭腕,以及綁定相應的更新函數(shù)
3、實現(xiàn)一個Watcher多糠,作為連接Observer和Compile的橋梁累舷,能夠訂閱并收到每個屬性變動的通知,執(zhí)行指令綁定的相應回調(diào)函數(shù)夹孔,從而更新視圖
4被盈、mvvm入口函數(shù),整合以上三者
|實現(xiàn)Observer - 監(jiān)聽每個數(shù)據(jù)的變化
現(xiàn)在我們知道可以利用Obeject.defineProperty()
來監(jiān)聽屬性變動析蝴, 那么將需要observe的數(shù)據(jù)對象進行遞歸遍歷害捕,包括子屬性對象的屬性,都加上 setter和getter闷畸。這樣的話尝盼,給這個對象的某個值賦值,就會觸發(fā)setter佑菩,那么就能監(jiān)聽到了數(shù)據(jù)變化盾沫。相關的代碼可以是這樣的
var data = {name: 'Crain'}
observe(data);
data.name = "Ocaka"; // 監(jiān)聽到數(shù)據(jù)變化 Crain => Ocaka (set方法中打印)
function observe() {
if (!data || typeof data !== 'object') return;
Object.keys(data).forEach(function(key) {
defineReactive(data, key, data[key]);
})
}
function defineReactive(data, key, val) {
observe(val); // 深度遞歸
Object.defineProperty(data, key, {
enumerable: true, // 可以被枚舉
configurable: false, // 不能再被defined
get: function() {
return val;
},
set: function(newVal) {
console.log('監(jiān)聽到變化 ', val, '=>', newVal)
val = newVal
}
})
}
| 將數(shù)據(jù)變化通知訂閱者
我們已經(jīng)可以監(jiān)聽每個數(shù)據(jù)的變化了,那么監(jiān)聽到變化之后就是怎么通知訂閱者了殿漠,所以接下來我們需要實現(xiàn)一個消息訂閱器赴精,很簡單,維護一個數(shù)組绞幌,用來收集訂閱者蕾哟,數(shù)據(jù)變動觸發(fā)notify
,再調(diào)用訂閱者的update
方法莲蜘,代碼改善之后是這樣:
// ..省略
function definedReactive(data, key, val) {
var dep = new Dep(); // 實例化一個訂閱器
observe(val); // 深度遞歸
Object.defineProperty(data, key, {
// ... 省略
set: function(newVal) {
if (val === newVal) return;
console.log('監(jiān)聽到變化 ', val, '=>', newVal);
val = newVal;
dep.notify(); // 通知所有訂閱者
}
});
}
// Dep訂閱器構(gòu)造函數(shù)
function Dep() {
this.subs = []; // 收集訂閱者
}
Dep.prototype = {
addSub: function(sub) {
this.subs.push(sub);
},
notify: function() {
this.subs.forEach(function(sub) {
sub.update();
})
}
}
那么問題來了谭确,誰是訂閱者?怎么往訂閱器添加訂閱者票渠?
沒錯逐哈,上面的思路整理中我們已經(jīng)明確訂閱者應該是Watcher
, 而且var dep = new Dep();
是在 defineReactive
方法內(nèi)部定義的,所以想通過dep
添加訂閱者问顷,就必須要在閉包內(nèi)操作昂秃,所以我們可以在 getter
里面動手腳:
// Observer.js
// ...省略
Object.defineProperty(data, key, {
get: function() {
// 由于需要在閉包內(nèi)添加watcher禀梳,所以通過Dep定義一個全局target屬性,暫存watcher, 添加完移除
Dep.target && dep.addDep(Dep.target);
return val;
}
// ... 省略
});
// Watcher.js
Watcher.prototype = {
get: function(key) {
Dep.target = this;
this.value = data[key]; // 這里會觸發(fā)屬性的getter肠骆,從而添加訂閱者
Dep.target = null;
}
}
這里已經(jīng)實現(xiàn)了一個Observer了算途,已經(jīng)具備了監(jiān)聽數(shù)據(jù)和數(shù)據(jù)變化通知訂閱者的功能。那么接下來就是實現(xiàn)Compile了
| 實現(xiàn) Compile
compile
主要做的事情是解析模板指令哗戈,將模板中的變量替換成數(shù)據(jù)郊艘,然后初始化渲染頁面視圖荷科,并將每個指令對應的節(jié)點綁定更新函數(shù)唯咬,添加監(jiān)聽數(shù)據(jù)的訂閱者,一旦數(shù)據(jù)有變動畏浆,收到通知胆胰,更新視圖,如圖所示:
因為遍歷解析的過程有多次操作dom節(jié)點刻获,為提高性能和效率蜀涨,會先將跟節(jié)點el轉(zhuǎn)換成文檔碎片fragment進行解析編譯操作,解析完成蝎毡,再將fragment添加回原來的真實dom節(jié)點中
function Compile(el) {
this.$el = this.isElementNode(el) ? el : document.querySelector(el);
if (this.$el) {
this.$fragment = this.node2Fragment(this.$el);
this.init();
this.$el.appendChild(this.$fragment);
}
}
Compile.prototype = {
init: function() { this.compileElement(this.$fragment); },
node2Fragment: function(el) {
var fragment = document.createDocumentFragment(), child;
// 將原生節(jié)點拷貝到fragment
while (child = el.firstChild) {
fragment.appendChild(child);
}
return fragment;
}
};
compileElement方法將遍歷所有節(jié)點及其子節(jié)點厚柳,進行掃描解析編譯,調(diào)用對應的指令渲染函數(shù)進行數(shù)據(jù)渲染沐兵,并調(diào)用對應的指令更新函數(shù)進行綁定别垮,詳看代碼及注釋說明:
Compile.prototype = {
// ... 省略
compileElement: function(el) {
var childNodes = el.childNodes, me = this;
[].slice.call(childNodes).forEach(function(node) {
var text = node.textContent;
var reg = /\{\{(.*)\}\}/; // 表達式文本
// 按元素節(jié)點方式編譯
if (me.isElementNode(node)) {
me.compile(node);
} else if (me.isTextNode(node) && reg.test(text)) {
me.compileText(node, RegExp.$1);
}
// 遍歷編譯子節(jié)點
if (node.childNodes && node.childNodes.length) {
me.compileElement(node);
}
});
},
compile: function(node) {
var nodeAttrs = node.attributes, me = this;
[].slice.call(nodeAttrs).forEach(function(attr) {
// 規(guī)定:指令以 v-xxx 命名
// 如 <span v-text="content"></span> 中指令為 v-text
var attrName = attr.name; // v-text
if (me.isDirective(attrName)) {
var exp = attr.value; // content
var dir = attrName.substring(2); // text
if (me.isEventDirective(dir)) {
// 事件指令, 如 v-on:click
compileUtil.eventHandler(node, me.$vm, exp, dir);
} else {
// 普通指令
compileUtil[dir] && compileUtil[dir](node, me.$vm, exp);
}
}
});
}
};
// 指令處理集合
var compileUtil = {
text: function(node, vm, exp) {
this.bind(node, vm, exp, 'text');
},
// ...省略
bind: function(node, vm, exp, dir) {
var updaterFn = updater[dir + 'Updater'];
// 第一次初始化視圖
updaterFn && updaterFn(node, vm[exp]);
// 實例化訂閱者,此操作會在對應的屬性消息訂閱器中添加了該訂閱者watcher
new Watcher(vm, exp, function(value, oldValue) {
// 一旦屬性值有變化扎谎,會收到通知執(zhí)行此更新函數(shù)碳想,更新視圖
updaterFn && updaterFn(node, value, oldValue);
});
}
};
// 更新函數(shù)
var updater = {
textUpdater: function(node, value) {
node.textContent = typeof value == 'undefined' ? '' : value;
}
// ...省略
};
這里通過遞歸遍歷保證了每個節(jié)點及子節(jié)點都會解析編譯到,包括了{{}}
表達式聲明的文本節(jié)點毁靶。指令的聲明規(guī)定是通過特定前綴的節(jié)點屬性來標記胧奔,如<span v-text="content" other-attr
中v-text
便是指令,而other-attr
不是指令预吆,只是普通的屬性龙填。
監(jiān)聽數(shù)據(jù)、綁定更新函數(shù)的處理是在compileUtil.bind()
這個方法中拐叉,通過new Watcher()
添加回調(diào)來接收數(shù)據(jù)變化的通知
至此岩遗,一個簡單的Compile就完成了。接下來要看看Watcher這個訂閱者的具體實現(xiàn)了
| 實現(xiàn)Watcher
Watcher訂閱者作為Observer和Compile之間通信的橋梁巷嚣,主要做的事情是:
1喘先、在自身實例化時往屬性訂閱器(dep
)里面添加自己
2、自身必須有一個update()
方法
3廷粒、待屬性變動dep.notice()
通知時窘拯,能調(diào)用自身的update()
方法红且,并觸發(fā)Compile
中綁定的回調(diào),則功成身退涤姊。
如果有點亂暇番,可以回顧下前面的思路整理
function Watcher(vm, exp, cb) {
this.cb = cb;
this.vm = vm;
this.exp = exp;
// 此處為了觸發(fā)屬性的getter,從而在dep添加自己思喊,結(jié)合Observer更易理解
this.value = this.get();
}
Watcher.prototype = {
update: function() {
this.run(); // 屬性值變化收到通知
},
run: function() {
var value = this.get(); // 取到最新值
var oldVal = this.value;
if (value !== oldVal) {
this.value = value;
this.cb.call(this.vm, value, oldVal); // 執(zhí)行Compile中綁定的回調(diào)壁酬,更新視圖
}
},
get: function() {
Dep.target = this; // 將當前訂閱者指向自己
var value = this.vm[exp]; // 觸發(fā)getter,添加自己到屬性訂閱器中
Dep.target = null; // 添加完畢恨课,重置
return value;
}
};
// 這里再次列出Observer和Dep舆乔,方便理解
Object.defineProperty(data, key, {
get: function() {
// 由于需要在閉包內(nèi)添加watcher,所以可以在Dep定義一個全局target屬性剂公,暫存watcher, 添加完移除
Dep.target && dep.addDep(Dep.target);
return val;
}
// ... 省略
});
Dep.prototype = {
notify: function() {
this.subs.forEach(function(sub) {
sub.update(); // 調(diào)用訂閱者的update方法希俩,通知變化
});
}
};
實例化Watcher
的時候,調(diào)用get()
方法纲辽,通過Dep.target = watcherInstance
標記訂閱者是當前watcher實例颜武,強行觸發(fā)屬性定義的getter
方法,getter
方法執(zhí)行的時候拖吼,就會在屬性的訂閱器dep
添加當前watcher實例鳞上,從而在屬性值有變化的時候,watcherInstance就能收到更新通知吊档。
ok, Watcher也已經(jīng)實現(xiàn)了篙议。
最后來講講MVVM入口文件的相關邏輯和實現(xiàn)吧,相對就比較簡單了~
| 實現(xiàn) MVVM
MVVM作為數(shù)據(jù)綁定的入口籍铁,整合Observer涡上、Compile和Watcher三者,通過Observer來監(jiān)聽自己的model數(shù)據(jù)變化拒名,通過Compile來解析編譯模板指令吩愧,最終利用Watcher搭起Observer和Compile之間的通信橋梁,達到數(shù)據(jù)變化 -> 視圖更新增显;視圖交互變化(input) -> 數(shù)據(jù)model變更的雙向綁定效果雁佳。
一個簡單的MVVM構(gòu)造器是這樣子:
function MVVM(options) {
this.$options = options;
var data = this._data = this.$options.data;
observe(data, this);
this.$compile = new Compile(options.el || document.body, this)
}
但是這里有個問題,從代碼中可看出監(jiān)聽的數(shù)據(jù)對象是options.data
同云,每次需要更新視圖糖权,則必須通過var vm = new MVVM({data:{name: 'kindeng'}}); vm._data.name = 'dmq';
這樣的方式來改變數(shù)據(jù)。
顯然不符合我們一開始的期望炸站,我們所期望的調(diào)用方式應該是這樣的:
var vm = new MVVM({data: {name: 'kindeng'}}); vm.name = 'dmq';
所以這里需要給MVVM實例添加一個屬性代理的方法星澳,使訪問vm的屬性代理為訪問vm._data的屬性,改造后的代碼如下:
function MVVM(options) {
this.$options = options;
var data = this._data = this.$options.data, me = this;
// 屬性代理旱易,實現(xiàn) vm.xxx -> vm._data.xxx
Object.keys(data).forEach(function(key) {
me._proxy(key);
});
observe(data, this);
this.$compile = new Compile(options.el || document.body, this)
}
MVVM.prototype = {
_proxy: function(key) {
var me = this;
Object.defineProperty(me, key, {
configurable: false,
enumerable: true,
get: function proxyGetter() {
return me._data[key];
},
set: function proxySetter(newVal) {
me._data[key] = newVal;
}
});
}
};
這里主要還是利用了Object.defineProperty()
這個方法來劫持了vm實例對象的屬性的讀寫權(quán)禁偎,使讀寫vm實例的屬性轉(zhuǎn)成讀寫了vm._data
的屬性值腿堤,達到魚目混珠的效果