从发布订阅到双向数据绑定

前言

双向数据绑定已经是一个谈烂的话题,若谈及原理,想必大家都能提到数据劫持defineProperty。但是,对于如何完整地实现一个双向数据绑定伪代码,我想大概很多人都没有去深究。于是,本文借着梳理发布订阅模式由浅到深地实现一下双向数据绑定。

发布订阅模式

双向数据绑定的底层设计模式为:发布订阅模式。
  • 发布订阅模式也称为观察者模式
  • 观察者同时订阅一个对象,当对象发生改变时,每一个观察者都能接收到通知

通俗举例

举一个最通俗的🌰:小红、小明、小白同时关注拼多多的AJ1,当AJ1一降价,三个人都能接到通知。

class Pinduoduo {
  constructor() {
    // 订阅者
    this.subscribers = [];
  }
  // 订阅方法
  subscribe({name, callback}) {
    if (~this.subscribers.indexOf(name)) return;
    this.subscribers.push({
      name, callback
    });
  }
  // 发布降价消息
  publish() {
    this.subscribers.forEach(({name, callback}) => {
      let prize = 666;
      if (name === '小明') prize = 100;
      callback && callback(name, prize);
    })
  }
}


const pinInstance = new Pinduoduo();
const commonFn = (name, prize) => {
  console.log(`${name}接收到了降价信息,AJ1现在的价格是${prize}`)
}


// 订阅
pinInstance.subscribe({
  name: '小红',
  callback: commonFn
});
pinInstance.subscribe({
  name: '小明',
  callback: commonFn
});
pinInstance.subscribe({
  name: '小白',
  callback: commonFn
});


// 发布
pinInstance.publish();
// 输出
// 小红接收到了降价信息,AJ1现在的价格是666
// 小明接收到了降价信息,AJ1现在的价格是100
// 小白接收到了降价信息,AJ1现在的价格是666

所以——记住实现发布订阅模式的两个要点:
发布(触发) & 订阅(监听)

EventEmitter

借此我们还可以实现一下EventEmitter的伪代码。

function EventEmitter() {
    this.events = Object.create(null);
}

// 实现监听方法
EventEmitter.prototype.on = (type, event) => {
    if (!this.events) this.events = Object.create(null);
    if (!this.events[type]) this.events[type] = [];
    this.events[type].push(event);
}

// 实现触发方法
EventEmitter.prototype.emit = (type, ...args) => {
    if (!this.events[type]) return;
    this.events[type].forEach(event => {
        event.call(this, ...args);
    })
}

// 执行
function Girl() {}
// 实现继承
Girl.prototype = Object.create(EventEmitter.prototype);
const lisa = new Girl();
lisa.on('逛街', () => {
    console.log('买买买!');
});

lisa.emit('逛街');

// console: 买买买!

深入浅出双向数据绑定

下述例子dom结构基于如下代码

<div id="app">
    <input type="text" v-model="data">
    <p v-text="data"></p>
</div>

最最简单的实现

不注释了,相信大家都能看懂。

const inputDom = document.getElementsByTagName('input')[0];
const textDom = document.getElementsByTagName('p')[0];
inputDom.addEventListener('input', e => {
    const val = e.target.value;
    textDom.innerText = val;
});

defineProperty实现

1、极简版

const vm = {
    data: ''
};
const inputDom = document.getElementsByTagName('input')[0];
const textDom = document.getElementsByTagName('p')[0];

Object.defineProperty(vm, 'data', {
    set(newVal) {
        if (vm['data'] === newVal) return;
        // 同时触发视图更新
        textDom.innerText = newVal;
    }
});


inputDom.addEventListener('input', e => {
    vm.data = e.target.value;
});

2、进阶版
假如我们更换个属性,或添加v-model上述代码就不能复用了。我们迭代一下,可以适应多个v-model的情况。

首先梳理一下需要做什么

  • 添加对象劫持器 Object.defineProperty
  • 添加订阅者管理中心,新增订阅者和通知订阅者更新
  • 遍历Dom Treev-modelv-text进行解析。对v-model进行事件绑定监听变化,对v-text添加订阅者,订阅vm变化实现视图更新。
/*
 * 定义对象监听
*/
const vm = {
    data: ''
};

function observe(obj) {
    Object.keys(obj).forEach(key => {
        let val = obj[key];
        Object.defineProperty(obj, key, {
            get() {
                return val;
            },
            set(newVal) {
                if (newVal === val) return;
                // 更新vm中的数据
                val = newVal;
            }
        })
    })
}
/*
 * 加入发布订阅模式
*/

const Dep = {
    target: null,
    subs: [],
    addSubs(sub) {
        this.subs.push(sub)
    },
    notify() {
        this.subs.forEach(sub => {
            sub.update();
        });
    }
}

在getter中,添加watcher
在setter观测到数据变化时,触发所有【订阅者】更新

// ...
get() {
    // 此时的target已经赋值成当前的watcher实例
    if (Dep.target) Dep.addSubs(Dep.target);
    return val;
},
set(newVal) {
    if (newVal === val) return;
    // 更新vm中的数据
    val = newVal;
    Dep.notify();
}
// ...

接下来定义【订阅者】watcher,在本例中可以理解成每一个node节点

function Watcher(node, vm, name) {
    Dep.target = this;
    this.node = node;
    this.vm = vm;
    // name是绑定数据的key
    this.name = name;
    // 将watcher添加进dep中
    this.update();
    Dep.target = null;
}

// Watcher包含update方法和get方法
Watcher.prototype = {
    update() {
        this.get();
        this.node.innerText = this.value;
    },
    // 这里主要是为了触发getter中Dep.addSub
    get() {
        this.value = this.vm[this.name];
    }
}

然后是对相应节点进行解析处理

function complie(node, vm) {
    if (node.nodeType === 1) {
        [...node.attributes].forEach(attr => {
            const name = attr.nodeValue;
            if (attr.nodeName === 'v-model') {
                node.addEventListener('input', e => {
                    vm[name] = e.target.value;
                })
            } else if (attr.nodeName === 'v-text') {
                new Watcher(node, vm, name)
            }
        })
    }
}

现在可以对每个节点进行绑定处理了

function MVVM(id, vm) {
    observe(vm);
    const node = document.getElementById(id);
    // 用fragment缓存节点,节约性能开支
    const fragment = document.createDocumentFragment();
    let child;
    while(child = node.firstChild) {
        fragment.appendChild(child)
    }
}

调用

MVVM(vm);

整个代码初看起来会比较绕,但只要理解observecomplieDepWacther这几个概念,相信就能基本看懂MVVM了。


(未完待续...待更新Proxy的MVVM实现方法)

阅读 148

推荐阅读
Nodes of Front-end
用户专栏

前端妹砸的笔记本。。

28 人关注
33 篇文章
专栏主页
目录