美文网首页
Vue2 双向绑定——defineProperty

Vue2 双向绑定——defineProperty

作者: anOnion | 来源:发表于2019-03-21 20:59 被阅读0次

    前言

    Vue三要素:响应式、模版引擎和渲染。其中,响应式就是通过著名的双向绑定(Two-way data binding)实现的。今天我们就聊聊这个老掉牙的话题——Vue2是如何实现双向绑定的。

    数据劫持

    我以前写过一篇《Vue计算属性简析》,那里也提到过数据劫持。什么是数据劫持呢?说白了就是利用Object.defineProperty()来劫持对象属性的setter和getter操作,以期达到代理更复杂操作的目的。给个简单的例子——山寨Vue,快速回顾一下数据劫持:

    class Vue {
        constructor({data}) {
            this.data = data();
            Object.keys(this.data).forEach( this.proxy.bind(this) );
        }
    
        proxy (key) {
            Object.defineProperty(this, key, {
                get () {
                    return Reflect.get(this.data, key);
                },
                set (newVal) {
                    Reflect.set(this.data, key, newVal)
                }
            })
        }
    }
    
    let vm = new Vue({
      data: () => ( {
        price: 5,
        quantity: 2,
      }),
    });
    
    console.log(Object.keys(vm)); // [ 'data' ]
    console.log(vm.price, vm.quantity); // 5 2
    

    如上所示,我们对Vue对象进行数据劫持,它本身并不拥有price或quantity域;但当调用vm.pricevm.quantity的setter或getter操作时,会自动代理到vm.data的price和quantity方法。现实开发中,我们也能在vue模版或方法里看到类似的调用。道理都是一样的,就是通过数据劫持来代理data域操作。

    <div>{{this.price}}</div>
    

    或是

    {
        data: () => ( {
            price: 5,
            quantity: 2,
        }),
        computed: {
            total () {
                return this.price * this.quantity;
            }
        }
    }
    

    双向绑定

    有了数据劫持的知识,我们进一步探索双向绑定的实现。

    极简实现

    上文我们通过Vue自身的数据劫持代理了私有域data。下面我写了一个极简版的双向绑定示例。由于单一职责的设计原则,我又进一步劫持了vm.data。原因很简单:除了data, Vue还会代理methodscomputed等方法,这些方法实现差异巨大,不适合全部耦合在Vue.proxy里。这里的实现就是将price和DOM的input做双向绑定。

    let vm = new Vue({
      data: () => ( {
        price: 5,
      }),
    });
    
    Object.defineProperty(vm.data, 'price', {
      get: function() {
        return vm.data['price']
      },
      set: function(newVal) {
        vm.data['price'] = newVal;
        document.getElementById('input').value = newVal;
      }
    });
    
    document.getElementById('input')
            .addEventListener('keyup', function cb(e) {
                vm.price  = e.target.value;
            })
    

    上述代码仅仅是个示例,仅反映我们可以通过数据劫持实现双向绑定;但是并没什么学习价值,耦合严重,违反了开发闭合原则——DOM操作不应该放在set方法里面。更大的问题是:只能监听一个属性。假如某个DOM绑定了this.pricethis.quantity两个域,实现就会变得很复杂。

    <span>total = {{price*quantity}}</span>
    

    订阅发布

    有什么改进法案呢?想想设计模式。
    Vue2就将数据劫持和订阅发布模式结合在了一起。看这张图:

    two-way bind

    有些复杂,我们先拆解来看:

    • Dep(订阅发布中心):负责存储订阅者,并处理消息分发。

    • Observer(观察者):用于监听data属性变化,实现注册和消息通知

    • Watcher(订阅者):在Dep里注册自己的信息,当Dep分发消息后触发自身方法

    • Viewer(显示):DOM更新(方便起见,后文将用console.log代替)

    山寨Vue

    先看一下我github上的山寨Vue:

    /* Step 1 */
    let watcher = function () {
      const total = this.price * this.quantity;
      console.log(`total = ${total}`); // Viewer!!
    };
    
    /* Step 2 */
    let vm = new Vue({
      data: () => ( {
        price: 5,
        quantity: 2,
      }),
    });
    
    /* Step 3 */
    vm.$mount( watcher ); // total = 10
    
    /* Step 4 */
    vm.price = 100; // total = 200
    vm.quantity = 100; // total = 10000
    
    1. 定义了一个watcher函数,用于模拟template里的数据绑定:——<span>total = {{price*quantity}}</span>

    2. 初始化Vue对象vm

    3. watcher函数挂载到vm里,total初始化成功

    4. 分别修改vm.pricevm.quantitytotal随之更新

    山寨Vue的使用方法已经列在上面了,看一下类实现(proxy见第一部分):

    class Vue {
        constructor({data}) {
            this.data = data();
            Object.keys(this.data).forEach( this.proxy.bind(this) );
            new Observer(this.data);
        }
    
        $mount(watcher) {
            Dep.target = watcher.bind(this);
            watcher.call(this);  // init and register
            Dep.target = null;
        }
    
        proxy (key) { ... }
    }
    

    Observer

    从上到下,我们就先说Observer吧。

    class Observer {
        constructor (data) {
            Object.keys(data).forEach( Observer.defineReactive.bind(null, data) )
        }
    
        static defineReactive(obj, key) {
            let val = Reflect.get(obj, key);
            const dep = new Dep();
    
            Object.defineProperty(obj, key, {
                get () {
                    dep.depend();
                    return val;
                },
                set (newVal) {
                    val = newVal;
                    dep.notify();
                }
            })
    
        }
    }
    

    Vue初始化后,将this.data交由Observer.defineReactive做数据劫持:

    • getter:返回数值,但重点是往Dep里注册绑定的依赖——Watcher

    • setter:在赋值后通知Dep分发消息至所有的订阅者——Watcher。

    Observer

    Dep

    Dep的实现有点小技巧,首先定义了一个静态变量target;当vue挂载watcher时,target指向该方法(后面会继续展开)。如上图所示,depend主要作用是将挂载了的watcher作为订阅者存储起来,并在notfiy调用时,触发这些订阅者。

    class Dep {
        constructor() {
            this.subscribers = [];
        }
        depend() {
            if( Dep.target && !this.subscribers.includes(Dep.target) ){
                this.subscribers.push(Dep.target);
            }
        }
        notify() {
            this.subscribers.forEach(sub => sub())
        }
    }
    
    Dep.target = null;
    

    Watcher

    再看一下订阅者watcher:

    let watcher =  function () {
      const total = this.price * this.quantity;
      console.log(`total = ${total}`)
    }
    
    class Vue {
        ...
        $mount( watcher ) {
            Dep.target = watcher.bind(this);
            watcher.call(this);  // init and register
            Dep.target = null;
        }
    }
    
    vm.$mount(watcher);
    

    这里重点还是在$mount函数。我们先将Dep.target指向watcher,然后运行watcher.call(this)初始化total。这时候price和quantity的getter被调用。我们知道这两个域的getter已经被Observer劫持了,并会触发depend方法。再回看一下depend实现:

    depend() {
        if( Dep.target && !this.subscribers.includes(Dep.target) ){
            this.subscribers.push(Dep.target);
        }
    }
    

    这时候,watcher就通过Dep.target添加到subscribers数组里了。至此,整个发布订阅模式被打通。

    Publish

    最后我们通过vm的setter方法,通知Dep,并调用所有订阅者watcher。

    vm.price = 100; // total = 200
    vm.quantity = 100; // total = 10000
    
    class Dep {
        ...
        notify() {
            this.subscribers.forEach(sub => sub())
        }
    }
    

    再来复盘一下消息流:

    workflow
    1. Observer劫持Vue.data

    2. Vue挂载模版方法——watcher

    3. 调用watcher并初始化Viewer

    4. 由于数据劫持,watcher自动触发Vue getter,并调取Dep.depend

    5. watcher通过Dep.target成功订阅Dep

    6. 触发Vue setter操作,setter将消息通知到Dep

    7. Dep将消息发布至订阅者watcher

    8. Viewer因watcher调用而更新

    小结

    这期我们利用数据劫持和订阅发布模式实现了一个山寨版的Vue,学习了Vue2双向绑定的设计思想。但是这个双向绑定还是存在一些漏洞的,尤雨溪也在今年宣布Vue3会重写双向绑定。至于新的实现又是什么,我们下次再聊。

    相关文章

      网友评论

          本文标题:Vue2 双向绑定——defineProperty

          本文链接:https://www.haomeiwen.com/subject/ijlhvqtx.html