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>
原文地址:https://www.cnblogs.com/chuanzi/p/12452141.html