JS 設計模式之觀察者模式
在翻閱設計模式的文章中,很多文章都是將觀察者模式等同於發佈訂閱模式,雖然兩者在本質一樣,但在設計思想上還是存在一些差異的;今天我們來看一下兩者有什麼異同,以及在 Vue 源碼中是如何利用發佈訂閱模式來實現數據響應式的。
我們先來看一下什麼是觀察者模式的定義:
觀察者模式定義了對象間的一種
一對多
的依賴關係,當一個對象的狀態發生改變時,所有依賴於它的對象都將得到通知,並自動更新。觀察者模式屬於行爲型模式。
這裏又多了一個術語,行爲型模式,它是對在不同的對象之間劃分責任
和算法
的抽象化,行爲型模式不僅僅關注類和對象的結構,而且重點關注它們之間的相互作用;行爲型模式一共有以下 11 種,今天我們要說的觀察者模式就是其中的一種:
- 模板方法模式(Template Method)
- 策略模式(Strategy)
- 命令模式(Command)
- 中介者模式(Mediator)
- 觀察者模式(Observer)
- 迭代器模式(Iteratior)
- 訪問者模式(Visiter)
- 責任鏈模式(Chain of Responsibility)
- 備忘錄模式(Memento)
- 狀態模式(State)
- 解釋器模式(Interpreter)
我們回到觀察者模式的定義,它定義一種一對多
的關係;這裏的一
我們稱爲目標對象(Subject),它有增加 / 刪除 / 通知等方法,而多
則稱爲觀察者對象(Observer),它可以接收目標對象(Subject)的狀態改變並進行處理;目標對象可以添加一系列的觀察者對象,當目標對象的狀態發生改變時,就會通知所有的觀察者對象。
下面我們通過代碼來更具體的看一下目標對象和觀察者對象是如何進行聯繫的:
1class Subject {
2 constructor() {
3 this.Observers = [];
4 }
5 add(observer) {
6
7 this.Observers.push(observer);
8 }
9 remove(observer) {
10
11 this.Observers.filter((item) => item === observer);
12 }
13 notify() {
14
15 this.Observers.forEach((item) => {
16 item.update();
17 });
18 }
19}
20
21class Observer {
22 constructor(name) {
23 this.name = name;
24 }
25 update() {
26 console.log(`my name is:${this.name}`);
27 }
28}
29
30let sub = new Subject();
31let obs1 = new Observer("observer11");
32let obs2 = new Observer("observer22");
33sub.add(obs1);
34sub.add(obs2);
35sub.notify();
我們在這裏定義了目標對象和觀察者對象兩個類,在目標對象中維護了一個觀察者的數組,新增時將觀察者向數組中 push;然後通過 notify 通知所有的觀察者;而觀察者只有一個 update 函數,用來接收觀察者更新後的一個回調;在有些版本的代碼中會將觀察者直接定義爲一個函數,而非一個類,但是其本質都是一樣的,都是調用觀察者的更新接口進行通知。
這種模式的應用在日常中也很常見,比如我們給 div 綁定 click 監聽事件,其本質就是觀察者模式的一種應用:
1var btn = document.getElementById('btn')
2btn.addEventListener('click', function(ev){
3 console.log(1)
4})
5btn.addEventListener('click', function(ev){
6 console.log(2)
7})
這裏的 btn 可以看作是我們的目標對象(被觀察對象),當它被點擊時,也就是它的狀態發生了變化,那麼它就會通知內部添加的觀察者對象,也就是我們通過addEventListener
函數添加的兩個匿名函數。
我們發現,觀察者模式好處是能夠降低耦合,目標對象和觀察者對象邏輯互不干擾,兩者都專注於自身的功能,只提供和調用了更新接口;而缺點也很明顯,在目標對象中維護的所有觀察者都能接收到通知,無法進行過濾篩選。
我們去搜索 24 種基本的設計模式,會發現其中並沒有發佈訂閱模式;剛開始發佈訂閱模式只是觀察者模式的一個別稱,但是經過時間的沉澱,他改進了觀察者模式的缺點,漸漸地開始獨立於觀察者模式;我們也來看一下它的一個定義:
發佈訂閱模式是基於一個事件(主題)通道,希望接收通知的對象
Subscriber
通過自定義事件訂閱主題,被激活事件的對象Publisher
通過發佈主題事件的方式通知各個訂閱該主題的Subscriber
對象。
我們看到定義裏面也涉及到了兩種對象:接收通知的對象(Subscriber)和被激活事件的對象(Publisher);被激活事件對象(Publisher)我們可以類比爲觀察者模式中的目標對象,來發布事件通知,而接收通知對象(Subscriber)可以類比爲觀察者對象,訂閱各種通知。
發佈訂閱模式和觀察者模式的不同在於,增加了第三方即事件中心
;目標對象狀態的改變並直接通知觀察者,而是通過第三方的事件中心來派發通知。
爲了加深理解,我們以生活中的情形爲例;比如我們訂閱報紙雜誌等,一般不會直接跑到報社去訂閱,而是通過一個平臺,比如街邊的報亭或者郵局也可以訂閱;而報紙雜誌也會有多種,比如晨報晚報日報等等;我們訂閱報紙後報社出版後會通過平臺來給我們投遞,通過郵局郵寄或者自取等等,那麼這裏就涉及到了報社、訂閱者和第三方平臺三個對象,我們通過代碼來模擬三者的動作:
1class Publisher {
2 constructor(name, channel) {
3 this.name = name;
4 this.channel = channel;
5 }
6
7 addTopic(topicName) {
8 this.channel.addTopic(topicName);
9 }
10
11 publish(topicName) {
12 this.channel.publish(topicName);
13 }
14}
15
16class Subscriber {
17 constructor(name, channel) {
18 this.name = name;
19 this.channel = channel;
20 }
21
22 subscribe(topicName) {
23 this.channel.subscribeTopic(topicName, this);
24 }
25
26 unSubscribe(topicName) {
27 this.channel.unSubscribeTopic(topicName, this);
28 }
29
30 update(topic) {
31 console.log(`${topic}已經送到${this.name}家了`);
32 }
33}
34
35class Channel {
36 constructor() {
37 this.topics = {};
38 }
39
40 addTopic(topicName) {
41 this.topics[topicName] = [];
42 }
43
44 removeTopic(topicName) {
45 delete this.topics[topicName];
46 }
47
48 subscribeTopic(topicName, sub) {
49 if (this.topics[topicName]) {
50 this.topics[topicName].push(sub);
51 }
52 }
53
54 unSubscribeTopic(topicName, sub) {
55 this.topics[topicName].forEach((item, index) => {
56 if (item === sub) {
57 this.topics[topicName].splice(index, 1);
58 }
59 });
60 }
61
62 publish(topicName) {
63 this.topics[topicName].forEach((item) => {
64 item.update(topicName);
65 });
66 }
67}
這裏的報社我們可以理解爲發佈者(Publisher)的角色,訂報紙的讀者理解爲訂閱者(Subscriber),第三方平臺就是事件中心;報社在平臺上註冊某一類型的報紙,然後讀者就可以在平臺訂閱這種報紙;三個類準備好了,我們來看下他們彼此如何進行聯繫:
1var channel = new Channel();
2
3var pub1 = new Publisher("報社1", channel);
4var pub2 = new Publisher("報社2", channel);
5
6pub1.addTopic("晨報1");
7pub1.addTopic("晚報1");
8pub2.addTopic("晨報2");
9
10var sub1 = new Subscriber("小明", channel);
11var sub2 = new Subscriber("小紅", channel);
12var sub3 = new Subscriber("小張", channel);
13
14sub1.subscribe("晨報1");
15sub2.subscribe("晨報1");
16sub2.subscribe("晨報2");
17sub3.subscribe("晚報1");
18
19sub3.subscribe("晨報2");
20sub3.unSubscribe("晨報2");
21
22pub1.publish("晨報1");
23pub1.publish("晚報1");
24pub2.publish("晨報2");
由於平臺是溝通的橋樑,因此我們先定義了一個調度中心 channel,然後分別定義了兩個報社 pub1、pub2,以及三個讀者 sub1、sub2 和 sub3;兩家報社在平臺註冊了晨報 1、晚報 1 和晨報 2 三種類型的報紙,三個讀者各自訂閱各家的報紙,也能取消訂閱。
我們可以發現在發佈者中並沒有直接維護訂閱者列表,而是註冊了一個事件主題,這裏的報紙類型相當於一個事件主題;訂閱者訂閱主題,發佈者推送某個主題時,訂閱該主題的所有讀者都會被通知到;這樣就避免了觀察者模式無法進行過濾篩選的缺陷。
我們通過一張圖來形象的描述兩種模式的區別。
- 觀察者模式把觀察者對象維護在目標對象中的,需要發佈消息時直接發消息給觀察者。在觀察者模式中,目標對象本身是知道觀察者存在的。
- 而發佈 / 訂閱模式中,發佈者並不維護訂閱者,也不知道訂閱者的存在,所以也不會直接通知訂閱者,而是通知調度中心,由調度中心通知訂閱者。
我們在深入學習 Object.defineProperty 和 Proxy 中介紹過,Vue2.0 響應式是通過Object.defineProperty()
來處理的,將每個組件 data 中的數據進行 get/set 劫持(也就是 Reactive 化),那麼劫持後是如何來通知頁面進行更新操作呢?這裏就用到了發佈訂閱模式,我們首先來看下官網是如何介紹的:
每個組件實例都對應一個 watcher 實例,它會在組件渲染的過程中把 “接觸” 過的數據 property 記錄爲依賴。之後當依賴項的 setter 觸發時,會通知 watcher,從而使它關聯的組件重新渲染。
相信看過源碼的同學對 Watcher 和 Dep 的代碼看的是雲裏霧裏,不瞭解這兩個類的作用;我們剔除不相關的代碼,對主要代碼逐段分析。
1export function defineReactive (
2 obj: Object,
3 key: string,
4 val: any,
5 customSetter?: Function
6) {
7
8 const dep = new Dep()
9
10 Object.defineProperty(obj, key, {
11 enumerable: true,
12 configurable: true,
13 get: function reactiveGetter () {
14 if (Dep.target) {
15 dep.depend()
16 }
17 return value
18 },
19 set: function reactiveSetter (newVal) {
20 val = newVal
21 dep.notify()
22 }
23 })
24}
我們在初始化 data 時或者用 $set 給 data 新增屬性都會給每個屬性循環遍歷調用 defineReactive 進行數據劫持;我們看到在每個屬性中構造了一個 dep 對象,並且在屬性觸發 getter 和 setter 時都會調用,它其實是依賴收集和觸發更新的一個第三方,相當於發佈訂閱模式中事件中心的一個角色;而且由於 getter/setter 函數內對它閉包引用,因此我們在this.num
和this.num=1
都是調用它下面的函數,因此我們來看下它的實現原理:
1class Dep {
2 static target: ?Watcher;
3 id: number;
4 subs: Array<Watcher>;
5 constructor () {
6 this.id = uid++
7 this.subs = []
8 }
9
10 addSub (sub: Watcher) {
11 this.subs.push(sub)
12 }
13
14 removeSub (sub: Watcher) {
15 remove(this.subs, sub)
16 }
17
18 depend () {
19 if (Dep.target) {
20 Dep.target.addDep(this)
21 }
22 }
23
24 notify () {
25 const subs = this.subs.slice()
26 for (let i = 0, l = subs.length; i < l; i++) {
27 subs[i].update()
28 }
29 }
30}
31Dep.target = null
Dep 的全程是 Dependency,翻譯過來也是依賴、依賴關係的意思,從意思上能看出來是用來做依賴收集的;我們看到 Dep 下面有一個 subs 數組,它是一組 Watcher 的列表,存放的就是我們收集的依賴列表;然後通過 addSub 和 removeSub 新增和刪除某個依賴,當數據更新時通過 notify 通知列表中所有的依賴對象;可以發現這些函數和我們的事件中心的代碼很相似,不過它不是基於事件主題,而是直接通過一個列表。
Dep 源碼看完了,下面就來看我們收集的依賴 Watcher,也就是訂閱者,都做了哪些事情:
1class Watcher {
2 constructor (
3 vm: Component,
4 expOrFn: string | Function,
5 cb: Function,
6 options?: Object
7 ) {
8 this.vm = vm
9 }
10 addDep (dep: Dep) {
11 dep.addSub(this)
12 }
13
14 update () {
15 }
16}
我們看到 Watcher 和我們的訂閱者代碼也很相似,在 update 中對視圖進行更新操作;由於 data 數據可以傳入不同的子組件,而在 data 中數據更新時,每個子組件中的頁面都需要重新更新,因此每一個 Vue 組件都會在 mount 階段都會創建一個 Watcher,然後保存在_watcher 上:
1function mountComponent (
2 vm: Component,
3 el: ?Element,
4 hydrating?: boolean
5): Component {
6 callHook(vm, 'beforeMount')
7 let updateComponent = () => {
8 vm._update(vm._render(), hydrating)
9 }
10 vm._watcher = new Watcher(vm, updateComponent, noop)
11 callHook(vm, 'mounted')
12 return vm
13}
因此 Dep 和 Watcher 兩者關係如下圖:
我們回到 Dep 的源碼中,發現有一個靜態屬性 Dep.target 是 Watcher,進行依賴收集的時候也是通過 Dep.target,那麼它是做什麼用的呢?讓我們繼續回到 Watcher 的構造器:
1Dep.target = null
2const targetStack = []
3
4export function pushTarget (_target: Watcher) {
5 if (Dep.target) targetStack.push(Dep.target)
6 Dep.target = _target
7}
8
9export function popTarget () {
10 Dep.target = targetStack.pop()
11}
12
13class Watcher {
14 constructor (
15 vm: Component,
16 expOrFn: string | Function,
17 cb: Function,
18 options?: Object
19 ) {
20 this.getter = expOrFn
21 this.get()
22 }
23 get () {
24 pushTarget(this)
25 value = this.getter.call(vm, vm)
26 popTarget()
27 return value
28 }
29}
在 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 則是訂閱者。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://juejin.cn/post/6940540244592689159?utm_source=gold_browser_extension