實(shí)現(xiàn)一個(gè)簡單的 MVVM 框架

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ù)也同步更新迅细,如下圖:


Vue 的 MVVM 基本框架

要實(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)控 nameage 作為變量的數(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 classupdata 函數(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ū)別就是在于:

  1. 需要監(jiān)控視圖上(View)輸入的值,作為數(shù)據(jù)源(Model)更改的來源盾戴;
  2. 實(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è)輸入框作為nameage的數(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í)候綁定該事件蚕涤,并且綁定到上述的 observeset函數(shù)中筐赔,實(shí)現(xiàn)了在視圖上修改時(shí),反饋到視圖反應(yīng)中揖铜,于是實(shí)現(xiàn)了簡單的雙向綁定茴丰。

雙向綁定的實(shí)現(xiàn)源碼和演示地址:
雙向綁定的實(shí)現(xiàn)源碼
MVVM 雙向綁定的演示

參考閱讀

  1. MVVM 框架解析之雙向綁定掘金用戶牧云云天吓。
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末贿肩,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子龄寞,更是在濱河造成了極大的恐慌汰规,老刑警劉巖,帶你破解...
    沈念sama閱讀 211,290評論 6 491
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件物邑,死亡現(xiàn)場離奇詭異溜哮,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)色解,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,107評論 2 385
  • 文/潘曉璐 我一進(jìn)店門茂嗓,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人冒签,你說我怎么就攤上這事在抛。” “怎么了萧恕?”我有些...
    開封第一講書人閱讀 156,872評論 0 347
  • 文/不壞的土叔 我叫張陵刚梭,是天一觀的道長。 經(jīng)常有香客問我票唆,道長朴读,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 56,415評論 1 283
  • 正文 為了忘掉前任走趋,我火速辦了婚禮衅金,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘簿煌。我一直安慰自己氮唯,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 65,453評論 6 385
  • 文/花漫 我一把揭開白布姨伟。 她就那樣靜靜地躺著惩琉,像睡著了一般。 火紅的嫁衣襯著肌膚如雪夺荒。 梳的紋絲不亂的頭發(fā)上瞒渠,一...
    開封第一講書人閱讀 49,784評論 1 290
  • 那天良蒸,我揣著相機(jī)與錄音,去河邊找鬼伍玖。 笑死嫩痰,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的窍箍。 我是一名探鬼主播串纺,決...
    沈念sama閱讀 38,927評論 3 406
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼仔燕!你這毒婦竟也來了造垛?” 一聲冷哼從身側(cè)響起魔招,我...
    開封第一講書人閱讀 37,691評論 0 266
  • 序言:老撾萬榮一對情侶失蹤晰搀,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后办斑,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體外恕,經(jīng)...
    沈念sama閱讀 44,137評論 1 303
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,472評論 2 326
  • 正文 我和宋清朗相戀三年乡翅,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了鳞疲。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 38,622評論 1 340
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡蠕蚜,死狀恐怖尚洽,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情靶累,我是刑警寧澤腺毫,帶...
    沈念sama閱讀 34,289評論 4 329
  • 正文 年R本政府宣布,位于F島的核電站挣柬,受9級特大地震影響潮酒,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜邪蛔,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,887評論 3 312
  • 文/蒙蒙 一急黎、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧侧到,春花似錦勃教、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,741評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至戈咳,卻和暖如春心软,著一層夾襖步出監(jiān)牢的瞬間壕吹,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,977評論 1 265
  • 我被黑心中介騙來泰國打工删铃, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留耳贬,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 46,316評論 2 360
  • 正文 我出身青樓猎唁,卻偏偏與公主長得像咒劲,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個(gè)殘疾皇子诫隅,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 43,490評論 2 348

推薦閱讀更多精彩內(nèi)容

  • MVC MVC是一種設(shè)計(jì)模式逐纬,它將應(yīng)用劃分為3個(gè)部分:數(shù)據(jù)(模型)蛔屹、展示層(視圖)和用戶交互層。結(jié)合一下下圖豁生,更能...
    公子世無雙ss閱讀 11,469評論 1 12
  • vue理解淺談 一 理解vue的核心理念 使用vue會讓人感到身心愉悅,它同時(shí)具備angular和react的優(yōu)點(diǎn)...
    ambeer閱讀 24,113評論 2 18
  • 前言 使用vue也好有一段時(shí)間了兔毒,雖然對其雙向綁定原理也有了解個(gè)大概,但也沒好好探究下其原理實(shí)現(xiàn)甸箱,所以這次特意花了...
    指尖跳動閱讀 7,981評論 0 16
  • 等與不等育叁,我都等了,在與不在乎芍殖,我都已經(jīng)在乎了豪嗽,剩下的要么靠你,要么靠命運(yùn)吧豌骏!…
    阿豆豆豆閱讀 178評論 0 1
  • 這周來說說你的人生想要的到底是高度龟梦、深度、寬度還是溫度? 最近基本上把所有的精力都投入在工作中肯适,周末也很少休息变秦,即...
    Carol在路上閱讀 407評論 2 0