手寫一套完整的基於Vue的MVVM原理

做爲前端面試官我面試必須問一下面試者:描述一下你對MVVM的理解?html

接下來,我將從零實現一套完整的基於Vue的MVVM,提供給來年「金三銀四」跳槽高峯期的小夥伴們閱讀也詳細梳理一下本身對MVVM的理解。前端

圖片alt

MVVM是什麼

在瞭解MVVM以前,咱們來對MVC說明一下。MVC架構起初以及如今一直存在於後端。MVC分別表明後臺的三層,M表明模型層、V表明視圖層、C表明控制器層,這三層架構徹底能夠知足於絕大分部的業務需求開發。
圖片altnode

MVC & 三層架構

下面以Java爲例,分別闡述下MVC和三層架構中各層表明的含義以及職責:git

  1. Model:模型層,表明着每個JavaBean。其分爲兩類,一類稱爲數據承載Bean,一類稱爲業務處理Bean。
  2. View:視圖層,表明着對應的視圖頁面,與用戶直接進行交互。
  3. Controller:控制層,該層是Model和View的「中間人」,用於將用戶請求轉發給相應的Model進行處理,並處理Model的計算結果向用戶提供相應響應。

以登陸爲例,介紹一下三層之間的邏輯關係。當用戶點擊View視圖頁面的登陸按鈕時,系統會調取Controller控制層裏的登陸接口。通常在Controller層中不會寫不少具體的業務邏輯代碼,只會寫一個接口方法,該方法具體的邏輯在Service層進行實現,而後service層裏的具體邏輯就會調用DAO層裏的Model模型,從而達到動態化的效果。github

MVVM 的描述

MVVM 設計模式,是由 MVC(最先來源於後端)、MVP 等設計模式進化而來。面試

  1. M - 數據模型(Model),簡單的JS對象
  2. VM - 視圖模型(ViewModel),鏈接Model與View
  3. V - 視圖層(View),呈現給用戶的DOM渲染界面

圖片alt
經過以上的MVVM模式圖,咱們能夠看出最核心的就是ViewModel,它主要的做用:對View中DOM元素的監聽和對Model中的數據進行綁定,當View變化會引發Modal中數據的改動,Model中數據的改動會觸發View視圖從新渲染,從而達到數據雙向綁定的效果,該效果也是Vue最爲核心的特性。後端

常見庫實現數據雙向綁定的作法:
  • 發佈訂閱模式(Backbone.js)
  • 髒值檢查(Angular.js)
  • 數據劫持(Vue.js)

面試者在回答Vue的雙向數據綁定原理時,幾乎全部人都會說:Vue是採用數據劫持結合發佈訂閱模式,經過Object.defineProperty()來劫持各個屬性的getter,setter, 在數據變更時發佈消息給訂閱者,觸發相應的回調函數,從而實現數據雙向綁定。但當繼續深刻問道:設計模式

  • 實現一個MVVM裏面須要那些核心模塊?
  • 爲何操做DOM要在內存上進行?
  • 各個核心模塊之間的關係是怎樣的?
  • Vue中如何對數組進行數據劫持?
  • 你本身手動完整的實現過一個MVVM嗎?
  • ...

圖片alt

接下來,我將一步一步的實現一套完整的MVVM,當再次問道MVVM相關問題,徹底能夠在面試過程當中脫穎而出。在開始編寫MVVM以前,咱們頗有必要對核心API和發佈訂閱模式熟悉一下:數組

介紹一下 Object.defineProperty 的使用

Object.defineProperty(obj, prop, desc) 的做用就是直接在一個對象上定義一個新屬性,或者修改一個已經存在的屬性瀏覽器

  1. obj: 須要定義屬性的當前對象
  2. prop: 當前須要定義的屬性名
  3. desc: 屬性描述符

注意:通常經過爲對象的屬性賦值的狀況下,對象的屬性能夠修改也能夠刪除,可是經過Object.defineProperty()定義屬性,經過描述符的設置能夠進行更精準的控制對象屬性。
圖片alt

let obj = {}
Object.defineProperty(obj, 'name', {
    configurable: true,   // 默認爲false,可配置的【刪除】
    writable: true,       // 默認爲false, 是否可寫【修改】
    enumerable: true,     // 默認爲false, 是否可枚舉【for in 遍歷】
    value: 'sfm',         // name屬性的值
    get() {
        // 獲取obj.name的值時會調用get函數
    },
    set(val) {
        // val就是從新賦值的值
        // 從新給obj.name賦值時會調用set函數
    }
})

注意:當出現get,set函數時,不能同時出現writable, enumerable屬性,不然系統報錯。而且該API不支持IE8如下的版本,也就是Vue不兼容IE8如下的瀏覽器。

DocumentFragment - 文檔碎片

DocumentFragment 表示文檔片斷,它不屬於 DOM 樹,可是它能夠存儲 DOM,而且能夠將所存儲的 DOM 加入到指定的 DOM 節點中去。那麼有人要問了,那要它何用,直接把元素加入到 DOM 中不就能夠了嗎?用它的緣由在於,使用它操做 DOM 要比直接操做 DOM 性能要高不少。

介紹一下 發佈訂閱模式

發佈者-訂閱者模式定義了一種一對多的依賴關係,即當一個對象的狀態發生改變時,全部依賴於他的對象都會獲得通知並自動更新,解決了主體對象與觀察者之間功能的耦合。如下是一個發佈訂閱模式的小例子,實際上能夠理解爲靠的就是數組關係,訂閱就是放入函數,發佈就是讓數組裏的函數執行。

// 發佈訂閱模式  先有訂閱後有發佈
function Dep() {
    this.subs = [];
}
// 訂閱
Dep.prototype.addSub = function(sub) {
    this.subs.push(sub);
}
Dep.prototype.notify = function() {
    this.subs.forEach(sub => sub.update());
}
// Watcher類,經過這個類建立的實例都有update方法
function Watcher(fn) {
    this.fn = fn;
}
Watcher.prototype.update = function() {
    this.fn();
}
let watcher1 = new Watcher(function() {
    console.log(123);
})
let watcher2 = new Watcher(function() {
    console.log(456);
})
let dep = new Dep();
dep.addSub(watcher1); // 將watcher放到了數組中
dep.addSub(watcher2);
dep.notify();

// 控制檯輸出:
// 123 456

圖片alt

實現本身的 MVVM

要實現mvvm的雙向綁定,就必需要實現如下幾點:
  1. 實現一個數據劫持 - Observer,可以對數據對象的全部屬性進行監聽,若有變更可拿到最新值並通知訂閱者
  2. 實現一個模板編譯 - Compiler,對每一個元素節點的指令進行掃描和解析,根據指令模板替換數據,以及綁定相應的更新函數
  3. 實現一個 - Watcher,做爲鏈接Observer和Compile的橋樑,可以訂閱並收到每一個屬性變更的通知,執行指令綁定的相應回調函數,從而更新視圖
  4. MVVM 做爲入口函數,整合以上三者

圖片alt

數據劫持 - Observer

Observer 類主要目的就是給 data 數據內的全部層級的數據都進行數據劫持,讓其具有監聽對象屬性變化的能力

【重點】:

  1. 當對象的屬性值也是對象時,也要對其值進行劫持 --- 遞歸
  2. 當對象賦值與舊值同樣,則不須要後續操做 --- 防止重複渲染
  3. 當模板渲染獲取對象屬性會調用get添加target,對象屬性改動通知訂閱者更新 --- 數據變化,視圖更新
// 數據劫持
class Observer {
    constructor(data) {
        this.observer(data);
    }
    observer(data) {
        if(data && typeof data == 'object') {
            // 判斷data數據存在 並 data是對象  才觀察
            for(let key in data) {
                this.defineReactive(data, key, data[key]);
            }
        }
    }
    defineReactive(obj, key, value) {
        let dep = new Dep();
        this.observer(value); // 若是value仍是對象,還須要觀察
        Object.defineProperty(obj, key, {
            get() {
                Dep.target && dep.addSub(Dep.target);
                return value;
            },
            set:(newVal) => { // 設置新值
                if(newVal != value) { // 新值和就值若是一致就不須要替換了
                    this.observer(newVal); // 若是賦值的也是對象的話  還須要觀察
                    value = newVal;
                    dep.notify(); // 通知全部訂閱者更新了
                }
            }
        })
    }
}

注意:該類只會對對象進行數據劫持,並不會對數組的監聽。

模板編譯 - Compiler

Compiler 是解析模板指令,將模板中的變量替換成數據,而後初始化渲染頁面視圖,並將每一個指令對應的節點綁定更新函數,添加監聽數據的訂閱者,一旦數據有變更,收到通知,更新視圖

Compiler 主要作了三件事:

  • 將當前根節點全部子節點遍歷放到內存中
  • 編譯文檔碎片,替換模板(元素、文本)節點中屬性的數據
  • 將編譯的內容回寫到真實DOM上

【重點】:

  1. 先把真實的 dom 移入到內存中操做 --- 文檔碎片
  2. 編譯 元素節點 和 文本節點
  3. 給模板中的表達式和屬性添加觀察者
// 模板編譯
class Compiler {
    /**
     * @param {*} el 元素 注意:el選項中有多是‘#app’字符串也有多是document.getElementById('#app')
     * @param {*} vm 實例
     */
    constructor(el, vm) {
        // 判斷el屬性  是否是一個元素  若是不是元素就獲取
        this.el = this.isElementNode(el) ? el : document.querySelector(el);
        // console.log(this.el);拿到當前的模板
        this.vm = vm;
        // 把當前節點中的元素獲取到  放到內存中  防止頁面重繪
        let fragment = this.node2fragment(this.el);
        // console.log(fragment);內存中全部的節點

        // 1. 編譯模板 用data中的數據編譯
        this.compile(fragment);
        // 2. 把內存中的內容進行替換
        this.el.appendChild(fragment);
        // 3. 再把替換後的內容回寫到頁面中
    }
    /**
     * 判斷是含有指令
     * @param {*} attrName 屬性名 type v-modal
     */
    isDirective(attrName) {
        return attrName.startsWith('v-'); // 是否含有v-
    }
    /**
     * 編譯元素節點
     * @param {*} node 元素節點
     */
    compileElement(node) {
        // 獲取當前元素節點的屬性;【類數組】NamedNodeMap; 也存在沒有屬性,則NamedNodeMap{length: 0}
        let attributes = node.attributes;
        // Array.from()、[...xxx]、[].slice.call 等均可以將類數組轉化爲真實數組
        [...attributes].forEach(attr => {
            // attr格式:type="text"  v-modal="obj.name"
            let {name, value: expr} = attr;
            // 判斷是否是指令
            if(this.isDirective(name)) { // v-modal v-html v-bind
                // console.log('element', node); 元素
                let [, directive] = name.split('-'); // 獲取指令名
                // 須要調用不一樣的指令來處理
                CompilerUtil[directive](node, expr, this.vm);
            }
        });
    }
    /**
     * 編譯文本節點 判斷當前文本節點中的內容是否含有 {{}}
     * @param {*} node 文本節點
     */
    compileText(node) {
        let content = node.textContent;
        // console.log(content, ‘內容’); 元素裏的內容
        if(/\{\{(.+?)\}\}/.test(content)) { // 經過正則去匹配只須要含有{{}}大括號的,空的不須要 獲取大括號中間的內容
            // console.log(content, ‘內容’); 只包含{{}} 不須要空的 和其餘沒有{{}}的子元素
            CompilerUtil['text'](node, content, this.vm);
        }
    }
    /**
     * 編譯內存中的DOM節點
     * @param {*} fragmentNode 文檔碎片
     */
    compile(fragmentNode) {
        // 從文檔碎片中拿到子節點  注意:childNodes【之包含第一層,不包含{{}}等】
        let childNodes = fragmentNode.childNodes; // 獲取的是類數組NodeLis
        [...childNodes].forEach(child => {
            // 是不是元素節點
            if (this.isElementNode(child)) {
                this.compileElement(child);
                // 若是是元素的話  須要把本身傳進去  再去遍歷子節點   遞歸
                this.compile(child);
            } else {
                // 文本節點
                // console.log('text', child);
                this.compileText(child);
            }
        });
    }
    /**
     * 將節點中的元素放到內存中
     * @param {*} node 節點
     */
    node2fragment(node) {
        // 建立一個穩定碎片;目的是爲了將這個節點中的每一個孩子都寫到這個文檔碎片中
        let fragment = document.createDocumentFragment();
        let firstChild; // 這個節點中的第一個孩子
        while (firstChild = node.firstChild) {
            // appendChild具備移動性,每移動一個節點到內存中,頁面上就會少一個節點
            fragment.appendChild(firstChild);
        }
        return fragment;
    }
    /**
     * 判斷是否是元素
     * @param {*} node 當前這個元素的節點
     */
    isElementNode(node) {
        return node.nodeType === 1;
    }
}
// 編譯功能
CompilerUtil = {
    /**
     * 根據表達式取到對應的數據
     * @param {*} vm 
     * @param {*} expr 
     */
    getVal(vm, expr) {
        return expr.split('.').reduce((data, current) => {
            return data[current];
        }, vm.$data);
    },
    setVal(vm, expr, value) {
        expr.split('.').reduce((data, current, index, arr) => {
          if (index === arr.length - 1) {
            return data[current] = value;
          }
          return data[current]
        }, vm.$data)
    },
    /**
     * 處理v-modal
     * @param {*} node 對應的節點
     * @param {*} expr 表達式
     * @param {*} vm 當前實例
     */
    modal(node, expr, vm) {
        // 給輸入框賦予value屬性 node.value = xxx
        let fn = this.updater['modalUpdater'];
        new Watcher(vm, expr, (newValue) => {//給輸入框加一個觀察者 數據更新會觸發此方法 會拿新值給 輸入框賦值
          fn(node, newValue)
        })
        node.addEventListener('input', e => {
          let value = e.target.value; // 獲取用戶輸入的內容
          this.setVal(vm, expr, value);
        })
        let value = this.getVal(vm, expr); // 返回tmc
        fn(node, value);
    },
    text(node, expr, vm) {
        let fn = this.updater['textUpdater'];
        let content = expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
            // 給表達式 每一個{{}} 加上觀察者
            new Watcher(vm, args[1], (newValue) => {
                fn(node, this.getContentValue(vm, expr)); // 返回了一個新的字符串
            })
            return this.getVal(vm, args[1].trim());
        });
        fn(node, content);
    },
    updater: {
        // 把數據插入到節點中
        modalUpdater(node, value) {
            node.value = value;
        },
        // 處理文本節點
        textUpdater(node, value) {
            node.textContent = value;
        }
    }
}

Complier 具有將 HTML 模版解析成 Document Fragment 的能力,而且會建立響應的 Watcher,讓視圖中綁定的數據產生變化。

發佈訂閱 - Watcher

Watcher 訂閱者做爲 Observer 和 Compile 之間通訊的橋樑,主要作的事情是:
  1. 在自身實例化時往屬性訂閱器(dep)裏面添加本身
  2. 自身必須有一個update()方法
  3. 待屬性變更dep.notice()通知時,能調用自身的update()方法,並觸發Compile中綁定的回調,則功成身退。
// 發佈訂閱
function Dep() { 
    this.subs = []
}
Dep.prototype.addSub = function(sub) {
    this.subs.push(sub)
}
Dep.prototype.notify = function() {
    this.subs.forEach(sub => sub.update())
}
/**
  * watcher
  * @param {*} vm 當前實例
  * @param {*} exp 表達式
  * @param {*} fn 監聽函數
  */
function Watcher(vm, exp, fn) { 
    this.fn = fn;
    this.vm = vm;
    this.exp = exp; // 添加到訂約中
    Dep.target = this;
    let val = vm;
    let arr = exp.split('.');
    arr.forEach(function (k) { 
        val = val[k];
    })
    Dep.target = null; // 保證watcher不會重複添加
}
Watcher.prototype.update = function() {
    let val = this.vm;
    let arr = this.exp.split('.');
    arr.forEach(function (k) { 
        val = val[k];
    })
    this.fn(val)
}

Dep 和 Watcher 是簡單的觀察者模式的實現,Dep 即訂閱者,它會管理全部的觀察者,而且有給觀察者發送消息的能力。Watcher 即觀察者,當接收到訂閱者的消息後,觀察者會作出本身的更新操做。

整合 - MVVM

MVVM做爲數據綁定的入口,整合Observer、Compile和Watcher三者,經過Observer來監聽本身的model數據變化,經過Compile來解析編譯模板指令,最終利用Watcher搭起Observer和Compile之間的通訊橋樑,達到數據變化 -> 視圖更新;視圖交互變化(input) -> 數據model變動的雙向綁定效果。

class MVVM {
    constructor(options) {
        // 當new該類時,參數就會傳到構造函數中 options就是el  data computed ...
        this.$el = options.el; // 建立一個當前實例$el
        this.$data = options.data;
        // 判斷根元素是否存在 <div id='app'></div> =>  編譯模板
        if (this.$el) {
            // 把data裏的數據 所有轉化成用Object.defineProperty來定義
            new Observer(this.$data);
            new Compiler(this.$el, this);
        }
    }
}
注意:這樣有個問題? 
在開發中是能經過實例+屬性(vm.a)來獲取數據,而咱們實現的MVVM獲取數據要經過myMvvm.$data.xxx來獲取到數據,中間多了一個$data,這樣顯然不是咱們想要的樣子。接下來實現讓實例this來代理$data數據,便可實現myMvvm.xxx獲取數據和真實場景同樣的操做。
數據代理

在MVVM實例上添加一個屬性代理的方法,使訪問myMvvm的屬性代理爲訪問myMvvm.$data的屬性。其實仍是利用了Object.defineProperty()方法來劫持了myMvvm實例對象的屬性。添加的代理方法以下:

// this 代理 $data
  for (let key in data) {
    Object.defineProperty(this, key, {
      enumerable: true,
      get() {
        return this.$data[key]; // this.xxx == {}
      },
      set(newVal) {
        this.$data[key] = newVal;
      }
    })
  }

圖片alt

擴展 - 實現computed

computed 具備緩存功能,當依賴的屬性發送變化,纔會更新視圖變化
function initComputed() {
    let vm = this; // 將當前this掛載到vm上
    let computed = this.$options.computed;  // 從options上拿到computed屬性
    // 獲得的都是對象的key能夠經過Object.keys轉化爲數組
    Object.keys(computed).forEach(key => {
        Object.defineProperty(vm, key, { // 映射到this實例上
            // 判斷是computed裏的key是對象仍是函數
            // 如果函數,則直接就調get方法
            // 如果對象,則須要手動調一下get方法
            // 由於computed只根據依賴的屬性進行觸發,當獲取依賴屬性時,系統會自動的去調用get方法,因此就不要用Watcher去監聽變化了
            get: typeof computed[key] === 'function' ? computed[key] : computed[key].get,
            set() {}
        });
    });
}

項目git地址

https://github.com/tangmengcheng/mvvm.git 歡迎小夥伴點贊、評論加關注哦🤭 star~

相關問題

  • Objest.defineProperty() 有那些缺點?
  • 實現數組的一個監聽?
  • Vue3 中是如何用 Proxy 實現的?
  • Vue2.x 源碼中數據雙向綁定大體的實現流程?

圖片alt

總結

經過以上描述和核心代碼的演示,相信小夥伴們對MVVM有從新的認識,面試中對面面試官的提問能夠對答如流。但願同行的小夥伴手動敲一遍,實現一個本身MVVM,這樣對其原理理解更加深刻。

隨着日益月薪需求的不斷增長,jQuery操做DOM的時代已經知足不了企業項目快速迭代的進度了。 MVVM 模式對於前端領域有着重大的意義,其核心原理:實時保證View層與Model層的數據同步,實現了雙向數據綁定。減小了頻繁的DOM操做,提升了頁面渲染的性能,也讓開發者把更多的時間放到數據的處理以及業務功能的開發上。

最後

若是本文對你有幫助得話,給本文點個贊❤️❤️❤️

歡迎你們加入,一塊兒學習前端,共同進步!
cmd-markdown-logo
cmd-markdown-logo

相關文章
相關標籤/搜索