2020年史上最全Vue框架整理從基礎到實戰(四)

file

相信只要你去面試vue,都會被問到vue的雙向數據綁定,你要是就說個mvvm就是視圖模型模型視圖,只要數據改變視圖也會同時更新!那你離被pass就不遠了!javascript

幾種實現雙向綁定的作法

目前幾種主流的mvc(vm)框架都實現了單向數據綁定,而我所理解的雙向數據綁定無非就是在單向綁定的基礎上給可輸入元素(input、textare等)添加了change(input)事件,來動態修改model和 view,並無多高深。因此無需太過介懷是實現的單向或雙向綁定。html

實現數據綁定的作法有大體以下幾種:前端

發佈者-訂閱者模式(backbone.js)vue

髒值檢查(angular.js)java

數據劫持(vue.js)node

發佈者-訂閱者模式: 通常經過sub, pub的方式實現數據和視圖的綁定監聽,更新數據方式一般作法是 vm.set('property', value),這裏有篇文章講的比較詳細,有興趣可點這裏面試

這種方式如今畢竟太low了,咱們更但願經過 vm.property = value這種方式更新數據,同時自動更新視圖,因而有了下面兩種方式數組

髒值檢查: angular.js 是經過髒值檢測的方式比對數據是否有變動,來決定是否更新視圖,最簡單的方式就是經過 setInterval() 定時輪詢檢測數據變更,固然Google不會這麼low,angular只有在指定的事件觸發時進入髒值檢測,大體以下:瀏覽器

  • DOM事件,譬如用戶輸入文本,點擊按鈕等。( ng-click )
  • XHR響應事件 ( $http )
  • 瀏覽器Location變動事件 ( $location )
  • Timer事件( timeout ,interval )
  • 執行 digest() 或apply()

數據劫持: vue.js 則是採用數據劫持結合發佈者-訂閱者模式的方式,經過Object.defineProperty()來劫持各個屬性的settergetter,在數據變更時發佈消息給訂閱者,觸發相應的監聽回調。緩存

MVVM原理

Vue響應式原理最核心的方法即是經過Object.defineProperty()來實現對屬性的劫持,達到監聽數據變更的目的,無疑這個方法是本文中最重要、最基礎的內容之一

整理了一下,要實現mvvm的雙向綁定,就必需要實現如下幾點:

  • 一、實現一個數據監聽器Observer,可以對數據對象的全部屬性進行監聽,若有變更可拿到最新值並通知訂閱者
  • 二、實現一個指令解析器Compile,對每一個元素節點的指令進行掃描和解析,根據指令模板替換數據,以及綁定相應的更新函數
  • 三、實現一個Watcher,做爲鏈接Observer和Compile的橋樑,可以訂閱並收到每一個屬性變更的通知,執行指令綁定的相應回調函數,從而更新視圖
  • 四、mvvm入口函數,整合以上三者

先看以前vue的功能

<!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">
        <h2>{{obj.name}}--{{obj.age}}</h2>
        <h2>{{obj.age}}</h2>
        <h3 v-text='obj.name'></h3>
        <h4 v-text='msg'></h4>
        <ul>
            <li>1</li>
            <li>2</li>
            <li>3</li>
        </ul>
        <h3>{{msg}}</h3>
        <div v-html='htmlStr'></div>
        <div v-html='obj.fav'></div>
        <input type="text" v-model='msg'>
        <img v-bind:src="imgSrc" v-bind:alt="altTitle">
        <button v-on:click='handlerClick'>按鈕1</button>
        <button v-on:click='handlerClick2'>按鈕2</button>
        <button @click='handlerClick2'>按鈕3</button>
    </div>
    <script src="./vue.js"></script>
    <script> let vm = new MVue({ el: '#app', data: { obj: { name: '小馬哥', age: 19, fav:'<h4>前端Vue</h4>' }, msg: 'MVVM實現原理', htmlStr:"<h3>hello MVVM</h3>", imgSrc:'https://timgsa.baidu.com/timg?image&quality=80&size=b9999_10000&sec=1568782284688&di=8635d17d550631caabfeb4306b5d76fa&imgtype=0&src=http%3A%2F%2Fh.hiphotos.baidu.com%2Fimage%2Fpic%2Fitem%2Fb3b7d0a20cf431ad7427dfad4136acaf2fdd98a9.jpg', altTitle:'眼睛', isActive:'true' }, methods: { handlerClick() { alert(1); console.log(this); }, handlerClick2(){ console.log(this); alert(2) } } }) </script>
</body>

</html>
複製代碼

實現指令解析器Compile

實現一個指令解析器Compile,對每一個元素節點的指令進行掃描和解析,根據指令模板替換數據,以及綁定相應的更新函數,添加監聽數據的訂閱者,一旦數據有變更,收到通知,更新視圖,如圖所示:

初始化

新建MVue.js

class MVue {
    constructor(options) {
        this.$el = options.el;
        this.$data = options.data;
        //保存 options參數,後面處理數據要用到
        this.$options = options;
        // 若是這個根元素存在則開始編譯模板
        if (this.$el) {
            // 1.實現一個指令解析器compile
            new Compile(this.$el, this)
        }
    }
}
class Compile{
    constructor(el,vm) {
        // 判斷el參數是不是一個元素節點,若是是直接賦值,若是不是 則獲取賦值
        this.el = this.isElementNode(el) ? el : document.querySelector(el);
        this.vm = vm;
    }
    isElementNode(node){
        // 判斷是不是元素節點
        return node.nodeType === 1
    }
}
複製代碼

這樣外界能夠這樣操做

let vm = new Vue({
    el:'#app'
})
//or
let vm = new Vue({
    el:document.getElementById('app')
})
複製代碼

優化編譯使用文檔碎片

<h2>{{obj.name}}--{{obj.age}}</h2>
<h2>{{obj.age}}</h2>
<h3 v-text='obj.name'></h3>
<h4 v-text='msg'></h4>
<ul>
    <li>1</li>
    <li>2</li>
    <li>3</li>
</ul>
<h3>{{msg}}</h3>
<div v-html='htmlStr'></div>
<div v-html='obj.fav'></div>
<input type="text" v-model='msg'>
<img v-bind:src="imgSrc" v-bind:alt="altTitle">
<button v-on:click='handlerClick'>按鈕1</button>
<button v-on:click='handlerClick2'>按鈕2</button>
<button @click='handlerClick2'>按鈕3</button>
複製代碼

接下來,找到子元素的值,好比obj.name,obj.age,obj.fav 找到obj 再找到fav,獲取數據中的值替換掉

可是在這裏咱們不得不想到一個問題,每次找到一個數據替換,都要從新渲染一遍,可能會形成頁面的迴流和重繪,那麼咱們最好的辦法就是把以上的元素放在內存中,在內存中操做完成以後,再替換掉.

class Compile {
    constructor(el, vm) {
        // 判斷el參數是不是一個元素節點,若是是直接賦值,若是不是 則獲取賦值
        this.el = this.isElementNode(el) ? el : document.querySelector(el);
        this.vm = vm;
        // 由於每次匹配到進行替換時,會致使頁面的迴流和重繪,影響頁面的性能
        // 因此須要建立文檔碎片來進行緩存,減小頁面的迴流和重繪
        // 1.獲取文檔碎片對象
        const fragment = this.node2Fragment(this.el);
        // console.log(fragment);
        // 2.編譯模板
        // 3.把子元素的全部內容添加到根元素中
        this.el.appendChild(fragment);

    }
    node2Fragment(el) {
        const fragment = document.createDocumentFragment();
        let firstChild;
        while (firstChild = el.firstChild) {
            fragment.appendChild(firstChild);
        }
        return fragment
    }
    isElementNode(el) {
        return el.nodeType === 1;
    }
}
複製代碼

這時候會發現頁面跟以前沒有任何變化,可是通過Fragment的處理,優化頁面渲染性能

編譯模板

// 編譯數據的類
class Compile {
    constructor(el, vm) {
        // 判斷el參數是不是一個元素節點,若是是直接賦值,若是不是 則獲取賦值
        this.el = this.isElementNode(el) ? el : document.querySelector(el);
        this.vm = vm;
        // 由於每次匹配到進行替換時,會致使頁面的迴流和重繪,影響頁面的性能
        // 因此須要建立文檔碎片來進行緩存,減小頁面的迴流和重繪
        // 1.獲取文檔碎片對象
        const fragment = this.node2Fragment(this.el);
        // console.log(fragment);
        // 2.編譯模板
        this.compile(fragment)

        // 3.把子元素的全部內容添加到根元素中
        this.el.appendChild(fragment);

    }
    compile(fragment) {
        // 1.獲取子節點
        const childNodes = fragment.childNodes;
        // 2.遍歷子節點
        [...childNodes].forEach(child => {

            // 3.對子節點的類型進行不一樣的處理
            if (this.isElementNode(child)) {
                // 是元素節點
                // 編譯元素節點
                // console.log('我是元素節點',child);
                this.compileElement(child);
            } else {
                // console.log('我是文本節點',child);
                this.compileText(child);
                // 剩下的就是文本節點
                // 編譯文本節點
            }
            // 4.必定要記得,遞歸遍歷子元素
            if (child.childNodes && child.childNodes.length) {
                this.compile(child);
            }
        })
    }
    // 編譯文本的方法
    compileText(node) {
        console.log('編譯文本')

    }
    node2Fragment(el) {
        const fragment = document.createDocumentFragment();
        // console.log(el.firstChild);
        let firstChild;
        while (firstChild = el.firstChild) {
            fragment.appendChild(firstChild);
        }
        return fragment
    }
    isElementNode(el) {
        return el.nodeType === 1;
    }
}
複製代碼

接下來根據不一樣子元素的類型進行渲染

編譯元素

compileElement(node) {
    // 獲取該節點的全部屬性
    const attributes = node.attributes;
    // 對屬性進行遍歷
    [...attributes].forEach(attr => {
        const { name, value } = attr; //v-text v-model v-on:click @click 
        // 看當前name是不是一個指令
        if (this.isDirective(name)) {
            //對v-text進行操做
            const [, directive] = name.split('-'); //text model html
            // v-bind:src
            const [dirName, eventName] = directive.split(':'); //對v-on:click 進行處理
            // 更新數據
            compileUtil[dirName] && compileUtil[dirName](node, value, this.vm, eventName);
            // 移除當前元素中的屬性
            node.removeAttribute('v-' + directive);

        }else if(this.isEventName(name)){
            // 對事件進行處理 在這裏處理的是@click
            let [,eventName] =  name.split('@');
            compileUtil['on'](node, value, this.vm, eventName)
        }

    })

}
// 是不是@click這樣事件名字
isEventName(attrName){
    return attrName.startsWith('@')
}
//判斷是不是一個指令
isDirective(attrName) {
    return attrName.startsWith('v-')
}
複製代碼

編譯文本

// 編譯文本的方法
compileText(node) {
    const content = node.textContent;
    // 匹配{{xxx}}的內容
    if (/\{\{(.+?)\}\}/.test(content)) {
        // 處理文本節點
        compileUtil['text'](node, content, this.vm)
    }

}
複製代碼

你們也會發現,compileUtil這個對象它是什麼鬼?真正的編譯操做我將它放入到這個對象中,根據不一樣的指令來作不一樣的處理.好比v-text是處理文本的 v-html是處理html元素 v-model是處理表單數據的.....

這樣咱們在當前對象compileUtil中經過updater函數來初始化視圖

處理元素/處理文本/處理事件....

const compileUtil = {
    // 獲取值的方法
    getVal(expr, vm) {
        return expr.split('.').reduce((data, currentVal) => {
            return data[currentVal]
        }, vm.$data)
    },
    getAttrs(expr,vm){

    },
    text(node, expr, vm) { //expr 多是 {{obj.name}}--{{obj.age}} 
        let val;
        if (expr.indexOf('{{') !== -1) {
            // 
            val = expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
                return this.getVal(args[1], vm);
            })
        }else{ //也多是v-text='obj.name' v-text='msg'
            val = this.getVal(expr,vm);
        }
        this.updater.textUpdater(node, val);
    },
    html(node, expr, vm) {
        // html處理 很是簡單 直接取值 而後調用更新函數便可
        let val = this.getVal(expr,vm);
        this.updater.htmlUpdater(node,val);
    },
    model(node, expr, vm) {
        const val = this.getVal(expr,vm);
        this.updater.modelUpdater(node,val);
    },
    // 對事件進行處理
    on(node, expr, vm, eventName) {
        // 獲取事件函數
        let fn = vm.$options.methods && vm.$options.methods[expr];
        // 添加事件 由於咱們使用vue時 都不須要關心this的指向問題,這是由於源碼的內部幫我們處理了this的指向
        node.addEventListener(eventName,fn.bind(vm),false);
    },
    // 綁定屬性 簡單的屬性 已經處理 類名樣式的綁定有點複雜 由於對應的值多是對象 也多是數組 你們根據我的能力嘗試寫一下
    bind(node,expr,vm,attrName){
        let attrVal = this.getVal(expr,vm);
        this.updater.attrUpdater(node,attrName,attrVal);
    },
    updater: {
        attrUpdater(node, attrName, attrVal){
            node.setAttribute(attrName,attrVal);
        },
        modelUpdater(node,value){
            node.value = value;
        },
        textUpdater(node, value) {

            node.textContent = value;

        },
        htmlUpdater(node,value){
            node.innerHTML = value;
        }
    }

}
複製代碼

經過以上操做:咱們實現了一個編譯器compile,用它來解析指令,經過updater初始化視圖

實現一個數據監聽器Observer

ok, 思路已經整理完畢,也已經比較明確相關邏輯和模塊功能了,let's do it 咱們知道能夠利用Obeject.defineProperty()來監聽屬性變更 那麼將須要observe的數據對象進行遞歸遍歷,包括子屬性對象的屬性,都加上 settergetter 這樣的話,給這個對象的某個值賦值,就會觸發setter,那麼就能監聽到了數據變化。。相關代碼能夠是這樣:

//test.js
let data = {name: 'kindeng'};
observe(data);
data.name = 'dmq'; // 哈哈哈,監聽到值變化了 kindeng --> dmq

function observe(data) {
    if (!data || typeof data !== 'object') {
        return;
    }
    // 取出全部屬性遍歷
    Object.keys(data).forEach(function(key) {
        defineReactive(data, key, data[key]);
    });
};

function defineReactive(data, key, val) {
    observe(val); // 監聽子屬性
    Object.defineProperty(data, key, {
        enumerable: true, // 可枚舉
        configurable: false, // 不能再define
        get: function() {
            return val;
        },
        set: function(newVal) {
            console.log('哈哈哈,監聽到值變化了 ', val, ' --> ', newVal);
            val = newVal;
        }
    });
}
複製代碼

再看這張圖,咱們接下來實現的是一個數據監聽器Observer,可以對數據對象的全部屬性進行監聽,若有變更可拿到最新值通知依賴收集對象(Dep)並通知訂閱者(Watcher)來更新視圖

// 建立一個數據監聽者 劫持並監聽全部數據的變化
class Observer{
    constructor(data) {
        this.observe(data);
    }
    observe(data){
        // 若是當前data是一個對象才劫持並監聽
        if(data && typeof data === 'object'){
            // 遍歷對象的屬性作監聽
            Object.keys(data).forEach(key=>{
                this.defineReactive(data,key,data[key]);
            })
            
        }
    }
    defineReactive(obj,key,value){
        // 循環遞歸 對全部層的數據進行觀察
        this.observe(value);//這樣obj也能被觀察了
        Object.defineProperty(obj,key,{
            get(){
                return value;
            },
            set:(newVal)=>{
                if (newVal !== value){
                    // 若是外界直接修改對象 則對新修改的值從新觀察
                    this.observe(newVal);
                    value = newVal;
                    // 通知變化
                    dep.notify();
                }
            }
        })
    }
}
複製代碼

這樣咱們已經能夠監聽每一個數據的變化了,那麼監聽到變化以後就是怎麼通知訂閱者了,因此接下來咱們須要實現一個消息訂閱器,很簡單,維護一個數組,用來收集訂閱者,數據變更觸發notify,再調用訂閱者的update方法,代碼改善以後是這樣:

建立Dep

  • 添加訂閱者
  • 定義通知的方法
class Dep{
    constructor() {
        this.subs = []
    }
    // 添加訂閱者
    addSub(watcher){
        this.subs.push(watcher);
 
    }
    // 通知變化
    notify(){
        // 觀察者中有個update方法 來更新視圖
        this.subs.forEach(w=>w.update());
    }
}
複製代碼

雖然咱們已經建立了Observer,Dep(訂閱器),那麼問題來了,誰是訂閱者?怎麼往訂閱器添加訂閱者?

沒錯,上面的思路整理中咱們已經明確訂閱者應該是Watcher, 並且const dep = new Dep();是在 defineReactive方法內部定義的,因此想經過dep添加訂閱者,就必需要在閉包內操做,因此咱們能夠在 getOldVal裏面動手腳:

實現一個Watcher

它做爲鏈接Observer和Compile的橋樑,可以訂閱並收到每一個屬性變更的通知,執行指令綁定的相應回調函數,從而更新視圖

只要所作事情:

一、在自身實例化時往屬性訂閱器(dep)裏面添加本身

二、自身必須有一個update()方法

三、待屬性變更dep.notify()通知時,能調用自身的update()方法,並觸發Compile中綁定的回調,則功成身退。

//Watcher.js
class Watcher{
    constructor(vm,expr,cb) {
        // 觀察新值和舊值的變化,若是有變化 更新視圖
        this.vm = vm;
        this.expr = expr;
        this.cb = cb;
        // 先把舊值存起來 
        this.oldVal = this.getOldVal();
    }
    getOldVal(){
        Dep.target = this;
        let oldVal = compileUtil.getVal(this.expr,this.vm);
        Dep.target = null;
        return oldVal;
    }
    update(){
        // 更新操做 數據變化後 Dep會發生通知 告訴觀察者更新視圖
        let newVal = compileUtil.getVal(this.expr, this.vm);
        if(newVal !== this.oldVal){
            this.cb(newVal);
        }
    }
}

//Observer.js
defineReactive(obj,key,value){
    // 循環遞歸 對全部層的數據進行觀察
    this.observe(value);//這樣obj也能被觀察了
    const dep = new Dep();
    Object.defineProperty(obj,key,{
        get(){
            //訂閱數據變化,往Dep中添加觀察者
            Dep.target && dep.addSub(Dep.target);
            return value;
        },
        //....省略
    })
}
複製代碼

當咱們修改某個數據時,數據已經發生了變化,可是視圖沒有更新

咱們在何時來添加綁定watcher呢,繼續看圖

也就是說,當咱們訂閱數據變化時,來綁定更新函數,從而讓watcher去更新視圖

修改

// 編譯模板工具類
const compileUtil = {
    // 獲取值的方法
    getVal(expr, vm) {
        return expr.split('.').reduce((data, currentVal) => {
            return data[currentVal]
        }, vm.$data)
    },
    //設置值
    setVal(vm,expr,val){
        return expr.split('.').reduce((data, currentVal, index, arr) => {
            return data[currentVal] = val
        }, vm.$data)
    },
    //獲取新值 對{{a}}--{{b}} 這種格式進行處理
    getContentVal(expr, vm) {
        return expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
            return this.getVal(args[1], vm);
        })
    },
    text(node, expr, vm) { //expr 多是 {{obj.name}}--{{obj.age}} 
        let val;
        if (expr.indexOf('{{') !== -1) {
            // 
            val = expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
                //綁定watcher從而更新視圖
                new Watcher(vm,args[1],()=>{           
                    this.updater.textUpdater(node,this.getContentVal(expr, vm));
                })
                return this.getVal(args[1], vm);
            })
        }else{ //也多是v-text='obj.name' v-text='msg'
            val = this.getVal(expr,vm);
        }
        this.updater.textUpdater(node, val);

    },
    html(node, expr, vm) {
        // html處理 很是簡單 直接取值 而後調用更新函數便可
        let val = this.getVal(expr,vm);
        // 訂閱數據變化時 綁定watcher,從而更新函數
        new Watcher(vm,expr,(newVal)=>{
            this.updater.htmlUpdater(node, newVal);
        })
        this.updater.htmlUpdater(node,val);
    },
    model(node, expr, vm) {
        const val = this.getVal(expr,vm);
        // 訂閱數據變化時 綁定更新函數 更新視圖的變化

        // 數據==>視圖
        new Watcher(vm, expr, (newVal) => {
            this.updater.modelUpdater(node, newVal);
        })
        // 視圖==>數據
        node.addEventListener('input',(e)=>{
            // 設置值
            this.setVal(vm,expr,e.target.value);

        },false);
        this.updater.modelUpdater(node,val);
    },
    // 對事件進行處理
    on(node, expr, vm, eventName) {
        // 獲取事件函數
        let fn = vm.$options.methods && vm.$options.methods[expr];
        // 添加事件 由於咱們使用vue時 都不須要關心this的指向問題,這是由於源碼的內部幫我們處理了this的指向
        node.addEventListener(eventName,fn.bind(vm),false);
    },
    // 綁定屬性 簡單的屬性 已經處理 類名樣式的綁定有點複雜 由於對應的值多是對象 也多是數組 你們根據我的能力嘗試寫一下
    bind(node,expr,vm,attrName){
        let attrVal = this.getVal(expr,vm);
        this.updater.attrUpdater(node,attrName,attrVal);
    },
    updater: {
        attrUpdater(node, attrName, attrVal){
            node.setAttribute(attrName,attrVal);
        },
        modelUpdater(node,value){
            node.value = value;
        },
        textUpdater(node, value) {
            node.textContent = value;
        },
        htmlUpdater(node,value){
            node.innerHTML = value;
        }
    }

}
複製代碼

代理proxy

咱們在使用vue的時候,一般能夠直接vm.msg來獲取數據,這是由於vue源碼內部作了一層代理.也就是說把數據獲取操做vm上的取值操做 都代理到vm.$data上

class Vue {
    constructor(options) {
        this.$data = options.data;
        this.$el = options.el;
        this.$options = options
        // 若是這個根元素存在開始編譯模板
        if (this.$el) {
            // 1.實現一個數據監聽器Observe
            // 可以對數據對象的全部屬性進行監聽,若有變更可拿到最新值並通知訂閱者
            // Object.definerProperty()來定義
            new Observer(this.$data);

            // 把數據獲取操做 vm上的取值操做 都代理到vm.$data上
            this.proxyData(this.$data);
            
            // 2.實現一個指令解析器Compile
            new Compiler(this.$el, this);

        }
    }
    // 作個代理
    proxyData(data){
       for (const key in data) {
          Object.defineProperty(this,key,{
              get(){
                  return data[key];
              },
              set(newVal){
                  data[key] = newVal;
              }
          })
       }
    }
}
複製代碼

面試題

闡述一下你所理解vue的MVVM響應式原理

vue.js 則是採用數據劫持結合發佈者-訂閱者模式的方式,經過Object.defineProperty()來劫持各個屬性的settergetter,在數據變更時發佈消息給訂閱者,觸發相應的監聽回調。

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

再配合上面的那張圖,想不入職都很難

往期文章

2019年末史上最全Vue框架整理從基礎到實戰(一)

2019年末史上最全Vue框架整理從基礎到實戰(二)

2019年末史上最全Vue框架整理從基礎到實戰(三)

2019年末史上最全Vue框架整理從基礎到實戰(五)

最後

還有2件事拜託你們

一:求贊 求收藏 求分享 求留言,讓更多的人看到這篇內容

二:歡迎添加個人我的微信

備註「資料」, 300多篇原創技術文章,海量的視頻資料便可得到

備註「加羣」,我會拉你進技術交流羣,羣裏大牛學霸具在,哪怕您作個潛水魚也會學到不少東西

本文由練識課堂發佈!

相關文章
相關標籤/搜索