vue雙向數據綁定原理

網上關於VUE雙向數據綁定的文章多如牛毛,此文章僅用做本身總結。javascript

VUE雙向數據綁定用到了文檔碎片documentFragmentObject.definePropertyproxy發佈訂閱模式,下面來分別介紹一下這幾個知識點,而後運用它們寫一個JS原生的雙向數據綁定案例。html

DocumentFragment

建立一個新的空白的文檔片斷。DocumentFragments是DOM節點。它們不是主DOM樹的一部分。一般的用例是建立文檔片斷,將元素附加到文檔片斷,而後將文檔片斷附加到DOM樹。在DOM樹中,文檔片斷被其全部的子元素所代替。由於文檔片斷存在於內存中,並不在DOM樹中,因此將子元素插入到文檔片斷時不會引發頁面迴流(reflow)(對元素位置和幾何上的計算)。所以,使用文檔片斷document fragments一般會起到優化性能的做用。vue

Demojava

<body>
    <ul data-uid="ul"></ul>
</body>

<script>
    let ul = document.querySelector(`[data-uid="ul"]`),
        docfrag = document.createDocumentFragment();
    
    const browserList = [
        "Internet Explorer", 
        "Mozilla Firefox", 
        "Safari", 
        "Chrome", 
        "Opera"
    ];
    
    browserList.forEach((e) => {
        let li = document.createElement("li");
        li.textContent = e;
        docfrag.appendChild(li);
    });
    
    ul.appendChild(docfrag);
</script>
複製代碼

defineProperty

對象的屬性分爲:數據屬性和訪問器屬性。若是要修改對象的默認特性,必須使用Object.defineProperty方法,它接收三個參數:屬性所在的對象、屬性的名字、一個描述符對象。node

數據屬性:

數據屬性包含一個數據值的位置,在這個位置能夠讀取和寫入值,數據屬性有4個描述其行爲的特性。es6

  • Configurable:表示可否經過delete刪除屬性從而從新定義屬性,可否修改屬性的特性,或者可否把屬性修改成訪問器屬性。默認值爲true。
  • Enumberable:表示可否經過for-in循環返回屬性。默認值爲true
  • Writable:表示可否修改屬性的值。默認值爲true
  • Value:包含這個屬性的數據值。讀取屬性值的時候,從這個位置讀;定稿屬性值的時候,把新值保存在這個位置。默認值爲true
訪問器屬性:

訪問器屬性不包含數據值;它們包含一對getter、setter函數(兩個函數不是必須的)。在讀取訪問器屬性時,會調用getter函數,這個函數負責返回有效的值;在寫入訪問器屬性時,會調用setter函數並傳入新值,這個函數負責決定如何處理數據。訪問器屬性有以下4個特性。設計模式

  • Configurable:表示可否經過delete刪除屬性從而從新定義屬性,可否修改屬性的特性,或者可否把屬性修改成數據屬性。默認值爲true
  • Enumerable:表示可否經過for-in循環返回屬性。默認值爲true
  • Get:在讀取屬性時調用的函數。默認值爲undefined
  • Set:在定稿屬性時調用的函數。默認值爲undefined

Demo數組

var book = {
    _year: 2018,
    edition: 1
};
Object.defineProperty(book, "year", {
    get: function(){
        return this._year;
    },
    set: function(newVal){
        if(newVal > 2008){
            this._year = newVal;
            this.edition += newVal - 2008;
        }
    }
});

book.year = 2019;
console.log(book._year);//2019
console.log(book.edition);//12
複製代碼

Object.defineProperty缺陷:bash

  1. 只能對屬性進行數據劫持,對於JS對象劫持須要深度遍歷;
  2. 對於數組不能監聽到數據的變化,而是經過一些hack辦法來實現,如pushpopshiftunshiftsplicesortreverse詳見文檔

proxy

ES6新方法,它能夠理解成,在目標對象以前架設一層「攔截」,外界對該對象的訪問,都必須先經過這層攔截,所以提供了一種機制,能夠對外界的訪問進行過濾和改寫。Proxy這個詞的原意是代理,用在這裏表示由它來「代理」某些操做,能夠譯爲「代理器」。proxy支持的方法有:app

  • get():攔截對象屬性的讀取。
  • set():攔截對象屬性的設置。
  • apply():攔截函數的調用、callapply操做。
  • has():即判斷對象是否具備某個屬性時,這個方法會生效,返回一個布爾值。它有兩個參數:目標對象、需查詢的屬性名。
  • construct():用於攔截new命令。參數:target(目標對象)、args(構造函數的參數對象)、newTarget(建立實例對象時,new命令做用的構造函數)。
  • deleteProperty():攔截delete proxy[propKey]的操做,返回一個布爾值。
  • defineProperty():攔截object.defineProperty操做。
  • getOwnPropertyDescriptor():攔截object.getownPropertyDescriptor(),返回一個屬性描述對象或者undefined
  • getPrototypeOf():用來攔截獲取對象原型。能夠攔截Object.prototype.__proto__Object.prototype.isPrototypeOf()Object.getPrototypeOf()Reflect.getPrototypeOf()instanceof
  • isExtensible():攔截Object.isExtensible操做,返回布爾值。
  • ownKeys():攔截對象自身屬性的讀取操做。可攔截Object.getOwnPropertyNames()Object.getOwnPropertySymbols()Object.keys()for...in循環。
  • preventExtensions():攔截Object.preventExtensions(),返回一個布爾值。
  • setPrototypeOf():攔截Object.setPrototypeOf方法。
  • revocable():返回一個可取消的proxy實例。

Demo

<body>
    <input type="text" id="input">
    <p id="p"></p>
</body>
<script>
    const input = document.getElementById('input');
    const p = document.getElementById('p');
    const obj = {};
    
    const newObj = new Proxy(obj, {
      get: function(target, key, receiver) {
        console.log(`getting ${key}!`);
        return Reflect.get(target, key, receiver);
      },
      set: function(target, key, value, receiver) {
        console.log(target, key, value, receiver);
        if (key === 'text') {
          input.value = value;
          p.innerHTML = value;
        }
        return Reflect.set(target, key, value, receiver);
      },
    });
    
    input.addEventListener('keyup', function(e) {
      newObj.text = e.target.value;
    });
</script>
複製代碼

設計模式-發佈訂閱模式

觀察者模式與發佈訂閱模式容易混,這裏順帶區別一下。

  • 觀察者模式:一個對象(稱爲subject)維持一系列依賴於它的對象(稱爲observer),將有關狀態的任何變動自動通知給它們(觀察者)。
  • 發佈訂閱模式:基於一個主題/事件通道,但願接收通知的對象(稱爲subscriber)經過自定義事件訂閱主題,被激活事件的對象(稱爲publisher)經過發佈主題事件的方式被通知。

差別:

  • Observer模式要求觀察者必須訂閱內容改變的事件,定義了一個一對多的依賴關係;
  • Publish/Subscribe模式使用了一個主題/事件通道,這個通道介於訂閱着與發佈者之間;
  • 觀察者模式裏面觀察者「被迫」執行內容改變事件(subject內容事件);發佈/訂閱模式中,訂閱着能夠自定義事件處理程序;
  • 觀察者模式兩個對象之間有很強的依賴關係;發佈/訂閱模式兩個對象之間的耦合度底。

Demo

// vm.$on
export function eventsMixin(Vue: Class<Component>) {
    const hookRE = /^hook:/
    //參數類型爲字符串或者字符串組成的數組
    Vue.prototype.$on = function (event: string | Array<string>, fn: Function): Component {
        const vm: Component = this
        // 傳入類型爲數組
        if (Array.isArray(event)) {
            for (let i = 0, l = event.length; i < l; i++) {
                this.$on(event[i], fn)
                //遞歸併傳入相應的回調
            }
        } else {
            (vm._events[event] || (vm._events[event] = [])).push(fn)
            // optimize hook:event cost by using a boolean flag marked at registration
            // instead of a hash lookup
            if (hookRE.test(event)) {
                vm._hasHookEvent = true
            }
        }
        return vm
    }

    // vm.$emit
    Vue.prototype.$emit = function (event: string): Component {
        const vm: Component = this
        if (process.env.NODE_ENV !== 'production') {
            const lowerCaseEvent = event.toLowerCase()
            if (lowerCaseEvent !== event && vm._events[lowerCaseEvent]) {
                tip(
                    `Event "${lowerCaseEvent}" is emitted in component ` +
                    `${formatComponentName(vm)} but the handler is registered for "${event}". ` +
                    `Note that HTML attributes are case-insensitive and you cannot use ` +
                    `v-on to listen to camelCase events when using in-DOM templates. ` +
                    `You should probably use "${hyphenate(event)}" instead of "${event}".`
                )
            }
        }
        let cbs = vm._events[event]
        if (cbs) {
            cbs = cbs.length > 1 ? toArray(cbs) : cbs
            const args = toArray(arguments, 1)
            for (let i = 0, l = cbs.length; i < l; i++) {
                try {
                    cbs[i].apply(vm, args)// 執行以前傳入的回調
                } catch (e) {
                    handleError(e, vm, `event handler for "${event}"`)
                }
            }
        }
        return vm
    }
}
複製代碼

MVVM的流程分析

下面原生的MVVM小框架主要針對Compile(模板編譯)、Observer(數據劫持)、Watcher(數據監聽)和Dep(發佈訂閱)幾個部分來實現。流程可參照下圖:

mvvm.html頁面,實例化一個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">
        <input type="text" v-model="message.a">
        <ul>
            <li>{{message.a}}</li>
        </ul>
        {{name}}
    </div>
    <script src="mvvm.js"></script>
    <script src="compile.js"></script>
    <script src="observer.js"></script>
    <script src="watcher.js"></script>
    <script> let vm = new MVVM({ el:'#app', data: { message: { a: 'hello' }, name: 'haoxl' } }) </script>
</body>
</html>
複製代碼

mvvm.js主要用來劫持數據,及將節點掛載到$el上,數據掛載到$data上。

class MVVM{
    constructor(options) {
        //將參數掛載到MVVM實例上
        this.$el = options.el;
        this.$data = options.data;
        //若是有要編譯的模板就開始編譯
        if(this.$el){
            //數據劫持-就是把對象的全部屬性改爲get和set方法
            new Observer(this.$data);
            //將this.$data上的數據代理到this上
            this.proxyData(this.$data);
            //用數據和元素進行編譯
            new Compile(this.$el, this);
        }
    }
    proxyData(data){
        Object.keys(data).forEach(key =>{
            Object.defineProperty(this, key, {
                get(){
                    return data[key]
                },
                set(newValue){
                    data[key] = newValue
                }
            })
        })
    }
}
複製代碼

observer.js利用Object.defineProerty來劫持數據,結合發佈訂閱模式來響應數據變化。

class Observer{
    constructor(data){
        this.observe(data);
    }
    observe(data){
        //將data數據原有屬性改爲set和get的形式,若是data不爲對象,則直接返回
        if(!data || typeof data !== 'object'){
            return;
        }
        //要將數據一一劫持,先獲取data中的key和value
        Object.keys(data).forEach(key => {
            //劫持
            this.defineReactive(data, key, data[key]);
            this.observe(data[key]);//遞歸劫持,data中的對象
        });
    }
    defineReactive(obj, key, value) {
        let that = this;
        let dep = new Dep();//每一個變化的數據都會對應一個數組,這個數組是存放全部更新的操做
        Object.defineProperty(obj, key, {
            enumerable: true,
            configurable: true,
            //取值時會觸發的方法
            get(){//當取值時調用的方法
                Dep.target && dep.addSub(Dep.target)
                return value;
            },
            //賦值時會觸發的方法
            set(newValue){
                //給data中的屬性賦新值
                if(newValue !== value){
                    //若是是對象繼續劫持
                    that.observe(newValue);
                    value = newValue;
                    dep.notify();//通知全部人數據更新了
                }
            }
        })
    }
}

//
class Dep{
    constructor(){
        //訂閱的數組
        this.subs = []
    }
    //添加訂閱
    addSub(watcher){
        this.subs.push(watcher);
    }
    notify(){
        //調用watcher的更新方法
        this.subs.forEach(watcher => watcher.update());
    }
}
複製代碼

watcher.js

//觀察者的目的就是給須要變化的元素加一個觀察者,當數據變化後執行對應的方法
class Watcher{
    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.b
            return prev[next];
        }, vm.$data)
    }
    get(){
        Dep.target = this;//將實例賦給target
        let value = this.getVal(this.vm, this.expr);
        Dep.target = null;//
        return value;//將舊值返回
    }
    // 對外暴露的方法
    update(){
        //值變化時將會觸發update,獲取新值,舊值已保存在value中
        let newValue = this.getVal(this.vm, this.expr);
        let oldValue = this.value;
        if(newValue !== oldValue){
            this.cb(newValue);//調用watch的回調函數
        }
    }
}
複製代碼

compile.js 利用DocumentFragment文檔碎片建立DOM節點,而後利用正則解析{{}},將數據渲染到此區域。

class Compile{
    constructor(el, vm){
        //el爲MVVM實例做用的根節點
        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;
    }
    //是不是指令
    isDirective(name){
        return name.includes('v-');
    }
    /*核心方法*/
    //將el中的內容所有放到內存中
    node2fragment(el){
        //文檔碎片-內存中的文檔碎片
        let fragment = document.createDocumentFragment();
        let firstChild;
        while(firstChild = el.firstChild) {
            fragment.appendChild(firstChild);
        }
        return fragment;//內存中的節點
    }
    //編譯元素
    compileElement(node){
        //獲取節點全部屬性
        let attrs = node.attributes;
        Array.from(attrs).forEach(attr => {
            //判斷屬性名是否是包含v-
            let attrName = attr.name;
            if(this.isDirective(attrName)){
                //取到對應的值放到節點中
                let expr = attr.value;
                //指令可能有多個,如v-model、v-text、v-html,因此要取相應的方法進行編譯
                let [,type] = attrName.split('-');//解構賦值[v,model]
                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){
        //當前父節點節點的子節點,包含文本節點,類數組對象
        let childNodes = fragment.childNodes;
        // 轉換成數組並循環判斷每個節點的類型
        Array.from(childNodes).forEach(node => {
            if(this.isElementNode(node)) {//是元素節點
                //編譯元素
                this.compileElement(node);
                //若是是元素節點,須要再遞歸
                this.compile(node)
            }else{//是文本節點
                //編譯文本
                this.compileText(node);
            }
        })
    }
}

//編譯方法,暫時只實現v-model及{{}}對應的方法
CompileUtil = {
    getVal(vm, expr){
        expr = expr.split('.');
        return expr.reduce((prev,next) => {//vm.$data.a.b
            return prev[next];
        }, vm.$data)
    },
    getTextVal(vm, expr){//獲取編譯後的文本內容
        return expr.replace(/\{\{([^}]+)\}\}/g,(...arguments)=>{
            return this.getVal(vm, arguments[1])
        })
    },
    text(node, vm, expr){//文本處理
        let updateFn = this.updater['textUpdater'];
        //將{{message.a}}轉爲裏面的值
        let value = this.getTextVal(vm, expr);
        //用正則匹配{{}},而後將其裏面的值替換掉
        expr.replace(/\{\{([^}]+)\}\}/g,(...arguments)=>{
            //解析時遇到模板中須要替換爲數據值的變量時,應添加一個觀察者
            //當變量從新賦值時,調用更新值節點到Dom的方法
            //new(實例化)後將調用observe.js中get方法
            new Watcher(vm, arguments[1],(newValue)=>{
                //若是數據變化了文本節點須要從新獲取依賴的屬性更新文本中的內容
                updateFn && updateFn(node,this.getTextVal(vm, expr));
            })
        })
        //若是有文本處理方法,則執行
        updateFn && updateFn(node,value)
    },
    setVal(vm, expr, value){//[message,a]給文本賦值
        expr = expr.split('.');//將對象先拆開成數組
        //收斂
        return expr.reduce((prev, next, currentIndex) => {
            //若是到對象最後一項時則開始賦值,如message:{a:1}將拆開成message.a = 1
            if(currentIndex === expr.length-1){
                return prev[next] = value;
            }
            return prev[next]// TODO
        },vm.$data);
    },
    model(node, vm, expr){//輸入框處理
        let updateFn = this.updater['modelUpdater'];
        //加一個監控,當數據發生變化,應該調用這個watch的callback
        new Watcher(vm, expr, (newValue)=>{
            //當值變化後會調用cb,將新值傳遞回來
            updateFn && updateFn(node,this.getVal(vm, expr))
        });
        //給輸入添加input事件,輸入值時將觸發
        node.addEventListener('input', (e) => {
            let newValue = e.target.value;
            this.setVal(vm, expr, newValue);
        });
        //若是有文本處理方法,則執行
        updateFn && updateFn(node,this.getVal(vm, expr))
    },
    updater: {
        //更新文本
        textUpdater(node, value){
            node.textContent = value
        },
        //更新輸入框的值
        modelUpdater(node, value){
            node.value = value;
        }
    }

}
複製代碼

總結:首先Vue會使用documentfragment劫持根元素裏包含的全部節點,這些節點不只包括標籤元素,還包括文本,甚至換行的回車。 而後Vue會把data中全部的數據,用defindProperty()變成Vue的訪問器屬性,這樣每次修改這些數據的時候,就會觸發相應屬性的get,set方法。 接下來編譯處理劫持到的dom節點,遍歷全部節點,根據nodeType來判斷節點類型,根據節點自己的屬性(是否有v-model等屬性)或者文本節點的內容(是否符合{{文本插值}}的格式)來判斷節點是否須要編譯。對v-model,綁定事件當輸入的時候,改變Vue中的數據。對文本節點,將他做爲一個觀察者watcher放入觀察者列表,當Vue數據改變的時候,會有一個主題對象,對列表中的觀察者們發佈改變的消息,觀察者們再更新本身,改變節點中的顯示,從而達到雙向綁定的目的。

相關文章
相關標籤/搜索