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

amadan發表於2021-09-09

原文連結,如果感興趣或者對美股感興趣可以加我微信: xiaobei060537, 一起交流 ?。

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

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

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

關於vue的內部原理其實有很多個重要的部分,變化偵測,模板編譯,virtualDOM,整體執行流程等。

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

如何偵測變化?

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

到目前為止vue還是用的 Object.defineProperty,所以我們拿 Object.defineProperty來舉例子說明這個原理。

這裡我想說的是,不管以後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是什麼?我們究竟要收集誰呢??

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

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

我們要通知那個使用到資料的地方,而使用這個資料的地方有很多,而且型別還不一樣,有可能是模板,有可能是使用者寫的一個 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變化偵測原理

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

getterwatcher 有一條線,上面寫著收集依賴,意思是說 getter 裡收集 watcher,也就是說當資料發生 get 動作時開始收集 watcher

setterwatcher 有一條線,寫著 Notify 意思是說在 setter 中觸發訊息,也就是當資料發生 set 動作時,通知 watcher

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

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

相關文章