vue雙向資料繫結原理

An_an發表於2019-03-04

網上關於VUE雙向資料繫結的文章多如牛毛,此文章僅用作自己總結。

VUE雙向資料繫結用到了文件碎片documentFragmentObject.definePropertyproxy釋出訂閱模式,下面來分別介紹一下這幾個知識點,然後運用它們寫一個JS原生的雙向資料繫結案例。

DocumentFragment

建立一個新的空白的文件片段。DocumentFragments是DOM節點。它們不是主DOM樹的一部分。通常的用例是建立文件片段,將元素附加到文件片段,然後將文件片段附加到DOM樹。在DOM樹中,文件片段被其所有的子元素所代替。因為文件片段存在於記憶體中,並不在DOM樹中,所以將子元素插入到文件片段時不會引起頁面迴流(reflow)(對元素位置和幾何上的計算)。因此,使用文件片段document fragments通常會起到優化效能的作用。

Demo

<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方法,它接收三個引數:屬性所在的物件、屬性的名字、一個描述符物件。

資料屬性:

資料屬性包含一個資料值的位置,在這個位置可以讀取和寫入值,資料屬性有4個描述其行為的特性。

  • 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缺陷:

  1. 只能對屬性進行資料劫持,對於JS物件劫持需要深度遍歷;
  2. 對於陣列不能監聽到資料的變化,而是通過一些hack辦法來實現,如pushpopshiftunshiftsplicesortreverse詳見文件

proxy

ES6新方法,它可以理解成,在目標物件之前架設一層“攔截”,外界對該物件的訪問,都必須先通過這層攔截,因此提供了一種機制,可以對外界的訪問進行過濾和改寫。Proxy這個詞的原意是代理,用在這裡表示由它來“代理”某些操作,可以譯為“代理器”。proxy支援的方法有:

  • 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)通過釋出主題事件的方式被通知。

vue雙向資料繫結原理

差異:

  • 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(釋出訂閱)幾個部分來實現。流程可參照下圖:

vue雙向資料繫結原理

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資料改變的時候,會有一個主題物件,對列表中的觀察者們釋出改變的訊息,觀察者們再更新自己,改變節點中的顯示,從而達到雙向繫結的目的。

相關文章