美文网首页
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