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

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

關(guān)于vue的內(nèi)部原理其實(shí)有很多個重要的部分,變化偵測,模板編譯,virtualDOM,整體運(yùn)行流程等。

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

如何偵測變化?

關(guān)于變化偵測首先要問一個問題,在 js 中,如何偵測一個對象的變化,其實(shí)這個問題還是比較簡單的,學(xué)過js的都能知道,js中有兩種方法可以偵測到變化,Object.defineProperty 和 ES6 的proxy。

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

這里我想說的是,不管以后vue是否會用 proxy 重寫這部分,我講的是原理,并不是api,所以不論以后vue會怎樣改,這個原理是不會變的,哪怕vue用了其他完全不同的原理實(shí)現(xiàn)了變化偵測,但是本篇文章講的原理一樣可以實(shí)現(xiàn)變化偵測,原理這個東西是不會過時的。

之前我寫文章有一個毛病就是喜歡對著源碼翻譯,結(jié)果過了半年一年人家源碼改了,我寫的文章就一毛錢都不值了,而且對著源碼翻譯還有一個缺點(diǎn)是對讀者的要求有點(diǎn)偏高,讀者如果沒看過源碼或者看的和我不是一個版本,那根本就不知道我在說什么。

好了不說廢話了,繼續(xù)講剛才的內(nèi)容。

知道 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
        }
    })
}

寫一個函數(shù)封裝一下 Object.defineProperty,畢竟 Object.defineProperty的用法這么復(fù)雜,封裝一下我只需要傳遞一個 data,和 key,val 就行了。

現(xiàn)在封裝好了之后每當(dāng) datakey 讀取數(shù)據(jù) get 這個函數(shù)可以被觸發(fā),設(shè)置數(shù)據(jù)的時候 set 這個函數(shù)可以被觸發(fā),但是,,,,,,,,,,,,,,,,,,發(fā)現(xiàn)好像并沒什么鳥用?

怎么觀察?

現(xiàn)在我要問第二個問題,“怎么觀察?”

思考一下,我們之所以要觀察一個數(shù)據(jù),目的是為了當(dāng)數(shù)據(jù)的屬性發(fā)生變化時,可以通知那些使用了這個 key 的地方。

舉個例子

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

模板中有兩處使用了 key,所以當(dāng)數(shù)據(jù)發(fā)生變化時,要把這兩處都通知到。

所以上面的問題,我的回答是,先收集依賴,把這些使用到 key 的地方先收集起來,然后等屬性發(fā)生變化時,把收集好的依賴循環(huán)觸發(fā)一遍就好了~

總結(jié)起來其實(shí)就一句話,getter中,收集依賴,setter中,觸發(fā)依賴。

依賴收集在哪?

現(xiàn)在我們已經(jīng)有了很明確的目標(biāo),就是要在getter中收集依賴,那么我們的依賴收集到哪里去呢??

思考一下,首先想到的是每個 key 都有一個數(shù)組,用來存儲當(dāng)前 key 的依賴,假設(shè)依賴是一個函數(shù)存在 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 中新增了數(shù)組 dep,用來存儲被收集的依賴。

然后在觸發(fā) set 觸發(fā)時,循環(huán)dep把收集到的依賴觸發(fā)。

但是這樣寫有點(diǎn)耦合,我們把依賴收集這部分代碼封裝起來,寫成下面的樣子:

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 是需要被收集的依賴,細(xì)心的同學(xué)可能已經(jīng)看到,上面的代碼 window.target 已經(jīng)改成了 Dep.target,那 Dep.target是什么?我們究竟要收集誰呢??

收集誰,換句話說是當(dāng)屬性發(fā)生變化后,通知誰。

我們要通知那個使用到數(shù)據(jù)的地方,而使用這個數(shù)據(jù)的地方有很多,而且類型還不一樣,有可能是模板,有可能是用戶寫的一個 watch,所以這個時候我們需要抽象出一個能集中處理這些不同情況的類,然后我們在依賴收集的階段只收集這個封裝好的類的實(shí)例進(jìn)來,通知也只通知它一個,然后它在負(fù)責(zé)通知其他地方,所以我們要抽象的這個東西需要先起一個好聽的名字,嗯,就叫它watcher吧~

所以現(xiàn)在可以回答上面的問題,收集誰??收集 Watcher。

什么是Watcher?

watcher 是一個中介的角色,數(shù)據(jù)發(fā)生變化通知給 watcher,然后watcher在通知給其他地方。

關(guān)于watcher我們先看一個經(jīng)典的使用方式:

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

這段代碼表示當(dāng) data.a.b.c 這個屬性發(fā)生變化時,觸發(fā)第二個參數(shù)這個函數(shù)。

思考一下怎么實(shí)現(xiàn)這個功能呢?

好像只要把這個 watcher 實(shí)例添加到 data.a.b.c 這個屬性的 Dep 中去就行了,然后 data.a.b.c 觸發(fā)時,會通知到watcher,然后watcher在執(zhí)行參數(shù)中的這個回調(diào)函數(shù)。

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

class Watch {
    constructor (expOrFn, cb) {
        // 執(zhí)行 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 中去。

因?yàn)槲以?get 這個方法中,先把 Dep.traget 設(shè)置成了 this,也就是當(dāng)前watcher實(shí)例,然后在讀一下 data.a.b.c 的值。

因?yàn)樽x了 data.a.b.c 的值,所以肯定會觸發(fā) getter

觸發(fā)了 getter 上面我們封裝的 defineReactive函數(shù)中有一段邏輯就會從 Dep.target 里讀一個依賴 pushDep 中。

所以就導(dǎo)致,我只要先在 Dep.target 賦一個 this,然后我在讀一下值,去觸發(fā)一下 getter,就可以把 this 主動 pushkeypath 的依賴中,有沒有很神奇~

依賴注入到 Dep 中去之后,當(dāng)這個 data.a.b.c 的值發(fā)生變化,就把所有的依賴循環(huán)觸發(fā) update 方法,也就是上面代碼中 update 那個方法。

update 方法會觸發(fā)參數(shù)中的回調(diào)函數(shù),將value 和 oldValue 傳到參數(shù)中。

所以其實(shí)不管是用戶執(zhí)行的 vm.$watch('a.b.c', (value, oldValue) => {}) 還是模板中用到的data,都是通過 watcher 來通知自己是否需要發(fā)生變化的。

遞歸偵測所有key

現(xiàn)在其實(shí)已經(jīng)可以實(shí)現(xiàn)變化偵測的功能了,但是我們之前寫的代碼只能偵測數(shù)據(jù)中的一個 key,所以我們要加工一下 defineReactive 這個函數(shù):

// 新增
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
        }
    })
}

這樣我們就可以通過執(zhí)行 walk(data),把 data 中的所有 key 都加工成可以被偵測的,因?yàn)槭且粋€遞歸的過程,所以 key 中的 value 如果是一個對象,那這個對象的所有key也會被偵測。

Array怎么進(jìn)行變化偵測?

現(xiàn)在又發(fā)現(xiàn)了新的問題,data 中不是所有的 value 都是對象和基本類型,如果是一個數(shù)組怎么辦??數(shù)組是沒有辦法通過 Object.defineProperty 來偵測到行為的。

vue 中對這個數(shù)組問題的解決方案非常的簡單粗暴,我說說vue是如何實(shí)現(xiàn)的,大體上分三步:

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

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

第三步:把加工后可以被攔截的原型,賦值到需要被攔截的 Array 類型的數(shù)據(jù)的原型上。

vue的實(shí)現(xiàn)

第一步:

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(method) // 打印數(shù)組方法
      return original.apply(this, args)
    },
    enumerable: false,
    writable: true,
    configurable: true
  })
})

現(xiàn)在可以看到,每當(dāng)被偵測的 array 執(zhí)行方法操作數(shù)組時,我都可以知道他執(zhí)行的方法是什么,并且打印到 console 中。

現(xiàn)在我要對這個數(shù)組方法類型進(jìn)行判斷,如果操作數(shù)組的方法是 push unshift splice (這種可以新增數(shù)組元素的方法),需要把新增的元素用上面封裝的 walk 來進(jìn)行變化檢測。

并且不論操作數(shù)組的是什么方法,我都要觸發(fā)消息,通知依賴列表中的依賴數(shù)據(jù)發(fā)生了變化。

那現(xiàn)在怎么訪問依賴列表呢,可能我們需要把上面封裝的 walk 加工一下:

// 工具函數(shù)
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])
    }
  }
}

我們定義了一個 Observer 類,他的職責(zé)是將 data 轉(zhuǎn)換成可以被偵測到變化的 data,并且新增了對類型的判斷,如果是 value 的類型是 Array 循環(huán) Array將每一個元素丟到 Observer 中。

并且在 value 上做了一個標(biāo)記 __ob__,這樣我們就可以通過 value__ob__ 拿到Observer實(shí)例,然后使用 __ob__ 上的 dep.notify() 就可以發(fā)送通知啦。

然后我們在改進(jìn)一下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 進(jìn)行判斷,如果是 push,unshift,splice 這種可以新增數(shù)組元素的方法就使用 ob.observeArray(inserted) 把新增的元素也丟到 Observer 中去轉(zhuǎn)換成可以被偵測到變化的數(shù)據(jù)。

在最后不論操作數(shù)組的方法是什么,都會調(diào)用 ob.dep.notify() 去通知 watcher 數(shù)據(jù)發(fā)生了改變。

arrayMethods 是怎么生效的?

現(xiàn)在我們有一個 arrayMenthods 是被加工后的 Array.prototype,那么怎么讓這個對象應(yīng)用到Array 上面呢?

思考一下,我們不能直接修改 Array.prototype因?yàn)檫@樣會污染全局的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__,就直接循環(huán) 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])
  }
}

關(guān)于Array的問題

關(guān)于vue對Array的攔截實(shí)現(xiàn)上面剛說完,正因?yàn)檫@種實(shí)現(xiàn)方式,其實(shí)有些數(shù)組操作vue是攔截不到的,例如:

this.list[0] = 2

修改數(shù)組第一個元素的值,無法偵測到數(shù)組的變化,所以并不會觸發(fā) re-renderwatch 等。

在例如:

this.list.length = 0

清空數(shù)組操作,無法偵測到數(shù)組的變化,所以也不會觸發(fā) re-renderwatch 等。

因?yàn)関ue的實(shí)現(xiàn)方式就決定了無法對上面舉得兩個例子做攔截,也就沒有辦法做到響應(yīng),ES6是有能力做到的,在ES6之前是無法做到模擬數(shù)組的原生行為的,現(xiàn)在 ES6 的 Proxy 可以模擬數(shù)組的原生行為,也可以通過 ES6 的繼承來繼承數(shù)組原生行為,從而進(jìn)行攔截。

總結(jié)

最后掏出vue官網(wǎng)上的一張圖,這張圖其實(shí)非常清晰,就是一個變化偵測的原理圖。

getterwatcher 有一條線,上面寫著收集依賴,意思是說 getter 里收集 watcher,也就是說當(dāng)數(shù)據(jù)發(fā)生 get 動作時開始收集 watcher。

setterwatcher 有一條線,寫著 Notify 意思是說在 setter 中觸發(fā)消息,也就是當(dāng)數(shù)據(jù)發(fā)生 set動作時,通知 watcher

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

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

推薦閱讀更多精彩內(nèi)容

  • 前言 Vue 最獨(dú)特的特性之一,是其非侵入性的響應(yīng)式系統(tǒng)。數(shù)據(jù)模型僅僅是普通的 JavaScript 對象。而當(dāng)你...
    浪里行舟閱讀 2,076評論 0 16
  • 這方面的文章很多,但是我感覺很多寫的比較抽象,本文會通過舉例更詳細(xì)的解釋。(此文面向的Vue新手們,如果你是個大牛...
    Ivy_2016閱讀 15,434評論 8 64
  • 摘要: 搞懂Vue響應(yīng)式原理! 作者:浪里行舟 原文:深入淺出Vue響應(yīng)式原理 Fundebug經(jīng)授權(quán)轉(zhuǎn)載,版權(quán)歸...
    Fundebug閱讀 5,498評論 0 9
  • vue 簡介 漸進(jìn)式框架:就是把框架分層。最核心的是視圖層渲染,然后往外是組件機(jī)制,在這個基礎(chǔ)上加入路由機(jī)制,再加...
    zx_lau閱讀 730評論 0 3
  • 作者簡介:凱特·湯姆森,1956年生于英國,愛爾蘭傳統(tǒng)音樂表演文學(xué)碩士,非凡的故事講述者,她是唯一一個四度獲得愛爾...
    鳳哥很忙閱讀 830評論 0 3