阅读 660

JS设计模式之观察者模式

  在翻阅设计模式的文章中,很多文章都是将观察者模式等同于发布订阅模式,虽然两者在本质一样,但在设计思想上还是存在一些差异的;今天我们来看一下两者有什么异同,以及在Vue源码中是如何利用发布订阅模式来实现数据响应式的。

本文首发于公众号【前端壹读】,更多精彩内容敬请关注公众号最新消息。

banner.png

观察者模式

  我们先来看一下什么是观察者模式的定义:

观察者模式定义了对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都将得到通知,并自动更新。观察者模式属于行为型模式。

  这里又多了一个术语,行为型模式,它是对在不同的对象之间划分责任算法的抽象化,行为型模式不仅仅关注类和对象的结构,而且重点关注它们之间的相互作用;行为型模式一共有以下11种,今天我们要说的观察者模式就是其中的一种:

  • 模板方法模式(Template Method)
  • 策略模式(Strategy)
  • 命令模式(Command)
  • 中介者模式(Mediator)
  • 观察者模式(Observer)
  • 迭代器模式(Iteratior)
  • 访问者模式(Visiter)
  • 责任链模式(Chain of Responsibility)
  • 备忘录模式(Memento)
  • 状态模式(State)
  • 解释器模式(Interpreter)

  我们回到观察者模式的定义,它定义一种一对多的关系;这里的我们称为目标对象(Subject),它有增加/删除/通知等方法,而则称为观察者对象(Observer),它可以接收目标对象(Subject)的状态改变并进行处理;目标对象可以添加一系列的观察者对象,当目标对象的状态发生改变时,就会通知所有的观察者对象。

  下面我们通过代码来更具体的看一下目标对象和观察者对象是如何进行联系的:

// 定义一个目标对象
class Subject {
  constructor() {
    this.Observers = [];
  }
  add(observer) {
    //添加
    this.Observers.push(observer);
  }
  remove(observer) {
    //移除
    this.Observers.filter((item) => item === observer);
  }
  notify() {
    //通知所有观察者
    this.Observers.forEach((item) => {
      item.update();
    });
  }
}
//定义观察者对象
class Observer {
  constructor(name) {
    this.name = name;
  }
  update() {
    console.log(`my name is:${this.name}`);
  }
}

let sub = new Subject();
let obs1 = new Observer("observer11");
let obs2 = new Observer("observer22");
sub.add(obs1);
sub.add(obs2);
sub.notify();
复制代码

  我们在这里定义了目标对象和观察者对象两个类,在目标对象中维护了一个观察者的数组,新增时将观察者向数组中push;然后通过notify通知所有的观察者;而观察者只有一个update函数,用来接收观察者更新后的一个回调;在有些版本的代码中会将观察者直接定义为一个函数,而非一个类,但是其本质都是一样的,都是调用观察者的更新接口进行通知。

  这种模式的应用在日常中也很常见,比如我们给div绑定click监听事件,其本质就是观察者模式的一种应用:

var btn = document.getElementById('btn')
btn.addEventListener('click', function(ev){
  console.log(1)
})
btn.addEventListener('click', function(ev){
  console.log(2)
})
复制代码

  这里的btn可以看作是我们的目标对象(被观察对象),当它被点击时,也就是它的状态发生了变化,那么它就会通知内部添加的观察者对象,也就是我们通过addEventListener函数添加的两个匿名函数。

  我们发现,观察者模式好处是能够降低耦合,目标对象和观察者对象逻辑互不干扰,两者都专注于自身的功能,只提供和调用了更新接口;而缺点也很明显,在目标对象中维护的所有观察者都能接收到通知,无法进行过滤筛选。

发布订阅模式

  我们去搜索24种基本的设计模式,会发现其中并没有发布订阅模式;刚开始发布订阅模式只是观察者模式的一个别称,但是经过时间的沉淀,他改进了观察者模式的缺点,渐渐地开始独立于观察者模式;我们也来看一下它的一个定义:

发布订阅模式是基于一个事件(主题)通道,希望接收通知的对象Subscriber通过自定义事件订阅主题,被激活事件的对象Publisher通过发布主题事件的方式通知各个订阅该主题的Subscriber对象。

  我们看到定义里面也涉及到了两种对象:接收通知的对象(Subscriber)和被激活事件的对象(Publisher);被激活事件对象(Publisher)我们可以类比为观察者模式中的目标对象,来发布事件通知,而接收通知对象(Subscriber)可以类比为观察者对象,订阅各种通知。

  发布订阅模式和观察者模式的不同在于,增加了第三方即事件中心;目标对象状态的改变并直接通知观察者,而是通过第三方的事件中心来派发通知。

publisher-subscriber

  为了加深理解,我们以生活中的情形为例;比如我们订阅报纸杂志等,一般不会直接跑到报社去订阅,而是通过一个平台,比如街边的报亭或者邮局也可以订阅;而报纸杂志也会有多种,比如晨报晚报日报等等;我们订阅报纸后报社出版后会通过平台来给我们投递,通过邮局邮寄或者自取等等,那么这里就涉及到了报社、订阅者和第三方平台三个对象,我们通过代码来模拟三者的动作:

// 报社
class Publisher {
  constructor(name, channel) {
    this.name = name;
    this.channel = channel;
  }
  // 注册报纸
  addTopic(topicName) {
    this.channel.addTopic(topicName);
  }
  // 推送报纸
  publish(topicName) {
    this.channel.publish(topicName);
  }
}
// 订阅者
class Subscriber {
  constructor(name, channel) {
    this.name = name;
    this.channel = channel;
  }
  //订阅报纸
  subscribe(topicName) {
    this.channel.subscribeTopic(topicName, this);
  }
  //取消订阅
  unSubscribe(topicName) {
    this.channel.unSubscribeTopic(topicName, this);
  }
  //接收推送
  update(topic) {
    console.log(`${topic}已经送到${this.name}家了`);
  }
}
// 第三方平台
class Channel {
  constructor() {
    this.topics = {};
  }
  //报社在平台注册报纸
  addTopic(topicName) {
    this.topics[topicName] = [];
  }
  //报社取消注册
  removeTopic(topicName) {
    delete this.topics[topicName];
  }
  //订阅者订阅报纸
  subscribeTopic(topicName, sub) {
    if (this.topics[topicName]) {
      this.topics[topicName].push(sub);
    }
  }
  //订阅者取消订阅
  unSubscribeTopic(topicName, sub) {
    this.topics[topicName].forEach((item, index) => {
      if (item === sub) {
        this.topics[topicName].splice(index, 1);
      }
    });
  }
  //平台通知某个报纸下所有订阅者
  publish(topicName) {
    this.topics[topicName].forEach((item) => {
      item.update(topicName);
    });
  }
}
复制代码

  这里的报社我们可以理解为发布者(Publisher)的角色,订报纸的读者理解为订阅者(Subscriber),第三方平台就是事件中心;报社在平台上注册某一类型的报纸,然后读者就可以在平台订阅这种报纸;三个类准备好了,我们来看下他们彼此如何进行联系:

var channel = new Channel();

var pub1 = new Publisher("报社1", channel);
var pub2 = new Publisher("报社2", channel);

pub1.addTopic("晨报1");
pub1.addTopic("晚报1");
pub2.addTopic("晨报2");

var sub1 = new Subscriber("小明", channel);
var sub2 = new Subscriber("小红", channel);
var sub3 = new Subscriber("小张", channel);

sub1.subscribe("晨报1");
sub2.subscribe("晨报1");
sub2.subscribe("晨报2");
sub3.subscribe("晚报1");

sub3.subscribe("晨报2");
sub3.unSubscribe("晨报2");

pub1.publish("晨报1");
pub1.publish("晚报1");
pub2.publish("晨报2");

//晨报1已经送到小明家了
//晨报1已经送到小红家了
//晚报1已经送到小张家了
//晨报2已经送到小红家了
复制代码

  由于平台是沟通的桥梁,因此我们先定义了一个调度中心channel,然后分别定义了两个报社pub1、pub2,以及三个读者sub1、sub2和sub3;两家报社在平台注册了晨报1、晚报1和晨报2三种类型的报纸,三个读者各自订阅各家的报纸,也能取消订阅。

  我们可以发现在发布者中并没有直接维护订阅者列表,而是注册了一个事件主题,这里的报纸类型相当于一个事件主题;订阅者订阅主题,发布者推送某个主题时,订阅该主题的所有读者都会被通知到;这样就避免了观察者模式无法进行过滤筛选的缺陷。

主要区别

  我们通过一张图来形象的描述两种模式的区别。

diff.jpg

  • 观察者模式把观察者对象维护在目标对象中的,需要发布消息时直接发消息给观察者。在观察者模式中,目标对象本身是知道观察者存在的。
  • 而发布/订阅模式中,发布者并不维护订阅者,也不知道订阅者的存在,所以也不会直接通知订阅者,而是通知调度中心,由调度中心通知订阅者。

Vue中的发布订阅模式

  我们在深入学习Object.defineProperty和Proxy中介绍过,Vue2.0响应式是通过Object.defineProperty()来处理的,将每个组件data中的数据进行get/set劫持(也就是Reactive化),那么劫持后是如何来通知页面进行更新操作呢?这里就用到了发布订阅模式,我们首先来看下官网是如何介绍的:

data.png

每个组件实例都对应一个 watcher 实例,它会在组件渲染的过程中把“接触”过的数据 property 记录为依赖。之后当依赖项的 setter 触发时,会通知 watcher,从而使它关联的组件重新渲染。

  相信看过源码的同学对Watcher和Dep的代码看的是云里雾里,不了解这两个类的作用;我们剔除不相关的代码,对主要代码逐段分析。

//defineReactive部分源码
//src\core\observer\index.js
export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: Function
) {
  // 每个data的属性都会有一个dep对象,用来进行收集依赖
  const dep = new Dep()

  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      if (Dep.target) {
        dep.depend()
      }
      return value
    },
    set: function reactiveSetter (newVal) {
      val = newVal
      dep.notify()
    }
  })
}
复制代码

  我们在初始化data时或者用$set给data新增属性都会给每个属性循环遍历调用defineReactive进行数据劫持;我们看到在每个属性中构造了一个dep对象,并且在属性触发getter和setter时都会调用,它其实是依赖收集和触发更新的一个第三方,相当于发布订阅模式中事件中心的一个角色;而且由于getter/setter函数内对它闭包引用,因此我们在this.numthis.num=1都是调用它下面的函数,因此我们来看下它的实现原理:

//src\core\observer\dep.js
class Dep {
  static target: ?Watcher;
  id: number;
  subs: Array<Watcher>;
  constructor () {
    this.id = uid++
    this.subs = []
  }
  //新增观察者
  addSub (sub: Watcher) {
    this.subs.push(sub)
  }
  //移除观察者
  removeSub (sub: Watcher) {
    remove(this.subs, sub)
  }
  // 依赖收集
  depend () {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }
  // 通知所有的观察者
  notify () {
    const subs = this.subs.slice()
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }
}
Dep.target = null
复制代码

  Dep的全程是Dependency,翻译过来也是依赖、依赖关系的意思,从意思上能看出来是用来做依赖收集的;我们看到Dep下面有一个subs数组,它是一组Watcher的列表,存放的就是我们收集的依赖列表;然后通过addSub和removeSub新增和删除某个依赖,当数据更新时通过notify通知列表中所有的依赖对象;可以发现这些函数和我们的事件中心的代码很相似,不过它不是基于事件主题,而是直接通过一个列表。

  Dep源码看完了,下面就来看我们收集的依赖Watcher,也就是订阅者,都做了哪些事情:

//src\core\observer\watcher.js
class Watcher {
  constructor (
    vm: Component,
    expOrFn: string | Function,
    cb: Function,
    options?: Object
  ) {
    this.vm = vm
  }
  addDep (dep: Dep) {
    dep.addSub(this)
  }
  //执行数据更新
  update () {
  }
}
复制代码

  我们看到Watcher和我们的订阅者代码也很相似,在update中对视图进行更新操作;由于data数据可以传入不同的子组件,而在data中数据更新时,每个子组件中的页面都需要重新更新,因此每一个Vue组件都会在mount阶段都会创建一个Watcher,然后保存在_watcher上:

//src\core\instance\lifecycle.js
function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  callHook(vm, 'beforeMount')
  let updateComponent = () => {
    vm._update(vm._render(), hydrating)
  }
  vm._watcher = new Watcher(vm, updateComponent, noop)
  callHook(vm, 'mounted')
  return vm
}
复制代码

  因此Dep和Watcher两者关系如下图:

dep-watch.png

  我们回到Dep的源码中,发现有一个静态属性Dep.target是Watcher,进行依赖收集的时候也是通过Dep.target,那么它是做什么用的呢?让我们继续回到Watcher的构造器:

//src\core\observer\dep.js
Dep.target = null
const targetStack = []

export function pushTarget (_target: Watcher) {
  if (Dep.target) targetStack.push(Dep.target)
  Dep.target = _target
}

export function popTarget () {
  Dep.target = targetStack.pop()
}
//src\core\observer\watcher.js
class Watcher {
  constructor (
    vm: Component,
    expOrFn: string | Function,
    cb: Function,
    options?: Object
  ) {
    this.getter = expOrFn
    this.get()
  }
  get () {
    pushTarget(this)
    value = this.getter.call(vm, vm)
    popTarget()
    return value
  }
}
复制代码

  在Dep代码中同时维护了一个targetStack,也就是我们常说的堆栈,它遵从着先进后出的原则,我们只能通过pushTarget(压栈)和popTarget(出栈)来对它进行操作,那么它是什么时候需要进行压栈和出栈的操作呢?

  在Watcher的源码中我们发现的原因,由于Water实例是在组件mounted时被构建的,在构建时需要把实例暂存到Dep.target上以便Dep进行依赖收集;如果Dep.target上有其他组件的watcher实例,需要先把其他的watcher实例暂存到targetStack中,然后调用expOrFn函数渲染组件;这里的expOrFn渲染组件时会将data中定义的数据取值,取值的过程就会自动调用Reactive化后的getter函数,因此就把Dep.target上的watcher实例收集到了每个数据的Dep中,收集完成后再把上一个watcher出栈。

  总结,经过两者关系的分析,我们发现Vue是一个典型的发布订阅模式,data中的数据就是我们需要观察的目标对象,Dep相当于事件中心,而Watcher则是订阅者。

更多前端资料请关注公众号【前端壹读】

如果觉得写得还不错,请关注我的掘金主页。更多文章请访问谢小飞的博客

文章分类
前端