Vue原理解析(七): 理解響應式-數組

上一篇: Vue 原理解析(六): 全面深入理解響應式原理-對象

數組更新

首先看下改變數組的兩種方式:

export default {
  data() {
    list: [1, 2, 3]
  },
  methods: {
    changeArr1() {  // 重新賦值
      this.list = [4, 5, 6]
    },
    changeArr2() {  // 方法改變
      this.list.push(7)
    }
  }
}

對於這兩種改變數據的方式, vue 內部的實現並不相同。

方式一: 重新複製

  • 實現原理和對象是一樣的, 在vm._render() 時有用到list, 就將依賴收集起來, 重新賦值後走對象派發更新的那一套。

方式二: 方法改變

  • 走對象的那一套就不行了, 因爲並不是重新賦值, 雖然改變了數組自身但並不會觸發set, 原有的響應式系統根本感知不到, 所以我們接下來就分析, vue 是如何解決使用數組方法改變自身觸發視圖的。

Dep收集依賴的位置

現在我們來重新認識它。 Dep類的主要作用就是管理依賴,在響應式系統中會有兩個地方要實例化它, 當然它們都會進行依賴的收集。 首先是之前具體包裝的時候:

function defineReactive(obj, key, val) {
  const dep = new Dep()  // 自動依賴管理器
  ...
  Object.defineProperty(obj, key, {
    get() {...},
    set() {...}
  })
}

這裏它會對每個讀取到的key都進行依賴收集, 無論是對象/數組/原始類型, 如果是通過重新賦值觸發set就會使用這裏收集到的依賴進行更新, 這裏就把它命名爲自動依賴管理器, 方便和之後的區分。

還有一個地方也會對它進行實例化就是Observer類中:

class Observer {
  constructor(value) {
    this.dep = new Dep() //  手動依賴管理器
    ...
  }
}

這個依賴管理器並不能通過set觸發, 而且是隻會收集對象/數組的依賴。也就是說對象的依賴會被收集兩次, 一次在自動依賴管理器內, 一次在這裏,爲什麼要收集兩次,本章之後說明。 而最重要的是數組使用方法改變自身去觸發更新的依賴就是再這收集的, 這個前提還是很有必要講解一下的:

數組的響應式原理

數組響應式數據的創建

數組示例:
export default {
  data() {
    return {
      list: [{
        name: 'cc',
        sex: 'man'
      }, {
        name: 'ww',
        sex: 'woman'
      }]
    }
  }
}

流程開始還是執行observer方法, 接下來我們更加詳細分析響應式系統:

function observe(value) {
  if (!isObject(value) { //不是數組或對象,再見
    return
  }
  
  let ob
  if(hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {  // 避免重複包裝
    ob = value.__ob__
  } else {
    ob = new Observer(value)
  }
  return ob
}

只要是響應式的數據都會有一個 ob 的屬性, 它是在Observer 類中掛載的,如果已經有 ob 屬性就直接賦值給ob, 不會再次去創建 Observer 實例, 避免重複包裝。 首次肯定沒有__ob__屬性了, 所以再重新看下Observer 類的定義:

class Observer {
  constructor(value) {
    this.value = value
    this.dep = new Dep()  // 手動依賴管理器
    
    def(value, '__ob__', this)  // 掛載__ob__屬性,三個參數
    ...
  }
}

首先定義一個手動依賴管理器, 然後掛載一個不可枚舉的__ob__屬性到傳入的value下, 表示它的一個響應式的數據, 而且 ob 的值就是當前 Observer 類的實例, 它擁有實例上的所有屬性和方法, 這很重要, 接下來看下def是如何完成屬性掛載的:

function def (obj, key, val, enumerable) {
  Object.defineProperty(obj, key, {
    value: val,
    enumerable: !!enumerable,
    writable: true,
    configurable: true
  })
}

其實就是一個簡單的封裝, 如果第四個參數不傳, enumerable 項就是不可枚舉的了。接下來看Observer類的定義:

class Observer {
  constructor(value) {
	...
    if (Array.isArray(value)) {  // 數組
      ...
    } else {  // 對象
      this.walk(value)  // {list: [{...}, {...}]}
    }
  }
  
  walk (obj) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])
    }
  }
}

首次傳入還是對象的格式, 所以會執行walk 遍歷的將對象每個屬性包裝爲響應式的,再來看下defineReactive 方法:

function defineReactive(obj, key, val) { 

  const dep = new Dep()  // 自動依賴管理器
  
  val = obj[key]  // val爲數組 [{...}, {...}]
  
  let childOb = observe(val)  // 傳入到observe裏,返回Observer類實例
  
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get() {  // 依賴收集
      if (Dep.target) {
        dep.depend()  // 自動依賴管理器收集依賴
        if (childOb) {  // 只有對象或數組纔有返回值
          childOb.dep.depend()  // 手動依賴管理器收集依賴
          if (Array.isArray(val)) { 如果是數組
            dependArray(val) // 將數組每一項包裝爲響應式
          }
        }
      }
      return value
    },
    set(newVal) {
      ...
    }
  }
}

首先遞歸執行 observe(val) 會有一個返回值了, 如果是對象或數組的話, childOb 就是 Observer 類的實例, 數組格式在observe 內做了什麼, 我們之後分析。接下來在get內的childOb.dep.depend() 執行的就是Observer 類裏定義的dep進行依賴收集, 收集的render-watcher 跟自動依賴管理器是一樣的。 接下來如果是數組就執行dependArray 方法:

function dependArray (value) {
  for (let e, i = 0, i < value.length; i++) {
    e = value[i]
    e && e.__ob__ && e.__ob__.dep.depend()  // 是響應式數據
    if (Array.isArray(e)) {  // 如果是嵌套數組
      dependArray(e)  // 遞歸調用自己
    }
  }
}

數組方法更新依賴

在之前defineReactive 方法裏有這麼一句, let childOb = observe(val) , 通過求值,val 現在就是具體的數組, 以數組的形式引入到observe 方法內, 我們來看下在 Observer 類中做什麼:

class Observer {
  constructor(value) {
    if (Array.isArray(value)) {  // 數組
      
      const augment = hasProto ? protoAugment : copyAugment  // 第一句
      
      augment(value, arrayMethods, arrayKeys)  // 第二句
      
      this.observeArray(value)  // 第三句
      
    }
  }
}

主要就是執行了三句邏輯, 所以我們首先來看下分別做了什麼。

數組方法改變自身觸發視圖原理: 首先覆蓋數組的__proto__ 隱式原型, 借用數組原生的方法,定義vue 內部自定義的數組異變方法攔截原生方法, 再調用異變方法改變自身之後手動觸發依賴。

首先分析第一句:

const augment = hasProto ? protoAugment : copyAugment

--------------------------------------------------------

const hasProto = '__proto__' in {}

function protoAugment (target, src) {  // src爲攔截器
  target.__proto__ = src
}

function copyAugment (target, src, keys) {  // src爲攔截器
  for (let i = 0; i < keys.length; i++) {
    const key = keys[i]
    def(target, key, src[key])
  }
}

proto 這個屬性並不是所有瀏覽器都有的, 筆者之前也一直依偎在這是一個通用屬性,原來IE11纔開始有這個屬性, 通過 ‘protp’ in {} 也可以快速判斷當前瀏覽器瀏覽是否IE10 以上? 比較好用!

是否有 proto 屬性處理方法也不相同, 如果有的話, 直接在protoAugment方法內使用攔截器覆蓋; 如果沒有__proto__ 屬性, 那就在當前調用數組下掛載攔截器裏的變異數組方法。

實現原理都是根據原型鏈的特性, 再數組使用原生方法之前加一個攔截器, 攔截器內定義的都是可以改變數組自身的變異方法, 如果攔截器內沒有就向上一層去找。

接下來分析第二句, 也是整個數組方法實現的核心:

augment(value, arrayMethods, arrayKeys)

----------------------------------------------------------------------------

const arrayProto = Array.prototype  // 數組原型,有所有數組原生方法
const arrayMethods = Object.create(arrayProto)  // 創建空對象攔截器

const methodsToPatch = [  // 七個數組使用會改變自身的方法
  'push','pop','shift','unshift','splice','sort','reverse'
]

methodsToPatch.forEach(function (method) {  // 往攔截器下掛載異變方法

  const original = arrayProto[method]  // 過濾出七個數組原生原始方法
  
  def(arrayMethods, method, function mutator (...args) {  // 不定參數
  
    const result = original.apply(this, args)  // 借用原生方法,this就是調用的數組
    
    const ob = this.__ob__  // 之前Observer類下掛載的__ob__
    
    let inserted  // 臨時保存數組新增的值
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args
        break
      case 'splice':
        inserted = args.slice(2)
        break
    }
    if (inserted) {
      ob.observeArray(inserted)  // 執行Observer類中的observeArray方法
    }
    ob.dep.notify()  // 觸發手動依賴收集器內的依賴
    
    return result  // 返回數組執行結果
  })
})

const arrayKeys = Object.getOwnPropertyNames(arrayMethods) 
// 獲取攔截器內掛載好的七個方法key的數組集合,用於沒有__proto__的情況

首先獲取數組的所有原生方法, 從中過濾出七個調用可以改變自身的方法, 然後創建攔截器在它下面掛載七個經過變異的方法, 這個變異方法的使用效果和原生方法是一致的, 因爲就是使用apply 借用的, 將執行結果保存給 result, 比如:

const arr = [1, 2, 3]
const result = arr.push(4)

這個時候arr就變成了 [1,2,3,4], result 保存的就是新數組的長度, 既然模仿就模仿的像一點。

接下來的賦值 const ob = this.ob , 之前定義的 ob 不僅僅是標記位, 保存的也是Observer 類的實例。

有三個操作數組的方法是全添加新值的, 使用inserted 變量保存新添加的值。 如果是使用splice 方法, 將前面兩個表示位置的參數截取掉。 然後使用observeArray 方法將新添加的參數包裝爲響應式的。

最後通過手動依賴管理器內收集到的依賴派發更新, 返回數組執行後的結果。

最後執行第三句:

this.observeArray(value)

observeArray(items) {
  for (let i = 0, i < items.length; i++) {
    observe(items[i])
  }
}

將數組內的數組或對象的每一項都包裝成響應式的。 所以當數組再使用方法時, 首先會去arrayMethods 攔截器內查找是否是變異方法, 不是的話纔去調用數組原生方法:

export default {
  data() {
    return {
      list: [1, 2, 3]
    }
  },
  methods: {
    changeArr1() {
      this.list.push(4)  // 調用攔截器裏的異變方法
    },
    changeArr2() {
      this.list = this.list.concat(5) 
      // 調用原生方法,因爲攔截器裏沒有,必須重新賦值因爲不會改變自身
    }
  }
}

至此數組響應式系統相關的也講解完畢, 整個響應式系統也分析完了。

數組響應式總結: 數組的依賴收集還是在get 方法裏, 不過依賴的存放位置會有不同, 不是在defineReactive 方法的dep, 而是在Observer 類中的dep裏, 依賴的更新是在攔截器裏的數組異變方法最後手動更新。

同樣數組響應式也不是完美的, 它也有缺點:

export default {
  data() {
    return {
      list: [1, 2, 3]
    }
  },
  methods: {
    changeListItem() {  // 改變數組某一項
      this.list[1] = 5
    },
    changeListLength() {  // 改變數組長度
      this.list.length = 0
    }
  }
}

以上兩種方式都改變了數組, 但響應式是無法監聽到的。 因爲不會觸發set 也沒有使用數組方法去改變。 還記得之前介紹的手動依賴管理器麼? 我們可以 手動去通知它更新依賴後觸發視圖變更~

export default {
  data() {
    return {
      list: [1, 2, 3],
      info: { name: 'cc' }
    }
  },
  methods: {
    changeListItem() {  // 改變數組某一項
      this.list[1] = 5
      this.list.__ob__.dep.notify()  // 手動通知
    },
    changeListLength() {  // 改變數組長度
      this.list.length = 0
      this.list.__ob__.dep.notify()  // 手動通知
    },
    changeInfo() {
      this.info.sex = 'man'
      this.info.__ob__.dep.notify()  // 對象也可以
    }
  }
}

常規的對象增加屬性是不會被感知到的, 也可以使用手動通知的形式觸發依賴, 知道這個原理還是很cool 的~

官方填坑

上面的方式並不被推薦使用, 我們來介紹下官方推薦的彌補響應式不足的兩個API。 $set 和 $delete, 其實它們只是處理一些情況, 都不滿足的最後還是調了一下手動依賴管理器來實現, 只是進行了簡單的二次封裝。

this.$set || Vue.set

function set(target, key, val) {
  if(Array.isArray(target)) {  // 數組
    target.length = Math.max(target.length, key)  // 最大值爲長度
    target.splice(key, 1, val)  // 移除一位,異變方法派發更新
    return val
  }
  
  if(key in target && !(key in Object.prototype)) {  // key屬於target
    target[key] = val  // 賦值操作觸發set
    return val
  }
  
  if(!target.__ob__) {  // 普通對象賦值操作
    target[key] = val
    return val
  }
  
  defineReactive(target.__ob__.value, key, val)  // 將新值包裝爲響應式
  
  target.__ob__.dep.notify()  // 手動觸發通知
  
  return val
}

首先判斷target 是否是數組, 是數組的話第二個參數就是長度了, 設置數組的長度, 然後使用splice 這個變異方法插入 val。 然後是判斷key是否屬於target, 屬於的話就是賦值操作了, 這個會觸發set 去派發更新。 接下來如果target 並不是響應式數據, 那就是普通對象, 那就設置一個對應key吧。 最後以上情況不滿足, 說明響應式數據上新增了一個屬性, 把新增的屬性轉爲響應式數據, 然後通知手動依賴管理器派發更新。

** this.$delete || Vue.delete**

function del (target, key) {
  if (Array.isArray(target)) {  // 數組
    target.splice(key, 1)  // 移除指定下表
    return
  }
  
  if (!hasOwn(target, key)) {  // key不屬於target,再見
    return
  }
  
  delete target[key]  // 刪除對象指定key
  
  if (!target.__ob__) {  // 普通對象,再見
    return
  }
  target.__ob__.dep.notify()  // 手動派發更新
}

this.$delete 就更加簡單了, 首先如果是數組就使用異變方法splice 移除指定下標值。 如果target是對象但key不屬於它, 再見。 然後刪除指定key的值, 如果target 不是響應式對象, 刪除的就是普通對象一個值, 刪了就刪了。 否則通知手動依賴管理器派發通知。

最後我們還是用一個問題來結束本章內容:

  • 簡單描述下vue 響應式系統?

解答:

  • 簡單來說就是使用Object.defineProperty 這個API爲數據設置getset。 當讀取到某個屬性時, 觸發get 將讀取它的組件對應的render watcher 收集起來; 當重置賦值時, 觸發set通知組件重新渲染頁面。 如果數據的類型是數組的話, 還做了單獨的處理, 對可以改變數組自身的方法進行重寫, 因爲這些方法不是通過重新賦值改變的數組, 不會觸發set, 所以要單獨處理。 響應式系統也有自身的不足, 所以官方給出了 $set 和 $delete 來彌補。

下一篇: Vue 原理解析(八):diff 算法

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章