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

guodada發表於2018-07-18

前言

這是本人的學習的記錄,因為最近在準備面試,很多情況下會被提問到:請簡述 mvvm ? 一般情況下我可能這麼答:mvvm 是檢視和邏輯的一個分離,是model view view-model 的縮寫,通過虛擬dom的方式實現雙向資料繫結(我隨便答得)

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

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

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

  • 編譯的流程圖

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

  • 整體分析

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

可以發現new MVVM()後的編譯過程主體分為兩個部分:

  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 類監聽變化到最後通知所有檢視的更新等等。

分解 Vue 例項

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

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

上面程式碼可以看出使用Vue,我們是先new 一個Vue 例項,傳一個物件引數,包含 eldata

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了,有需要的自取。 原始碼連結

相關文章