Vue數據雙向綁定原理和實現

1、Vue實現雙向綁定的兩大機制

Vue實現數據雙向綁定主要利用的就是: 數據劫持發佈訂閱模式
所謂發佈訂閱模式就是,定義了對象間的一種 一對多的關係讓多個觀察者對象同時監聽某一個主題對象,當一個對象發生改變時,全部依賴於它的對象都將獲得通知
所謂數據劫持,就是 利用JavaScript的訪問器屬性,即 Object.defineProperty()方法,當對對象的屬性進行賦值時,Object.defineProperty就能夠 經過set方法劫持到數據的變化,而後 通知發佈者(主題對象)去通知全部觀察者,觀察者收到通知後,就會對視圖進行更新。

vue雙向綁定原理.png

如上圖所示,View模板首先通過 Compiler(編譯器對象)進行編譯,在編譯的過程當中, 會分析模板中哪裏使用到了Vue數據(Model中的數據)一旦使用到了Vue數據(Model中的數據),就會建立一個Water(觀察者對象),而且將這個觀察者對象添加到發佈者對象的數組中,同時獲取到Vue中的數據替換編譯生成一個新的View視圖。
在建立Vue實例的過程當中,會對Vue data中的數據進行數據劫持操做,即將data上的屬性都經過Object.definePropery()的方式代理到Vue實例上, 當View視圖或者Vue Model中發生數據變化的時候,就會被劫持,而後通知Dep發佈者對象進行視圖的更新,從而實現數據的雙向綁定。

2、從零實現一個簡易Vue

⓪ 項目初始化

// index.htmlhtml

<body>
    <div id="app">
        <input type="text"  v-model="scholl.name">
        <div>{{scholl.name}} {{scholl.age}}</div>
    </div>
</body>
<script  src="./vue.js"></script>
<script>
let vm = new  Vue({
    el:  "#app",
    data :  {
        scholl :  {
            name:  "zf",
            age:  10
        }
    }
});
</script>
咱們使用Vue的時候,是直接new一個Vue對象,並傳入一個options配置對象,裏面有el和data,先簡單點只配置el和data兩個屬性,因此 vue.js中存在一個Vue類,如:
// vue.js
class  Vue {
    constructor(options) {
        this.$el = options.el; // 保存傳遞的el屬性
        this.$data = options.data; // 保存傳入的data屬性
    }
}

① 編譯模板

要實現一個簡易Vue,第一步就是要編譯模板,那麼咱們該什麼時候發起模板的編譯操做呢?咱們應該在建立Vue實例的時候,在 其構造函數中就應該開始發起模板編譯操做,如:
// vue.js
class  Vue {
    constructor(options) {
        this.$el = options.el; // 保存傳遞的el屬性
        this.$data = options.data; // 保存傳入的data屬性
        new Complier(this.$el, this); // 在建立Vue實例的過程當中當即發起模板編譯操做
    }
}

1.1 劫持模板內容到內存

從上面能夠看出Compiler也是一個類,傳入了el和Vue實例對象, 編譯的第一步就是將View模板中的內容所有轉換爲文檔片斷進行操做,由於模板可能會很是的複雜,而模板的編譯是一個頻繁操做DOM的過程,若是直接操做真實的DOM會很是影響頁面性能,由於 文檔片斷存在於內存中並不在DOM樹中,因此將子元素插入到文檔片斷時不會引發頁面迴流,從而能夠提高頁面性能, document.createDocumentFragment()方法能夠建立文檔片斷,如:
class  Complier {
    constructor(el, vm) {
        // 由於配置options.el的時候el能夠傳入選擇器還能夠直接傳入DOM元素
        this.el = this.isElementNode(el) ? el :  document.querySelector(el);
        this.vm = vm; // 將Vue實例保存到編譯器對象上
        // 傳入this.el,即el對應的DOM元素,也就是根節點DOM
        let fragment = this.node2fragment(this.el);
        // 編譯模板,將真實DOM劫持到文檔片斷中後,就能夠開始進行模板編譯了,用Vue中的數據進行替換等
        this.compile(fragment);
        // 將編譯好的模板添加回到頁面中,以便在頁面中顯示出來
        this.el.appendChild(fragment);
    }
    isElementNode(node) { // 判斷是不是DOM元素節點
        return  node.nodeType  ===  1;
    }
    node2fragment(node) { // 將真實DOM劫持到內存中
        let fragment =  document.createDocumentFragment(); // 建立一個文檔片斷
        let firstChild;
        while(firstChild = node.firstChild) { // 遍歷傳入節點中的全部子節點,而後依次添加到文檔片斷中
            // appendChild具備移動性,能夠劫持頁面中的真實DOM到內存中
            fragment.appendChild(firstChild);
        }
        return fragment;
    }
}

1.2 遍歷節點,根據節點類型進行相應的編譯

將el中的因此子節點劫持到內存中後,就能夠開始在內存中進行編譯操做了,從上面能夠看到,是直接調用Compiler中的compile方法,因此接下來咱們須要實現這個compile()方法,編譯過程就是 遍歷文檔片斷中的全部子節點而後根據子節點的類型進行區分,若是是元素節點,那麼進行元素節點編譯,若是是文本節點,那麼進行文本節點編譯,而且, 若是是元素節點,那麼還有對該元素節點繼續遞歸編譯,即 繼續遍歷該元素節點的子節點,如:
class Compiler {
    compile(node) {
        let childNodes = node.childNodes; // 獲取傳遞節點的全部子節點
        [...childNodes].forEach((child) => { // 遍歷傳遞節點的全部子節點
            if (this.isElementNode(child)) { // 若是是元素節點
                this.compileElement(child); // 編譯元素節點,好比元素上面的指令等
                this.compile(child); // 遞歸編譯元素節點
            } else {
                this.compileText(child); // 編譯文本節點,即{{}}mustache表達式
            }

        });

    }
}

1.3 找到元素節點上的指令開始編譯元素節點

接下來就是要 實現對元素節點和文本節點的編譯,即實現compileElement()和compileText()方法,對於元素節點,首先 獲取到元素節點上的全部屬性,而後開始 遍歷屬性判斷是否有帶"v-"的屬性,若是有那麼就是一個指令,而後對指令進行處理,指令的做用就是操做DOM,因此須要傳入DOM節點,vm、指令表達式,如:
// 在Complier中添加一個compileElement()方法
class  Complier {
    compileElement(node){
        let attributes =  node.attributes; // 取出元素節點上的全部屬性
        [...attributes].forEach((attr) => {
            let {name, value:expr} = attr; // 獲取到帶v-的指令名和指令表達式
            if (this.isDirective(name)) { // 若是該屬性名是vue指令,即以v-開頭
                let [, directive] =  name.split("-"); // 去除v-,獲取帶參數和修飾符的指令名
                let [directiveName, eventName] =  directive.split(":"); // 將指令名和事件名拆開,如v-on:click, 則分別爲 on click
                CompileUtil[directiveName](node, expr, this.vm, eventName); // 傳遞DOM元素和指令表達式以及vm進行指令處理
            }
        });
    }
}
上面使用到了CompileUtil編譯工具對象專門進行各類指令的具體處理,添加一個CompileUtil對象裏面有各類工具方法,如model、text,因爲指令的做用,主要就是操做DOM,因此裏面主要就是根據指令表達式從vm中獲取到數據,而後操做DOM進行值的設置,如:
// 添加一個CompileUtil工具對象
var CompileUtil = {
    getVal(vm, expr) { // 根據vm和指令表達式從vm中獲取數據
        return  expr.split(".").reduce((data, current) => {
            return data[current];
        }, vm.$data);
    },
    model(node, expr, vm) {
        const value =  this.getVal(vm, expr); // 獲取表達式的值
        node.value  = value; // 對於v-model指令,直接給DOM的value屬性賦值便可
    }
}
這裏主要理解getVal()方法便可,這裏用到了 reduce()進行累加操做,主要是由於表達式,若是是多個點的形式,如"scholl.name",那麼能夠以vm中的data做爲最初數據,而後遍歷每一個屬性名, 進行"."的累加操做,即vm.$data.scholl.name進行獲取值。

1.4 找到帶mustache表達式的文本節點開始編譯文本節點

能夠經過 /\{\{(.+?)\}\}/正則表達式檢測是否存在{{}},而後對{{}}表達式進行替換便可,如:
// 在Complier中添加一個compileText()方法
class  Complier {
    compileText(node){
        const content =  node.textContent;
        if(/\{\{(.+?)\}\}/.test(content)) { // 檢測文本節點中是否含有{{}}表達式
            CompileUtil["text"](node, content, this.vm);
        }
    }
}
將整個文本內容交給CompileUtil中的text方法進行處理,即將{{}}替換掉而後用替換後的值再替換DOM的文本內容,如:
var CompileUtil = {
    text(node, expr, vm) {
        let content =  expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
            return this.getVal(vm, args[1]);
        });
        node.textContent  = content; // 替換文本節點的內容
    }
}
至此,編譯已經完成,已經能夠在頁面上看到 vue指令{{}}表達式編譯後的數據了。

② 數據劫持

此時模板雖然編譯成功了,可是當vue中data裏的數據發生變化的時候,整個Vue對象並不能檢測到數據發生了變化,由於vue中的data尚未添加數據劫持,即 尚未經過Object.defineProperty()方法進行從新定義,因此 須要在編譯模板前對vue中data進行觀察即數據劫持
class  Vue {
    constructor(options) {
        this.$el = options.el; // 保存傳遞的el屬性
        this.$data = options.data; // 保存傳入的data屬性
        // 添加數據劫持,將數據所有轉化成Object.defineProperty()來定義
        new Observer(this.$data);
        new Complier(this.$el, this); // 在建立Vue實例的過程當中當即發起模板編譯操做
    }
}
上面是直接建立Observer對象並傳入data進行數據劫持的,因此須要建立一個Observer類,在其構造函數中進行數據劫持,如:
class Observer {
    constructor(data) {
        this.observer(data);
    }
    observer(data) { 
        if (data &&  typeof data \===  "object") { // 若是傳入的data是一個對象,遍歷data對象中的全部屬性改爲Object.defineProperty的形式
            for (let key in data) { 
                this.defineReactive(data, key, data[key]);
            }
        }
    }
    defineReactive(obj, key, value) {
        this.observer(value); // 遞歸觀察數據,若是data中的某個屬性的屬性值爲對象,則也要進行觀察
        Object.defineProperty(obj, key, {
            get() {
                return value;
            },
            set: (newValue) => {
                if (newValue != value) {
                    this.observer(newValue); // 若是賦值的是對象那麼也進行新數據監控
                    value = newValue;
                }
            }
        });
    }
}
這樣,當vue中data數據發生變化的時候就會被get()和set()劫持到,從而能夠進行視圖的更新。

③ 發佈訂閱模式

此時雖然已經能夠劫持到vue中data的數據變化了,可是還不能進行頁面的更新,由於 目前還不知道頁面上有哪些地方用到了該數據,因此必須在編譯的時候,若是發現有某個地方用到了vue中的數據,那麼就註冊一個Watcher觀察者,而後檢測到數據發生變化的時候,經過發佈者去通知全部觀察者,觀察者收到通知後進行頁面的更新便可實現數據的雙向綁定。
// 添加Watcher觀察者類
class  Watcher {
    constructor(vm, expr, cb) {
        Dep.target  =  this; // 每次建立Watcher對象的時候,將建立的Watcher對象在獲取值的時候添加到dep中
        this.vm  = vm;
        this.expr = expr;
        this.cb = cb;
        // 默認先存放舊值
        this.oldValue = this.get();
        Dep.target = null; // 添加Watcher對象後清空,防止每次獲取數據的時候都添加Watcher對象
    }
    get() {
        let value =  CompileUtil.getVal(this.vm, this.expr);
        return value;
    }
    update() {
        let newValue =  CompileUtil.getVal(this.vm, this.expr);
        if (newValue !==  this.oldValue) {
            this.cb(newValue);
        }
    }
}
// 添加Dep發佈者類
class  Dep { 
    constructor() {
        this.subs  = []; // 存放全部的watcher
    }
    // 訂閱
    addSub(watcher) { // 添加watcher
        this.subs.push(watcher);
    }
    // 發佈,遍歷全部的觀察者,調用觀察者的update進行頁面的更新
    notify() {
        this.subs.forEach((watcher) => {
            watcher.update();
        });
    }
}
建立Watcher對象的時候,須要傳遞vm和表達式,爲了獲取到表達式的值,同時傳遞了一個回調函數,主要是爲了把變化後的值傳遞出去以便更新視圖。那麼應該在何時建立Watcher對象呢?應該在模板編譯的時候,當檢測到元素上使用了vue指令綁定data中的數據或者使用mustache表達式綁定data中的數據的時候,就須要建立一個Watcher對象了,如:
CompileUtil  =  {
    model(node, expr, vm) {
        new Watcher(vm, expr, (newValue) => {
            node.value = newValue;
        });
        const value =  this.getVal(vm, expr); // 獲取表達式的值
        node.value  = value; // 對於v-model指令,直接給DOM的value屬性賦值便可
    },
    getContentValue(vm, expr) {
        return  expr.replace(/\{\{(.+?)\}\}/g,(...args) => {
            return  this.getVal(vm, args\[1\]); // 從新獲取最新的值
        });
    },
    text(node, expr, vm) {
        let content =  expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
            new  Watcher(vm, args[1], () => { //每次匹配到一個就建立一個Watcher對象
                node.textContent = this.getContentValue(vm, expr); 
            });
            return this.getVal(vm, args[1]);
        });
        node.textContent  = content; // 替換文本節點的內容
    }
}
Watcher對象建立好以後,那麼又須要在何時添加到對應的發佈對象中呢?當Watcher對象建立好以後,會當即去獲取對應的值,從而會觸發對應數據的getter方法,因此在調用getter方法的時候將建立的Watcher對象添加到發佈者對象中,如:
class  Observer {
    defineReactive(obj, key, value) { // 每一個key對應一個發佈者對象
        let dep = new  Dep(); // 爲data中的每個屬性建立一個發佈者對象
        Object.defineProperty(obj, key, {
            get() {
                Dep.target  &&  dep.addSub(Dep.target); // 將建立的Watcher對象添加到發佈者中
            }
        });
    }
}
至此,已經實現了Vue的數據雙向綁定,但還不支持計算屬性。

④ 實現Computed計算屬性

好比有計算屬性{{getNewName}}和普通表達式{{scholl.name}},那麼兩者有什麼共同點呢?就是不給是計算屬性仍是普通表達式,都是要從vm.\$data中去取值,當咱們給{{getNewName}}建立Watcher的時候,咱們但願獲取到vm.\$data.getNewName的值,要想從vm.\$data中獲取到值,那麼必須將getNewName代理到vm.$data,而後獲取getNewName的值時,直接執行計算屬性函數便可。如:
class  Vue {
    this.$el = options.el;
    this.$data = options.data;
    let computed = options.computed;
    let methods = options.methods;
    new Observer(this.$data);
    for (let key in computed) { // 計算屬性代理到data上
        Object.defineProperty(this.$data, key, { // 須要從$data中取值,因此須要將計算屬性定義到this.$data上而不是vm上
            get: () => {
                return computed[key].call(this);
            }
        }
    }
    for (let key in methods) { // 將methods上的數據代理到vm上
        Object.defineProperty(this, key, {
            get() {
                return methods[key];
            }
        });

    }
    // 爲了方便,把數據獲取操做,將data上的數據都代理到vm上
    this.proxyVm(this.$data);
    proxyVm(data) {
        for (let key in data) {
            Object.defineProperty(this, key, {
                get() {
                    return data[key];
                },
                set(newValue) {
                    data[key] = newValue;
                }
            });
        }
    }
}

3、總結

總之就是,在建立Vue實例的時候給傳入的data進行數據劫持,同時視圖編譯的時候,對於使用到data中數據的地方進行建立Watcher對象,而後在數據劫持的getter中添加到發佈者對象中,當劫持到數據發生變化的時候,就經過發佈訂閱模式以回調函數的方式通知全部觀察者操做DOM進行更新,從而實現數據的雙向綁定。
相關文章
相關標籤/搜索