從vue原始碼看觀察者模式

qiangdada發表於2019-03-04

摘要:原始碼解讀設計模式系列文章將陸陸續續進行更新中 ~

觀察者模式

首先話題下來,我們得反問一下自己,什麼是觀察者模式?

概念

觀察者模式(Observer):通常又被稱作為釋出-訂閱者模式。它定義了一種一對多的依賴關係,即當一個物件的狀態發生改變的時候,所有依賴於它的物件都會得到通知並自動更新,解決了主體物件與觀察者之間功能的耦合。

講個故事

上面對於觀察者模式的概念可能會比較官方化,所以我們講個故事來理解它。

  • A:是共產黨派往國民黨密探,代號 001(釋出者)
  • B:是共產黨的通訊人員,負責與 A 進行祕密交接(訂閱者)
  1. A 日常工作就是在明面採集國民黨的一些情報
  2. B 則負責暗中觀察著 A
  3. 一旦 A 傳遞出一些有關國民黨的訊息(更多時候需要對訊息進行封裝傳遞,後面根據原始碼具體分析)
  4. B 會立馬訂閱到該訊息,然後做一些相對應的變更,比如說通知共產黨們做一些事情應對國民黨的一些動作。

適用性

以下任一場景都可以使用觀察者模式

  1. 當一個抽象模型有兩個方面,其中一個方面依賴於另一方面。講這兩者封裝在獨立的物件中可以讓它們可以各自獨立的改變和複用
  2. 當一個物件的改變的時候,需要同時改變其它物件,但是卻不知道具體多少物件有待改變
  3. 當一個物件必須通知其它物件,但是卻不知道具體物件到底是誰。換句話說,你不希望這些物件是緊密耦合的。

vue 對於觀察者模式的使用

vue 使用到觀察者模式的地方有很多,這裡我們主要談談對於資料初始化這一塊的。

var vm = new Vue({
  data () {
    return {
      a: 'hello vue'
    }
  }
})
複製程式碼

從vue原始碼看觀察者模式

1、實現資料劫持

上圖我們可以看到,vue 是利用的是 Object.defineProperty() 對資料進行劫持。 並在資料傳遞變更的時候封裝了一層中轉站,即我們看到的 DepWatcher 兩個類。

這一小節,我們只看如何通過觀察者模式對資料進行劫持。

1.1、遞迴遍歷

我們都知道,vue 對於 data 裡面的資料都做了劫持的,那隻能對物件進行遍歷從而完成每個屬性的劫持,原始碼具體如下

walk (obj: Object) {
  const keys = Object.keys(obj)
  // 遍歷將其變成 vue 的訪問器屬性
  for (let i = 0; i < keys.length; i++) {
    defineReactive(obj, keys[i], obj[keys[i]])
  }
}
複製程式碼

1.2、釋出/訂閱

從上面物件的遍歷我們看到了 defineReactive ,那麼劫持最關鍵的點也在於這個函式,該函式裡面封裝了 gettersetter 函式,使用觀察者模式,互相監聽

// 設定為訪問器屬性,並在其 getter 和 setter 函式中,使用釋出/訂閱模式,互相監聽。
export function defineReactive (
  obj: Object,
  key: string,
  val: any
) {
  // 這裡用到了觀察者(釋出/訂閱)模式進行了劫持封裝,它定義了一種一對多的關係,讓多個觀察者監聽一個主題物件,這個主題物件的狀態發生改變時會通知所有觀察者物件,觀察者物件就可以更新自己的狀態。
  // 例項化一個主題物件,物件中有空的觀察者列表
  const dep = new Dep()
  
  // 獲取屬性描述符物件(更多的為了 computed 裡面的自定義 get 和 set 進行的設計)
  const property = Object.getOwnPropertyDescriptor(obj, key)
  if (property && property.configurable === false) {
    return
  }

  const getter = property && property.get
  const setter = property && property.set
  
  let childOb = observe(val)
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    // 收集依賴,建立一對多的的關係,讓多個觀察者監聽當前主題物件
    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val
      if (Dep.target) {
        dep.depend()
        if (childOb) {
          childOb.dep.depend()
          // 這裡是對陣列進行劫持
          if (Array.isArray(value)) {
            dependArray(value)
          }
        }
      }
      return value
    },
    // 劫持到資料變更,併發布訊息進行通知
    set: function reactiveSetter (newVal) {
      const value = getter ? getter.call(obj) : val
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      childOb = observe(newVal)
      dep.notify()
    }
  })
}
複製程式碼

1.3、返回 Observer 例項

上面我們看到了observe 函式,核心就是返回一個 Observer 例項

return new Observer(value)
複製程式碼

2、訊息封裝,實現 "中轉站"

首先我們要理解,為什麼要做一層訊息傳遞的封裝?

我們在講解觀察者模式的時候有提到它的 適用性 。這裡也同理,我們在劫持到資料變更的時候,並進行資料變更通知的時候,如果不做一個"中轉站"的話,我們根本不知道到底誰訂閱了訊息,具體有多少物件訂閱了訊息。

這就好比上文中我提到的故事中的密探 A(釋出者) 和共產黨 B(訂閱者)。密探 A 與 共產黨 B 進行資訊傳遞,兩人都知道對方這麼一個人的存在,但密探 A 不知道具體 B 是誰以及到底有多少共產黨(訂閱者)訂閱著自己,可能很多共產黨都訂閱著密探 A 的資訊,so 密探 A(釋出者) 需要通過暗號 收集到所有訂閱著其訊息的共產黨們(訂閱者),這裡對於訂閱者的收集其實就是一層封裝。然後密探 A 只需將訊息釋出出去,而訂閱者們接受到通知,只管進行自己的 update 操作即可。

簡單一點,即收集完訂閱者們的密探 A 只管釋出訊息,共產黨 B 以及更多的共產黨只管訂閱訊息並進行對應的 update 操作,每個模組確保其獨立性,實現高內聚低耦合這兩大原則。

廢話不多說,我們接下來直接開始講 vue 是如何做的訊息封裝的

2.1、Dep

Dep,全名 Dependency,從名字我們也能大概看出 Dep 類是用來做依賴收集的,具體怎麼收集呢。我們直接看原始碼

let uid = 0

export default class Dep {
  static target: ?Watcher;
  id: number;
  subs: Array<Watcher>;

  constructor () {
    // 用來給每個訂閱者 Watcher 做唯一識別符號,防止重複收集
    this.id = uid++
    // 定義subs陣列,用來做依賴收集(收集所有的訂閱者 Watcher)
    this.subs = []
  }

  // 收集訂閱者
  addSub (sub: Watcher) {
    this.subs.push(sub)
  }

  depend () {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }

  notify () {
    // stabilize the subscriber list first
    const subs = this.subs.slice()
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }
}

// the current target watcher being evaluated.
// this is globally unique because there could be only one
// watcher being evaluated at any time.
Dep.target = null
複製程式碼

程式碼很簡短,但它做的事情卻很重要

  1. 定義subs陣列,用來收集訂閱者Watcher
  2. 當劫持到資料變更的時候,通知訂閱者Watcher進行update操作

原始碼中,還丟擲了兩個方法用來操作 Dep.target ,具體如下

// 定義收集目標棧
const targetStack = []

export function pushTarget (_target: Watcher) {
  if (Dep.target) targetStack.push(Dep.target)
  // 改變目標指向
  Dep.target = _target
}

export function popTarget () {
  // 刪除當前目標,重算指向
  Dep.target = targetStack.pop()
}
複製程式碼

2.2、 Watcher

Watcher 意為觀察者,它負責做的事情就是訂閱 Dep ,當Dep 發出訊息傳遞(notify)的時候,所以訂閱著 DepWatchers 會進行自己的 update 操作。廢話不多說,直接看原始碼就知道了。

export default class Watcher {
  vm: Component;
  expression: string;
  cb: Function;

  constructor (
    vm: Component,
    expOrFn: string | Function,
    cb: Function,
    options?: Object
  ) {
    this.vm = vm
    vm._watchers.push(this)
    this.cb = cb
    // parse expression for getter
    if (typeof expOrFn === 'function') {
      this.getter = expOrFn
    } else {
      // 解析表示式
      this.getter = parsePath(expOrFn)
      if (!this.getter) {
        this.getter = function () {}
      }
    }
    this.value = this.get()
  }

  get () {
    // 將目標收集到目標棧
    pushTarget(this)
    const vm = this.vm
    
    let value = this.getter.call(vm, vm)
    // 刪除目標
    popTarget()
    
    return value
  }

  // 訂閱 Dep,同時讓 Dep 知道自己訂閱著它
  addDep (dep: Dep) {
    const id = dep.id
    if (!this.newDepIds.has(id)) {
      this.newDepIds.add(id)
      this.newDeps.push(dep)
      if (!this.depIds.has(id)) {
        // 收集訂閱者
        dep.addSub(this)
      }
    }
  }

  // 訂閱者'消費'動作,當接收到變更時則會執行
  update () {
    this.run()
  }

  run () {
    const value = this.get()
    const oldValue = this.value
    this.value = value
    this.cb.call(this.vm, value, oldValue)
  }
}
複製程式碼

上述程式碼中,我刪除了一些與目前探討無關的程式碼,如果需要進行詳細研究的,可以自行查閱 vue2.5.3 版本的原始碼。

現在再去看 DepWatcher,我們需要知道兩個點

  1. Dep 負責收集所有的訂閱者 Watcher ,具體誰不用管,具體有多少也不用管,只需要通過 target 指向的計算去收集訂閱其訊息的 Watcher 即可,然後只需要做好訊息釋出 notify 即可。
  2. Watcher 負責訂閱 Dep ,並在訂閱的時候讓 Dep 進行收集,接收到 Dep 釋出的訊息時,做好其 update 操作即可。

兩者看似相互依賴,實則卻保證了其獨立性,保證了模組的單一性。

更多的應用

vue 還有一些地方用到了"萬能"的觀察者模式,比如我們熟知的元件之間的事件傳遞,$on 以及 $emit 的設計。

$emit 負責釋出訊息,並對訂閱者 $on 做統一消費,即執行 cbs 裡面所有的事件。

Vue.prototype.$on = function (event: string | Array<string>, fn: Function): Component {
  const vm: Component = this
  if (Array.isArray(event)) {
    for (let i = 0, l = event.length; i < l; i++) {
      this.$on(event[i], fn)
    }
  } else {
    (vm._events[event] || (vm._events[event] = [])).push(fn)
  }
  return vm
}

Vue.prototype.$emit = function (event: string): Component {
  const vm: Component = this
  let cbs = vm._events[event]
  if (cbs) {
    cbs = cbs.length > 1 ? toArray(cbs) : cbs
    const args = toArray(arguments, 1)
    for (let i = 0, l = cbs.length; i < l; i++) {
      cbs[i].apply(vm, args)
    }
  }
  return vm
}
複製程式碼

總結

本文探討了觀察者模式的基本概念、適用場景,以及在 vue 原始碼中的具體應用。這一節將總結一下觀察者模式的一些優缺點

  1. 目標和觀察者間的抽象耦合:一個目標只知道他有一系列的觀察者(目標進行依賴收集),卻不知道其中任意一個觀察者屬於哪一個具體的類,這樣目標與觀察者之間的耦合是抽象的和最小的。
  2. 支援廣播通訊:觀察者裡面的通訊,不像其它通常的一些請求需要指定它的接受者。通知將會自動廣播給所有已訂閱該目標物件的相關物件,即上文中的 dep.notify() 。當然,目標物件並不關心到底有多少物件對自己感興趣,它唯一的職責就是通知它的各位觀察者,處理還是忽略一個通知取決於觀察者本身。
  3. 一些意外的更新:因為一個觀察者它自己並不知道其它觀察者的存在,它可能對改變目標的最終代價一無所知。如果觀察者直接在目標上做操作的話,可能會引起一系列對觀察者以及依賴於這些觀察者的那些物件的更新,所以一般我們會把一些操作放在目標內部,防止出現上述的問題。

OK,本文到這就差不多了,更多的原始碼設計思路細節將在同系列的其它文章中進行一一解讀。

相關文章