一步步實現VUE-MVVM 系列,儲備面試技能

前言

這是本人的學習的記錄,由於最近在準備面試,不少狀況下會被提問到:請簡述 mvvm ? 通常狀況下我可能這麼答:mvvm 是視圖和邏輯的一個分離,是model view view-model 的縮寫,經過虛擬dom的方式實現雙向數據綁定(我隨便答得)javascript

那麼問題來了,你知道 mvvm 是怎麼實現的? 回答: mvvm 主要經過 ObjectdefineProperty 屬性,重寫 datasetget 函數來實現。 ok,回答得60分,那麼你知道具體實現過程麼?想一想看,就算他沒問到而你答了出來是否是更好?前提下,必定要手擼一下簡單的mvvm纔會對它有印象~html

話很少說,接下來是參考自張仁陽老師的教學視頻而做,採用的是ES6語法,其中也包含了我我的的理解,若是能幫助到您,我將十分高興。若有錯誤之處,請各位大佬指正出來,不勝感激~~~vue

在實現以前,請先了解基本的mvvm的編譯過程以及使用java

  • 編譯的流程圖 node

  • 總體分析 git

能夠發現new MVVM()後的編譯過程主體分爲兩個部分:es6

  1. 一部分是模板的編譯 Compile
    • 編譯元素和文本,最終渲染到頁面中
    • 其中標籤中有模板指令的標籤才執行編譯 例如<div>我很帥</div> 不執行編譯
  2. 一部分是數據劫持 Observer
    • Dep 發佈訂閱,將全部須要通知變化的data添加到一個數組中
    • Watcher 若是數據發生改變,在ObjectdefinePropertyset函數中調用Watcherupdate方法

明確本文須要實現的目標

  1. 實現模板編譯的過程 完成Vue實例中的屬性能夠正確綁定在標籤中,而且渲染在頁面中
    • 工做:指令的解析,正則替換{{}}
    • 將節點的內容node.textContent或者inputvalue編譯出來
  2. 完成數據的雙向綁定
    • 工做:經過observe類劫持數據變化
    • 添加發布與訂閱:Object.definePropertyget鉤子中addSub,set鉤子中通知變化dep.notify()
    • dep.notify()調用的是Watcherupdate方法,也就是說須要在input變化時調用更新

先明確咱們的目標是:視圖的渲染和雙向的數據綁定以及通知變化!步驟:先從怎麼使用Vue入手一步步解析,從入口類Vue到編譯compile 目標【實現視圖渲染】,在此以前還有observe對數據進行劫持後再調用視圖的更新,watcher 類監聽變化到最後通知全部視圖的更新等等。github

分解 Vue 實例

如何入手?首先從怎麼使用Vue開始。讓咱們一步步解析Vue的使用:面試

let vm = new Vue({
    el: '#app'
    data: {
        message: 'hello world'
    }
})
複製代碼

上面代碼能夠看出使用Vue,咱們是先new 一個Vue 實例,傳一個對象參數,包含 eldatajson

ok,以上獲得了信息,接下來讓咱們實現目標1:將Vue實例的data編譯到頁面中

實現 Complie 編譯模板的過程

先看看頁面的使用:index.html

<div id="app">
    <input type="text" v-model="jsonText.text">
    <div>{{message}}</div>
    {{jsonText.text}}
</div>
<script src="./watcher.js"></script>
<script src="./observer.js"></script>
<script src="./compile.js"></script>
<script src="./vue.js"></script>
<script> let vm = new Vue({ el: '#app', data: { message: 'gershonv', jsonText:{ text: 'hello Vue' } } }) </script>
複製代碼

第一步固然是添加Vue類做爲一個入口文件。

vue 類-入口文件的添加

新建一個vue.js文件,其代碼以下 構造函數中定義$el$data,由於後面的編譯要使用到

class Vue {
    constructor(options) {
        this.$el = options.el; // 掛載
        this.$data = options.data;

        // 若是有要編譯的模板就開始編譯
        if (this.$el) {
            // 用數據和元素進行編譯
            new Compile(this.$el, this)
        }
    }
}
複製代碼
  • 這裏暫時未添加數據劫持obeserve,實現目標1暫時未用到,後續再添加
  • 編譯須要 el 和相關數據,上面代碼執行後會有編譯,因此咱們新建一個執行編譯的類的文件

這裏在入口文件vue.jsnew了一個Compile實例,因此接下來新建compile.js

Compile 類-模板編譯的添加

Compile 須要作什麼? 咱們知道頁面中操做dom會消耗性能,因此能夠把dom移入內存處理:

  1. 先把真實的 dom 移入到內存中 (在內存中操做dom速度比較快)
    • 怎麼放在內存中?能夠利用文檔碎片 fragment
  2. 編譯 compile(fragment){}
    • 提取想要的元素節點和文本節點 v-model {{}},而後進行相關操做。
  3. 把編譯好的fragment塞回頁面裏去
class Compile {
    constructor(el, vm) {
        this.el = this.isElementNode(el) ? el : document.querySelector(el);
        this.vm = vm;
        if (this.el) {// 若是這個元素能獲取到 咱們纔開始編譯
            // 1.先把這些真實的DOM移入到內存中 fragment[文檔碎片]
            let fragment = this.node2fragment(this.el)
            // 2.編譯 => 提取想要的元素節點 v-model 和文本節點 {{}}
            this.compile(fragment)
            // 3.編譯好的fragment在塞回頁面裏去
            this.el.appendChild(fragment)
        }
    }

    /* 專門寫一些輔助的方法 */
    isElementNode(node) { // 判斷是否爲元素及節點,用於遞歸遍歷節點條件
        return node.nodeType === 1;
    }

    /* 核心方法 */
    node2fragment(el) { // 將el的內容所有放入內存中
        // 文檔碎片
        let fragment = document.createDocumentFragment();
        while (el.firstChild) { // 移動DOM到文檔碎片中
            fragment.appendChild(firstChild)
        }
        return fragment;
    }
    
    compile(fragment) {
    }
}
複製代碼

補充:將el中的內容移入文檔碎片fragment 中是一個進出棧的過程。el 的子元素被移到fragment【出棧】後,el 下一個子元素會變成firstChild

編譯的過程就是把咱們的數據渲染好,表如今視圖中

編譯過程 compile(fragment)

  • 第一步:獲取元素的節點,提取其中的指令或者模板{{}}
    • 首先須要遍歷節點,用到了遞歸方法,由於有節點嵌套的關係,isElementNode 表明是節點元素,也是遞歸的終止的判斷條件。
  • 第二步:分類編譯指令的方法compileElement 和 編譯文本{{}}的方法
    • compileElementv-modelv-text等指令的解析
    • compileText 編譯文本節點 {{}}
class Compile{
    // ...
    compile(fragment) {
        // 遍歷節點 可能節點套着又一層節點 因此須要遞歸
        let childNodes = fragment.childNodes
        Array.from(childNodes).forEach(node => {
            if (this.isElementNode(node)) {
                // 是元素節點 繼續遞歸
                // 這裏須要編譯元素
                this.compileElement(node);
                this.compile(node)
            } else {
                // 文本節點
                // 這裏須要編譯文本
                this.compileText(node)
            }
        })
    }
}
複製代碼
compileElement && compileText
  1. 取出元素的屬性 node.attributes 先判斷是否包含指令
  2. 判斷指令類型(v-html v-text v-model...) 調用不同的數據更新方法
    • 這裏提取了編譯的工具對象 CompileUtil
    • 調用方法: CompileUtil[type](node, this.vm, expr)
      • CompileUtil.類型(節點,實例,v-XX 綁定的屬性值)
class Compile{
    // ...
    
    // 判斷是不是指令 ==> compileElement 中遞歸標籤屬性中使用
    isDirective(name) {
        return name.includes('v-')
    }
    
    compileElement(node) {
        // v-model 編譯
        let attrs = node.attributes; // 取出當前節點的屬性
        Array.from(attrs).forEach(attr => {
            let attrName = attr.name;
            // 判斷屬性名是否包含 v-
            if (this.isDirective(attrName)) {
                // 取到對應的值,放到節點中
                let expr = attr.value;
                // v-model v-html v-text...
                let [, type] = attrName.split('-')
                CompileUtil[type](node, this.vm, expr);
            }
        })
    }
    compileText(node) {
        // 編譯 {{}}
        let expr = node.textContent; //取文本中的內容
        let reg = /\{\{([^}]+)\}\}/g;
        if (reg.test(expr)) {
            CompileUtil['text'](node, this.vm, expr)
        }
    }
    
    // compile(fragment){...}
}
CompileUtil = {
    getVal(vm, expr) { // 獲取實例上對應的數據
        expr = expr.split('.'); // 處理 jsonText.text 的狀況
        return expr.reduce((prev, next) => { 
            return prev[next] // 譬如 vm.$data.jsonText.text、vm.$data.message
        }, vm.$data)
    },
    getTextVal(vm, expr) { // 獲取文本編譯後的結果
        return expr.replace(/\{\{([^}]+)\}\}/g, (...arguments) => {
            return this.getVal(vm, arguments[1])
        })
    },
    text(node, vm, expr) { // 文本處理 參數 [節點, vm 實例, 指令的屬性值]
        let updateFn = this.updater['textUpdater'];
        let value = this.getTextVal(vm, expr)
        updateFn && updateFn(node, value)
    },
    model(node, vm, expr) { // 輸入框處理
        let updateFn = this.updater['modelUpdater'];
        updateFn && updateFn(node, this.getVal(vm, expr))
    },
    updater: {
        // 文本更新
        textUpdater(node, value) {
            node.textContent = value
        },
        // 輸入框更新
        modelUpdater(node, value) {
            node.value = value;
        }
    }
}
複製代碼

到如今爲止 就完成了數據的綁定,也就是說new Vue 實例中的 data 已經能夠正確顯示在頁面中了,如今要解決的就是如何實現雙向綁定

結合開篇的vue編譯過程的圖能夠知道咱們還少一個observe 數據劫持,Dep通知變化,添加Watcher監聽變化, 以及最終重寫data屬性

實現雙向綁定

Observer 類-觀察者的添加

  1. vue.js 中劫持數據
class Vue{
    //...
    if(this.$el){
       new Observer(this.$data); // 數據劫持
       new Compile(this.$el, this); // 用數據和元素進行編譯
    }  
}
複製代碼
  1. 新建 observer.js 文件

代碼步驟:

  • 構造器中添加直接進行 observe
    • 判斷data 是否存在, 是不是個對象(new Vue 時可能不寫data屬性)
    • 將數據一一劫持,獲取data中的keyvalue
class Observer {
    constructor(data) {
        this.observe(data)
    }

    observe(data) {
        // 要對這個數據將原有的屬性改爲 set 和 get 的形式
        if (!data || typeof data !== 'object') {
            return
        }
        // 將數據一一劫持
        Object.keys(data).forEach(key => {
            // 劫持
            this.defineReactive(data, key, data[key])
            this.observe(data[key]) //遞歸深度劫持
        })
    }

    defineReactive(obj, key, value) {
        let that = this
        Object.defineProperty(obj, key, {
            enumerable: true,
            configurable: true,
            get() { // 取值時調用的方法
                return value
            },
            set(newValue) { // 當給data屬性中設置的時候,更改屬性的值
                if (newValue !== value) {
                    // 這裏的this不是實例
                    that.observe(newValue) // 若是是對象繼續劫持
                    value = newValue
                }
            }
        })
    }
}
複製代碼

雖然有了observer,可是並未關聯,以及通知變化。下面就添加Watcher

Watcher 類的添加

新建watcher.js文件

  • 觀察者的目的就是給須要變化的那個元素增長一個觀察者,當數據變化後執行對應的方法

先回憶下watch的用法:this.$watch(vm, 'a', function(){...}) 咱們在添加發布訂閱者時須要傳入參數有: vm實例,v-XX綁定的屬性, cb回調函數getVal 方法拷貝了以前 CompileUtil 的方法,其實能夠提取出來的...)

class Watcher {
    // 觀察者的目的就是給須要變化的那個元素增長一個觀察者,當數據變化後執行對應的方法
    // this.$watch(vm, 'a', function(){...})
    constructor(vm, expr, cb) {
        this.vm = vm;
        this.expr = expr;
        this.cb = cb;

        // 先獲取下老的值
        this.value = this.get();
    }

    getVal(vm, expr) { // 獲取實例上對應的數據
        expr = expr.split('.');
        return expr.reduce((prev, next) => { //vm.$data.a
            return prev[next]
        }, vm.$data)
    }

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

    // 對外暴露的方法
    update(){
        let newValue = this.getVal(this.vm, this.expr);
        let oldValue = this.value

        if(newValue !== oldValue){
            this.cb(newValue); // 對應 watch 的callback
        }
    }
}

複製代碼

Watcher 定義了可是尚未調用,模板編譯的時候,須要調觀察的時候觀察一下 Compile

class Compile{
    //...
}
CompileUtil = {
    //...
    text(node, vm, expr) { // 文本處理 參數 [節點, vm 實例, 指令的屬性值]
        let updateFn = this.updater['textUpdater'];
        let value = this.getTextVal(vm, expr)
        updateFn && updateFn(node, value)

        expr.replace(/\{\{([^}]+)\}\}/g, (...arguments) => {
            new Watcher(vm, arguments[1], () => {
                // 若是數據變化了,文本節點須要從新獲取依賴的屬性更新文本中的內容
                updateFn && updateFn(node, this.getTextVal(vm, expr))
            })
        })
    },
    //...
    model(node, vm, expr) { // 輸入框處理
        let updateFn = this.updater['modelUpdater'];
        // 這裏應該加一個監控,數據變化了,應該調用watch 的callback
        new Watcher(vm, expr, (newValue) => {
            // 當值變化後會調用cb 將newValue傳遞過來()
            updateFn && updateFn(node, this.getVal(vm, expr))
        });

        node.addEventListener('input', e => {
            let newValue = e.target.value;
            this.setVal(vm, expr, newValue)
        })
        updateFn && updateFn(node, this.getVal(vm, expr))
    },
    
    //...
}
複製代碼

實現了監聽後發現變化並無通知到全部指令綁定的模板或是{{}},因此咱們須要Dep 監控、實例的發佈訂閱屬性的一個類,咱們能夠添加到observer.js

Dep 類的添加

注意 第一次編譯的時候不會調用Watcherdep.target不存在,new Watcher的時候target纔有值 有點繞,看下面代碼:

class Watcher {
    constructor(vm, expr, cb) {
        //...
        this.value = this.get()
    }
    get(){
        Dep.target = this;
        let value = this.getVal(this.vm, this.expr);
        Dep.target = null;
        return value
    }
    //...
}

// compile.js
CompileUtil = {
    model(node, vm, expr) { // 輸入框處理
        //...
        new Watcher(vm, expr, (newValue) => {
            // 當值變化後會調用cb 將newValue傳遞過來()
            updateFn && updateFn(node, this.getVal(vm, expr))
        });
    }
}
複製代碼
class Observer{
    //...
    defineReactive(obj, key, value){
        let that = this;
        let dep = new Dep(); // 每一個變化的數據 都會對應一個數組,這個數組存放全部更新的操做
        Object.defineProperty(obj, key, {
            //...
            get(){
                Dep.target && dep.addSub(Dep.target)
                //...
            }
             set(newValue){
                 if (newValue !== value) {
                    // 這裏的this不是實例
                    that.observe(newValue) // 若是是對象繼續劫持
                    value = newValue;
                    dep.notify(); //通知全部人更新了
                }
             }
        })
    }
}
class Dep {
    constructor() {
        // 訂閱的數組
        this.subs = []
    }

    addSub(watcher) {
        this.subs.push(watcher)
    }

    notify() {
        this.subs.forEach(watcher => watcher.update())
    }
}
複製代碼

以上代碼 就完成了發佈訂閱者模式,簡單的實現。。也就是說雙向綁定的目標2已經完成了


結語

板門弄斧了,本人無心譁衆取寵,這只是一篇個人學習記錄的文章。想分享出來,這樣纔有進步。 若是這篇文章幫助到您,我將十分高興。有問題能夠提issue,有錯誤之處也但願你們能提出來,很是感激。

具體源碼我放在了個人github了,有須要的自取。 源碼連接

相關文章
相關標籤/搜索