這應該是最詳細的響應式系統講解了

前言

本文從一個簡單的雙向綁定開始,逐步升級到由definePropertyProxy分別實現的響應式系統,注重入手思路,抓住關鍵細節,但願能對你有所幫助。html

1、極簡雙向綁定

首先從最簡單的雙向綁定入手:數組

// html
<input type="text" id="input">
<span id="span"></span>

// js
let input = document.getElementById('input')
let span = document.getElementById('span')
input.addEventListener('keyup', function(e) {
  span.innerHTML = e.target.value
})
複製代碼

以上彷佛運行起來也沒毛病,但咱們要的是數據驅動,而不是直接操做dom數據結構

// 操做obj數據來驅動更新
let obj = {}
let input = document.getElementById('input')
let span = document.getElementById('span')
Object.defineProperty(obj, 'text', {
  configurable: true,
  enumerable: true,
  get() {
    console.log('獲取數據了')
  },
  set(newVal) {
    console.log('數據更新了')
    input.value = newVal
    span.innerHTML = newVal
  }
})
input.addEventListener('keyup', function(e) {
  obj.text = e.target.value
})
複製代碼

以上就是一個簡單的雙向數據綁定,但顯然是不足的,下面繼續升級。dom

2、以defineProperty實現響應系統

在Vue3版原本臨前以defineProperty實現的數據響應,基於發佈訂閱模式,其主要包含三部分:Observer、Dep、Watcher異步

1. 一個思路例子

// 須要劫持的數據
let data = {
  a: 1,
  b: {
    c: 3
  }
}

// 劫持數據data
observer(data)

// 監聽訂閱數據data的屬性
new Watch('a', () => {
    alert(1)
})
new Watch('a', () => {
    alert(2)
})
new Watch('b.c', () => {
    alert(3)
})
複製代碼

以上就是一個簡單的劫持和監聽流程,那對應的observerWatch該如何實現?函數

2. Observer

observer的做用就是劫持數據,將數據屬性轉換爲訪問器屬性,理一下實現思路:oop

  • Observer須要將數據轉化爲響應式的,那它就應該是一個函數(類),能接收參數。
  • ②爲了將數據變成響應式,那須要使用Object.defineProperty
  • ③數據不止一種類型,這就須要遞歸遍從來判斷。
// 定義一個類供傳入監聽數據
class Observer {
  constructor(data) {
    let keys = Object.keys(data)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(data, keys[i], data[keys[i]])
    }
  }
}
// 使用Object.defineProperty
function defineReactive (data, key, val) {
  // 每次設置訪問器前都先驗證值是否爲對象,實現遞歸每一個屬性
  observer(val)
  // 劫持數據屬性
  Object.defineProperty(data, key, {
    configurable: true,
    enumerable: true,
    get () {
      return val
    },
    set (newVal) {
      if (newVal === val) {
        return
      } else {
        data[key] = newVal
        // 新值也要劫持
        observer(newVal)
      }
    }
  })
}

// 遞歸判斷
function observer (data) {
  if (Object.prototype.toString.call(data) === '[object, Object]') {
    new Observer(data)
  } else {
    return
  }
}

// 監聽obj
observer(data)

複製代碼

3. Watcher

根據new Watch('a', () => {alert(1)})咱們猜想Watch應該是這樣的:post

class Watch {
  // 第一個參數爲表達式,第二個參數爲回調函數
  constructor (exp, cb) {
    this.exp = exp
    this.cb = cb
  }
}
複製代碼

Watchobserver該如何關聯?想一想它們之間有沒有關聯的點?彷佛能夠從exp下手,這是它們共有的點:學習

class Watch {
  // 第一個參數爲表達式,第二個參數爲回調函數
  constructor (exp, cb) {
    this.exp = exp
    this.cb = cb
    data[exp]   // 想一想多了這句有什麼做用
  }
}
複製代碼

data[exp]這句話是否是表示在取某個值,若是expa的話,那就表示data.a,在這以前data下的屬性已經被咱們劫持爲訪問器屬性了,那這就代表咱們能觸發對應屬性的get函數,那這就與observer產生了關聯,那既然如此,那在觸發get函數的時候能不能把觸發者Watch給收集起來呢?此時就得須要一個橋樑Dep來協助了。ui

4. Dep

思路應該是data下的每個屬性都有一個惟一的Dep對象,在get中收集僅針對該屬性的依賴,而後在set方法中觸發全部收集的依賴,這樣就搞定了,看以下代碼:

class Dep {
  constructor () {
    // 定義一個收集對應屬性依賴的容器
    this.subs = []
  }
  // 收集依賴的方法
  addSub () {
    // Dep.target是個全局變量,用於存儲當前的一個watcher
    this.subs.push(Dep.target)
  }
  // set方法被觸發時會通知依賴
  notify () {
    for (let i = 1; i < this.subs.length; i++) {
      this.subs[i].cb()
    }
  }
}

Dep.target = null

class Watch {
  constructor (exp, cb) {
    this.exp = exp
    this.cb = cb
    // 將Watch實例賦給全局變量Dep.target,這樣get中就能拿到它了
    Dep.target = this
    data[exp]
  }
}

複製代碼

此時對應的defineReactive咱們也要增長一些代碼:

function defineReactive (data, key, val) {
  observer()
  let dep = new Dep() // 新增:這樣每一個屬性就能對應一個Dep實例了
  Object.defineProperty(data, key, {
    configurable: true,
    enumerable: true,
    get () {
      dep.addSub() // 新增:get觸發時會觸發addSub來收集當前的Dep.target,即watcher
      return val
    },
    set (newVal) {
      if (newVal === val) {
        return
      } else {
        data[key] = newVal
        observer(newVal)
        dep.notify() // 新增:通知對應的依賴
      }
    }
  })
}
複製代碼

至此observer、Dep、Watch三者就造成了一個總體,分工明確。但還有一些地方須要處理,好比咱們直接對被劫持過的對象添加新的屬性是監測不到的,修改數組的元素值也是如此。這裏就順便提一下Vue源碼中是如何解決這個問題的:

對於對象:Vue中提供了Vue.setvm.$set這兩個方法供咱們添加新的屬性,其原理就是先判斷該屬性是否爲響應式的,若是不是,則經過defineReactive方法將其轉爲響應式。

對於數組:直接使用下標修改值仍是無效的,Vuehack了數組中的七個方法:pop','push','shift','unshift','splice','sort','reverse',使得咱們用起來依舊是響應式的。其原理是:在咱們調用數組的這七個方法時,Vue會改造這些方法,它內部一樣也會執行這些方法原有的邏輯,只是增長了一些邏輯:取到所增長的值,而後將其變成響應式,而後再手動出發dep.notify()

3、以Proxy實現響應系統

Proxy是在目標前架設一層"攔截",外界對該對象的訪問,都必須先經過這層攔截,所以提供了一種機制,能夠對外界的訪問進行過濾和改寫,咱們能夠這樣認爲,ProxyObject.defineProperty的全方位增強版。

依舊是三大件:Observer、Dep、Watch,咱們在以前的基礎再完善這三大件。

1. Dep

let uid = 0 // 新增:定義一個id
class Dep {
  constructor () {
    this.id = uid++ // 新增:給dep添加id,避免Watch重複訂閱
    this.subs = []
  }
  depend() {  // 新增:源碼中在觸發get時是先觸發depend方法再進行依賴收集的,這樣能將dep傳給Watch
    Dep.target.addDep(this);
  }
  addSub () {
    this.subs.push(Dep.target)
  }
  notify () {
    for (let i = 1; i < this.subs.length; i++) {
      this.subs[i].cb()
    }
  }
}
複製代碼

2. Watch

class Watch {
  constructor (exp, cb) {
    this.depIds = {} // 新增:儲存訂閱者的id,避免重複訂閱
    this.exp = exp
    this.cb = cb
    Dep.target = this
    data[exp]
    // 新增:判斷是否訂閱過該dep,沒有則存儲該id並調用dep.addSub收集當前watcher
    addDep (dep) {  
      if (!this.depIds.hasOwnProperty(dep.id)) {
        dep.addSub(this)
        this.depIds[dep.id] = dep
      }
    }
    // 新增:將訂閱者放入待更新隊列等待批量更新
    update () {
      pushQueue(this)
    }
    // 新增:觸發真正的更新操做
    run () {
      this.cb()
    }
  }
}
複製代碼

3. Observer

Object.defineProperty監聽屬性不一樣,Proxy能夠監聽(實際是代理)整個對象,所以就不須要遍歷對象的屬性依次監聽了,可是若是對象的屬性依然是個對象,那麼Proxy也沒法監聽,因此依舊使用遞歸套路便可。

function Observer (data) {
  let dep = new Dep()
  return new Proxy(data, {
    get () {
      // 若是訂閱者存在,進去depend方法
      if (Dep.target) {
        dep.depend()
      }
      // Reflect.get瞭解一下
      return Reflect.get(data, key)
    },
    set (data, key, newVal) {
      // 若是值未變,則直接返回,不觸發後續操做
      if (Reflect.get(data, key) === newVal) {
        return
      } else {
        // 設置新值的同時對新值判斷是否要遞歸監聽
        Reflect.set(target, key, observer(newVal))
        // 當值被觸發更改的時候,觸發Dep的通知方法
        dep.notify(key)
      }
    }
  })
}

// 遞歸監聽
function observer (data) {
  // 若是不是對象則直接返回
  if (Object.prototype.toString.call(data) !== '[object, Object]') {
    return data
  }
  // 爲對象時則遞歸判斷屬性值
  Object.keys(data).forEach(key => {
    data[key] = observer(data[key])
  })
  return Observer(data)
}

// 監聽obj
Observer(data)
複製代碼

至此就基本完成了三大件了,同時其不須要hack也能對數組進行監聽。

4、觸發依賴收集與批量異步更新

完成了響應式系統,也順便提一下Vue源碼中是如何觸發依賴收集與批量異步更新的。

1. 觸發依賴收集

Vue源碼中的$mount方法調用時會間接觸發了一段代碼:

vm._watcher = new Watcher(vm, () => {
  vm._update(vm._render(), hydrating)
}, noop)
複製代碼

這使得new Watcher()會先對其傳入的參數進行求值,也就間接觸發了vm._render(),這其實就會觸發了對數據的訪問,進而觸發屬性的get方法而達到依賴的收集。

2. 批量異步更新

Vue在更新DOM時是異步執行的。只要偵聽到數據變化,Vue將開啓一個隊列,並緩衝在同一事件循環中發生的全部數據變動。若是同一個watcher被屢次觸發,只會被推入到隊列中一次。這種在緩衝時去除重複數據對於避免沒必要要的計算和DOM操做是很是重要的。而後,在下一個的事件循環「tick」中,Vue刷新隊列並執行實際 (已去重的) 工做。Vue在內部對異步隊列嘗試使用原生的Promise.then、MutationObserversetImmediate,若是執行環境不支持,則會採用setTimeout(fn, 0)代替。

根據以上這段官方文檔,這個隊列主要是異步去重,首先咱們來整理一下思路:

  1. 須要有一個隊列來存儲一個事件循環中的數據變動,且要對它去重。
  2. 將當前事件循環中的數據變動添加到隊列。
  3. 異步的去執行這個隊列中的全部數據變動。
// 使用Set數據結構建立一個隊列,這樣可自動去重
let queue = new Set()

// 在屬性出發set方法時會觸發watcher.update,繼而執行如下方法
function pushQueue (watcher) {
  // 將數據變動添加到隊列
  queue.add(watcher)
  // 下一個tick執行該數據變動,因此nextTick接受的應該是一個能執行queue隊列的函數
  nextTick('一個能遍歷執行queue的函數')
}

// 用Promise模擬nextTick
function nextTick('一個能遍歷執行queue的函數') {
  Promise.resolve().then('一個能遍歷執行queue的函數')
}
複製代碼

以上已經有個大致的思路了,那接下來完成'一個能遍歷執行queue的函數'

// queue是一個數組,因此直接遍歷執行便可
function flushQueue () {
  queue.forEach(watcher => {
    // 觸發watcher中的run方法進行真正的更新操做
    watcher.run()
  })
  // 執行後清空隊列
  queue = new Set()
}
複製代碼

還有一個問題,那就是同一個事件循環中應該只要觸發一次nextTick便可,而不是每次添加隊列時都觸發:

// 設置一個是否觸發了nextTick的標識
let waiting = false
function pushQueue (watcher) {
  queue.add(watcher)
  if (!waiting) {
    // 保證nextTick只觸發一次
    waiting = true
    nextTick('一個能遍歷執行queue的函數')
  }
}
複製代碼

完整代碼以下:

// 定義隊列
let queue = new Set()

// 供傳入nextTick中的執行隊列的函數
function flushQueue () {
  queue.forEach(watcher => {
    watcher.run()
  })
  queue = new Set()
}

// nextTick
function nextTick(flushQueue) {
  Promise.resolve().then(flushQueue)
}

// 添加到隊列並調用nextTick
let waiting = false
function pushQueue (watcher) {
  queue.add(watcher)
  if (!waiting) {
    waiting = true
    nextTick(flushQueue)
  }
}
複製代碼

最後

以上就是響應式的一個大概原理,固然還有不少細節沒說,感興趣的能夠去擼一擼源碼,若是以爲有所幫助,歡迎關注點個贊!

相關參考:

相關文章
相關標籤/搜索