淺談前端MVVM及其簡單實(shí)現(xiàn)

1 什么是MVVM

? MVVMModel-View-ViewModel (模型-視圖-視圖模型)的縮寫,其本質(zhì) MVCModel-View-Controller)的改進(jìn)版,將其中 View 前端視圖層的狀態(tài)和行為抽象化,以便將視圖 UI 和業(yè)務(wù)邏輯分離。[1]

? MVVMMModel,模型)指的是前端靜態(tài)數(shù)據(jù)及后端傳遞數(shù)據(jù),VView,視圖)指的是前端顯示頁面,VMViewModel,視圖模型)是 MVVM 模式的核心,它是連接 ViewModel 的橋梁。

? 在 MVVM 模式下,ViewModel 是不能直接通信的,它們通過 ViewModel 來通信。ViewModel 有兩個方向:

? 一是通過數(shù)據(jù)綁定,當(dāng) Model 數(shù)據(jù)發(fā)生變化時,ViewModelobserver 觀察者監(jiān)聽到數(shù)據(jù)變化,然后通知對應(yīng) View 視圖自動更新;

? 二是通過 DOM 事件監(jiān)聽,當(dāng)用戶操作視圖時,ViewModelobserver 觀察者監(jiān)聽到視圖變化,然后通知對應(yīng)的Model 數(shù)據(jù)改動。[2]

1.1.jpg

? 通過 數(shù)據(jù)綁定和 DOM 事件監(jiān)聽,MVVM 模式實(shí)現(xiàn)了 ViewView 的互相通信,即數(shù)據(jù)的雙向綁定。

2 為什么會產(chǎn)生MVVM

? 1989 件,歐洲核子研究中心的物理學(xué)家 Tim Berners-Lee 發(fā)明了超文本標(biāo)記語言(HyperText Markup Language),簡稱HTML,并在 1993 年成為互聯(lián)網(wǎng)草案。

? 最早的 HTML 頁面是完全靜態(tài)的網(wǎng)頁,它們是預(yù)先編寫好的存放在 Web 服務(wù)器上的 html 文件。瀏覽器請求某個 url 時,Web 服務(wù)器把對應(yīng)的 html 文件 傳遞給瀏覽器,顯示 html 文件內(nèi)容。

? 如果需要針對不同的用戶顯示不同的頁面,不可能給成千上萬的用戶準(zhǔn)備成千上萬的 html 文件。所以,服務(wù)器需要針對不同的用戶,動態(tài)生成不同的 html 文件。而在 html 文件中,大多數(shù)字符串都是不變的 HTML 片段,變化的只有少數(shù)和用戶相關(guān)的數(shù)據(jù),所以出現(xiàn)了創(chuàng)建動態(tài) HTML 的方式:ASPJSPPHP。

? 在 PHP 中,一個 PHP 文件就是一個 HTML 頁面,需要替換的變量用特殊的 <?php ?> 標(biāo)記出來,再配合循環(huán)、條件判斷等,動態(tài)創(chuàng)建出HTML

? 但是,瀏覽器顯示了一個 HTML 頁面,一旦需要更新內(nèi)容,唯一的方法就是重新向服務(wù)器獲取一份新的 HTML 內(nèi)容。直到 1995 年底,JavaScript 被引入到瀏覽器后,瀏覽器可以通過 JavaScript 對頁面進(jìn)行一些修改。JavaScript 還可以通過修改 HTMLDOM 結(jié)構(gòu)和 CSS 來實(shí)現(xiàn)一些動畫效果,這些功能無法通過服務(wù)器完成,必須在瀏覽器實(shí)現(xiàn)。

? JavaScript 可以使用瀏覽器提供的原生 API,直接操作 DOM 節(jié)點(diǎn)。但是原生 API 并不好用,且有瀏覽器兼容性問題。JavaScriptJQuery 出現(xiàn)后,已其簡潔的 API 迅速推廣。

? 現(xiàn)在,由于前端開發(fā)混合了 HTML、CSSJavaScript,且前端頁面越來越復(fù)雜,用戶對于交互性的要求也原來越高,導(dǎo)致代碼的組織和維護(hù)難度更加復(fù)雜,MVVM 應(yīng)運(yùn)而生。

? MVVM 借鑒了 MVC 分層開發(fā)的思想,在前端頁面中,把 Model 用 純 JavaScript 對象表示,View 負(fù)責(zé)顯示,做到最大限度的分離。兩者通過 ViewModel 相關(guān)聯(lián),ViewModel 負(fù)責(zé)把 Model 的數(shù)據(jù)同步到 View 顯示,還負(fù)責(zé)把 View 的修改同步回 Model。

? 使用 JQueryMVVM 操作 DOM 節(jié)點(diǎn)的對比:

<!-- HTML -->
<p>Hello, <span id="name">Zhangsan</span></p>
<p>You are <span id="age">12</span></p>

? 使用 JQuery 修改 nameage 節(jié)點(diǎn)的內(nèi)容:

// JQuery
let name = 'Lisi'
let age = 13

$('#name').text(name)
$('#age').text(age)

? 使用 MVVM 修改 nameage 節(jié)點(diǎn)的內(nèi)容:

// Model 中的 person 與 View 中的 DOM 節(jié)點(diǎn)相關(guān)聯(lián)
let person = {
      name: 'zhangsan',
      age: 12
}

// MVVM
person.name = 'lisi'
person.age = 13

? 由此可見,MVVM 并不關(guān)心頁面的 DOM 結(jié)構(gòu),而是關(guān)心數(shù)據(jù)如何存儲。修改頁面內(nèi)容是并不操作 DOM,而是直接修改數(shù)據(jù)內(nèi)容。這讓我們的關(guān)注點(diǎn)從如何操作 DOM 變?yōu)榱?如何更新數(shù)據(jù)的狀態(tài),而操作數(shù)據(jù)狀態(tài)比操作 DOM 簡單的多。MVVM 模式的使用將開發(fā)者從繁瑣的 DOM 操作中解脫出來。[3]

3 MVVM優(yōu)缺點(diǎn)

3.1 MVVM的優(yōu)點(diǎn)

  • 自動更新 DOM

    利用雙向綁定,數(shù)據(jù)更新后視圖自動更新,將開發(fā)者從繁瑣的手動 DOM 中解放。

  • 降低代碼耦合

    分離 ViewModel,降低代碼耦合。當(dāng) View 變化的時候,Model 可以不變;當(dāng) Model 變化的時候, View 也可以不變。

  • 提高可重用性

    一個 ViewModel 可以綁定到不同的 View 上,讓很多 View 重用這段 ViewModel。

  • 提高可測試姓

    ViewModel 的存在可以幫助開發(fā)者更好的編寫測試代碼。

3.2 MVVM的缺點(diǎn)

  • Bug難被調(diào)試

    由于采用雙向綁定模式,當(dāng)界面異常時,有可能是 View 的代碼有問題,也有可能是 Model 代碼有問題。數(shù)據(jù)綁定使得一個位置的 Bug 快速被傳遞到了另一個位置,定位原位置變得困難。另外,由于數(shù)據(jù)綁定的聲明是指令式的寫在 View 模板中,這些內(nèi)容無法采用 debug 斷點(diǎn)調(diào)試。

  • 占用內(nèi)存多

    一個大的模塊中的 model 也會很大,雖然使用方便也很容易保證了數(shù)據(jù)的一致性,但是長期持有,不釋放內(nèi)存造成耗費(fèi)很多內(nèi)存。

  • 維護(hù)成本提高

    對于大型的圖形應(yīng)用程序,視圖狀態(tài)較多,ViewModel 的構(gòu)建和維護(hù)成本提高。[4]

4 MVVM簡單實(shí)現(xiàn)

? 本部分MVVM框架主要分為三部分,Compile 模板編譯、 Observer 數(shù)據(jù)劫持與發(fā)布訂閱連接視圖與數(shù)據(jù)。

4.jpg

? 本部分為代碼按步實(shí)現(xiàn)過程,完整代碼見 5 完整代碼。

4.1 創(chuàng)建并使用 MVVM 對象

? 首先,創(chuàng)建一個 MVVM 對象,并在模板中引入。MVVM 是連接 Compiler 模板編譯與 Observer 數(shù)據(jù)劫持的橋梁。

4.1.1 創(chuàng)建 MVVM 對象

? 創(chuàng)建 MVVM 對象并將屬性綁定在實(shí)例上。

// MVVM.js

class MVVM {
  constructor (options) {
    // 一般情況下,在寫庫或者框架時,都需要將屬性掛載到實(shí)例上,保證其原型或方法能夠取到該屬性
    this.$el = options.el
    this.$data = options.data
  }
}
4.1.2 在模板中使用 MVVM 對象

? 在模板中引入 MVVM 對象并實(shí)例化

<!-- index.html -->

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
</head>
<body>
  <div id="app">
    <input type="text" v-model="person.name">
    <div>{{person.name}}</div>
    {{person.name}}{{person.age}}
  </div>
    
  <script src="./MVVM.JS"></script>
  <script>
    // 實(shí)例化 MVVM 對象
    let vm = new MVVM({
      el: '#app',
      data: {
        person: {
          name: 'zhangsan',
          age: 12
        }
      }
    })
  </script>
</body>
</html>

4.2 Compiler 模板編譯

? 完成 4.1 創(chuàng)建并引入 MVVM 對象 后,頁面顯示的是模板字符串,需采用 Compiler 模板編譯,將模板字符串內(nèi)容替換為實(shí)例數(shù)據(jù)。

4.2.1 創(chuàng)建并使用 Compiler 對象

? 創(chuàng)建 Compiler 對象 并在 MVVM 對象中使用,由于需要對文檔 DOM 中模板內(nèi)容使用實(shí)例中的數(shù)據(jù)進(jìn)行替換,故在 Compiler 中引入文檔節(jié)點(diǎn) el 與實(shí)例 vmthis)。

4.2.1.1 創(chuàng)建 Compiler 對象

? 用戶在傳入 el 時,可能會傳入 '#app' 或 document.getElementById('app') 形式,對此,需進(jìn)行是否是節(jié)點(diǎn)判斷。

? 為實(shí)現(xiàn)解耦,將 Compiler 對象的方法整體氛圍三部分:核心方法、輔助方法、編譯工具。核心方法主要用來真實(shí)替換模板與數(shù)據(jù),輔助方法用來進(jìn)行是判斷否是元素、是否是文本及提取指令等操作。

// Compiler.js

class Compiler {
  constructor (el, vm) { // el 為模板,vm 為 this 實(shí)例
    // el 的值可能是字符串 '#app',也有可能是元素 document.getElementById('#app')
    // 判斷 el屬性 是否是元素,如果不是元素,則獲取它
    // 為了擴(kuò)展時所有類的屬性都能在原型上取到,將所有值都綁定到實(shí)例上
    this.el = this.isElementNode(el) ? el : document.querySelector(el)
    this.vm = vm
  }
}

/**
 * 輔助方法,如判斷是否是元素,判斷是否是文本,判斷指令
 */
/**
 * 判斷是否是元素節(jié)點(diǎn)
 * @param {*} node 節(jié)點(diǎn)
 */
isElementNode (node) {
  return node.nodeType === 1
}
4.2.1.2 在 MVVM 對象 中使用 Compiler 對象

? 引入 ompiler 對象后的 MVVM 對象如下,此時需注意,只有在用戶傳入 DOM 節(jié)點(diǎn)即 this.$eltrue 時才進(jìn)行編譯。

// MVVM.js

class MVVM {
  constructor (options) {
    this.$el = options.el
    this.$data = options.data

    // 如果有需要編譯的模板,則開始編譯
    if (this.$el) {
      // 用數(shù)據(jù)和元素進(jìn)行編譯
      new Compiler(this.$el, this) // 后期 this 上可能會有很多屬性,this.$el 模板中也需要很多屬性而不僅僅是 this.$data,所以此處使用范圍更廣的 this
    }
  }
}
4.2.2 編譯執(zhí)行

? 在匹配 data 時,如果每匹配到一個數(shù)據(jù)就渲染一次,會造成頁面不停的回流與重繪,可先將模板放入內(nèi)存中,在內(nèi)存中完全替換完畢后,再放回頁面,性能比每匹配到一個就替換性能更佳。此部分主要分為三步:

? 1. 將真實(shí) DOM 節(jié)點(diǎn)放入內(nèi)存;2. 在內(nèi)存內(nèi)對模板內(nèi)容進(jìn)行替換;3. 替換好的節(jié)點(diǎn)重新渲染回頁面

// Compiler.js

class Compiler {
  constructor (el, vm) {
    this.el = this.isElementNode(el) ? el : document.querySelector(el)
    this.vm = vm

    // 在匹配 data 時,如果每匹配到一個數(shù)據(jù)就渲染一次,會造成頁面不停的回流與重繪,可先將模板放入內(nèi)存中,在內(nèi)存中完全替換完畢后,再放回頁面,性能比每匹配到一個就替換性能更佳。
    // 把當(dāng)前節(jié)點(diǎn)中的元素獲取到,放到內(nèi)存中
    if (this.el) { // 如果能獲取到這個元素,才開始編譯
      // 1. 通過文檔碎片 fragemnt 先把真實(shí) DOM 移入到內(nèi)存中,在內(nèi)存中操作 DOM 比在真實(shí) DOM 中操作快
      let fragement = this.node2fragement(this.el)
      // 2. 提取 fragement 中的元素節(jié)點(diǎn) v-model 和文本節(jié)點(diǎn) {{}} 進(jìn)行編譯,對節(jié)點(diǎn)中的內(nèi)容進(jìn)行替換
      this.compile(fragement)
      // 3. 把編譯好的 fragement 放回頁面中
      this.el.appendChild(fragement)
    }
  }
}
4.2.2.1 將真實(shí) DOM 節(jié)點(diǎn)放入內(nèi)存

? 此處注意 appendChild 的移動性,其在將頁面 DOM 節(jié)點(diǎn)移入內(nèi)存中的同時,會將頁面中原有節(jié)點(diǎn)移除。

// Compiler.js

class Compiler {
    
  /**
   * 核心方法
   */

  /**
   * 頁面 DOM 節(jié)點(diǎn) 轉(zhuǎn) 文檔碎片 節(jié)點(diǎn)
   * @param {*} node 頁面 DOM 節(jié)點(diǎn)
   */
  node2fragement (node) { // 需要將 node(el)中的內(nèi)容全部放入到內(nèi)存中
    let fragement = document.createDocumentFragment() // 創(chuàng)建文檔碎片,存放于內(nèi)存中
    let firstChild
    while (firstChild = node.firstChild) { // firstChild = node.firstChild 這樣永遠(yuǎn)拿到的第第一個子元素,會出現(xiàn)死循環(huán),所以可以拿到一個子元素就將其放入內(nèi)存中,然后使用 appendChild 將 node 中對應(yīng)的子節(jié)點(diǎn)移除,下次遍歷時自動獲取到下一個子節(jié)點(diǎn)。
      // 頁面 DOM 都具有 DOM映射,將頁面一個節(jié)點(diǎn)移入內(nèi)存中,則頁面節(jié)點(diǎn)少一個
      // appendChild 具有移動性,可以對 DOM 節(jié)點(diǎn)進(jìn)行移動
      fragement.appendChild(firstChild)
    }
    return fragement
  }
}
4.2.2.2 在內(nèi)存內(nèi)對模板內(nèi)容進(jìn)行替換

? 注意在遍歷節(jié)點(diǎn)時,對于元素節(jié)點(diǎn),其還有可能存在子元素及更深層內(nèi)容,此時需要遞歸檢查。編譯主要分為編譯元素(含 'v-' 指令)部分和編譯文本兩部分。

? 這一步只是原始 data 中的數(shù)據(jù)在初始化頁面時替換模板顯示于頁面,即初始化賦值,并未考慮更新。

// Compiler.js

class Compiler {
    
  /**
   * 核心方法
   */

  /**
   * 核心編譯方法:編譯所有節(jié)點(diǎn)
   * @param {*} fragement 文檔碎片(內(nèi)存中的所有節(jié)點(diǎn))
   */
  compile (fragement) {
    let childNodes = [...fragement.childNodes] // 拿到的是 類數(shù)組,需轉(zhuǎn)為數(shù)組
    childNodes.forEach(node => {
      if (this.isElementNode(node)) { // 元素節(jié)點(diǎn)
        // 編譯元素
        this.compileElement(node)
        // 如果是元素節(jié)點(diǎn),還需要遞歸深入檢查子元素節(jié)點(diǎn)和文本節(jié)點(diǎn)
        this.compile(node) // 注意:此處還是使用 this,因?yàn)?forEach 中回調(diào)用的箭頭函數(shù),現(xiàn)在的 this 還是指向?qū)嵗?      } else { // 文本節(jié)點(diǎn)
        // 編譯器文本
        this.compileText(node)
      }
    })
  }
}

? 編譯元素

// Compiler.js

class Compiler {
  /**
   * 核心方法
   */
  
  /**
   * 編譯元素
   * @param {*} node 節(jié)點(diǎn)
   */
  compileElement (node) {
    let attributes = [...node.attributes]
    attributes.forEach(attr => {
      // 判斷是否是指令
      let {name: attrName, value: expr} = attr
      if (this.isDirective(attrName)) {
        // 取到對應(yīng)的值,放到節(jié)點(diǎn)中
        let [, directive] = attrName.split('-')
        let [directiveName, eventName] = directive.split(':')
        CompileUtil[directiveName](node, this.vm, expr, eventName) // 去 this.vm 中 找到 expr 值放入到 node 中
      }
    })
  }
}

// 通過 CompileUtil,將 compileElement 和 compileText 中的實(shí)際編譯內(nèi)容拆分解耦,以后增加新的指令方法只需在 CompileUtil 中增加對應(yīng)方法即可

/**
 * 編譯工具
 */
CompileUtil = {
  /**
   * 根據(jù)表達(dá)式獲取實(shí)例上對應(yīng)的數(shù)據(jù)
   * @param {*} vm 
   * @param {*} expr 
   */
  getVal (vm, expr) {
    return expr.split('.').reduce((prev, next) => {
      return prev[next]
    }, vm.$data)
  }
  },
  /**
   * 編譯輸入框
   * @param {*} node 
   * @param {*} vm 
   * @param {*} expr 
   */
  model (node, vm, expr) { // 這里的 expr 是 字符串 person.name 形式,正常getVal()取值
    let updateFn = this.updater['modelUpdater']
    updateFn && updateFn(node, this.getVal(vm, expr)) // 數(shù)據(jù)初始化賦值(注意,這一步只是原始 data 中的數(shù)據(jù)在初始化頁面時替換模板顯示于頁面,并未考慮更新)
  },

  updater: {
    // 輸入框更新
    modelUpdater (node, value) {
      node.value = value
    }
  }
}

? 編譯文本

// Compiler.js

class Compiler {
    
  /**
   * 核心方法
   */

  /**
   * 編譯文本
   * @param {*} node 節(jié)點(diǎn)
   */
  compileText (node) {
    let expr = node.textContent // 取文本中的內(nèi)容
    if (/\{\{(.+?)\}\}/.test(expr)) { // 找到所有文本
      CompileUtil['text'](node, this.vm, expr)
    }
  }
}

// 通過 CompileUtil,將 compileElement 和 compileText 中的實(shí)際編譯內(nèi)容拆分解耦,以后增加新的指令方法只需在 CompileUtil 中增加對應(yīng)方法即可

/**
 * 編譯工具
 */
CompileUtil = {
  /**
   * 根據(jù)表達(dá)式獲取實(shí)例上對應(yīng)的數(shù)據(jù)
   * @param {*} vm 
   * @param {*} expr 
   */
  getVal (vm, expr) {
    return expr.split('.').reduce((prev, next) => {
      return prev[next]
    }, vm.$data)
  },
  /**
   * 獲取編譯文本后的結(jié)果
   * @param {*} vm 
   * @param {*} expr 
   */
  getTextVal (vm, expr) {
    return expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
      return this.getVal(vm, args[1])
    })
  },
  /**
   * 編譯文本
   * @param {*} node 
   * @param {*} vm 
   * @param {*} expr 
   */
  text (node, vm, expr) { // 這里的 expr 是 插值表達(dá)式 {{person.name}} 形式,通過 getTextVal() 正則匹配后 getVal() 取值
    let updateFn = this.updater['textUpdater']
    expr = this.getTextVal(vm, expr)
    updateFn && updateFn(node, expr)
  },
  updater: {
    // 文本更新
    textUpdater (node, value) {
      node.textContent = value
    }
  }
}
4.2.2.3 替換好的節(jié)點(diǎn)重新渲染回頁面
// Compiler.js

class Compiler {
  constructor (el, vm) {
    this.el = this.isElementNode(el) ? el : document.querySelector(el)
    this.vm = vm
    if (this.el) {
      let fragement = this.node2fragement(this.el)
      this.compile(fragement)
      // 把編譯好的 fragement 放回頁面中
      this.el.appendChild(fragement)
    }
  }
}

? 至此,模板編譯基本邏輯結(jié)束。

4.3 Observer 數(shù)據(jù)劫持

4.3.1 創(chuàng)建并使用 Observer 對象

? 在編譯前進(jìn)行響應(yīng)式定義(數(shù)據(jù)劫持),即將對象所有屬性改為 getset 方法。 創(chuàng)建 Compiler 對象并在 MVVM 對象中使用,由于需要響應(yīng)式定義的數(shù)據(jù)存在于 data 屬性上,故在 Observer 中引入實(shí)例 數(shù)據(jù) vm.datathis.$data)。

4.3.1.1 創(chuàng)建 Observer 對象
// Observer.js

class Observer {
  constructor (data) {
      
  }
}
4.3.1.2 在 MVVM 對象中使用 Observer 對象
// MVVM.js

class MVVM {
  constructor (options) {
    this.$el = options.el
    this.$data = options.data

    if (this.$el) {
      //  數(shù)據(jù)劫持(觀察對象,給對象添加 Object.defineProperty,把數(shù)據(jù)全部轉(zhuǎn)化為用 Object.defineProperty 來定義)
      new Observer(this.$data)
      new Compiler(this.$el, this)
    }
  }
}
4.3.2 劫持?jǐn)?shù)據(jù)

? 此處主要采用了 Object.defineProperty() 方法,以往我們采用的是 obj.key 取值, obj.key = value 賦值的形式,但是如果我們想在取值或賦值的時候進(jìn)行其他操作,如彈窗,這種取值賦值方法是無法做到的。此時可采用 Object.defineProperty(),這里我們采用此種形式,方便后期訂閱發(fā)布事件的執(zhí)行,以達(dá)到數(shù)據(jù)雙向綁定。[5] [6]

? 另外,還需注意深層次數(shù)據(jù)的響應(yīng)式劫持,故需進(jìn)行深度遞歸。

class Observer {
  constructor (data) {
    this.observe(data)
  }

  observe (data) { // 對 data 數(shù)據(jù)原有屬性改為 set 和 get 形式
    // 如果 data 數(shù)據(jù)不存在或者不是對象,不進(jìn)行劫持
    if (!data || typeof data !== 'object') return
    // 對數(shù)據(jù)一一劫持,現(xiàn)獲取到 data 的 key 和 value
    Object.keys(data).forEach(key => {
      // 劫持
      this.defineReactive(data, key, data[key])
      this.observe(data[key]) // 深度遞歸劫持,因?yàn)閷ο蟮闹颠€有可能是對象,都要賦予 get 與 set
    })
  }

  /**
   * 定義響應(yīng)式(數(shù)據(jù)劫持)
   */
  defineReactive(obj, key, value) {
    // 以往我們采用的是 obj.key 取值, obj.key = value 賦值的形式,但是如果我們想在取值或賦值的時候進(jìn)行其他操作,如彈窗,這種取值賦值方法是無法做到的。此時可采用 Object.defineProperty()
    Object.defineProperty(obj, key, {
      enumerable: true,
      configurable: true,
      get: () => { // 取值操作,代替 obj.key
        return value
      },
      set: (newValue) => { // 賦值操作,代替 obj.key = value
        this.observe(newValue) // 新值劫持(如果是對象,繼續(xù)劫持)
        if (newValue !== value) value = newValue
      }
    })
  }
}

? 至此,數(shù)據(jù)劫持基本邏輯結(jié)束。

4.4 發(fā)布訂閱實(shí)現(xiàn)數(shù)據(jù)雙向綁定

? 通過 4.2 Compiler 模板編譯4.3 Observer 數(shù)據(jù)劫持 已經(jīng)完成了數(shù)據(jù)在頁面的渲染和數(shù)據(jù)的響應(yīng)式綁定,但此時還未將響應(yīng)式數(shù)據(jù)與其在頁面渲染相關(guān)聯(lián)。

? 據(jù)此,可以采用觀察者模式(發(fā)布訂閱模式),當(dāng)頁面初次渲染時,為所有的數(shù)據(jù)綁定監(jiān)聽事件(訂閱),當(dāng)數(shù)據(jù)變化時,觸發(fā)監(jiān)聽事件(發(fā)布),使用新數(shù)據(jù)渲染頁面。由此實(shí)現(xiàn)數(shù)據(jù)的雙向綁定。

4.4.1 Watcher 訂閱者

? 創(chuàng)建訂閱者(觀察者),即每個數(shù)據(jù)的監(jiān)聽對象,并使用于每個數(shù)據(jù)。

4.4.1.1 創(chuàng)建 Watcher 對象

? 給需要變化的元素添加訂閱者,將新值與老值進(jìn)行比對,當(dāng)數(shù)據(jù)變化時,執(zhí)行對應(yīng)的更新方法。

// Watcher.js

// 訂閱者(觀察者):給需要變化的元素添加觀察者,將新值與老值進(jìn)行比對,當(dāng)數(shù)據(jù)變化時,執(zhí)行對應(yīng)的更新方法。
// 例如,為 <input type="text" v-nodel="name" /> 元素添加觀察者,當(dāng) data 中 { name:'zhangsan' } 變化時,執(zhí)行更新方法。
class Watcher {
  constructor (vm, expr, cb) {
    this.vm = vm
    this.expr = expr
    this.cb = cb
    // 由于要對比新值與老值,所以 new Watcher() 時即先獲取到老值
    this.oldVal = this.get()
  }

  /**
   * 根據(jù)表達(dá)式獲取實(shí)例上對應(yīng)的數(shù)據(jù)
   * @param {*} vm 
   * @param {*} expr 
   */
  getVal (vm, expr) {
    return expr.split('.').reduce((prev, next) => { //[9]
      return prev[next]
    }, vm.$data)
  }

  get () {
    let value = this.getVal(this.vm, this.expr)
    return value
  }

  /**
   * 對外暴露的更新方法
   */
  update () {
    let newVal = this.getVal(this.vm, this.expr) // 獲取新值
    if (newVal !== this.oldVal) { // 比較老值與新值
      this.cb() // 調(diào)用 Watcher 的 callback
    }
  }
}
4.4.1.2 為數(shù)據(jù)綁定觀察者

? 前面 Compiler.js 中,通過 updateFn 實(shí)現(xiàn)了將初始化數(shù)據(jù)代替頁面模板數(shù)據(jù)而將 data 內(nèi)容顯示于頁面,但在數(shù)據(jù)更新時并不能重新渲染頁面數(shù)據(jù)。所以,可以在此處設(shè)置訂閱者,使每個數(shù)據(jù)都有一個單獨(dú)的訂閱者(new Watcher),以在數(shù)據(jù)變化時重新渲染頁面數(shù)據(jù)。

// Compiler.js

/**
 * 編譯工具
 */
CompileUtil = {
   /**
   * 編譯文本
   */
  text (node, vm, expr) {
    let updateFn = this.updater['textUpdater']
    // 增加觀察者
    expr.replace(/\{\{(.+?)\}\}/g, (...args) => { // expr 是 插值表達(dá)式 {{person.name}} 形式,故需通過正則取出其文本值進(jìn)行比較
      new Watcher(vm, args[1], () => {
        // 如果數(shù)據(jù)變化了,文本節(jié)點(diǎn)需要重新獲取依賴的屬性更新文本中的內(nèi)容
        updateFn && updateFn(node, this.getTextVal(vm, expr)) // 更新視圖
      })
    })
    updateFn && updateFn(node, this.getTextVal(vm, expr)) // 初始化視圖
  },
   /**
   * 編譯輸入框
   */
  model (node, vm, expr) {
    let updateFn = this.updater['modelUpdater']
    // 增加觀察者
    new Watcher(vm, expr, () => {
      updateFn && updateFn(node, this.getVal(vm, expr)) // cb 中監(jiān)測到值變化時,再次調(diào)用此更新頁面節(jié)點(diǎn)數(shù)據(jù)方法,實(shí)現(xiàn)頁面數(shù)據(jù)更新
    })
    updateFn && updateFn(node, this.getVal(vm, expr)) // 初始化視圖
  }
}
4.4.2 Dep 發(fā)布者

? 通過 4.4.1 Watcher 訂閱者 為每個數(shù)據(jù)實(shí)例化了一個 watcher,其中的更新方法 update 只有在數(shù)據(jù)變化時才會更新。此時需要進(jìn)行訂閱的發(fā)布,以獲取到所有的 watcher 并在數(shù)據(jù)變化時進(jìn)行依次更新。

4.4.2.1 創(chuàng)建 Dep 對象
// Observer.js

class Dep {
  constructor () {
    this.subs = [] // 訂閱的數(shù)組
  }
  /**
   * 添加訂閱
   */
  addSub (watcher) {
    this.subs.push(watcher)
  }
  /**
   * 發(fā)布
   */
  notify () {
    this.subs.forEach(watcher => watcher.update())
  }
}
4.4.2.2 使用 Dep,訂閱發(fā)布,連接視圖與數(shù)據(jù)

? 我們使用發(fā)布訂閱的目的是在數(shù)據(jù)或頁面變化時更新響應(yīng)的頁面或數(shù)據(jù),而在初始化時已經(jīng)創(chuàng)建了每個數(shù)據(jù)的 watcher,但綁定到數(shù)據(jù)上。所以,我們可以在初始化(get 取值)時,為每個數(shù)據(jù)定義一個發(fā)布者,并存儲其監(jiān)聽 watcher,在數(shù)據(jù)變化(set 賦值)時,調(diào)用 watcher 進(jìn)行發(fā)布,實(shí)現(xiàn)更新。

// Observer.js

class Observer {
   /**
   * 定義響應(yīng)式(數(shù)據(jù)劫持)
   */
  defineReactive(obj, key, value) {
    let dep = new Dep() // 每個變化的數(shù)據(jù)都會對應(yīng)一個存放所有更新的數(shù)組
    Object.defineProperty(obj, key, {
      enumerable: true,
      configurable: true,
      get: () => {
        Dep.target && dep.addSub(Dep.target) // 獲取訂閱者
        return value
      },
      set: (newValue) => {
        if (newValue !== value) {
          this.observe(newValue)
          value = newValue
          dep.notify() // 通知數(shù)據(jù)更新
        }
      }
    })
  }
} 
}

? 同時,在訂閱(new Watcher)時,要將 watcher 實(shí)例賦予發(fā)布者存儲,以施行 update 發(fā)布更新。每次賦值完畢清空發(fā)布中的 watcher,以防影響下一個數(shù)據(jù)取值。

// Watcher.js

class Watcher {
  get () {
    Dep.target = this // this 指 new 的 watcher 實(shí)例
    let value = this.getVal(this.vm, this.expr)
    Dep.target = null
    return value
  }
}

? 至此,實(shí)現(xiàn)了一個簡單的 MVVM 框架。

4.4.2.2.gif

5 完整代碼

5.1 github 地址

? https://github.com/trp1119/MVVM.git

5.2 代碼拆分

5.2.1 index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
</head>
<body>
  <div id="app">
    <input type="text" v-model="person.name">
    <div>{{person.name}}</div>
    {{person.name}}{{person.age}}
  </div>
  <script src="./Dep.js"></script>
  <script src="./Watcher.js"></script>
  <script src="./Observer.js"></script>
  <script src="./Compiler.js"></script>
  <script src="./MVVM.JS"></script>
  <script>
    // 實(shí)例化 MVVM 對象
    let vm = new MVVM({
      el: '#app',
      data: {
        person: {
          name: 'zhangsan',
          age: 12
        }
      }
    })
  </script>
</body>
</html>
5.2.2 MVVM.js
// MVVM是連接 Compiler 模板編譯與 Observer 數(shù)據(jù)劫持的橋梁

class MVVM {
  constructor (options) {
    // 一般情況下,在寫庫或者框架時,都需要將屬性掛載到實(shí)例上,保證其原型或方法能夠取到該屬性
    this.$el = options.el
    this.$data = options.data

    // 如果有需要編譯的模板,則開始編譯
    if (this.$el) {
      //  數(shù)據(jù)劫持(觀察對象,給對象添加 Object.defineProperty,把數(shù)據(jù)全部轉(zhuǎn)化為用 Object.defineProperty 來定義)
      new Observer(this.$data)
      // 用數(shù)據(jù)和元素進(jìn)行編譯
      new Compiler(this.$el, this) // 后期 this 上可能會有很多屬性,this.$el 模板中也需要很多屬性而不僅僅是 this.$data,所以此處使用范圍更廣的 this
    }
  }
}
5.2.3 Compiler.js
class Compiler {
  constructor (el, vm) { // el 為模板,vm 為 this 實(shí)例
    // el 的值可能是字符串 '#app',也有可能是元素 document.getElementById('#app')
    // 判斷 el屬性 是否是元素,如果不是元素,則獲取它
    // 為了擴(kuò)展時所有類的屬性都能在原型上取到,將所有值都綁定到實(shí)例上
    this.el = this.isElementNode(el) ? el : document.querySelector(el)
    this.vm = vm

    // 在匹配 data 時,如果每匹配到一個數(shù)據(jù)就渲染一次,會造成頁面不停的回流與重繪,可先將模板放入內(nèi)存中,在內(nèi)存中完全替換完畢后,再放回頁面,性能比每匹配到一個就替換性能更佳。
    // 把當(dāng)前節(jié)點(diǎn)中的元素獲取到,放到內(nèi)存中
    if (this.el) { // 如果能獲取到這個元素,才開始編譯
      // 1. 通過文檔碎片 fragemnt 先把真實(shí) DOM 移入到內(nèi)存中,在內(nèi)存中操作 DOM 比在真實(shí) DOM 中操作快
      let fragement = this.node2fragement(this.el)
      // 2. 提取 fragement 中的元素節(jié)點(diǎn) v-model 和文本節(jié)點(diǎn) {{}} 進(jìn)行編譯,對節(jié)點(diǎn)中的內(nèi)容進(jìn)行替換
      this.compile(fragement)
      // 3. 把編譯好的 fragement 放回頁面中
      this.el.appendChild(fragement)
    }
  }

  /**
   * 輔助方法,如判斷是否是元素,判斷是否是文本,判斷指令
   */

  /**
   * 判斷是否是元素節(jié)點(diǎn)
   * @param {*} node 節(jié)點(diǎn)
   */
  isElementNode (node) {
    return node.nodeType === 1 // [7]
  }
  /**
   * 判斷是否是指令(判斷屬性名是否包含 v-)
   * @param {*} attrName 屬性名
   */
  isDirective (attrName) {
    return attrName.startsWith('v-')
  }

  /**
   * 核心方法
   */

  /**
   * 頁面 DOM 節(jié)點(diǎn) 轉(zhuǎn) 文檔碎片 節(jié)點(diǎn)
   * @param {*} node 頁面 DOM 節(jié)點(diǎn)
   */
  node2fragement (node) { // 需要將 node(el)中的內(nèi)容全部放入到內(nèi)存中
    let fragement = document.createDocumentFragment() // 創(chuàng)建文檔碎片,存放于內(nèi)存中 [8]
    let firstChild
    while (firstChild = node.firstChild) { // firstChild = node.firstChild 這樣永遠(yuǎn)拿到的第第一個子元素,會出現(xiàn)死循環(huán),所以可以拿到一個子元素就將其放入內(nèi)存中,然后使用 appendChild 將 node 中對應(yīng)的子節(jié)點(diǎn)移除,下次遍歷時自動獲取到下一個子節(jié)點(diǎn)。
      // 頁面 DOM 都具有 DOM映射,將頁面一個節(jié)點(diǎn)移入內(nèi)存中,則頁面節(jié)點(diǎn)少一個
      // appendChild 具有移動性,可以對 DOM 節(jié)點(diǎn)進(jìn)行移動
      fragement.appendChild(firstChild)
    }
    return fragement
  }
  /**
   * 核心編譯方法:編譯所有節(jié)點(diǎn)
   * @param {*} fragement 文檔碎片(內(nèi)存中的所有節(jié)點(diǎn))
   */
  compile (fragement) {
    let childNodes = [...fragement.childNodes] // 拿到的是 類數(shù)組,需轉(zhuǎn)為數(shù)組
    childNodes.forEach(node => {
      if (this.isElementNode(node)) { // 元素節(jié)點(diǎn)
        // 編譯元素
        this.compileElement(node)
        // 如果是元素節(jié)點(diǎn),還需要遞歸深入檢查子元素節(jié)點(diǎn)和文本節(jié)點(diǎn)
        this.compile(node) // 注意:此處還是使用 this,因?yàn)?forEach 中回調(diào)用的箭頭函數(shù),現(xiàn)在的 this 還是指向?qū)嵗?      } else { // 文本節(jié)點(diǎn)
        // 編譯器文本
        this.compileText(node)
      }
    })
  }
  /**
   * 編譯元素
   * @param {*} node 節(jié)點(diǎn)
   */
  compileElement (node) {
    let attributes = [...node.attributes]
    attributes.forEach(attr => {
      // 判斷是否是指令
      let {name: attrName, value: expr} = attr
      if (this.isDirective(attrName)) {
        // 取到對應(yīng)的值,放到節(jié)點(diǎn)中
        let [, directive] = attrName.split('-')
        let [directiveName, eventName] = directive.split(':')
        CompileUtil[directiveName](node, this.vm, expr, eventName) // 去 this.vm 中 找到 expr 值放入到 node 中
      }
    })
  }
  /**
   * 編譯文本
   * @param {*} node 節(jié)點(diǎn)
   */
  compileText (node) {
    let expr = node.textContent // 取文本中的內(nèi)容
    if (/\{\{(.+?)\}\}/.test(expr)) { // 找到所有文本
      CompileUtil['text'](node, this.vm, expr)
    }
  }
}

// 通過 CompileUtil,將 compileElement 和 compileText 中的實(shí)際編譯內(nèi)容拆分解耦,以后增加新的指令方法只需在 CompileUtil 中增加對應(yīng)方法即可

/**
 * 編譯工具
 */
CompileUtil = {
  /**
   * 根據(jù)表達(dá)式獲取實(shí)例上對應(yīng)的數(shù)據(jù)
   * @param {*} vm 
   * @param {*} expr 
   */
  getVal (vm, expr) {
    return expr.split('.').reduce((prev, next) => {
      return prev[next]
    }, vm.$data)
  },
  /**
   * 設(shè)置值,輸入框使用
   * @param {*} vm 
   * @param {*} expr 
   */
  setVal (vm, expr, value) {
    expr.split('.').reduce((prev, next, currentIndex, arr) => { // 收斂
      if (currentIndex === arr.length - 1) {
        return prev[next] = value
      }
      return prev[next]
    }, vm.$data)
  },
  /**
   * 獲取編譯文本后的結(jié)果
   * @param {*} vm
   * @param {*} expr
   */
  getTextVal (vm, expr) {
    return expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
      return this.getVal(vm, args[1])
    })
  },
  /**
   * 編譯文本
   * @param {*} node 
   * @param {*} vm 
   * @param {*} expr 
   */
  text (node, vm, expr) { // 這里的 expr 是 插值表達(dá)式 {{person.name}} 形式,通過 getTextVal() 正則匹配后 getVal() 取值
    let updateFn = this.updater['textUpdater']
    // 增加觀察者
    expr.replace(/\{\{(.+?)\}\}/g, (...args) => { // expr 是 插值表達(dá)式 {{person.name}} 形式,故需通過正則取出其文本值進(jìn)行比較
      new Watcher(vm, args[1], () => {
        // 如果數(shù)據(jù)變化了,文本節(jié)點(diǎn)需要重新獲取依賴的屬性更新文本中的內(nèi)容
        updateFn && updateFn(node, this.getTextVal(vm, expr)) // 更新視圖
      })
    })
    updateFn && updateFn(node, this.getTextVal(vm, expr)) // 初始化視圖
  },
  /**
   * 編譯輸入框
   */
  model (node, vm, expr) { // 這里的 expr 是 字符串 person.name 形式,正常 getVal() 取值
    let updateFn = this.updater['modelUpdater']
    // 增加觀察者
    new Watcher(vm, expr, () => {
      updateFn && updateFn(node, this.getVal(vm, expr)) // cb 中監(jiān)測到值變化時,再次調(diào)用此更新頁面節(jié)點(diǎn)數(shù)據(jù)方法,實(shí)現(xiàn)頁面數(shù)據(jù)更新
    })
    updateFn && updateFn(node, this.getVal(vm, expr)) // 數(shù)據(jù)初始化賦值(注意,這一步只是原始 data 中的數(shù)據(jù)在初始化頁面時替換模板顯示于頁面,并未考慮更新)
    node.addEventListener('input', (e) => {
      let value = e.target.value // 獲取用戶輸入的內(nèi)容
      this.setVal(vm, expr, value)
    })
  },
  updater: {
    // 文本更新
    textUpdater (node, value) {
      node.textContent = value
    },
    // 輸入框更新
    modelUpdater (node, value) {
      node.value = value
    }
  }
}
5.2.4 Observer.js
class Observer {
  constructor (data) {
    this.observe(data)
  }

  observe (data) { // 對 data 數(shù)據(jù)原有屬性改為 set 和 get 形式
    // 如果 data 數(shù)據(jù)不存在或者不是對象,不進(jìn)行劫持
    if (!data || typeof data !== 'object') return
    // 對數(shù)據(jù)一一劫持,現(xiàn)獲取到 data 的 key 和 value
    Object.keys(data).forEach(key => {
      // 劫持
      this.defineReactive(data, key, data[key])
      this.observe(data[key]) // 深度遞歸劫持,因?yàn)閷ο蟮闹颠€有可能是對象,都要賦予 get 與 set
    })
  }

  /**
   * 定義響應(yīng)式(數(shù)據(jù)劫持)
   */
  defineReactive(obj, key, value) {
    let dep = new Dep() // 每個變化的數(shù)據(jù)都會對應(yīng)一個存放所有更新的數(shù)組
    // 以前我們采用的是 obj.key 取值, obj.key = value 賦值的形式,但是如果我們想在取值或賦值的時候進(jìn)行其他操作,如彈窗,這種取值賦值方法是無法做到的。此時可采用 Object.defineProperty()
    Object.defineProperty(obj, key, { // 通過 object.defineProperty 的方式定義 data 屬性
      enumerable: true,
      configurable: true,
      get: () => { // 取值操作,代替 obj.key
        Dep.target && dep.addSub(Dep.target) // 獲取訂閱者
        return value
      },
      set: (newValue) => { // 賦值操作,代替 obj.key = value // 注意此處使用箭頭函數(shù),以將 this 指向 Observer,取到其 observer 方法。否則 this 是 obj
        if (newValue !== value) { // 只有新值與老值不同才更新
          this.observe(newValue) // 新值劫持(如果是對象,繼續(xù)劫持)
          value = newValue
          dep.notify() // 通知數(shù)據(jù)更新
        }
      }
    })
  }
}
5.2.5 Watcher.js
// 訂閱者(觀察者):給需要變化的元素添加觀察者,將新值與老值進(jìn)行比對,當(dāng)數(shù)據(jù)變化時,執(zhí)行對應(yīng)的更新方法。
// 例如,為 <input type="text" v-nodel="name" /> 元素添加觀察者,當(dāng) data 中 { name:'zhangsan' } 變化時,執(zhí)行更新方法。
class Watcher {
  constructor (vm, expr, cb) {
    this.vm = vm
    this.expr = expr
    this.cb = cb
    // 由于要對比新值與老值,所以 new Watcher() 時即先獲取到老值
    this.oldVal = this.get()
  }

  /**
   * 根據(jù)表達(dá)式獲取實(shí)例上對應(yīng)的數(shù)據(jù)
   * @param {*} vm 
   * @param {*} expr 
   */
  getVal (vm, expr) {
    return expr.split('.').reduce((prev, next) => {
      return prev[next]
    }, vm.$data)
  }

  get () {
    Dep.target = this // this 指 new 的 watcher 實(shí)例
    let value = this.getVal(this.vm, this.expr)
    Dep.target = null
    return value
  }

  /**
   * 對外暴露的更新方法
   */
  update () {
    let newVal = this.getVal(this.vm, this.expr) // 獲取新值
    if (newVal !== this.oldVal) { // 比較老值與新值
      this.cb() // 調(diào)用 Watcher 的 callback
    }
  }
}
5.2.6 Dep.js
// 發(fā)布者

class Dep {
  constructor () {
    this.subs = [] // 訂閱的數(shù)組
  }
  /**
   * 添加訂閱
   */
  addSub (watcher) {
    this.subs.push(watcher)
  }
  /**
   * 發(fā)布
   */
  notify () {
    this.subs.forEach(watcher => watcher.update())
  }
}

6 參考資料

[1] MVVM [EB/OL]. (2019-12-04)[2020-05-04]. https://baike.baidu.com/item/MVVM/96310?fr=aladdin.

[2] 隔壁老主. 由淺入深講述MVVM [EB/OL]. (2019-03-18)[2020-05-04]. https://www.cnblogs.com/wzfwaf/p/10553160.html.

[3] 廖雪峰. MVVM[EB/OL]. [2020-05-04]. https://www.liaoxuefeng.com/wiki/1022910821149312/1108898947791072.

[4] 前端問答. MVVM的優(yōu)缺點(diǎn)?[EB/OL]. (2019-11-24)[2020-05-04]. https://developer.aliyun.com/ask/259836?groupCode=othertech

[5] 趙望野, 梁杰. 你不知道的JavaScript(上卷)[M]. 北京: 人民郵電出版社, 2015: 111-119.

[6] Object.defineProperty() [EB/OL]. (2020-03-02)[2020-05-04]. https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty.

[7] Node.nodeType [EB/OL]. (2019-07-28)[2020-05-04]. https://developer.mozilla.org/zh-CN/docs/Web/API/Node/nodeType

[8] Document.createDocumentFragment() [EB/OL]. (2019-03-23)[2020-05-04]. https://developer.mozilla.org/zh-CN/docs/Web/API/Document/createDocumentFragment

[9] Array.prototype.reduce() [EB/OL]. (2020-04-29)[2020-05-04]. https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Array/Reduce

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容