0年前端的Vue響應式原理學習總結1:基本原理

同窗們,你是否想學習Vue的數據響應式原理而無從下手呢?是否有過被複雜的源碼教程勸退的經歷呢?若是你和我同樣,作過一個項目以後想深刻原理的話,恭喜你,你來對地方了。這個系列文章將從純粹的Vue響應式原理出發,沒有其餘因素的干擾,帶領你們實現一個本身的響應式系統。vue

友情提示:由於咱們的代碼會通過多個版本的修改,因此我但願你們在看文章的時候可以把涉及到的代碼手敲一遍,這樣可以幫助理解。react

項目地址:giteegit

系列地址:github

2:數組的處理shell

3:渲染watcherexpress

4:最終章數組

0.前言

數據模型僅僅是普通的 JavaScript 對象。而當你修改它們時,視圖會進行更新。markdown

使用Vue時,咱們只須要修改數據(state),視圖就可以得到相應的更新,這就是響應式系統。要實現一個本身的響應式系統,咱們首先要明白要作什麼事情:數據結構

  1. 數據劫持:當數據變化時,咱們能夠作一些特定的事情
  2. 依賴收集:咱們要知道那些視圖層的內容(DOM)依賴了哪些數據(state)
  3. 派發更新:數據變化後,如何通知依賴這些數據的DOM

接下來,咱們將一步步地實現一個本身的玩具響應式系統閉包

1. 數據劫持

幾乎全部的文章和教程,在講解Vue響應式系統時都會先講:Vue使用Object.defineProperty來進行數據劫持。那麼,咱們也從數據劫持講起,你們可能會對劫持這個概念有些迷茫,沒有關係,看完下面的內容,你必定會明白。

Object.defineProperty的用法在此很少作介紹,不明白的同窗可在MDN上查閱。下面,咱們爲obj定義一個a屬性

const obj = {}

let val = 1
Object.defineProperty(obj, a, {
  get() { // 下文中該方法統稱爲getter
    console.log('get property a')
    return val
  },
  set(newVal) { // 下文中該方法統稱爲setter
    if (val === newVal) return
    console.log(`set property a -> ${newVal}`)
    val = newVal
  }
})
複製代碼

這樣,當咱們訪問obj.a時,打印get property a並返回1,obj.a = 2設置新的值時,打印set property a -> 2。這至關於咱們自定義了obj.a取值和賦值的行爲,使用自定義的gettersetter來重寫了原有的行爲,這也就是數據劫持的含義。

可是上面的代碼有一個問題:咱們須要一個全局的變量來保存這個屬性的值,所以,咱們能夠用下面的寫法

// value使用了參數默認值
function defineReactive(data, key, value = data[key]) {
  Object.defineProperty(data, key, {
    get: function reactiveGetter() {
      return value
    },
    set: function reactiveSetter(newValue) {
      if (newValue === value) return
      value = newValue
    }
  })
}

defineReactive(obj, a, 1)
複製代碼

若是obj有多個屬性呢?咱們能夠新建一個類Observer來遍歷該對象

class Observer {
  constructor(value) {
    this.value = value
    this.walk()
  }
  walk() {
    Object.keys(this.value).forEach((key) => defineReactive(this.value, key))
  }
}

const obj = { a: 1, b: 2 }
new Observer(obj)
複製代碼

若是obj內有嵌套的屬性呢?咱們可使用遞歸來完成嵌套屬性的數據劫持

// 入口函數
function observe(data) {
  if (typeof data !== 'object') return
  // 調用Observer
  new Observer(data)
}

class Observer {
  constructor(value) {
    this.value = value
    this.walk()
  }
  walk() {
    // 遍歷該對象,並進行數據劫持
    Object.keys(this.value).forEach((key) => defineReactive(this.value, key))
  }
}

function defineReactive(data, key, value = data[key]) {
  // 若是value是對象,遞歸調用observe來監測該對象
  // 若是value不是對象,observe函數會直接返回
  observe(value)
  Object.defineProperty(data, key, {
    get: function reactiveGetter() {
      return value
    },
    set: function reactiveSetter(newValue) {
      if (newValue === value) return
      value = newValue
      observe(newValue) // 設置的新值也要被監聽
    }
  })
}

const obj = {
  a: 1,
  b: {
    c: 2
  }
}

observe(obj)
複製代碼

對於這一部分,你們可能有點暈,接下來梳理一下:

執行observe(obj)
├── new Observer(obj),並執行this.walk()遍歷obj的屬性,執行defineReactive()
    ├── defineReactive(obj, a)
        ├── 執行observe(obj.a) 發現obj.a不是對象,直接返回
        ├── 執行defineReactive(obj, a) 的剩餘代碼
    ├── defineReactive(obj, b) 
	    ├── 執行observe(obj.b) 發現obj.b是對象
	        ├── 執行 new Observer(obj.b),遍歷obj.b的屬性,執行defineReactive()
                    ├── 執行defineReactive(obj.b, c)
                        ├── 執行observe(obj.b.c) 發現obj.b.c不是對象,直接返回
                        ├── 執行defineReactive(obj.b, c)的剩餘代碼
            ├── 執行defineReactive(obj, b)的剩餘代碼
代碼執行結束
複製代碼

能夠看出,上面三個函數的調用關係以下:

三個函數相互調用從而造成了遞歸,與普通的遞歸有所不一樣。 有些同窗可能會想,只要在setter中調用一下渲染函數來從新渲染頁面,不就能完成在數據變化時更新頁面了嗎?確實能夠,可是這樣作的代價就是:任何一個數據的變化,都會致使這個頁面的從新渲染,代價未免太大了吧。咱們想作的效果是:數據變化時,只更新與這個數據有關的DOM結構,那就涉及到下文的內容了:依賴

2. 收集依賴與派發更新

依賴

在正式講解依賴收集以前,咱們先看看什麼是依賴。舉一個生活中的例子:淘寶購物。如今淘寶某店鋪上有一塊顯卡(空氣)處於預售階段,若是咱們想買的話,咱們能夠點擊預售提醒,當顯卡開始賣的時候,淘寶爲咱們推送一條消息,咱們看到消息後,能夠開始購買。

將這個例子抽象一下就是發佈-訂閱模式:買家點擊預售提醒,就至關於在淘寶上登記了本身的信息(訂閱),淘寶則會將買家的信息保存在一個數據結構中(好比數組)。顯卡正式開放購買時,淘寶會通知全部的買家:顯卡開賣了(發佈),買家會根據這個消息進行一些動做(好比買回來挖礦)。

Vue響應式系統中,顯卡對應數據,那麼例子中的買家對應什麼呢?就是一個抽象的類: Watcher。你們沒必要糾結這個名字的含義,只須要知道它作什麼事情:每一個Watcher實例訂閱一個或者多個數據,這些數據也被稱爲wacther的依賴(商品就是買家的依賴);當依賴發生變化,Watcher實例會接收到數據發生變化這條消息,以後會執行一個回調函數來實現某些功能,好比更新頁面(買家進行一些動做)。

所以Watcher類能夠以下實現

class Watcher {
  constructor(data, expression, cb) {
    // data: 數據對象,如obj
    // expression:表達式,如b.c,根據data和expression就能夠獲取watcher依賴的數據
    // cb:依賴變化時觸發的回調
    this.data = data
    this.expression = expression
    this.cb = cb
    // 初始化watcher實例時訂閱數據
    this.value = this.get()
  }
  
  get() {
    const value = parsePath(this.data, this.expression)
    return value
  }
  
  // 當收到數據變化的消息時執行該方法,從而調用cb
  update() {
    this.value = parsePath(this.data, this.expression) // 對存儲的數據進行更新
    cb()
  }
}

function parsePath(obj, expression) {
  const segments = expression.split('.')
  for (let key of segments) {
    if (!obj) return
    obj = obj[key]
  }
  return obj
}
複製代碼

若是你對Watcher這個類何時實例化有疑問的話,不要緊,下面立刻就會講到

其實前文例子中還有一個點咱們還沒有提到:顯卡例子中說到,淘寶會將買家信息保存在一個數組中,那麼咱們的響應式系統中也應該有一個數組來保存買家信息,也就是watcher

總結一下咱們須要實現的功能:

  1. 有一個數組來存儲watcher
  2. watcher實例須要訂閱(依賴)數據,也就是獲取依賴或者收集依賴
  3. watcher的依賴發生變化時觸發watcher的回調函數,也就是派發更新。

每一個數據都應該維護一個屬於本身的數組,該數組來存放依賴本身的watcher,咱們能夠在defineReactive中定義一個數組dep,這樣經過閉包,每一個屬性就能擁有一個屬於本身的dep

function defineReactive(data, key, value = data[key]) {
  const dep = [] // 增長
  observe(value)
  Object.defineProperty(data, key, {
    get: function reactiveGetter() {
      return value
    },
    set: function reactiveSetter(newValue) {
      if (newValue === value) return
      value = newValue
      observe(newValue)
      dep.notify()
    }
  })
}
複製代碼

到這裏,咱們實現了第一個功能,接下來實現收集依賴的過程。

依賴收集

如今咱們把目光集中到頁面的初次渲染過程當中(暫時忽略渲染函數和虛擬DOM等部分):渲染引擎會解析模板,好比引擎遇到了一個插值表達式,若是咱們此時實例化一個watcher,會發生什麼事情呢?從Watcher的代碼中能夠看到,實例化時會執行get方法,get方法的做用就是獲取本身依賴的數據,而咱們重寫了數據的訪問行爲,爲每一個數據定義了getter,所以getter函數就會執行,若是咱們在getter中把當前的watcher添加到dep數組中(淘寶低登記買家信息),不就可以完成依賴收集了嗎!!

注意:執行到getter時,new Watcher()get方法尚未執行完畢。

new Watcher()時執行constructor,調用了實例的get方法,實例的get方法會讀取數據的值,從而觸發了數據的gettergetter執行完畢後,實例的get方法執行完畢,並返回值,constructor執行完畢,實例化完畢。

有些同窗可能會有疑惑:明明是watcher收集依賴,應該是watcher收集數據,怎麼成了數據的dep收集watcher了呢?有此疑問的同窗能夠再看一下前面淘寶的例子(是淘寶記錄了用戶信息),或者深刻了解一下發布-訂閱模式。

經過上面的分析,咱們只須要對getter進行一些修改:

get: function reactiveGetter() {
  dep.push(watcher) // 新增
  return value
}
複製代碼

問題又來了,watcher這個變量從哪裏來呢?咱們是在模板編譯函數中的實例化watcher的,getter中取不到這個實例啊。解決方法也很簡單,將watcher實例放到全局不就好了嗎,好比放到window.target上。所以,Watcherget方法作以下修改

get() {
  window.target = this // 新增
  const value = parsePath(this.data, this.expression)
  return value
}
複製代碼

這樣,將get方法中的dep.push(watcher)修改成dep.push(window.target)便可。

注意,不能這樣寫window.target = new Watcher()。由於執行到getter的時候,實例化watcher尚未完成,因此window.target仍是undefined

依賴收集過程:渲染頁面時碰到插值表達式,v-bind等須要數據等地方,會實例化一個watcher,實例化watcher就會對依賴的數據求值,從而觸發getter,數據的getter函數就會添加依賴本身的watcher,從而完成依賴收集。咱們能夠理解爲watcher在收集依賴,而代碼的實現方式是在數據中存儲依賴本身的watcher

細心的讀者可能會發現,利用這種方法,每遇到一個插值表達式就會新建一個watcher,這樣每一個節點就會對應一個watcher。實際上這是vue1.x的作法,以節點爲單位進行更新,粒度較細。而vue2.x的作法是每一個組件對應一個watcher,實例化watcher時傳入的也再也不是一個expression,而是渲染函數,渲染函數由組件的模板轉化而來,這樣一個組件的watcher就能收集到本身的全部依賴,以組件爲單位進行更新,是一種中等粒度的方式。要實現vue2.x的響應式系統涉及到不少其餘的東西,好比組件化,虛擬DOM等,而這個系列文章只專一於數據響應式的原理,所以不能實現vue2.x,可是二者關於響應式的方面,原理相同。

派發更新

實現依賴收集後,咱們最後要實現的功能是派發更新,也就是依賴變化時觸發watcher的回調。從依賴收集部分咱們知道,獲取哪一個數據,也就是說觸發哪一個數據的getter,就說明watcher依賴哪一個數據,那數據變化的時候如何通知watcher呢?相信不少同窗都已經猜到了:在setter中派發更新。

set: function reactiveSetter(newValue) {
  if (newValue === value) return
  value = newValue
  observe(newValue)
  dep.forEach(d => d.update()) // 新增 update方法見Watcher類
}
複製代碼

3. 優化代碼

1. Dep類

咱們能夠將dep數組抽象爲一個類:

class Dep {
  constructor() {
    this.subs = []
  }

  depend() {
    this.addSub(Dep.target)
  }

  notify() {
    const subs = [...this.subs]
    subs.forEach((s) => s.update())
  }

  addSub(sub) {
    this.subs.push(sub)
  }
}
複製代碼

defineReactive函數只需作相應的修改

function defineReactive(data, key, value = data[key]) {
  const dep = new Dep() // 修改
  observe(value)
  Object.defineProperty(data, key, {
    get: function reactiveGetter() {
      dep.depend() // 修改
      return value
    },
    set: function reactiveSetter(newValue) {
      if (newValue === value) return
      value = newValue
      observe(newValue)
      dep.notify() // 修改
    }
  })
}
複製代碼

2. window.target

watcherget方法中

get() {
  window.target = this // 設置了window.target
  const value = parsePath(this.data, this.expression)
  return value
}
複製代碼

你們可能注意到了,咱們沒有重置window.target。有些同窗可能認爲這沒什麼問題,可是考慮以下場景:有一個對象obj: { a: 1, b: 2 }咱們先實例化了一個watcher1watcher1依賴obj.a,那麼window.target就是watcher1。以後咱們訪問了obj.b,會發生什麼呢?訪問obj.b會觸發obj.bgettergetter會調用dep.depend(),那麼obj.bdep就會收集window.target, 也就是watcher1,這就致使watcher1依賴了obj.b,但事實並不是如此。爲解決這個問題,咱們作以下修改:

// Watcher的get方法
get() {
  window.target = this
  const value = parsePath(this.data, this.expression)
  window.target = null // 新增,求值完畢後重置window.target
  return value
}

// Dep的depend方法
depend() {
  if (Dep.target) { // 新增
    this.addSub(Dep.target)
  }
}
複製代碼

經過上面的分析可以看出,window.target的含義就是當前執行上下文中的watcher實例。因爲js單線程的特性,同一時刻只有一個watcher的代碼在執行,所以window.target就是當前正在處於實例化過程當中的watcher

3. update方法

咱們以前實現的update方法以下:

update() {
  this.value = parsePath(this.data, this.expression)
  this.cb()
}
複製代碼

你們回顧一下vm.$watch方法,咱們能夠在定義的回調中訪問this,而且該回調能夠接收到監聽數據的新值和舊值,所以作以下修改

update() {
  const oldValue = this.value
  this.value = parsePath(this.data, this.expression)
  this.cb.call(this.data, this.value, oldValue)
}
複製代碼

4. 學習一下Vue源碼

Vue源碼--56行中,咱們會看到這樣一個變量:targetStack,看起來好像和咱們的window.target有點關係,沒錯,確實有關係。設想一個這樣的場景:咱們有兩個嵌套的父子組件,渲染父組件時會新建一個父組件的watcher,渲染過程當中發現還有子組件,就會開始渲染子組件,也會新建一個子組件的watcher。在咱們的實現中,新建父組件watcher時,window.target會指向父組件watcher,以後新建子組件watcherwindow.target將被子組件watcher覆蓋,子組件渲染完畢,回到父組件watcher時,window.target變成了null,這就會出現問題,所以,咱們用一個棧結構來保存watcher

const targetStack = []

function pushTarget(_target) {
  targetStack.push(window.target)
  window.target = _target
}

function popTarget() {
  window.target = targetStack.pop()
}
複製代碼

Watcherget方法作以下修改

get() {
  pushTarget(this) // 修改
  const value = parsePath(this.data, this.expression)
  popTarget() // 修改
  return value
}
複製代碼

此外,Vue中使用Dep.target而不是window.target來保存當前的watcher,這一點影響不大,只要能保證有一個全局惟一的變量來保存當前的watcher便可

5.總結代碼

現將代碼總結以下:

// 調用該方法來檢測數據
function observe(data) {
  if (typeof data !== 'object') return
  new Observer(data)
}

class Observer {
  constructor(value) {
    this.value = value
    this.walk()
  }
  walk() {
    Object.keys(this.value).forEach((key) => defineReactive(this.value, key))
  }
}

// 數據攔截
function defineReactive(data, key, value = data[key]) {
  const dep = new Dep()
  observe(value)
  Object.defineProperty(data, key, {
    get: function reactiveGetter() {
      dep.depend()
      return value
    },
    set: function reactiveSetter(newValue) {
      if (newValue === value) return
      value = newValue
      observe(newValue)
      dep.notify()
    }
  })
}

// 依賴
class Dep {
  constructor() {
    this.subs = []
  }

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

  notify() {
    const subs = [...this.subs]
    subs.forEach((s) => s.update())
  }

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

Dep.target = null

const TargetStack = []

function pushTarget(_target) {
  TargetStack.push(Dep.target)
  Dep.target = _target
}

function popTarget() {
  Dep.target = TargetStack.pop()
}

// watcher
class Watcher {
  constructor(data, expression, cb) {
    this.data = data
    this.expression = expression
    this.cb = cb
    this.value = this.get()
  }

  get() {
    pushTarget(this)
    const value = parsePath(this.data, this.expression)
    popTarget()
    return value
  }

  update() {
    const oldValue = this.value
    this.value = parsePath(this.data, this.expression)
    this.cb.call(this.data, this.value, oldValue)
  }
}

// 工具函數
function parsePath(obj, expression) {
  const segments = expression.split('.')
  for (let key of segments) {
    if (!obj) return
    obj = obj[key]
  }
  return obj
}

// for test
let obj = {
  a: 1,
  b: {
    m: {
      n: 4
    }
  }
}

observe(obj)

let w1 = new Watcher(obj, 'a', (val, oldVal) => {
  console.log(`obj.a 從 ${oldVal}(oldVal) 變成了 ${val}(newVal)`)
})

複製代碼

4. 注意事項

1. 閉包

Vue可以實現如此強大的功能,離不開閉包的功勞:在defineReactive中就造成了閉包,這樣每一個對象的每一個屬性就能保存本身的值value和依賴對象dep

2. 只要觸發getter就會收集依賴嗎

答案是否認的。在Depdepend方法中,咱們看到,只有Dep.target爲真時纔會添加依賴。好比在派發更新時會觸發watcherupdate方法,該方法也會觸發parsePath來取值,可是此時的Dep.targetnull,不會添加依賴。仔細觀察能夠發現,只有watcherget方法中會調用pushTarget(this)來對Dep.target賦值,其餘時候Dep.target都是null,而get方法只會在實例化watcher的時候調用,所以,在咱們的實現中,一個watcher的依賴在其實例化時就已經肯定了,以後任何讀取值的操做均不會增長依賴。

3. 依賴嵌套的對象屬性

咱們結合上面的代碼來思考下面這個問題:

let w2 = new Watcher(obj, 'b.m.n', (val, oldVal) => {
  console.log(`obj.b.m.n 從 ${oldVal}(oldVal) 變成了 ${val}(newVal)`)
})
複製代碼

咱們知道,w2會依賴obj.b.m.n, 可是w2會依賴obj.b, obj.b.m嗎?或者說,obj.b,和obj.b.m,它們閉包中保存的dep中會有w2嗎?答案是會。咱們先不從代碼角度分析,設想一下,若是咱們讓obj.b = null,那麼很顯然w2的回調函數應該被觸發,這就說明w2會依賴中間層級的對象屬性。

接下來咱們從代碼層面分析一下:new Watcher()時,會調用watcher的get方法,將Dep.target設置爲w2get方法會調用parsePath來取值,咱們來看一下取值的具體過程:

function parsePath(obj, expression) {
  const segments = expression.split('.') // 先將表達式分割,segments:['b', 'm', 'n']
  // 循環取值
  for (let key of segments) {
    if (!obj) return
    obj = obj[key]
  }
  return obj
}
複製代碼

以上代碼流程以下:

  1. 局部變量obj爲對象obj,讀取obj.b的值,觸發getter,觸發dep.depend()(該depobj.b的閉包中的dep),Dep.target存在,添加依賴
  2. 局部變量objobj.b,讀取obj.b.m的值,觸發getter,觸發dep.depend()(該depobj.b.m的閉包中的dep),Dep.target存在,添加依賴
  3. 局部變量obj爲對象obj.b.m,讀取obj.b.m.n的值,觸發getter,觸發dep.depend()(該depobj.b.m.n的閉包中的dep),Dep.target存在,添加依賴

從上面的代碼能夠看出,w2會依賴與目標屬性相關的每一項,這也是符合邏輯的。

5. 總結

總結一下:

  1. 調用observe(obj),將obj設置爲響應式對象,observe函數,Observe, defineReactive函數三者互相調用,從而遞歸地將obj設置爲響應式對象
  2. 渲染頁面時實例化watcher,這個過程會讀取依賴數據的值,從而完成在getter中獲取依賴
  3. 依賴變化時觸發setter,從而派發更新,執行回調,完成在setter中派發更新

佔個坑

從嚴格意義來講,咱們如今完成的響應式系統還不能用於渲染頁面,由於真正用於渲染頁面的watcher是不須要設置回調函數的,咱們稱之爲渲染watcher。此外,渲染watcher能夠接收一個渲染函數而不是表達式做爲參數,當依賴變化時自動從新渲染,而這樣又會帶來重複依賴的問題。此外,另外一個重要的內容咱們尚未涉及到,就是數組的處理。

如今看不懂前面提到的問題,沒有關係,這個系列以後的文章會一步步來解決這些問題,但願你們可以繼續關注。

喜歡的話就請各位看官點個贊吧!!!

相關文章
相關標籤/搜索