MVVM 框架的全稱是 Model-View-ViewModel,它是 MVC(Model-View-Controller)的變種脐湾。在 MVC 框架中犁珠,負(fù)責(zé)數(shù)據(jù)源的模型(Model)可以直接和視圖進(jìn)行交互袱蜡,而根據(jù)軟件工程的模塊解耦的原則之一丝蹭,需要將數(shù)據(jù)和視圖分離,開發(fā)者只需關(guān)心數(shù)據(jù)坪蚁,而將視圖 DOM 封裝奔穿,當(dāng)數(shù)據(jù)變化時(shí),可以及時(shí)地將表現(xiàn)層面對應(yīng)的數(shù)據(jù)也同步更新迅细,如下圖:
要實(shí)現(xiàn)上述框架巫橄,首先將數(shù)據(jù)要封裝起來淘邻。當(dāng) Model 中的數(shù)據(jù)更改的時(shí)候茵典,需要將該數(shù)據(jù)改變反應(yīng)出來,也就是——數(shù)據(jù)劫持宾舅。
第一步——實(shí)現(xiàn)數(shù)據(jù)劫持
實(shí)現(xiàn)數(shù)據(jù)劫持统阿,我們需要用到一個(gè) Object 對象的核心方法:Object.defineProperty
彩倚,在 MDN 的定義中,它是這樣的:
Object.defineProperty 會在對象上定義一個(gè)新屬性扶平,或者修改一個(gè)對象的已有屬性帆离,并將這個(gè)對象返回。
它可以實(shí)現(xiàn)在一個(gè)對象(也就是數(shù)據(jù) Model)上定義一個(gè)新屬性结澄,或修改已有屬性(數(shù)據(jù)修改)哥谷,并且將對象返回。這就實(shí)現(xiàn)了我們基本的對數(shù)據(jù)改變后返回通知的需求(通過相應(yīng)方法)麻献。
Object.defineProperty(obj, prop, descriptor)
三個(gè)參數(shù)中们妥,分別是:需要修改的對象,需要修改對象中的某個(gè)屬性勉吻,對該屬性的描述监婶。因此在實(shí)際應(yīng)用中,如果需要對某個(gè)對象的全部屬性進(jìn)行劫持齿桃,則需要用類似for-in
循環(huán)惑惶、Object.keys
等枚舉的方法。
// exp1
var dataObj = {
name: 'fejv',
age: 22,
skill:['javascript', 'html', 'CSS', 'ES6']
}
// 劫持函數(shù)
function observe(data) {
if(!data|| typeof data !== 'object') return ;
for(let key in data) {
let val = data[key];
Object.defineProperty(data, key,{
enumerable: true,
configurable: true,
get:function() {
console.log('get fun '+val);
return val;
},
set:function(newVal) {
console.log('new value: '+newVal);
val = newVal;
}
});
if(typeof val === 'object') {
observe(val);
}
}
}
observe(dataObj);
/*
// 綁定測試
>dataObj.name = 'jab';
>"new value: jab"
>"jab"
>dataObj.name
>"jab"
*/
上面代碼中短纵,設(shè)置了一個(gè)簡單的數(shù)據(jù)模型 dataObj
带污,它有3個(gè)屬性,每一個(gè)屬性的變動都需要被觀察(劫持)香到。因此我們在觀察函數(shù) observe
中使用了 for-in
循環(huán)刮刑,將所有在dataObj
中的屬性都進(jìn)行了觀察。在修改了 dataObj.name
后养渴,調(diào)用了set
函數(shù)雷绢,將對象dataObj
的值修改為了新的值,實(shí)現(xiàn)了對象中屬性值的綁定理卑。
實(shí)現(xiàn)一個(gè)數(shù)據(jù)劫持
第二步——實(shí)現(xiàn)發(fā)布訂閱模式
在實(shí)現(xiàn)了對數(shù)據(jù)源(Model)的數(shù)據(jù)劫持后翘紊,我們需要能夠?qū)⒆兓ㄖ揭晥D(View),因此運(yùn)用到了 javaScript 設(shè)計(jì)模式中的“發(fā)布——訂閱模式”藐唠。發(fā)布的角色——被訂閱的頻道帆疟,就是數(shù)據(jù)源(Model)中的數(shù)據(jù),它將數(shù)據(jù)的變化發(fā)布出去宇立;而訂閱者的角色就是(View)踪宠,它訂閱數(shù)據(jù)源的變化,并且根據(jù)變化的數(shù)據(jù)改變自己的視圖妈嘹。
首先柳琢,我們要實(shí)現(xiàn)一個(gè)被訂閱者,也就是一個(gè)頻道。它需要具有發(fā)布消息柬脸;可以增加/刪除訂閱者到列表他去,并且在發(fā)布更新的時(shí)候需要將更新的內(nèi)容發(fā)布出去。
// exp2
class Subject {
constructor(){
this.observers = []; // 訂閱者列表
}
addObserver(observer) {
this.observers.push(observer); // 增加訂閱者
}
removeObserver(observer) {
var index = this.observers.indexOf(observer);
if(index > -1) {
this.observers.splice(index, 1); // 刪除訂閱者
}
}
notify(msg){
this.observers.forEach( observer => {
observer.update(msg); // 發(fā)布更新
});
}
}
該頻道由一個(gè)基本的訂閱者數(shù)組組成倒堕,具有增加/刪除訂閱者的功能灾测,并且在發(fā)布新消息之后用 notify(msg)
函數(shù)發(fā)布到全部的訂閱者(觀察者)observer
中。我們還需要一個(gè)觀察者的原型:
// exp3
class Observer {
constructor(name) {
this.name = name;
}
update(msg) {
console.log(this.name+' update: '+ msg);
}
subscribe(sub) {
sub.addObserver(this);
}
}
在上面的觀察者(訂閱者)中垦巴,使用了 ES6 的寫法媳搪,觀察者主要的更新函數(shù)和訂閱函數(shù)寫了出來,當(dāng)使用Observer
構(gòu)造函數(shù)生成一個(gè)新的 Observer
對象之后骤宣,執(zhí)行該對象中的訂閱函數(shù) subscribe
才會將其增加到生成的頻道中蛾号,當(dāng)該頻道更新,會將更新發(fā)布到曾訂閱過他的函數(shù)中涯雅。
// exp4
>var subA = new Subject;
>var fejv = new Observer('fejv');
>fejv.subscribe(subA);
>subA.notify('subA initial version');
>"fejv update: subA initial version"
>subA.notify("version 2");
>"fejv update: version 2"
觀察者模式或者發(fā)布訂閱模式的兩種寫法
原型寫法
ES6寫法
第三步——實(shí)現(xiàn)數(shù)據(jù)的單向綁定
實(shí)現(xiàn)數(shù)據(jù)的觀察者模式(發(fā)布——訂閱模式)之后鲜结,我們需要結(jié)合數(shù)據(jù)劫持和發(fā)布訂閱模式,將數(shù)據(jù)劫持中活逆,劫持的數(shù)據(jù)變化發(fā)布到所有的對應(yīng)的訂閱者精刷,在 MVVM 中就是將變化的數(shù)據(jù)劫持反應(yīng)到 View 的網(wǎng)頁模板中。
借鑒 Vue 的模板語法:
<div id="app" >
<h1>{{name}} 's age is {{age}}</h1>
</div>
我們需要監(jiān)控 name
和 age
作為變量的數(shù)據(jù)源(Model)中的變化蔗候,并且在即使反應(yīng)到視圖 View 上怒允,因此我們要解析 html 模板,取得變量锈遥,將每個(gè)變量觀察起來纫事,當(dāng)它產(chǎn)生變化的時(shí)候,將原本數(shù)據(jù)源的數(shù)據(jù)一并修改并且反應(yīng)到視圖中所灸。
首先需要一個(gè)入口文件
// exp5
class MVVM {
constructor(opts) {
this.data = opts.data;
this.node = document.querySelector(opts.node);
this.observers = [];
observe(this.data);
this.compile(this.node);
}
compile(node) {
console.log('compile fun in class MVVM');
if(node.nodeType === 1) {
// 節(jié)點(diǎn)仍是 DOM 結(jié)構(gòu)丽惶,繼續(xù)解析子節(jié)點(diǎn)
node.childNodes.forEach(childNode => {
this.compile(childNode);
});
}else if(node.nodeType === 3) {
this.renderText(node); // 已解析到文字
}
}
// 匹配函數(shù),匹配模板語言中的 name 和 age 變量
renderText(node) {
console.log('render fun in class MVVM');
let reg = /{{(.+?)}}/g;
let match;
while(match = reg.exec(node.nodeValue)) {
let sample = match[0];
let key = match[1].trim();
// console.log(sample,key);
node.nodeValue = node.nodeValue.replace(sample, this.data[key]);
new Observer(this, key, function(newVal, oldVal) {
node.nodeValue = node.nodeValue.replace(oldVal, newVal);
});
}
}
}
/*
let demoMVVM = new MVVM({
node: '#app',
data:{
name: 'fejv',
age: 23
}
});
*/
該入口函數(shù)中爬立,先將數(shù)據(jù)源觀察起來(數(shù)據(jù)劫持)钾唬,以便在數(shù)據(jù)有更改的時(shí)候即使通知到各數(shù)據(jù)視圖,然后解析 HTML 中的節(jié)點(diǎn)侠驯,將解析后的節(jié)點(diǎn)換成相應(yīng)的值抡秆,也就是demo.data.name/age
中的值。
這是初始化的時(shí)候吟策,將數(shù)據(jù)中的值換到 HTML 的 DOM 模板上儒士,但是當(dāng)數(shù)據(jù)源的值改變時(shí)我們,需要及時(shí)地將更改的值換到 HTML 頁面中檩坚,就是:
// exp6
new Observer(this, key, function(newVal, oldVal) {
node.nodeValue = node.nodeValue.replace(oldVal, newVal);
});
這需要配合在 Observer class
的 updata
函數(shù)中:
// exp7
class Observer {
// ....
update() {
var oldVal = this.val;
var newVal = this.getVal();
if(oldVal !== newVal) {
this.val = newVal;
this.callback.bind(this.vm)(oldVal, newVal)
}
}
}
在新的 update
函數(shù)中着撩,將上一次的值設(shè)置為舊值诅福,最新的值需要調(diào)用 getVal
函數(shù)獲取,然后將新舊值一并傳入回調(diào)函數(shù)中睹酌,由回調(diào)函數(shù)執(zhí)行將新知更換权谁,getVal
函數(shù)就成了獲取新值的關(guān)鍵剩檀。
// exp8
class Observer {
//....
getVal() {
console.log('getVal fun in class Observer');
currentObs = this;
let val = this.vm.data[this.key];
// 在獲取vm.data 的值的時(shí)候憋沿,會調(diào)用observe函數(shù)中的 get 函數(shù)
currentObs = null;
return val;
}
}
由于數(shù)值的更改是在 sujects 對所有 observers 發(fā)出的,因此需要在調(diào)用 Observer
中的 get
函數(shù)時(shí)沪猴,將該觀察者(observer
)添加到sujects
的列表中辐啄。但在observer
函數(shù)中無法訪問Observer
對象,因此上面代碼中运嗜,將當(dāng)前的Observer
賦值給一個(gè)全局的currentObs
壶辜,并在調(diào)用observe
函數(shù)中的get
函數(shù)時(shí),將這個(gè)全局的担租,也就是當(dāng)前的Observe
添加到subject
頻道中砸民,當(dāng)下次有值更新的時(shí)候,才能notify
到相關(guān)的Observer
奋救。
// exp9
function observe(data) {
//...
Object.defineProperty(data, key, {
//...
get:function() {
if(currentObs) {
console.log('get fun in observe fun,current observer is not null');
currentObs.subscribeTo(subj);
}
return val;
}
});
}
配合相關(guān)的上述函數(shù)岭参,加以修改,就實(shí)現(xiàn)了簡單的單向綁定尝艘。
單向綁定的ES6寫法
第四步——雙向綁定的實(shí)現(xiàn)
雙向綁定演侯,就是在第三步單向綁定的基礎(chǔ)上,數(shù)據(jù)流從 Model => ViewModel => View背亥,增加到Model <=> ViewModel <=> View秒际,也就是視圖中的可以改變數(shù)據(jù),改變可以反饋到數(shù)據(jù)源中狡汉,再從數(shù)據(jù)源反饋到表現(xiàn)的視圖中娄徊。
跟單向綁定的區(qū)別就是在于:
- 需要監(jiān)控視圖上(View)輸入的值,作為數(shù)據(jù)源(Model)更改的來源盾戴;
- 實(shí)現(xiàn)初步的視圖上的對事件進(jìn)行綁定嵌莉,例如 Vue 中的
v-on: click
等語法。
因此需要在模板語言上有一個(gè)輸入框:
// exp10
<div id="app">
<input v-model="name" v-on:click="hello">
<input v-model="age" >
<h3>{{name}}'s age is: {{age}}</h3>
</div>
上述代碼中參考了 Vue 框架的 HTML 語言模板捻脖,用兩個(gè)輸入框作為name
和age
的數(shù)值的雙向綁定锐峭,而v-on:click
作為事件進(jìn)行綁定,在 JS 中需要修改新的解析函數(shù)可婶,判斷是作為模型或者是指令沿癞,并且綁定該輸入框。
// exp11
class MVVM {
//...
// 處理模板節(jié)點(diǎn)
compileNode(node) {
let attrsArr = Array.from(node.attributes);
attrsArr.forEach(attr => {
if(this.isModel(attr.name)) {
this.bindModel(node, attr); // 綁定數(shù)據(jù)
}else if(this.isHandle(attr.name)) {
this.bindHandle(node, attr); // 綁定指令
}
});
}
// 初始化輸入框的值
bindModel(node, attr) {
let key = attr.value;
node.value = this.vm.$data[key];
new Observer(this.vm, key, function(newVal){
node.value= newVal;
});
// 綁定輸入的值作為數(shù)據(jù)源
node.oninput = (e) => {
this.vm.$data[key] = e.target.value;
};
}
// 解析時(shí)間模板矛渴,綁定事件椎扬。
bindHandle(node, attr) {
let startIndex = attr.name.indexOf(':')+1;
let endIndex = attr.name.length;
let eventType = attr.name.substring(startIndex, attr.name.length);
let method = attr.value;
node.addEventListener(eventType, this.vm.methods[method]);
}
// 判斷數(shù)據(jù)模板
isModel(attrName) {
return (attrName === 'v-model');
}
// 判斷指令
isHandle(attrName) {
return (attrName.indexOf('v-on') > -1);
}
}
上述代碼分別對 HTML 中的兩個(gè)input
實(shí)現(xiàn)了數(shù)值和事件的綁定惫搏,并且第一步將初始化時(shí)候的值作為輸入框的初始值,在每個(gè)輸入框的值改變的時(shí)候綁定該事件蚕涤,并且綁定到上述的 observe
的set
函數(shù)中筐赔,實(shí)現(xiàn)了在視圖上修改時(shí),反饋到視圖反應(yīng)中揖铜,于是實(shí)現(xiàn)了簡單的雙向綁定茴丰。
雙向綁定的實(shí)現(xiàn)源碼和演示地址:
雙向綁定的實(shí)現(xiàn)源碼
MVVM 雙向綁定的演示