深刻淺出 - vue變化偵測原理

原文連接vue

深刻淺出 - vue變化偵測原理

其實在一年前我已經寫過一篇關於 vue響應式原理的文章,可是最近我翻開看看發現講的內容和我如今內心想的有些不太同樣,因此我打算從新寫一篇更通俗易懂的文章。git

個人目標是能讓讀者讀完我寫的文章能學到知識,有一部分文章標題都以深刻淺出開頭,目的是把一個複雜的東西排除掉干擾學習的因素後剩下的核心原理經過很簡單的描述來讓讀者學習到知識。github

關於vue的內部原理其實有不少個重要的部分,變化偵測,模板編譯,virtualDOM,總體運行流程等。swift

今天主要把變化偵測這部分單獨拿出來說一講。api

如何偵測變化?

關於變化偵測首先要問一個問題,在 js 中,如何偵測一個對象的變化,其實這個問題仍是比較簡單的,學過js的都能知道,js中有兩種方法能夠偵測到變化,Object.defineProperty 和 ES6 的proxy數組

到目前爲止vue仍是用的 Object.defineProperty,因此咱們拿 Object.defineProperty來舉例子說明這個原理。app

這裏我想說的是,無論之後vue是否會用 proxy 重寫這部分,我講的是原理,並非api,因此不論之後vue會怎樣改,這個原理是不會變的,哪怕vue用了其餘徹底不一樣的原理實現了變化偵測,可是本篇文章講的原理同樣能夠實現變化偵測,原理這個東西是不會過期的。函數

以前我寫文章有一個毛病就是喜歡對着源碼翻譯,結果過了半年一年人家源碼改了,我寫的文章就一毛錢都不值了,並且對着源碼翻譯還有一個缺點是對讀者的要求有點偏高,讀者若是沒看過源碼或者看的和我不是一個版本,那根本就不知道我在說什麼。工具

好了不說廢話了,繼續講剛纔的內容。學習

知道 Object.defineProperty 能夠偵測到對象的變化,那麼咱們瞬間能夠寫出這樣的代碼:

function defineReactive (data, key, val) {
    Object.defineProperty(data, key, {
        enumerable: true,
        configurable: true,
        get: function () {
            return val
        },
        set: function (newVal) {
            if(val === newVal){
                return
            }
            val = newVal
        }
    })
}

寫一個函數封裝一下 Object.defineProperty,畢竟 Object.defineProperty 的用法這麼複雜,封裝一下我只須要傳遞一個 data,和 key,val 就好了。

如今封裝好了以後每當 datakey 讀取數據 get 這個函數能夠被觸發,設置數據的時候 set 這個函數能夠被觸發,可是,,,,,,,,,,,,,,,,,,發現好像並沒什麼鳥用?

怎麼觀察?

如今我要問第二個問題,「怎麼觀察?」

思考一下,咱們之因此要觀察一個數據,目的是爲了當數據的屬性發生變化時,能夠通知那些使用了這個 key 的地方。

舉個?:

<template>
  <div>{{ key }}</div>
  <p>{{ key }}</p>
</template>

模板中有兩處使用了 key,因此當數據發生變化時,要把這兩處都通知到。

因此上面的問題,個人回答是,先收集依賴,把這些使用到 key 的地方先收集起來,而後等屬性發生變化時,把收集好的依賴循環觸發一遍就行了~

總結起來其實就一句話,getter中,收集依賴,setter中,觸發依賴

依賴收集在哪?

如今咱們已經有了很明確的目標,就是要在getter中收集依賴,那麼咱們的依賴收集到哪裏去呢??

思考一下,首先想到的是每一個 key 都有一個數組,用來存儲當前 key 的依賴,假設依賴是一個函數存在 window.target 上,先把 defineReactive 稍微改造一下:

function defineReactive (data, key, val) {
    let dep = [] // 新增
    Object.defineProperty(data, key, {
        enumerable: true,
        configurable: true,
        get: function () {
            dep.push(window.target) // 新增
            return val
        },
        set: function (newVal) {
            if(val === newVal){
                return
            }
            
            // 新增
            for (let i = 0; i < dep.length; i++) {
                 dep[i](newVal, val)
            }
            val = newVal
        }
    })
}

defineReactive 中新增了數組 dep,用來存儲被收集的依賴。

而後在觸發 set 觸發時,循環dep把收集到的依賴觸發。

可是這樣寫有點耦合,咱們把依賴收集這部分代碼封裝起來,寫成下面的樣子:

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

  constructor () {
    this.id = uid++
    this.subs = []
  }

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

  removeSub (sub: Watcher) {
    remove(this.subs, sub)
  }

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

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

而後在改造一下 defineReactive

function defineReactive (data, key, val) {
    let dep = new Dep() // 修改
    Object.defineProperty(data, key, {
        enumerable: true,
        configurable: true,
        get: function () {
            dep.depend() // 修改
            return val
        },
        set: function (newVal) {
            if(val === newVal){
                return
            }

            dep.notify() // 新增
            val = newVal
        }
    })
}

這一次代碼看起來清晰多了,順便回答一下上面問的問題,依賴收集到哪?收集到Dep中,Dep是專門用來存儲依賴的。

收集誰?

上面咱們僞裝 window.target 是須要被收集的依賴,細心的同窗可能已經看到,上面的代碼 window.target 已經改爲了 Dep.target,那 Dep.target是什麼?咱們究竟要收集誰呢??

收集誰,換句話說是當屬性發生變化後,通知誰。

咱們要通知那個使用到數據的地方,而使用這個數據的地方有不少,並且類型還不同,有多是模板,有多是用戶寫的一個 watch,因此這個時候咱們須要抽象出一個能集中處理這些不一樣狀況的類,而後咱們在依賴收集的階段只收集這個封裝好的類的實例進來,通知也只通知它一個,而後它在負責通知其餘地方,因此咱們要抽象的這個東西須要先起一個好聽的名字,嗯,就叫它watcher吧~

因此如今能夠回答上面的問題,收集誰??收集 Watcher。

什麼是Watcher?

watcher 是一箇中介的角色,數據發生變化通知給 watcher,而後watcher在通知給其餘地方。

關於watcher咱們先看一個經典的使用方式:

// keypath
vm.$watch('a.b.c', function (newVal, oldVal) {
  // do something
})

這段代碼表示當 data.a.b.c 這個屬性發生變化時,觸發第二個參數這個函數。

思考一下怎麼實現這個功能呢?

好像只要把這個 watcher 實例添加到 data.a.b.c 這個屬性的 Dep 中去就好了,而後 data.a.b.c 觸發時,會通知到watcher,而後watcher在執行參數中的這個回調函數。

好,思考完畢,開工,寫出以下代碼:

class Watch {
    constructor (expOrFn, cb) {
        // 執行 this.getter() 就能夠拿到 data.a.b.c
        this.getter = parsePath(expOrFn)
        this.cb = cb
        this.value = this.get()
    }

    get () {
        Dep.target = this
        value = this.getter.call(vm, vm)
        Dep.target = undefined
    }

    update () {
        const oldValue = this.value
        this.value = this.get()
        this.cb.call(this.vm, this.value, oldValue)
    }
}

這段代碼能夠把本身主動 pushdata.a.b.c 的 Dep 中去。

由於我在 get 這個方法中,先把 Dep.traget 設置成了 this,也就是當前watcher實例,而後在讀一下 data.a.b.c 的值。

由於讀了 data.a.b.c 的值,因此確定會觸發 getter

觸發了 getter 上面咱們封裝的 defineReactive 函數中有一段邏輯就會從 Dep.target 裏讀一個依賴 pushDep 中。

因此就致使,我只要先在 Dep.target 賦一個 this,而後我在讀一下值,去觸發一下 getter,就能夠把 this 主動 pushkeypath 的依賴中,有沒有很神奇~

依賴注入到 Dep 中去以後,當這個 data.a.b.c 的值發生變化,就把全部的依賴循環觸發 update 方法,也就是上面代碼中 update 那個方法。

update 方法會觸發參數中的回調函數,將value 和 oldValue 傳到參數中。

因此其實不論是用戶執行的 vm.$watch('a.b.c', (value, oldValue) => {}) 仍是模板中用到的data,都是經過 watcher 來通知本身是否須要發生變化的。

遞歸偵測全部key

如今其實已經能夠實現變化偵測的功能了,可是咱們以前寫的代碼只能偵測數據中的一個 key,因此咱們要加工一下 defineReactive 這個函數:

// 新增
function walk (obj: Object) {
  const keys = Object.keys(obj)
  for (let i = 0; i < keys.length; i++) {
    defineReactive(obj, keys[i], obj[keys[i]])
  }
}

function defineReactive (data, key, val) {
    walk(val) // 新增
    let dep = new Dep()
    Object.defineProperty(data, key, {
        enumerable: true,
        configurable: true,
        get: function () {
            dep.depend()
            return val
        },
        set: function (newVal) {
            if(val === newVal){
                return
            }

            dep.notify()
            val = newVal
        }
    })
}

這樣咱們就能夠經過執行 walk(data),把 data 中的全部 key 都加工成能夠被偵測的,由於是一個遞歸的過程,因此 key 中的 value 若是是一個對象,那這個對象的全部key也會被偵測。

Array怎麼進行變化偵測?

如今又發現了新的問題,data 中不是全部的 value 都是對象和基本類型,若是是一個數組怎麼辦??數組是沒有辦法經過 Object.defineProperty 來偵測到行爲的。

vue 中對這個數組問題的解決方案很是的簡單粗暴,我說說vue是如何實現的,大致上分三步:

第一步:先把原生 Array 的原型方法繼承下來。

第二步:對繼承後的對象使用 Object.defineProperty 作一些攔截操做。

第三步:把加工後能夠被攔截的原型,賦值到須要被攔截的 Array 類型的數據的原型上。

vue的實現

第一步:

const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)

第二步:

;[
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]
.forEach(function (method) {
  // cache original method
  const original = arrayProto[method]

  Object.defineProperty(arrayMethods, method, {
    value: function mutator (...args) {
      console.log(methods) // 打印數組方法
      return original.apply(this, args)
    },
    enumerable: false,
    writable: true,
    configurable: true
  })
})

如今能夠看到,每當被偵測的 array 執行方法操做數組時,我均可以知道他執行的方法是什麼,而且打印到 console 中。

如今我要對這個數組方法類型進行判斷,若是操做數組的方法是 push unshift splice (這種能夠新增數組元素的方法),須要把新增的元素用上面封裝的 walk 來進行變化檢測。

而且不論操做數組的是什麼方法,我都要觸發消息,通知依賴列表中的依賴數據發生了變化。

那如今怎麼訪問依賴列表呢,可能咱們須要把上面封裝的 walk 加工一下:

// 工具函數
function def (obj: Object, key: string, val: any, enumerable?: boolean) {
  Object.defineProperty(obj, key, {
    value: val,
    enumerable: !!enumerable,
    writable: true,
    configurable: true
  })
}

export class Observer {
  value: any;
  dep: Dep;
  vmCount: number; // number of vms that has this object as root $data

  constructor (value: any) {
    this.value = value
    this.dep = new Dep() // 新增
    this.vmCount = 0
    def(value, '__ob__', this) // 新增

    // 新增
    if (Array.isArray(value)) {
      this.observeArray(value)
    } else {
      this.walk(value)
    }
  }

  /**
   * Walk through each property and convert them into
   * getter/setters. This method should only be called when
   * value type is Object.
   */
  walk (obj: Object) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i], obj[keys[i]])
    }
  }

  /**
   * Observe a list of Array items.
   */
  observeArray (items: Array<any>) {
    for (let i = 0, l = items.length; i < l; i++) {
      new Observer(items[i])
    }
  }
}

咱們定義了一個 Observerspace######space類,他的職責是將 data 轉換成能夠被偵測到變化的 data,而且新增了對類型的判斷,若是是 value 的類型是 Array 循環 Array將每個元素丟到 Observer 中。

而且在 value 上作了一個標記 __ob__,這樣咱們就能夠經過 value__ob__ 拿到Observer實例,而後使用 __ob__ 上的 dep.notify() 就能夠發送通知啦。

而後咱們在改進一下Array原型的攔截器:

;[
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]
.forEach(function (method) {
  // cache original method
  const original = arrayProto[method]
  def(arrayMethods, method, function mutator (...args) {
    const result = original.apply(this, args)
    const ob = this.__ob__
    let inserted
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args
        break
      case 'splice':
        inserted = args.slice(2)
        break
    }
    if (inserted) ob.observeArray(inserted)
    // notify change
    ob.dep.notify()
    return result
  })
})

能夠看到寫了一個 switchmethod 進行判斷,若是是 pushunshiftsplice 這種能夠新增數組元素的方法就使用 ob.observeArray(inserted) 把新增的元素也丟到 Observer 中去轉換成能夠被偵測到變化的數據。

在最後不論操做數組的方法是什麼,都會調用 ob.dep.notify() 去通知 watcher 數據發生了改變。

arrayMethods 是怎麼生效的?

如今咱們有一個 arrayMenthods 是被加工後的 Array.prototype,那麼怎麼讓這個對象應用到Array 上面呢?

思考一下,咱們不能直接修改 Array.prototype由於這樣會污染全局的Array,咱們但願 arrayMenthods 只對 data中的Array 生效。

因此咱們只須要把 arrayMenthods 賦值給 value__proto__ 上就行了。

咱們改造一下 Observer

export class Observer {
  constructor (value: any) {
    this.value = value
    this.dep = new Dep()
    this.vmCount = 0
    def(value, '__ob__', this)

    if (Array.isArray(value)) {
      value.__proto__ = arrayMethods // 新增
      this.observeArray(value)
    } else {
      this.walk(value)
    }
  }
}

若是不能使用 __proto__,就直接循環 arrayMethods 把它身上的這些方法直接裝到 value 身上好了。

什麼狀況不能使用 __proto__ 我也不知道,各位大佬誰知道可否給我留個言?跪謝~

因此咱們的代碼又要改造一下:

// can we use __proto__?
const hasProto = '__proto__' in {} // 新增
export class Observer {
  constructor (value: any) {
    this.value = value
    this.dep = new Dep()
    this.vmCount = 0
    def(value, '__ob__', this)

    if (Array.isArray(value)) {
      // 修改
      const augment = hasProto
        ? protoAugment
        : copyAugment
      augment(value, arrayMethods, arrayKeys)
      this.observeArray(value)
    } else {
      this.walk(value)
    }
  }
}

function protoAugment (target, src: Object, keys: any) {
  target.__proto__ = src
}

function copyAugment (target: Object, src: Object, keys: Array<string>) {
  for (let i = 0, l = keys.length; i < l; i++) {
    const key = keys[i]
    def(target, key, src[key])
  }
}

關於Array的問題

關於vue對Array的攔截實現上面剛說完,正由於這種實現方式,其實有些數組操做vue是攔截不到的,例如:

this.list[0] = 2

修改數組第一個元素的值,沒法偵測到數組的變化,因此並不會觸發 re-renderwatch 等。

在例如:

this.list.length = 0

清空數組操做,沒法偵測到數組的變化,因此也不會觸發 re-renderwatch 等。

由於vue的實現方式就決定了沒法對上面舉得兩個例子作攔截,也就沒有辦法作到響應,ES6是有能力作到的,在ES6以前是沒法作到模擬數組的原生行爲的,如今 ES6 的 Proxy 能夠模擬數組的原生行爲,也能夠經過 ES6 的繼承來繼承數組原生行爲,從而進行攔截。

總結

最後掏出vue官網上的一張圖,這張圖其實很是清晰,就是一個變化偵測的原理圖。

getterwatcher 有一條線,上面寫着收集依賴,意思是說 getter 裏收集 watcher,也就是說當數據發生 get 動做時開始收集 watcher

setterwatcher 有一條線,寫着 Notify 意思是說在 setter 中觸發消息,也就是當數據發生 set 動做時,通知 watcher

Watcher 到 ComponentRenderFunction 有一條線,寫着 Trigger re-render 意思很明顯了。

相關文章
相關標籤/搜索