1. 程式人生 > >vue.js動態資料繫結學習

vue.js動態資料繫結學習

對於vue.js的動態資料繫結,經過反覆地看原始碼和部落格講解,總算能夠理解它的實現了,心累~ 分享一下學習成果,同時也算是做個記錄。完整程式碼GitHub地址:https://github.com/hanrenguang/Dynamic-data-binding。也可以到倉庫的 README 閱讀本文,容我厚臉皮地求 star,求 follow

整體思路

不知道有沒有同學和我一樣,看著vue的原始碼卻不知從何開始,真叫人頭大。硬生生地看了observer, watcher, compile這幾部分的原始碼,只覺得一臉懵逼。最終,從這裡得到啟發,作者寫得很好,值得一讀。

關於動態資料繫結呢,需要搞定的是 Dep

, Observer , Watcher , Compile 這幾個類,他們之間有著各種聯絡,想要搞懂原始碼,就得先了解他們之間的聯絡。下面來理一理:

  • Observer 所做的就是劫持監聽所有屬性,當有變動時通知 Dep
  • WatcherDep 新增訂閱,同時,屬性有變化時,Observer 通知 DepDep 則通知 Watcher
  • Watcher 得到通知後,呼叫回撥函式更新檢視
  • Compile 則是解析所繫結元素的 DOM 結構,對所有需要繫結的屬性新增 Watcher 訂閱

由此可以看出,當屬性發生變化時,是由Observer -> Dep -> Watcher

-> update viewCompile 在最開始解析 DOM 並新增 Watcher 訂閱後就功成身退了。

從程式執行的順序來看的話,即 new Vue({}) 之後,應該是這樣的:先通過 Observer 劫持所有屬性,然後 Compile 解析 DOM 結構,並新增 Watcher 訂閱,再之後就是屬性變化 -> Observer -> Dep -> Watcher -> update view,接下來就說說具體的實現。

從new一個例項開始談起

網上的很多原始碼解讀都是從 Observer 開始的,而我會從 new 一個MVVM例項開始,按照程式執行順序去解釋或許更容易理解。先來看一個簡單的例子:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>test</title>
</head>
<body>
    <div class="test">
        <p>{{user.name}}</p>
        <p>{{user.age}}</p>
    </div>

    <script type="text/javascript" src="hue.js"></script>
    <script type="text/javascript">
        let vm = new Hue({
            el: '.test',
            data: {
                user: {
                    name: 'Jack',
                    age: '18'
                }
            }
        });
    </script>
</body>
</html>

接下來都將以其為例來分析。下面來看一個簡略的 MVVM 的實現,在此將其命名為 hue。為了方便起見,為 data 屬性設定了一個代理,通過 vm._data 來訪問 data 的屬性顯得麻煩且冗餘,通過代理,可以很好地解決這個問題,在註釋中也有說明。新增完屬性代理後,呼叫了一個 observe 函式,這一步做的就是 Observer 的屬性劫持了,這一步具體怎麼實現,暫時先不展開。先記住他為 data 的屬性添加了 gettersetter

function Hue(options) {
    this.$options = options || {};
    let data = this._data = this.$options.data,
        self = this;

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

    observe(data);

    self.$compile = new Compile(self, options.el || document.body);
}

// 為 data 做了一個代理,
// 訪問 vm.xxx 會觸發 vm._data[xxx] 的getter,取得 vm._data[xxx] 的值,
// 為 vm.xxx 賦值則會觸發 vm._data[xxx] 的setter
Hue.prototype._proxyData = function(key) {
    let self = this;
    Object.defineProperty(self, key, {
        configurable: false,
        enumerable: true,
        get: function proxyGetter() {
            return self._data[key];
        },
        set: function proxySetter(newVal) {
            self._data[key] = newVal;
        }
    });
};

再往下看,最後一步 new 了一個 Compile,下面我們就來講講 Compile

Compile

new Compile(self, options.el || document.body) 這一行程式碼中,第一個引數是當前 Hue 例項,第二個引數是繫結的元素,在上面的示例中為class為 .test 的div。

關於 Compile,這裡只實現最簡單的 textContent 的繫結。而 Compile 的程式碼沒什麼難點,很輕易就能讀懂,所做的就是解析 DOM,並新增 Watcher 訂閱。關於 DOM 的解析,先將根節點 el 轉換成文件碎片 fragment 進行解析編譯操作,解析完成後,再將 fragment 添加回原來的真實 DOM 節點中。來看看這部分的程式碼:

function Compile(vm, el) {
    this.$vm = vm;
    this.$el = this.isElementNode(el)
        ? el
        : document.querySelector(el);

    if (this.$el) {
        this.$fragment = this.node2Fragment(this.$el);
        this.init();
        this.$el.appendChild(this.$fragment);
    }
}

Compile.prototype.node2Fragment = function(el) {
    let fragment = document.createDocumentFragment(),
        child;

    // 也許有同學不太理解這一步,不妨動手寫個小例子觀察一下他的行為
    while (child = el.firstChild) {
        fragment.appendChild(child);
    }

    return fragment;
};

Compile.prototype.init = function() {
    // 解析 fragment
    this.compileElement(this.$fragment);
};

以上面示例為例,此時若打印出 fragment,可觀察到其包含兩個p元素:

<p>{{user.name}}</p>
<p>{{user.age}}</p>

下一步就是解析 fragment,直接看程式碼及註釋吧:

Compile.prototype.compileElement = function(el) {
    let childNodes = Array.from(el.childNodes),
        self = this;

    childNodes.forEach(function(node) {
        let text = node.textContent,
            reg = /\{\{(.*)\}\}/;

        // 若為 textNode 元素,且匹配 reg 正則
        // 在上例中會匹配 '{{user.name}}' 及 '{{user.age}}'
        if (self.isTextNode(node) && reg.test(text)) {
            // 解析 textContent,RegExp.$1 為匹配到的內容,在上例中為 'user.name' 及 'user.age'
            self.compileText(node, RegExp.$1);
        }

        // 遞迴
        if (node.childNodes && node.childNodes.length) {
            self.compileElement(node);
        }
    });
};

Compile.prototype.compileText = function(node, exp) {
    // this.$vm 即為 Hue 例項,exp 為正則匹配到的內容,即 'user.name' 或 'user.age'
    compileUtil.text(node, this.$vm, exp);
};

let compileUtil = {
    text: function(node, vm, exp) {
        this.bind(node, vm, exp, 'text');
    },

    bind: function(node, vm, exp, dir) {
        // 獲取更新檢視的回撥函式
        let updaterFn = updater[dir + 'Updater'];

        // 先呼叫一次 updaterFn,更新檢視
        updaterFn && updaterFn(node, this._getVMVal(vm, exp));

        // 新增 Watcher 訂閱
        new Watcher(vm, exp, function(value, oldValue) {
            updaterFn && updaterFn(node, value, oldValue);
        });
    },

    // 根據 exp,獲得其值,在上例中即 'vm.user.name' 或 'vm.user.age'
    _getVMVal: function(vm, exp) {
        let val = vm;
        exp = exp.trim().split('.');
        exp.forEach(function(k) {
            val = val[k];
        });
        return val;
    }
};

let updater = {
    // Watcher 訂閱的回撥函式
    // 在此即更新 node.textContent,即 update view
    textUpdater: function(node, value) {
        node.textContent = typeof value === 'undefined'
            ? ''
            : value;
    }
};

正如程式碼中所看到的,Compile 在解析到 {{xxx}} 後便添加了 xxx 屬性的訂閱,即 new Watcher(vm, exp, callback)。理解了這一步後,接下來就需要了解怎麼實現相關屬性的訂閱了。先從 Observer 開始談起。

Observer

從最簡單的情況來考慮,即不考慮陣列元素的變化。暫時先不考慮 DepObserver 的聯絡。先看看 Observer 建構函式:

function Observer(data) {
    this.data = data;
    this.walk(data);
}

Observer.prototype.walk = function(data) {
    const keys = Object.keys(data);
    // 遍歷 data 的所有屬性
    for (let i = 0; i < keys.length; i++) {
        // 呼叫 defineReactive 新增 getter 和 setter
        defineReactive(data, keys[i], data[keys[i]]);
    }
};

接下來通過 Object.defineProperty 方法給所有屬性新增 gettersetter,就達到了我們的目的。屬性有可能也是物件,因此需要對屬性值進行遞迴呼叫。

function defineReactive(obj, key, val) {
    // 對屬性值遞迴,對應屬性值為物件的情況
    let childObj = observe(val);

    Object.defineProperty(obj, key, {
        enumerable: true,
        configurable: true,
        get: function() {
            // 直接返回屬性值
            return val;
        },
        set: function(newVal) {
            if (newVal === val) {
                return;
            }
            // 值發生變化時修改閉包中的 val,
            // 保證在觸發 getter 時返回正確的值
            val = newVal;

            // 對新賦的值進行遞迴,防止賦的值為物件的情況
            childObj = observe(newVal);
        }
    });
}

最後補充上 observe 函式,也即 Hue 建構函式中呼叫的 observe 函式:

function observe(val) {
    // 若 val 是物件且非陣列,則 new 一個 Observer 例項,val 作為引數
    // 簡單點說:是物件就繼續。
    if (!Array.isArray(val) && typeof val === "object") {
        return new Observer(val);
    }
}

這樣一來就對 data 的所有子孫屬性(不知有沒有這種說法。。)都進行了“劫持”。顯然到目前為止,這並沒什麼用,或者說如果只做到這裡,那麼和什麼都不做沒差別。於是 Dep 上場了。我認為理解 DepObserverWatcher 之間的聯絡是最重要的,先來談談 DepObserver 裡做了什麼。

Observer & Dep

在每一次 defineReactive 函式被呼叫之後,都會在閉包中新建一個 Dep 例項,即 let dep = new Dep()Dep 提供了一些方法,先來說說 notify 這個方法,它做了什麼事?就是在屬性值發生變化的時候通知 Dep,那麼我們的程式碼可以增加如下:

function defineReactive(obj, key, val) {
    let childObj = observe(val);
    const dep = new Dep();

    Object.defineProperty(obj, key, {
        enumerable: true,
        configurable: true,
        get: function() {
            return val;
        },
        set: function(newVal) {
            if (newVal === val) {
                return;
            }

            val = newVal;
            childObj = observe(newVal);

            // 發生變動
            dep.notify();
        }
    });
}

如果僅考慮 ObserverDep 的聯絡,即有變動時通知 Dep,那麼這裡就算完了,然而在 vue.js 的原始碼中,我們還可以看到一段增加在 getter 中的程式碼:

// ...
get: function() {
    if (Dep.target) {
        dep.depend();
    }
    return val;
}
// ...

這個 depend 方法呢,它又做了啥?答案是為閉包中的 Dep 例項添加了一個 Watcher 的訂閱,而 Dep.target 又是啥?他其實是一個 Watcher 例項,???一臉懵逼,先記住就好,先看一部份的 Dep 原始碼:

// 識別符號,在 Watcher 中有用到,先不用管
let uid = 0;

function Dep() {
    this.id = uid++;
    this.subs = [];
}

Dep.prototype.depend = function() {
    // 這一步相當於做了這麼一件事:this.subs.push(Dep.target)
    // 即添加了 Watcher 訂閱,addDep 是 Watcher 的方法
    Dep.target.addDep(this);
};

// 通知更新
Dep.prototype.notify = function() {
    // this.subs 的每一項都為一個 Watcher 例項
    this.subs.forEach(function(sub) {
        // update 為 Watcher 的一個方法,更新檢視
        // 沒錯,實際上這個方法最終會呼叫到 Compile 中的 updaterFn,
        // 也即 new Watcher(vm, exp, callback) 中的 callback
        sub.update();
    });
};

// 在 Watcher 中呼叫
Dep.prototype.addSub = function(sub) {
    this.subs.push(sub);
};

// 初始時引用為空
Dep.target = null;

也許看到這還是一臉懵逼,沒關係,接著往下。大概有同學會疑惑,為什麼要把新增 Watcher 訂閱放在 getter 中,接下來我們來說說這 WatcherDep 的故事。

Watcher & Dep

先讓我們回顧一下 Compile 做的事,解析 fragment,然後給相應屬性新增訂閱:new Watcher(vm, exp, cb)new 了這個 Watcher 之後,Watcher 怎麼辦呢,就有了下面這樣的對話:

Watcher:hey Dep,我需要訂閱 exp 屬性的變動。

Dep:這我可做不到,你得去找 exp 屬性中的 dep,他能做到這件事。

Watcher:可是他在閉包中啊,我無法和他聯絡。

Dep:你拿到了整個 Hue 例項 vm,又知道屬性 exp,你可以觸發他的 getter 啊,你在 getter 裡動些手腳不就行了。

Watcher:有道理,可是我得讓 dep 知道是我訂閱的啊,不然他通知不到我。

Dep:這個簡單,我幫你,你每次觸發 getter 前,把你的引用告訴 Dep.target 就行了。記得辦完事後給 Dep.target 置空。

於是就有了上面 getter 中的程式碼:

// ...
get: function() {
    // 是否是 Watcher 觸發的
    if (Dep.target) {
        // 是就新增進來
        dep.depend();
    }
    return val;
}
// ...

現在再回頭看看 Dep 部分的程式碼,是不是好理解些了。如此一來, Watcher 需要做的事情就簡單明瞭了:

function Watcher(vm, exp, cb) {
    this.$vm = vm;
    this.cb = cb;
    this.exp = exp;
    this.depIds = new Set();

    // 返回一個用於獲取相應屬性值的函式
    this.getter = parseGetter(exp.trim());

    // 呼叫 get 方法,觸發 getter
    this.value = this.get();
}

Watcher.prototype.get = function() {
    const vm = this.$vm;
    // 將 Dep.target 指向當前 Watcher 例項
    Dep.target = this;
    // 觸發 getter
    let value = this.getter.call(vm, vm);
    // Dep.target 置空
    Dep.target = null;
    return value;
};

Watcher.prototype.addDep = function(dep) {
    const id = dep.id;
    if (!this.depIds.has(id)) {
        // 新增訂閱,相當於 dep.subs.push(this)
        dep.addSub(this);
        this.depIds.add(id);
    }
};

function parseGetter(exp) {
    if (/[^\w.$]/.test(exp)) {
        return;
    }

    let exps = exp.split(".");

    return function(obj) {
        for (let i = 0; i < exps.length; i++) {
            if (!obj)
                return;
            obj = obj[exps[i]];
        }
        return obj;
    };
}

最後還差一部分,即 Dep 通知變化後,Watcher 的處理,具體的函式呼叫流程是這樣的:dep.notify() -> sub.update(),直接上程式碼:

Watcher.prototype.update = function() {
    this.run();
};

Watcher.prototype.run = function() {
    let value = this.get();
    let oldVal = this.value;

    if (value !== oldVal) {
        this.value = value;
        // 呼叫回撥函式更新檢視
        this.cb.call(this.$vm, value, oldVal);
    }
};

結語

到這就算寫完了,本人水平有限,若有不足之處歡迎指出,一起探討。

參考資料