JS设计模式 - 观察者模式

前端 11月 16, 2020 ~

前言

很多时候,对象不是独立存在的,一个对象的行为发生改变可能会导致一个或者多个其他对象的行为也发生改变。

比如现实生活中车辆遇红灯则停,遇绿灯则行;又比如 MVC 或 MVVM 模式中的模型与视图的关系。将这些有依赖关系的对象关联起来,并触发相应的行为,就是观察者模式的应用场景。

1. 什么是观察者模式

1.1 举个🌰

首先我们先来看两个常见的例子,以初步了解观察者模式。

DOM事件

我们时常需要在用户点击DOM元素时实现一些事情,但却无法预知客户会在什么时候点击。

在 DOM 节点上面绑定事件函数,其实就是观察者模式的实现:

var dom = document.getElementById('box');
dom.addEventListener('click', function () {
  console.log("click box");
}, false);

// 模拟点击
dom.click();  // click box

上例中监听了dom的click事件,当dom被点击时,就会触发回调函数的执行。

此时,dom的click事件就是被观察者,监听事件就是观察者。

微信公众号文章推送

订阅微信公众号、接收文章推送的过程,也是一个典型的应用观察者模式的例子,简单实现:

// 被观察者:微信公众号
class WxBar {
  constructor(topic) {
    this.topic = topic;
    this.observers = new Set();  // 缓存列表
  }
  addObserver(ob) {
    this.observers.add(ob);
  }
  removeObserver(ob) {
    this.observers.delete(ob);
  }
  // 推送文章
  pushArticle(article) {
    console.log('push article');
    for (let ob of this.observers) {  // 遍历缓存列表
      ob.update(this.topic, article)  // 触发观察者的回调函数
    }
  }
}

// 观察者:微信用户
class WxUser {
  constructor(name) {
    this.name = name;
  }
  update(topic, article) {
    console.log(this.name + ' receive article from ' + topic)
  }
}

const wxBar = new WxBar("JS DP");
const livia = new WxUser('Livia');
const cindy = new WxUser('Cindy');
wxBar.addObserver(livia);
wxBar.addObserver(cindy);
wxBar.pushArticle({});
// push article
// Livia receive article from JS DP
// Cindy receive article from JS DP

// 取消订阅公众号
wxBar.removeObserver(cindy);
wxBar.pushArticle({});
// push article
// Livia receive article from JS DP

以上过程大致分为以下几点:

  • 首先要指定被观察者,即微信公众号
  • 然后给被观察者添加一个缓存列表,用于存放回调函数以便通知观察者,即微信用户
  • 推送文章的时候,被观察者会遍历这个缓存列表,依次触发里面存放的观察者回调函数
  • 取消订阅的时候,由被观察者将观察者从缓存列表中移除

1.2 定义与特点

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

从以上的例子可以看出,观察者模式降低了目标与观察者之间的耦合关系,在目标与观察者之间建立了一套触发机制。但目标与观察者之间的依赖关系并没有完全解除,而且有可能出现循环引用。

2. 观察者模式 vs 发布订阅模式

大多数文档都记录观察者模式与发布订阅模式是一个东西,主张发布订阅是观察者模式的别称。

然而相对来说,观察者模式是面向目标和观察者编程的,而发布-订阅模式则是面向调度中心编程的。

JSDP.001ogdzmwea

上文微信公众号文章推送的例子加入调度中心后,可以简单改写为:

// 调度中心
class PubSub {
  constructor() {
    this.subscribers = new Set();  // 缓存列表
  }
  subscribe(ob) {
    this.subscribers.add(ob);
  }
  unsubscribe(ob) {
    this.subscribers.delete(ob);
  }
  publish(topic, params) {
    for (let ob of this.subscribers) {  // 遍历缓存列表
      ob.update(topic, params)
    }
  }
}
// 发布者:微信公众号
class WxBar {
  constructor(topic) {
    this.topic = topic;
  }
  // 推送文章
  pushArticle(pubSub, article) {
    console.log('push article');
    pubSub.publish(this.topic, article)
  }
}
// 订阅者:微信用户
class WxUser {
  constructor(name) {
    this.name = name;
  }
  update(topic, article) {
    console.log(this.name + ' receive article from ' + topic)
  }
}

let pubSub = new PubSub();

const wxBar = new WxBar("JS DP");
const livia = new WxUser('Livia');
const cindy = new WxUser('Cindy');

pubSub.subscribe(livia);
pubSub.subscribe(cindy);
wxBar.pushArticle(pubSub, {});

以上例子中,发布订阅模式里多了一个消息队列或代理PubSub的角色。

由于这个角色的加入,发布者和订阅者不需要相互了解,它们只是在调度中心的帮助下进行通信。发布者和订阅者之间完全不存在直接联系,实现了解耦合。

观察者模式主要以同步的方式实现,即当某些事件发生时,主体调用其所有观察者的适当方法。发布者/订阅者模式则主要通过使用消息队列以异步方式实现。

3. 应用实例—— Vue 响应式原理

说到观察者模式,不免就要谈谈Vue的响应式原理。以下是以对象属性变更即更新视图为例的简单实现:

class Dep {
  constructor() {
    // 观察者列表
    this.subs = [];
  }

  addSub(sub) {
    this.subs.push(sub);
  }

  notify() {
    this.subs.forEach((sub) => {
      sub.update();
    })
  }
}

// 观察者
class Watcher {
  constructor() {
    // 将 Dep.target 指向当前观察者
    Dep.target = this;
  }

  update() {
    // do something
    console.log("re render view");
  }
}

function defineReactive(obj, key, val) {
  const dep = new Dep();
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function () {
      // Dep.target 指向观察者,收集 watcher
      Dep.target && dep.addSub(Dep.target);
      return val;
    },
    set: function (newVal) {
      // 新值与旧值相等,或新值与旧值都为NaN
      if (newVal === val || (newVal !== newVal && value !== value)) return;
      val = newVal;
      dep.notify();
    }
  });
}

// 将对象处理为可被观察的(暂时只处理对象第一层属性)
function observer(value) {
  if (!value || (typeof value !== 'object')) {
    return;
  }
  Object.keys(value).forEach((key) => {
    defineReactive(value, key, value[key]);
  });
}

// 简单的Vue构造函数
class Vue {
  constructor(options) {
    this._data = options.data;
    observer(this._data);
    new Watcher();
    console.log('render:', this._data.test);  // 触发 get > dep.addSub(Dep.target)
  }
}

const o = new Vue({
  data: {
    test: "init test."
  }
});
o._data.test = "Hello world.";  // 变更后触发 set > dep.notify()

Dep.target = null;

执行步骤:

  • 首先确定被观察者,即Vuedata对象,或者说是对data属性变更的监听。
  • 通过defineReactive > Object.defineProperty使data对象的属性成为可被观察的。
  • 创建观察者,将Dep.target指向当前观察者,在打印this._data.test的值时将观察者加入缓存列表。
  • 更改o._data.test时,触发对象set属性的监听,通过dep.notify()通知观察者进行更新。

其中Dep.target是类Dep而非其实例dep的属性,可以在全局访问并任意改变它的值。通过在订阅前将Dep.target指向观察者,订阅后置为null,可以灵活的在相关属性被应用时才将观察者加入Dep的缓存列表。

总结

观察者模式的本质是触发联动,当修改目标对象的状态时,触发相应的通知,通过遍历观察者对象的缓存列表,联动到观察者的变化。

从形式上,发布订阅模式比观察者模式多了一个调度中心。但从意图上,两者都是为了实现对象间一对多的依赖关系,由主体的状态变化触发依赖对象的自动更新。

标签

Livia

人生没有对错,都是选择