Vue2 双向绑定——defineProperty

前言

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会重写双向绑定。至于新的实现又是什么,我们下次再聊。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,445评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,889评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,047评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,760评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,745评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,638评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,011评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,669评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,923评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,655评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,740评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,406评论 4 320
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,995评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,961评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,023评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,483评论 2 342