撸一个vue的双向绑定

1、前言

说起双向绑定可能大家都会说:Vue内部通过Object.defineProperty方法属性拦截的方式,把data对象里每个数据的读写转化成getter/setter,当数据变化时通知视图更新。虽然一句话把大概原理概括了,但是其内部的实现方式还是值得深究的,本文就以从简入繁的形式给大家撸一遍,让大家了解双向绑定的技术细节。

2、来一个简单的版本

让我们的数据变得可观测,实现原理不难,利用Object.defineProperty重新定义对象的属性描述符。

 /**
    * 把一个对象的每一项都转化成可观测对象
    * @param { Object } obj 对象
    */
    function observable(obj) {
        if (!obj || typeof obj !== 'object') {
            return;
        }
        let keys = Object.keys(obj);
        keys.forEach((key) => {
            defineReactive(obj, key, obj[key])
        })
        return obj;
    }
    /**
    * 使一个对象转化成可观测对象
    * @param { Object } obj 对象
    * @param { String } key 对象的key
    * @param { Any } val 对象的某个key的值
    */
    function defineReactive(obj, key, val) {
        Object.defineProperty(obj, key, {
            get() {
                console.log(`${key}属性被读取了`);
                return val;
            },
            set(newVal) {
                console.log(`${key}属性被修改了`);
                val = newVal;
            }
        })
    }
    let car = observable({
        'brand': 'BMW',
        'price': 3000
    })
    //测试
    console.log(car.brand);

3、一步一步实现一个观察者模式的双向绑定

先给一张思维导向图吧(图盗的,链接:https://www.cnblogs.com/libin-1/p/6893712.html),本文章不涉及Compile部分。
这张图我就不解释,我们先跟着一步一步的把代码撸出来,再回头来看这张图,问题不大。

建议在读之前一定要了解观察者模式和发布订阅模式以及其区别,一篇简单的文章总结了一下两种模式的区别(链接:https://www.cnblogs.com/chenlei987/p/10504956.html),Vue的双向绑定使用的就是观察者模式,其中Dep对象就是观察者的目标对象,而Watcher就是观察者,然后等待Dep对象的通知更新的,其中update方法是由watcher自己管理的,并非如发布订阅模式由目标对象去管理,在观察者模式中,目标对象管理的订阅者列表应该是Watcher本身,而不是事件/订阅主题。

3.1、声明一个Vue类,并将data里面的数据代理到Vue实例上面。

var Vue = (function (){
    class Vue{
            constructor (options = {}){

                //简化处理
                this.$options = options;

                let data = (this._data = 
                    typeof this.$options.data == 'function' ? 
                        this.$options.data() 
                        : 
                        this.$options.data);
                Object.keys(data).forEach(key =>{ this._proxy(key) });

                // 监听数据
                //observe(data);
            }
            _proxy (key){
                //用this这个对象去代理 this._data这个对象里面的key
                Object.defineProperty(this, key, {
                    configurable: true,
                    enumerable: true,
                    set: (val) => {
                        this._data[key] = val
                    },
                    get: () =>{
                        return this._data[key]
                    }
                })
            }
            
        }
        return Vue;
}
let VM = new Vue({
    data (){
        return {
            a: 1,
            arr: [1,2,3,4,5,6]
        }
    },
});
//说明 _proxy代理成功了
console.log(VM.a);
VM.a = 2;
console.log(VM.a);

3.2、让data里面的数据变得可观测,开启observe之旅

注:下面我所说的"data里面"就是指vue实例的data属性。
上面代码Vue类的constructor里面我注释了一行代码,下面我取消注释,并且开始定义observe函数

// 监听数据
 observe(data);

在定义observe方法之前,首先明白我们observe要做什么?
实参是data数据,我们要遍历整个data数据的key,为data数据的每一个key都用Object.defineProperty去重新定义它的 getter和setter函数,从而使其可观测。

class Observer{
            constructor (value){
                this.value = value;
                this.walk(value);
            }
            // 遍历属性值并监听
            walk(value) {
                Object.keys(value).forEach(key => this.convert(key, value[key]));
            }
            // 执行监听的具体方法
            convert(key, val) {
                defineReactive(this.value, key, val);
            }
        }
        function defineReactive(obj, key, val) {
            const dep = new Dep();
            // 给当前属性的值添加监听
            let chlidOb = observe(val);
            Object.defineProperty(obj, key, {
                enumerable: true,
                configurable: true,
                get: () => {
                  
                  //do something
                  //  if (Dep.target) {
                    //    dep.depend();
                    //}
                    return val;
                },
                set: newVal => {
                    if (val === newVal) return;
                    val = newVal;
                    
                    //do something
                    // 对新值进行监听
                    //chlidOb = observe(newVal);
                    // 通知所有订阅者,数值被改变了
                    //dep.notify();
                },
            });
        }

        function observe(value) {
             // 当值不存在,或者不是复杂数据类型时,不再需要继续深入监听
            if (!value || typeof value !== 'object') {
                return;
            }
            return new Observer(value);
        }

看到在get和set函数里面的do something了吗,可以理解为在data里面的每个key的设置和获取都被我们截取到了,在每个key的设置和获取时我们可以干些事情了。比如更数据对应的DOM。
要做什么呢?
get函数: 从思维图图1可以看出需要把当前的Watcher添加进Dep对象,等待数据更新,调用回调。
set函数: 数据更新,Dep对象通知所有订阅的watcher更新,调用回调,更新视图。

3.3、Watcher

先声明一个Watcher类,用于添加进Dep对象并通知更新视图使用。

 let uid = 0;
        class Watcher {
            constructor(vm, expOrFn, cb) {
                // 设置id,用于区分新Watcher和只改变属性值后新产生的Watcher
                this.id = uid++;

                this.vm = vm; // 被订阅的数据一定来自于当前Vue实例
                this.cb = cb; // 当数据更新时想要做的事情
                this.expOrFn = expOrFn; // 被订阅的数据
                this.val = this.get(); // 维护更新之前的数据
            }
            // 对外暴露的接口,用于在订阅的数据被更新时,由订阅者管理员(Dep)调用
            update() {
                this.run();
            }
            addDep(dep) {
                // 如果在depIds的hash中没有当前的id,可以判断是新Watcher,因此可以添加到dep的数组中储存
                // 此判断是避免同id的Watcher被多次储存
                //这里要是不限制重复,你会发现在响应的过程中,Dep实例下的subs会成倍的增加watcher。多输入几个字浏览器就卡死了。
                if (!dep.depIds.hasOwnProperty(this.id)) {
                    dep.addSubs(this);
                    dep.depIds[this.id] = dep;
                }
            }
            run() {
                const val = this.get();
                if (val !== this.val) {
                    this.val = val;
                    this.cb.call(this.vm, val);
                }
            }
            get() {
                // 当前订阅者(Watcher)读取被订阅数据的最新更新后的值时,通知订阅者管理员收集当前订阅者
                Dep.target = this;
                //注意:在这里获取该属性 从而就触发了defineProperty的get方法,该watcher已经进入Dep的subs队列了
                const val = this.vm._data[this.expOrFn]; 
                
                //初始化执行一遍回调
                this.cb.call(this.vm, val);

                //  置空,用于下一个Watcher使用
                Dep.target = null;
                return val;
            }
        }

上面代码我们先从constructor看起,接受三个参数,vm当前的vue实例,expOrFn实例化时该watcher实例所 代表/处理 的"data里面"(‘data里面’上面有解释,这里提醒一下)的哪个值,cb,回调函数,也就是当数据更新后需要做什么(自然是更新DOM咯)。
然后在constructor里面还调用了 this.get()。详细看一下get函数的定义,两行代码需要注意:

// 当前订阅者(Watcher)读取被订阅数据的最新更新后的值时,通知订阅者管理员收集当前订阅者
Dep.target = this;
//注意:在这里获取该属性 从而就触发了defineProperty的get方法,该watcher已经进入Dep的subs队列了
const val = this.vm._data[this.expOrFn]; 

Dep.target = this;确定了当前的活动的watcher实例,Dep.target我们可以认为它是一个全局变量,用于存放当前活动的watcher实例。
const val = this.vm._data[this.expOrFn]; 获取数据,这句话其实就已经触发了其自身的getter方法(这点要注意,不然你连流程都理解不通)。
进入了getter方法,也就把当前活动的实例的watcher添加进dep对象等待更新。
添加进Dep对象后,置空,用于下一个Watcher使用 Dep.target = null;

3.4、Dep

一直在说dep对象,我们一定要知道dep对象就是观察者模式里面的目标对象,用于存放watcher和负责通知更新的。
下面来定义一个Dep对象,放到class Watcher前面。 注意Dep的作用范围.

class Dep{
            constructor (){
                this.depIds = {}; // hash储存订阅者的id,避免重复的订阅者
                //订阅者列表  watcher实例列表
                this.subs = [];
            }
            depend (){
                Dep.target.addDep(this);//相当于调用this.addSubs 将 watcher实例添加进订阅列表 等待通知更新
                //本来按照我们的理解,在denpend里面是需要将watcher添加进 Dep对象, 等待通知更新的,所以应该调用 this.addSubs(Dep.target)
                //但是由于需要解耦 所以 先调用 watcher的addDep 在addDep中调用Dep实例的addSubs
                //简化理解就是 将 watcher实例添加进订阅列表 等待通知更新
            }
            addSubs (sub) {
                //这里的sub肯定是watcher实例
                this.subs.push(sub);
            }
            notify (){
                //监听到值的变化,通知所有订阅者watcher更新
                this.subs.forEach((sub) =>{
                    sub.update();
                });
            }
        }
         Dep.target = null;//存储当前活动的watcher

再改改defineReactive,把注释打开

function defineReactive(obj, key, val) {
            const dep = new Dep();
            // 给当前属性的值添加监听
            let chlidOb = observe(val);
            Object.defineProperty(obj, key, {
                enumerable: true,
                configurable: true,
                get: () => {
                    // 如果Dep类存在target属性,将其添加到dep实例的subs数组中
                    // target指向一个Watcher实例,每个Watcher都是一个订阅者
                    // Watcher实例在实例化过程中,会读取data中的某个属性,从而触发当前get方法
                    if (Dep.target) {
                        dep.depend();
                    }
                    return val;
                },
                set: newVal => {
                    if (val === newVal) return;
                    val = newVal;
                    // 对新值进行监听
                    chlidOb = observe(newVal);
                    // 通知所有订阅者,数值被改变了
                    dep.notify();
                },
            });
        }

然后起一个watcher来监听

3.5、让数据响应起来

先给Vue暴露一个方法 $watcher 可以调用实例化Watcher。

class Vue{
            constructor (options = {}){

                //简化处理
                this.$options = options;

                let data = (this._data = 
                    typeof this.$options.data == 'function' ? 
                        this.$options.data() 
                        : 
                        this.$options.data);
                Object.keys(data).forEach(key =>{ this._proxy(key) });

                // 监听数据
                observe(data);
            }
            // 对外暴露调用订阅者的接口,内部主要在指令中使用订阅者
            $watch(expOrFn, cb) {
                //property需要监听的属性  cb在监听到更新后的回调
                new Watcher(this, expOrFn, cb);
            }
            _proxy (key){
                //用this这个对象去代理 this._data这个对象里面的key
                Object.defineProperty(this, key, {
                    configurable: true,
                    enumerable: true,
                    set: (val) => {
                        this._data[key] = val
                    },
                    get: () =>{
                        return this._data[key]
                    }
                })
            }
        }

3.6、测试: 声明一个实例

html部分

 <h3>Vue双向绑定</h3>
    <input type="text" id="input">
    <p id="react"></p>
    <h3>Vue数组双向绑定</h3>
    <input type="text" id="arr-input">
    <p id="arr-reat"></p>

let reactElement = document.querySelector("#react");
    let input = document.getElementById('input');
    input.addEventListener('keyup', function (e) {
        VM.a = e.target.value;
    });

    VM.$watch('a', val => reactElement.innerHTML = val); //监听属性 a 当a发生改变时


    //数组的响应并不能实现
    let arrReactElement = document.querySelector("#arr-reat");
    let arrInput = document.getElementById('arr-input');
    arrInput.addEventListener('keyup', function (e) {
        VM.arr.push(e.target.value);
        console.log(VM.arr);
    });
    VM.$watch('arr', val => arrReactElement.innerHTML = val); //监听属性 a 当a并没有发生改变时

VM.$watch就可以实例化一个watcher,从而去劫持data里面某个属性的改变,在改变时调用回调函数。
数组的改变并没有实现。上面的代码见https://gitee.com/cchennlleii/MyTest/blob/master/vueReactive/vue-reactive%E7%AE%80%E5%8D%95%E5%AE%9E%E7%8E%B0.html

4、对数组的支持

在说这个之前我们先去看一看vue官网对于数组更新检测的说明,链接:https://cn.vuejs.org/v2/guide/list.html#数组更新检测


总的来说,对于数组支持更新的只是数组原型上的方法,对于vm.items[index] = newValue是不支持的。
其实Object.defineProperty对于数组都是不支持的,根据消息vue3.0用的proxy对于数组得到了完美的支持,但是兼容性不怎么样。
既然vue实现了对数组原型方法的支持,那么我们也来让我们的例子对数组方法也支持吧。
原理不难,vue对于所有的数组原型方法都写了一层hack,让其支持更新。那么下面我们就一步一步来实现。

4.1、准备一套数组原型方法的hack

/**
        * Define a expOrFn.
        */
        function def(obj, key, val, enumerable) {
            Object.defineProperty(obj, key, {
                value: val,
                enumerable: !!enumerable,
                writable: true,
                configurable: true
            });
        }

        //数组改变的监听
        var arrayProto = Array.prototype;
        var arrayMethods = Object.create(arrayProto);
        var methodsToPatch = [
            'push',
            'pop',
            'shift',
            'unshift',
            'splice',
            'sort',
            'reverse'
        ];
        /**
        * Intercept mutating methods and emit events
        */
        methodsToPatch.forEach(function (method) {
            // cache original method
            var original = arrayProto[method];
            def(arrayMethods, method, function mutator() {
                var args = [], len = arguments.length;
                while (len--) args[len] = arguments[len];

                var result = original.apply(this, args);
                var ob = this.__ob__;
                var inserted;
                switch (method) {
                    case 'push':
                    case 'unshift':
                        inserted = args;
                        break
                    case 'splice':
                        inserted = args.slice(2);
                        break
                }
                if (inserted) { ob.observeArray(inserted); }
                // notify change
                ob.dep.notify(); //调用该数组下的 __ob__.dep 详细可见class Observer的constructor里的注释
                return result
            });
        });

上面代码准备了一个arrayMethods的对象,它继承自Array.prototype,并且对methodsToPatch里面的方法进行了改写,后面我们会把arrayMethods这个对象挂到"data里面"每个数组下,让该数组调用数组原生方法,比如[].push其实调用的是arrayMethods里面被改写的方法,从而在该数组改变时获取到该数组的更新。
下面开始挂载arrayMethods对象,在挂载我之前我们看到有一个this.__ob__属性,这里的this指向要观测的数组。这个__ob__就是前面的observe对象,并且每个observe下面还有一个dep对象。下面我们来理清楚这层关系。

class Observer{
    constructor (value){
        this.value = value;

        //下面两行代码虽然很简单,但是我们需要从这里理清楚关系
        //假如 有数据如 {a: [1,2,3], b: 1},  然后调用oberve(vm.a),vm当前vue实例
        //会自动挂载 __ob__ 和 __ob__.dep
        // 那么对数组a进行oberserve的对象就是a.__ob__, 它所对应的dep对象就是 a.__ob__.dep
        //详细使用可以在对数组的方法进行hack的时候 使用到
        def(value, '__ob__', this);//让被监听的数据都带上一个不可枚举的属性 __ob__ 代表observe对象
        this.dep = new Dep();//首先每个oberserve实例下有一个dep对象
        
        
        //在这里处理数组
        if (Array.isArray(value)){
            //调用数组的hack方法, 让数组也能被监听  arrayMethods
            var arrayKeys = Object.getOwnPropertyNames(arrayMethods);
            for (var i = 0, l = arrayKeys.length; i < l; i++) {
                var key = arrayKeys[i];
                def(value, key, arrayMethods[key]);
            }
        }   
        else{
            //对象 遍历key  添加监听
            this.walk(value);
        }
    }
    //Observer的其他方法
    //...
}

上面代码首先给每个值挂载__ob__属性(不可枚举),然后给每个Obeserve对象挂载Dep对象。然后根据value的类型,如果是数组就会挂载arrayMethods方法。
现在我们来理清数组在哪里依赖收集,在哪里通知更新的。
在对数组hack的方法里面(上上一段代码)有一段ob.dep.notify(); 这里通知更新,所以依赖收集也一定要收集到value.ob.dep对象里面,两个dep对象应该是相同的,下面我们来看看依赖收集写在哪里的。

function defineReactive(obj, key, val) {
            const dep = new Dep();
            // 给当前属性的值添加监听
            let childOb = observe(val);
            Object.defineProperty(obj, key, {
                enumerable: true,
                configurable: true,
                get: () => {
                    // 如果Dep类存在target属性,将其添加到dep实例的subs数组中
                    // target指向一个Watcher实例,每个Watcher都是一个订阅者
                    // Watcher实例在实例化过程中,会读取data中的某个属性,从而触发当前get方法
                    if (Dep.target) {
                        dep.depend();
                        if (childOb) {
                            childOb.dep.depend();
                            if (Array.isArray(val)) {
                                dependArray(val);
                            }
                        }
                    }
                    return val;
                },
                set: newVal => {
                    if (val === newVal) return;
                    val = newVal;
                    // 对新值进行监听
                    childOb = observe(newVal);
                    // 通知所有订阅者,数值被改变了
                    dep.notify();
                },
            });
        }
        function dependArray(value) {
            for (var e = (void 0), i = 0, l = value.length; i < l; i++) {
                e = value[i];
                e && e.__ob__ && e.__ob__.dep.depend();
                if (Array.isArray(e)) {
                    dependArray(e);
                }
            }
        }

数组虽然在Object.defineProperty里面set方法无法响应,但是get方法是没有问题的,所以在数组get的时候,判断val如果是array,会调用value.ob.dep.depend进行依赖收集。与上面依赖通知使用了同意个dep对象,也就是挂载在自身的__ob__.dep。
写到这里我们就完全实现对数组原生方法的支持了。
下面看一下效果 代码地址:https://gitee.com/cchennlleii/MyTest/blob/master/vueReactive/vue-reactive对数组的支持.html

4.2测试代码

html部分

<h3>Vue双向绑定</h3>
    <input type="text" id="input">
    <p id="react"></p>
    <h3>Vue数组双向绑定</h3>
    <input type="text" id="arr-input">
    <p id="arr-reat"></p>
    <h3>Vue对nextTick实现</h3>
    <button id="addBtn">加100000次</button>
    <p id="react-tick"></p>

let reactElement = document.querySelector("#react");
    let input = document.getElementById('input');
    input.addEventListener('keyup', function (e) {
        VM.a = e.target.value;
    });

    VM.$watch('a', val => reactElement.innerHTML = val); //监听属性 a 当a发生改变时


    //数组的响应并能实现
    let arrReactElement = document.querySelector("#arr-reat");
    let arrInput = document.getElementById('arr-input');
    arrInput.addEventListener('keyup', function (e) {
        VM.arr.push(e.target.value);
        console.log(VM.arr);
    });
    VM.$watch('arr', val => arrReactElement.innerHTML = val); //监听属性 a 当a发生改变时


    let reactTick = document.querySelector("#react-tick");
    VM.$watch('tickData', val => {
        console.log(val);
        reactTick.innerHTML = val;
    }); //监听属性 a 当a发生改变时
    document.querySelector('#addBtn').addEventListener('click', function () {
        for (let i = 0; i < 100000; i++) {
            VM.tickData = i;
        }
    }, false)

效果:

5、对nextTick的支持

vue官网对nextTick的解释:

nextTick如果自己实现就是在下一个envet loop执行,不在本次同步任务中执行。
自己实现一个简单的:

//nextTick的实现
let callbacks = [];
let pending = false;

function nextTick(cb) {
    callbacks.push(cb);
    if (!pending) {
        pending = true;
        setTimeout(flushCallbacks, 0);
    }
}
function flushCallbacks() {
    pending = false;
    const copies = callbacks.slice(0);
    callbacks.length = 0;
    for (let i = 0; i < copies.length; i++) {
        copies[i]();
    }
}

简单理解: 在本次event loop中收集cb(任务),放到下一个event loop去执行。 关于不知道event loop的可以参考这篇文章:https://www.cnblogs.com/chenlei987/p/10479433.html,我总结的很简单。我参考的http://www.ruanyifeng.com/blog/2014/10/event-loop.html。
在理解event loop的同时也需要同时了解 microtask和macrotask的区别。
好了言归正传,在vue的'data里面'某个属性发生了改变,并被观测到后,调用了watcher.update,并不会立即调用watcher.run去更新视图,它会经过nextTick之后再更新视图,说起来有点牵强。
还是第四部=步的代码,没有实现对nextTick的优化。
代码:

<h3>Vue双向绑定</h3>
    <input type="text" id="input">
    <p id="react"></p>
    <h3>Vue数组双向绑定</h3>
    <input type="text" id="arr-input">
    <p id="arr-reat"></p>
    <h3>Vue对nextTick实现</h3>
    <button id="addBtn">加1000次</button>
    <p id="react-tick"></p>

let reactTick = document.querySelector("#react-tick");
    VM.$watch('tickData', val => {
        console.log(val);
        reactTick.innerHTML = val;
    }); //监听属性 a 当a发生改变时
    document.querySelector('#addBtn').addEventListener('click', function () {
        for (let i = 0; i < 1000; i++) {
            VM.tickData = i;
        }
    }, false)

效果是这样的:

现在的效果是VM.tickData加1000次,那么cb(回调)就会调用1000次,这样是非常影响性能的,我们想要的效果是无论VM.tickData在本次event loop加多少次,都不会触发回调,只需要在VM.tickData加完之后,触发一次最终的cb(回调)就ok了。
下面我们就来实现这种优化,代码不多。

//nextTick的实现
            let callbacks = [];
            let pending = false;

            function nextTick(cb) {
                callbacks.push(cb);
                if (!pending) {
                    pending = true;
                    setTimeout(flushCallbacks, 0);
                }
            }
            function flushCallbacks() {
                pending = false;
                const copies = callbacks.slice(0);
                callbacks.length = 0;
                for (let i = 0; i < copies.length; i++) {
                    copies[i]();
                }
            }
            
            let has = {};
            let queue = [];
            let waiting = false;
            function queueWatcher(watcher) {
                const id = watcher.id;
                if (has[id] == null) {
                    has[id] = true;
                    queue.push(watcher);

                    if (!waiting) {
                        waiting = true;
                        nextTick(flushSchedulerQueue);
                    }
                }
            }
            function flushSchedulerQueue() {
                let watcher, id;

                for (index = 0; index < queue.length; index++) {
                    watcher = queue[index];
                    id = watcher.id;
                    has[id] = null;
                    watcher.run();
                }

                waiting = false;
            }

然后更改Watcher里面的update方法,并不直接调用watcher.run,而是经过queueWatcher控制

update() {
    queueWatcher(this);
    // this.run();
}

代码地址:https://gitee.com/cchennlleii/MyTest/blob/master/vueReactive/vue-reactive对nextTck的支持.html

6、总结

如果面试官问我关于双向绑定的问题,从这三个方面去回答,Object.definproperty,观察者模式,nextTick,当然,你需要把这三个点联系起来去描述,相信我你把上面的看懂了,联系起来完全没问题的,你是最棒的!

7、本文参考:

https://codepen.io/xiaomuzhu/pen/jxBRgj/
https://www.jianshu.com/p/2df6dcddb0d7

posted @ 2019-06-19 12:44  寻路、  阅读(677)  评论(0编辑  收藏  举报