1. 程式人生 > >一個基於ES6 的Mvvm Demo

一個基於ES6 的Mvvm Demo

很多次面試都被問到雙向繫結的原理,從一開始的啥都不知道到後來知道使用Object.defineProperty 劫持屬性,使用釋出訂閱進行訊息傳遞,再後來看了很多篇相關的文章和程式碼,依然應付不了面試官的追問。還是對其中的原理和實現瞭解的不透徹,所以最終決定自己親手寫一個。網上寫mvvm 的部落格有很多,都挺詳細的也都貼了大段的程式碼,想了解的可以直接走下面的傳送門,這篇文章的程式碼實現很大一部分都是參考了以下兩個連結:

為了防止內容同質化,這篇文章就講講跟這兩篇文章中不一樣的地方(具體表現就是沒有大量程式碼)。一方面是將ES5 實現轉換成了ES6;另一方面,更多的還是講我在實現這個mvvm demo 中遇到的一些問題和困惑,以及在動手開始寫程式碼之前應該如何從整體上來看待mvvm,就算是以上兩篇文章的補充吧。

1. 觀察者模式與釋出訂閱

(1)先看概念

觀察者模式與釋出訂閱是兩個很相似的概念,所以經常也會被一起提及,這兩個概念確實差別很小,主要差別就在於排程中心。釋出訂閱是有排程中心(鬆耦合)的,而觀察者模式是沒有排程中心的,詳細講解可以參考這篇文章:

(2)舉個例子

Mvvm 中使用的是釋出訂閱,所以我們就來詳細看一下這個模式是如何工作的。為了通俗易懂,先來講個例子吧。

小明放暑假了,天天在家裡做作業。有一天,家裡要來客人,客人說是上午過來,但不一定幾點來。吃過早飯,小明爸爸說:“我去上班了,如果客人來了,你給我打電話,我回來陪客人喝茶”。小明媽媽說:“我去打麻將了,如果客人來,你給我發微信,我去菜市場買菜回來做飯”。小明默默地把這一切記在了心上,等爸媽走後就在家裡看電視直到客人來了然後通知他們。

在這個例子中的幾個角色分別對應了釋出訂閱模式中的幾個概念。首先,對於小明父母來說,他們是兩個訂閱者,他們需要在發生某件事情的時候有人通知自己並執行相應的操作(喝茶和買菜)。小明是一個訂閱管理器,也就是我們上面說到的排程中心,他可以新增或移除訂閱者,並可以執行通知操作(當客人來了,他需要把這個訊息通知給他的父母,至於通知的手段,不管是打電話還是發微信都無所謂了)。客人則是釋出者,因為他的到來會促使小明通知父母,所以他是訊息的源頭。

(3)小明一家之於mvvm

從上面小明的故事裡可以看到,在訂閱釋出模式中需要有三種角色的參與:訂閱者、訂閱管理器、釋出者。三者分別對應了mvvm ES6 實現中的三個類:Watcher、Dep、Observer。

Watcher 類中必須具備的方法包含三個(不算建構函式):

① 更新操作(update),更新操作中執行需要執行回撥函式,對應以上故事裡的泡茶和買菜做飯。

② 將自己新增到訂閱管理器中(addDep),從上面的故事中我們可以看到,是小明的父母告訴小明當客人來的時候通知他們,相當於是父母將自己新增到了小明這個訂閱管理器中,因為只有訂閱者才知道自己想要訂閱的訊息以及回撥操作的內容。

③ 獲取當前的值(get),因為要訂閱訊息,所以訂閱者首先要知道當前的值是什麼,這樣它下一次拿到值才知道是否發生了變化。

Dep 類中必須具備的方法包含以下幾個:

① 新增訂閱者(addSub),很好理解,把媽媽爸爸分別新增到自己的通知佇列中去。

② 移除訂閱者(removeSub),上午爸爸突然打來電話說今天單位裡領導來視察工作,即使客人來了也不能回家陪客人喝茶,這個時候如果客人來了小明就不需要再通知爸爸了。

③ 通知訂閱者有訊息了(notify),客人來了,小明通知爸爸媽媽回家。

④ depend 方法,這個方法不知道該怎麼描述,也是花了一點時間才弄清楚這個方法存在的意義。與這個方法配合使用的一個屬性是Dep.target,程式碼如下:

depend() {
  // Dep.target 為Watcher 例項
  Dep.target.addDep(this);
}

那這個Dep.target 在這裡是什麼意思呢?爸爸和媽媽都告訴小明客人來了要通知他們,可是小明在記住這兩個訊息的時候怎麼記住誰是爸爸誰是媽媽呢?這樣說有點抽象,咱們稍微寫實一下。上面的例子中提到一點是給爸爸打電話,給媽媽發微信,於是這裡可以把Dep.target 用來作為區分打電話和發微信的標誌。當爸爸交代小明的時候,此時的target 是爸爸,而媽媽交代小明的時候,此時的target 是媽媽。於是通過target 小明就可以知道打電話和發微信的目標是誰。(這個場景只是為了來強行理解target,與實際程式碼功能可能稍有出入,具體作用還是建議去看程式碼)。

Observer 類是訊息源,在小明的故事裡代表著客人到來,在mvvm 中就是代表著資料發生了變化,怎麼樣才能知道資料發生了變化,此時就輪到Object.defineProperty 出場了。使用Object.defineProperty 去監聽資料物件的屬性,一旦資料發生了變化,就會通知訂閱管理器,讓訂閱管理器通知所有訂閱者執行他們的回撥操作。

2. 什麼時候訂閱

通過小明一家的例子我們大概知道了mvvm 工作的過程以及都需要哪些角色和方法。但是在實際開發過程中面臨的就是很具體的問題了,比如說什麼時候進行訂閱,訂閱的程式碼要寫在哪裡,牽涉到哪幾個類中的哪幾個方法?這一節就以文字框輸入內容繫結到js 變數上然後實時顯示在介面上的p 標籤中為例,來看一下兩個方向的繫結是如何通過程式碼中的環環相扣實現的。

(1)文字框輸入值到js 變數的對映(View –> Model)

上面提到的三個類是實現釋出訂閱的基礎類。在實現mvvm 的過程中,我們首先必須要有一個入口類,Mvvm。另外,還要有一個Compiler 類,在頁面初始化的瞬間遍歷頁面上的dom 元素,對使用不同標籤定義的元素解析並執行相應的操作。概念說起來很乾,來看場景。

如果我的html 程式碼如下:

<div id="mvvm">
  <input type="text" v-model="word">
  <p>{{word}}</p>
</div>

Js 程式碼如下:

const vm = new MVVM({
  el: '#mvvm',
  data: {
    word: 'hello world'
  }
})

那麼我希望頁面啟動的時候可以將我的輸入框的input 值單向繫結到data 中的word 屬性上,就是說word 的值會隨著我在輸入框中輸入的內容的變化而變化。怎麼做呢?很簡單,給input 新增事件監聽,檢測到值發生變化的時候就改變js 中data 物件的word 屬性的值,主要程式碼如下:

node.addEventListener('input', (e) => {
  let newVal = e.target.value;
  if (val === newVal) {
    return;
  }
  // exp 為變數名,此處為word
  this._setVMVal(vm, exp, newVal);
})

(2)js 變數到p 標籤內容的對映(Model -> View)

除此之外,我想讓word 的值可以及時反映到頁面的p 標籤中,即實現變數值到頁面展示內容的繫結。

這一點要講的內容就體現了這一小節的title:什麼時候訂閱?Compiler 類中做的主要是dom 相關的操作,所以它需要知道什麼時候資料發生了變化並修改dom 的值。上面講了,頁面初始化的時候Compiler 會去遍歷所有的dom 元素,所以這一節的重點就在於當他遍歷到以下內容的html 時做了什麼操作。

<p>{{word}}</p>

當遇到上面的html 的時候,他會例項化一個Watcher 類,傳遞的引數包括當前的全域性例項(vm)、變數名(word)和回撥(操作dom 改變內容)。Watcher 在例項化的過程中會用我們上面提到過的get 方法來獲取一下它訂閱的變數當前的值,這樣當下次收到訊息的時候他才知道值是否發生了變化。Get 方法中的程式碼如下:

get() {
  // 設定當前例項為Dep.target
  Dep.target = this;
  // expOrFn 是指令關聯的變數或者函式,這裡就是word
  let value = this.vm[this.expOrFn];
  Dep.target = null;
  return value;
}

從程式碼中可以看到,get 方法只做了三件事:首先將Dep 類的target 指向當前Watcher 的例項,然後獲取了一下相關變數的值,再將Dep.target 設為null。

做完這三件事就已經完成了訂閱操作,為啥?是因為我們之前提到過的屬性劫持啊!!來看看Observer 中的相關程式碼:

Object.defineProperty(data, key, {
  enumerable: true,
  configurable: false,
  get: () => {
    if (Dep.target) {
      // 這裡訂閱了啊
      dep.depend();
    }
    return val;
  },
  set: (newVal) => {
    if (val === newVal) {
      return;
    }
    val = newVal;
    // 變數的值發生了變化就釋出訊息
    dep.notify();
  }
});

3. 簡單總結一下

Ok,說到這裡了,需要來捋一捋在這個過程中釋出訂閱的流程。

(1)頁面初始化的時候會新建一個Compiler 類來遍歷頁面中的dom 元素並根據不同元素的標籤對它們進行操作。

(2)操作的內容可能包含例項化一個Watcher 類並訂閱相關變數的資訊。

(3)在Watcher 類例項化過程中我們使用get 方法獲取變數的值,已經劫持了變數的get 屬性的Observer 會通知Dep 的例項將當前的Watcher 例項新增到自己的列表中。

(4)當變數的值發生變化的時候,劫持了變數的set 屬性的Observer 會讓Dep 的例項通知它所有列表中的訂閱者進行更新操作。

嗯……如此說來,是先進行屬性劫持,然後例項化Compiler 的類的,那當然啦!

class MVVM {
  constructor(options) {
    this.$options = options;
    let data = this._data = this.$options.data;
    observe(data, this);
    this.$compile = new Compiler(options.el || document.body, this);
  }
}

4. github demo

Ref:已經寫在上面了~