Vue 的數據劫持 + 發布訂閱


Vue 的雙向綁定策略基礎是數據劫持,在 Vue2.0 中使用了 ES5 語法 Object.defineProperty,來劫持各個屬性的 setter/getter,在數據變動時發布消息給訂閱者(Wacther), 觸發相應的監聽回調。先來看一下這個 ES5 特性,我們可以通過 Object.defineProperty 這個方法,直接在一個對象上定義一個新的屬性,或者修改已存在的屬性,最終這個方法會返回該對象,如下為簡單說明,對該特性不了解的同學可以查看《JavaScript 高級程序設計》的第六章,或者在線訪問 MDN Web 文檔: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty 

var o = {};
var value = 1;
Object.defineProperty(o, 'a', {
  get: function() { return value; },
  set: function(newValue) { value = newValue; },
  enumerable: true,
  configurable: true
});
o.a; // 1
o.a = 2;
o.a; // 2

結合這一特定與發布訂閱機制,可以實現完整的雙向綁定。如下所示,Observer 數據監聽器能夠對數據對象的所有屬性進行監聽,如有變動可拿到最新值並通知訂閱者,內部采用 Object.defineProperty 的 getter 和 setter 來實現 [3]。

Compile 指令解析器,它的作用對每個元素節點的指令進行掃描和解析,根據指令模板替換數據,以及綁定相應的更新函數。

Watcher 訂閱者, 作為連接 Observer 和 Compile 的橋梁,能夠訂閱並收到每個屬性變動的通知,執行指令綁定的相應回調函數。Dep 消息訂閱器,內部維護了一個數組,用來收集訂閱者(Watcher),數據變動觸發 notify 函數,再調用訂閱者的 update 方法。

當執行 new Vue() 時,Vue 就進入了初始化階段,一方面會遍歷 data 選項中的屬性,用 Object.defineProperty 將它們轉為 getter/setter,實現數據變化監聽功能;另一方面,Vue 的指令編譯器 Compile 對元素節點的指令進行掃描和解析,初始化視圖,並訂閱 Watcher 來更新視圖, 此時 Wather 會將自己添加到消息訂閱器中 (Dep), 初始化完畢。當數據發生變化時,Observer 中的 setter 方法被觸發,setter 會立即調用 Dep.notify(),Dep 開始遍歷所有的訂閱者,並調用訂閱者的 update 方法,訂閱者收到通知后對視圖進行相應的更新 

使用 Object.defineProperty 這個特性存在一些明顯的缺點,總結起來大概是下面兩個:

  1. Object.defineProperty 無法監控到數組下標的變化,當監控數組數據對象的時候,實質上就是監控數組的地址,地址不變也就不會被監測到。為了解決這個問題,經過 Vue 內部處理后可以使用 push、pop、shift、unshift、splice、sort、reverse 來監聽數組。
  2. Object.defineProperty 只能劫持對象的屬性, 因此我們需要對每個對象的每個屬性進行遍歷。Vue 2.x 里,是通過遞歸 + 遍歷 data 對象來實現對數據的監控的。如果屬性值也是對象,那么需要深度遍歷,顯然如果能劫持一個完整的對象是才是更好的選擇。

由於只針對了以上八種方法進行了 hack 處理,所以其他數組的屬性也是檢測不到的,還是具有一定的局限性。Vue3.0 中使用了 ES6 語法 Proxy,用於取代 defineProperty,使用 Proxy 有以下兩個優點:

  1. 可以劫持整個對象,並返回一個新對象。
  2. 有 13 種劫持操作。

既然 Proxy 能解決以上兩個問題,而且 Proxy 作為 es6 的新屬性在 Vue2.x 之前就有了,為什么 Vue2.x 不使用 Proxy 呢?一個很重要的原因就是,Proxy 是 ES6 提供的新特性,兼容性不好,並且是這個屬性無法用 polyfill 來兼容。

Vue 的雙向綁定策略成為當前考察前端人員技術功底的重點,我們以 Object.defineProperty 特性實現一個簡單的雙向綁定,實現最初的 hello everyone 效果。

<!DOCTYPE html>
<html lang="en">
    <head>
        <title>雙向綁定最最最初級 demo</title>
        <meta charset="UTF-8">
    </head>
    <body>
        <div id="app">
            <input type="text" id="txt">
            <id="show-txt"></p>
            <button onClick="changeData()">更新數據</button>
        </div>
    </body>
    <script>
        var obj={}
        Object.defineProperty(obj,'txt',{
            get:function(){
                return obj
            },
            set:function(newValue){
                document.getElementById('txt').value = newValue
                document.getElementById('show-txt').innerHTML = newValue
            }
        })
        document.addEventListener('keyup',function(e){
            obj.txt = e.target.value
        })
        changeData = function() {
            obj.txt = 'hello world';
        }
    </script>
</html>

由於 Object.defineProperty 默認只能劫持值類型數據,對引用類型數據的內部修改無法劫持,需要重寫覆蓋原原型方法,以 Array 為例,如下可以支持到 7 種數組方法:

let arr = [];
let arrayMethod = Object.create(Array.prototype);
['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach(function (method) {
    Object.defineProperty(arrayMethod, method, {
        enumerable: true,
        configurable: true,
        value: function () {
            let args = [...arguments]
            Array.prototype[method].apply(this, args);
            console.log(`operation: ${method}`);
        }
    })
});
arr.__proto__ = arrayMethod;
arr.push(1); // 劫持到了 push 方法

相對完整的仿 Vue 雙向綁定實現,來自雙向綁定數組源碼: https://gitee.com/merico/Blog/tree/master/Object.defineProperty_Array 

<!DOCTYPE html>
<html lang="en">
    <head>
        <title>雙向綁定支持數組監聽</title>
        <meta charset="UTF-8">
    </head>
    <body>
        <div id="app">
            <div id='list'></div>
            <input type="button" value="添加" onclick="btnAdd()" />
            <input type="button" value="刪除" onclick="btnDel()" />
        </div>
    </body>
    <script>
        // 數據源
        let vm = {
            list: [1, 2, 3, 4]
        }
        // 用於管理 watcher 的 Dep 對象
        let Dep = function () {
            this.list = [];
            this.add = function (watcher) {
                this.list.push(watcher)
            };
            this.notify = function (newValue) {
                this.list.forEach(function (fn) {
                    fn(newValue)
                })
            }
        };
        // 模擬 compile, 通過對 Html 的解析生成一系列訂閱者(watcher)
        function renderList() {
            let listContainer = document.querySelector('#list');
            let contentList = '';
            vm.list.forEach(function (item) {
                contentList = contentList + `<div><h3>${item}</h3></div>`
            })
            listContainer.innerHTML = contentList;
        }
        // 將解析出來的 watcher 存入 Dep 中待用
        let dep = new Dep();
        dep.add(renderList)
        // 核心方法
        function initMVVM(vm) {
            Object.keys(vm).forEach(function (key) {
                let value = vm[key];
                if (Array.isArray(value)) {
                    observeArray(vm, key)
                }
            })
        }
        function observeArray(vm, key) {
            let arrayMethod = bindWatcherToArray();
            vm[key].__proto__ = arrayMethod;
        }
        function bindWatcherToArray() {
            let arrayMethod = Object.create(Array.prototype);
            ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach(function (method) {
                Object.defineProperty(arrayMethod, method, {
                    enumerable: true,
                    configurable: true,
                    value: function () {
                        let args = [...arguments]
                        Array.prototype[method].apply(this, args);
                        console.log(`operation: ${method}`)
                        dep.notify();
                    }
                })
            });
            return arrayMethod
        }
        // 頁面引用的方法
        function btnAdd() {
            vm.list.push(Math.random())
        }
        function btnDel() {
            vm.list.pop()
        }
        // 初始化數據源
        initMVVM(vm)
        // 初始化頁面
        dep.notify();
    </script>
</html>


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM