基於vue實現的雙向資料繫結

一隻蔥貝發表於2018-05-24

基於vue實現的雙向資料繫結

  • 資料劫持+訂閱者-釋出者模式;
  • 運用Object.defineProperty;
  • 每當一個watcher訂閱者初次獲取vue例項的data的某個屬性時,將它新增為這個屬性的訂閱者,當data的這個屬性改變時,會通知訂閱者,呼叫它們各自的update方法;

前話

  • 何為資料劫持?

    vue通過Object.defineProperty()來劫持各個屬性的settergetter;每個資料在修改時,會自動呼叫setter,在獲取它時,會自動呼叫getter。

  • 何為釋出者-訂閱者模式的方式?

    • 定義了一種一對多的關係;
    • 當一個物件的狀態發生改變時,所有依賴它的物件都將得到通知;
    • 比如買房子,選購者都去售樓小姐那裡登記一下,等有房源的時候,售樓小姐就通知所有登記的選購者過來看房;
  • 關於vue的雙向資料繫結,以下連結的這篇博文講的還不錯,以下是思路整理和對他的程式碼進行的一些註釋。

    <http://www.cnblogs.com/canfoo/p/6891868.html>


將data的屬性變成響應式


注:以下data都是指代一個vue例項的屬性data

1.實現一個Dep類(釋出者):

//釋出者類
function Dep () {
    //subs陣列儲存訂閱者
    this.subs = [];
}
Dep.prototype = {
    //新增訂閱者
    addSub: function(sub) {
        this.subs.push(sub);
    },
    //通知所有訂閱者執行update方法
    notify: function() {
        this.subs.forEach(function(sub) {
            sub.update();
        });
    }
};
//target為靜態屬性,Wathcer部分有使用解釋;
Dep.target = null;
複製程式碼

2. 實現一個Watcher類(訂閱者)

  • Watcher類用於定義一個訂閱者類;
  • 建構函式需要傳入的引數為一個vue物件,一個data的屬性(這個屬性作為釋出者),一個回撥函式cb
  • 這個回撥函式cb就是釋出者在notify時,訂閱者會執行的回撥函式。
  • 我們需要在初始化一個Watcher物件時,並且只在初始化時,將Watcher物件新增進相應data屬性的訂閱者,所以在建構函式裡呼叫get方法來實現;
function Watcher(vm, exp, cb) {
    this.cb = cb;
    this.vm = vm;
    this.exp = exp;
    //在第一次獲取data的屬性值時,將自己新增進該屬性值訂閱器
    this.value = this.get();
}

Watcher.prototype = {
    //釋出者notify時呼叫的回撥函式;
    update: function() {
        this.run();
    },
    run: function() {
        var value = this.vm.data[this.exp];
        var oldVal = this.value;
        if (value !== oldVal) {
            this.value = value;
            this.cb.call(this.vm, value, oldVal);
        }
    },
    get: function() {
        Dep.target = this;  // 快取自己
        var value = this.vm.data[this.exp]  // 執行監聽器裡的get函式,把自己新增為訂閱者
        Dep.target = null;  // 釋放自己
        return value;
    }
};
複製程式碼

3. 實現一個Observer類(關聯釋出者和訂閱者);

  • Observer類的作用遍歷data的所有屬性,設定它們的gettersetter,把data的屬性都變成響應式;
  • getter裡,將想要獲取該屬性的物件新增進訂閱者列表;
  • setter裡,通知所有訂閱者該屬性更改啦~,你們要update啦~
function Observer(data) {
    this.data = data;
    this.walk(data);
}

Observer.prototype = {
    walk: function(data) {
        var self = this;
        Object.keys(data).forEach(function(key) {
            self.defineReactive(data, key, data[key]);
        });
    },
    defineReactive: function(data, key, val) {
        var dep = new Dep();
        //當屬性為物件時,要遞迴遍歷;
        var childObj = observe(val);
        //將屬性變為響應式
        Object.defineProperty(data, key, {
            enumerable: true,
            configurable: true,
            get: function() {
                if (Dep.target) {
                    //判斷訂閱者是否是第一次呼叫get,如果是第一次,將它新增到釋出者陣列裡;
                    dep.addSub(Dep.target);
                }
                return val;
            },
            set: function(newVal) {
                if (newVal === val) {
                    return;
                }
                val = newVal;
                dep.notify();
            }
        });
    }
};

function observe(value, vm) {
    if (!value || typeof value !== 'object') {
        return;
    }
    return new Observer(value);
};

複製程式碼

4. 實現一個SelfVue類

  • SelfVue類就是自己實現的Vue啦~
  • proxyKeys(key)用於實現 vm.xxx -> vm._data.xxx;
function SelfVue (options) {
    var self = this;
    this.vm = this;
    this.data = options.data;

    Object.keys(this.data).forEach(function(key) {
        self.proxyKeys(key);
    });

    observe(this.data);
    new Compile(options.el, this.vm);
    return this;
}

SelfVue.prototype = {
    proxyKeys: function (key) {
        var self = this;
        Object.defineProperty(this, key, {
            enumerable: false,
            configurable: true,
            get: function proxyGetter() {
                return self.data[key];
            },
            set: function proxySetter(newVal) {
                self.data[key] = newVal;
            }
        });
    }
}
複製程式碼

5. 編譯模板,實現雙向資料繫結

  • 建立一個compile.js,用於將我們的vue語句編譯成html節點;

  • 首先建構函式會找到vueel屬性繫結的html節點;

  • nodeToFragment方法:將這個節點內容移動到fragment中;

  • compileModel方法用於編譯v-model指令

    • 其中

    new Watcher(this.vm, exp, function (value) { self.modelUpdater(node, value);});將自己新增為監聽者;

    • node.addEventListener('input', function(e) {...self.vm[exp] = newValue;...})實現從輸入框的值改變到data屬性值的過程;這裡就實現了雙向的資料繫結啦~
function Compile(el, vm) {
    this.vm = vm;
    this.el = document.querySelector(el);
    this.fragment = null;
    this.init();
}

Compile.prototype = {
    init: function () {
        if (this.el) {
            this.fragment = this.nodeToFragment(this.el);
            this.compileElement(this.fragment);
            this.el.appendChild(this.fragment);
        } else {
            console.log('Dom元素不存在');
        }
    },
    nodeToFragment: function (el) {
        var fragment = document.createDocumentFragment();
        var child = el.firstChild;
        while (child) {
            // 將Dom元素移入fragment中
            fragment.appendChild(child);
            child = el.firstChild
        }
        return fragment;
    },
    compileElement: function (el) {
        var childNodes = el.childNodes;
        var self = this;
        [].slice.call(childNodes).forEach(function(node) {
            var reg = /\{\{(.*)\}\}/;
            var text = node.textContent;

            if (self.isElementNode(node)) {  
                self.compile(node);
            } else if (self.isTextNode(node) &amp;&amp; reg.test(text)) {
                self.compileText(node, reg.exec(text)[1]);
            }

            if (node.childNodes &amp;&amp; node.childNodes.length) {
                self.compileElement(node);
            }
        });
    },
    compile: function(node) {
        var nodeAttrs = node.attributes;
        var self = this;
        Array.prototype.forEach.call(nodeAttrs, function(attr) {
            var attrName = attr.name;
            if (self.isDirective(attrName)) {
                var exp = attr.value;
                var dir = attrName.substring(2);
                if (self.isEventDirective(dir)) {  // 事件指令
                    self.compileEvent(node, self.vm, exp, dir);
                } else {  // v-model 指令
                    self.compileModel(node, self.vm, exp, dir);
                }
                node.removeAttribute(attrName);
            }
        });
    },
    compileText: function(node, exp) {
        var self = this;
        var initText = this.vm[exp];
        this.updateText(node, initText);
        new Watcher(this.vm, exp, function (value) {
            self.updateText(node, value);
        });
    },
    compileEvent: function (node, vm, exp, dir) {
        var eventType = dir.split(':')[1];
        var cb = vm.methods &amp;&amp; vm.methods[exp];

        if (eventType &amp;&amp; cb) {
            node.addEventListener(eventType, cb.bind(vm), false);
        }
    },
    compileModel: function (node, vm, exp, dir) {
        var self = this;
        var val = this.vm[exp];
        this.modelUpdater(node, val);
        new Watcher(this.vm, exp, function (value) {
            self.modelUpdater(node, value);
        });

        node.addEventListener('input', function(e) {
            var newValue = e.target.value;
            if (val === newValue) {
                return;
            }
            self.vm[exp] = newValue;
            val = newValue;
        });
    },
    updateText: function (node, value) {
        node.textContent = typeof value == 'undefined' ? '' : value;
    },
    modelUpdater: function(node, value, oldValue) {
        node.value = typeof value == 'undefined' ? '' : value;
    },
    isDirective: function(attr) {
        return attr.indexOf('v-') == 0;
    },
    isEventDirective: function(dir) {
        return dir.indexOf('on:') === 0;
    },
    isElementNode: function (node) {
        return node.nodeType == 1;
    },
    isTextNode: function(node) {
        return node.nodeType == 3;
    }
}

複製程式碼
  • 這裡說下有個坑:

    var child = el.firstChild;
            while (child) {
                // 將Dom元素移入fragment中
                fragment.appendChild(child);
                child = el.firstChild
            }
    複製程式碼

    一開始不知道為什麼這個while迴圈可以生效,查了一下mdn才知道:

    appendChild()的用法

The Node.appendChild() method adds a node to the end of the list of children of a specified parent node.If the given child is a reference to an existing node in the document, appendChild() moves it from its current position to the new position (there is no requirement to remove the node from its parent node before appending it to some other node).

如果這個給定的要插入的child是document中已存在的節點中的引用,那麼appendChild()方法會把它從它現在的位置轉移到新的位置,相當於一個剪下的效果;

  • [].slice.call(childNodes)可以將類陣列物件轉換成陣列物件;
  • 當一個文字節點前面和後面有換行符的時候,都只當做一個文字節點。

7. 自己實現的效果~

最後樓主也根據以上程式碼,實現的結果如下:

img

程式碼連結: https://github.com/dy21335/Practice/tree/master/MVVM


Last

歡迎大家關注公粽號:CSandCatti

日常推送英語精讀,演算法題,前端知識~

img
   

相關文章