深刻了解 vue 響應式原理

深刻了解 vue 響應式原理

1 概述

vue 的響應式原理主要基於:數據劫持、依賴收集和異步更新,經過對象劫持來作 依賴的收集 和 數據變化的偵測,經過維持一個隊列來異步更新視圖。javascript

1.2 什麼是對象劫持?

在 JavaScript 中,對象做爲一種 key/value 鍵值對的形式存在,而對對象的基本會有 增、刪、改、查 的基本操做:html

const dog = {
  name: 'dog',
  single: false,
  girlFriend: 'charm',
  house: 'villa'
}
delete dog.house
dog.single = true
dog.character = 'easy'
delete dog.girlFriend
console.log(dog.name)
複製代碼

而咱們說的對象劫持,就是但願劫持這些對對象的操做方法,那麼先來看看vue

1.3 怎麼劫持?

主要是經過 Object.defineProperty 方法來實現。舉個小例子:「若是一個窮小子,有房子就有女友,房子沒了,那麼女友也就沒了」java

const dog = {
  name: 'dog',
  single: true,
  girlFriend: null
}
_house = null
Object.defineProperty(dog, 'house', {
  configurable: true,
  get: () => _house,
  set: (house) => {
    if (house) {
      _house = house
      dog.girlFriend = 'charm'
      dog.single = false
    } else {
      _house = ''
      dog.girlFriend = null
      dog.single = true
    }
  }
})
dog.house = 'villa'
// {
// name: 'dog',
// single: false,
// girlFriend: 'charm'
// }
dog.house = null
// {
// name: 'dog',
// single: true,
// girlFriend: null
// }
複製代碼

1.4 爲何要劫持?

從上面數據劫持的例子能夠看出,經過數據劫持,能夠經過攔截某個屬性的修改操做,進而去處理這個改變應該觸發的其餘值、狀態的更新。這個就很是適合處理:數據驅動視圖更新。react

2 深刻

2.1 Object.defineProperty 的侷限

2.1.1 不能檢測新增、刪除屬性操做

Object.defineProperty 沒法作到新增屬性的攔截:數組

const dog = {}
dog.name = 'dog'
複製代碼

vue 官網中也提到:app

Vue 沒法檢測 property 的添加或移除。因爲 Vue 會在初始化實例時對 property 執行 getter/setter 轉化,因此 property 必須在 data 對象上存在才能讓 Vue 將它轉換爲響應式的。例如:dom

var vm = new Vue({
    data: () => ({
      a: 1
    })
})
// `vm.a` 是響應式的
vm.b = 2
// `vm.b` 是非響應式的
複製代碼

由於沒法劫持到 b 這個新增屬性,因此即便視圖中已經引用了 b ,視圖也不會進行響應式的修改。 Vue 組件實例中提供了 Vue.set 方法來解決對象新增屬性的問題。異步

Object.defineProperty 沒法感知到已有屬性的刪除:async

const dog = {}
Object.defineProperty(dog, 'name', {
  configurable: true,
  get () { return 'dog' },
  set (value) { console.log(value) }
})
console.log(dog.name) // 'dog'
delete dog.name
console.log(dog.name) // undefined
複製代碼

defineProperty 的 set 描述符並不能劫持到 delete 操做。因此在 vue 中,會專門提供一個 Vue.delete 方法來刪除一個屬性。

2.1.2 不能檢測數組

const dogs = []
Object.defineProperty(dogs, 0, {
  configurable: true,
  get: () => 'easy',
  set: console.log
})
Object.defineProperty(dogs, 1, {
  configurable: true,
  get: () => 'poor',
  set: console.log
})
dogs.length // 2
dogs[0] // 'easy'
dogs[1] // 'poor'
複製代碼

看起來經過 Object.defineProperty 配置的數組元素表現正常,那麼試一試操做數組的方法:

dogs.push('newdog') // ['easy', 'poor', 'newdogs']
dogs.unshift('newdog2')
// easy
// newdog2
// 4
// ['easy', 'poor', 'poor', 'newdogs']
複製代碼

從這個打印輸出來看 push 方法沒有問題,可是 unshift 方法卻不符合預期;unshift 方法的內部應該是首先將第一個元素賦值給第二,第二個賦值給第三個,以此類推,而後將 newdog2 複製給第一個元素。不過這從原理上說得通的,畢竟咱們只是對 index 爲 0 和 1 的屬性作攔截。

2.1.3. 注意使用 Object.assign

MDN 官網上提到 Object.assign 方法在執行的時候,僅僅調用屬性的 getter、setter 方法,因此在執行過程當中,屬性描述符會丟失:

const dog = {}
Object.defineProperty(dog, 'name', {
  configurable: true,
  enumerable: true,
  get () {
    return 'dog'
  },
  set (value) {
    console.log(value)
  }
})
const dogBackup = Object.assign({}, dog) // { name: 'dog' }
dogBackup.name = 'dogBackup' // { name: 'dogBackup' }
複製代碼

2.2 升級方案 Proxy

Proxy 用於定義基本操做的自定義行爲(如屬性查找、賦值、枚舉、函數調用等);上述 Object.defineProperty 的侷限均可以經過 Proxy 來解決:

2.2.1 攔截對象的基本操做

const dog = {
  name: 'dog',
  single: true,
  girlFriend: null,
  house: null
}
const proxyDog = new Proxy(dog, {
  get (target, prop, receiver) {
    // 攔截查找操做
    return Reflect.get(target, prop, receiver)
  },
  set (target, prop, value, receiver) {
    // 攔截新增屬性
		if (!Reflect.has(target, prop)) {
      throw TypeError('Unknown type ' + prop)
    }
    // 攔截賦值操做
    if (prop === 'house') {
      if (value) {
        Reflect.set(target, 'girlFriend', 'charm', receiver)
        Reflect.set(target, 'single', false, receiver)
      } else {
        Reflect.set(target, 'girlFriend', null, receiver)
        Reflect.set(target, 'single', true, receiver)
      }
    }
    return Reflect.set(target, prop, value, receiver)
  },
  deleteProperty (target, prop) {
    // 攔截刪除操做
    if (prop === 'house') {
      Reflect.set(target, 'girlFriend', null)
      Reflect.set(target, 'single', true)
    }
		return Reflect.deleteProperty(target, prop)
  }
})
複製代碼

2.2.2 攔截數組原型上的方法

const dogs = []
var proxyDog = new Proxy(dogs, {
	apply (targetFun, ctx, args) {
    // 攔截方法調用
    return Reflect.apply(targetFun, ctx, args)
  }
})
proxyDog.push('easy')
複製代碼

2.3 響應式原理 - Object.defineProperty 在 Vue2 中的運用

理解 Vue2 的響應式原理,能夠從三個角度:變化偵測機制、收集依賴、異步更新 來探究:

2.3.1 變化偵測機制 - 數據劫持

在實例化 vue 組件的時候,就會對組件的 props、data 進行 defineReactive ,這個方法是對 Object.defineProperty 的封裝,主要作很核心的幾件事情:

  1. 實例化一個依賴管理類 Dep

這是很重要的一點,對象的每一個屬性都會實例化一個 Dep 類,經過這個類來收集依賴,以及通知更新

  1. 經過 Object.defineProperty 劫持 getter ,收集依賴
Object.defineProperty(obj, key, {
  enumerable: true,
  configurable: true,
  get: function reactiveGetter () {
    const value = getter ? getter.call(obj) : val
    if (Dep.target) {
      dep.depend()
      if (childOb) {
        childOb.dep.depend()
        if (Array.isArray(value)) {
          dependArray(value)
        }
      }
    }
    return value
  }
})
複製代碼

這裏問題就來了,咱們正常取值 data.name 或者 this.name ,會觸發 reactiveGetter ,可是這時候 Dep.target 確定是不存在的,只有當 Dep.target 存在的時候才進行依賴收集:dep.depend() 。那麼何時 Dep.target 才存在呢?dep.depend() 方法作了些什麼?

  1. 經過 Object.defineProperty 劫持 setter ,通知依賴更新
Object.defineProperty({
  set: function reactiveSetter (newVal) {
    const value = getter ? getter.call(obj) : val
		// 判斷是否須要更新
    if (newVal === value || (newVal !== newVal && value !== value)) {
      return
    }
    /* eslint-enable no-self-compare */
    if (process.env.NODE_ENV !== 'production' && customSetter) {
      customSetter()
    }
    // #7981: for accessor properties without setter
    if (getter && !setter) return
    if (setter) {
      setter.call(obj, newVal)
    } else {
      val = newVal
    }
    childOb = !shallow && observe(newVal)
    // 通知更新
    dep.notify()
  }
})
複製代碼

reactiveSetter 所作的事情就比較簡單,主要作了兩件事:1. 判斷值是否發生變化;2. 通知依賴更新

2.3.2 收集依賴

在收集依賴的過程當中,提出了兩個問題:

問題1:Dep.target 何時存在?

首先 target 的類型是一個 Watcher 實例,在一個 vue 組件實例化的時候,會建立一個渲染 watcher ,渲染 watcher 是一個非惰性 watcher,實例化的的時候會當即將 Dep.target 設置成本身;

而在模版編譯的時候,即 vm._render 函數執行,經過 with 的方式定義做用域爲當前的組件:

with(this){return ${code}}
複製代碼

with 語法一般在模版引擎使用,這樣在模版編譯的時候訪問變量時的做用域,都是 with 指定的做用域,這樣就能夠觸發對象屬性的 getter 方法了。

Dep.target 存在的條件能夠認爲是:須要數據來驅動更新;這在 Vue 中體如今:

  1. 視圖渲染
  2. 計算屬性
  3. $watch 方法

問題2:dep.depend 方法作了什麼事情?

在對象屬性的 getter 觸發時,調用 dep.depend() 方法:

class Dep {
	depend () {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }
}
複製代碼

2.3.3 異步更新

記得我剛開始接觸 Vue 的時候,有人問我:vue 數據驅動視圖更新是同步的仍是異步的?若是是異步,怎麼實現的?

很顯然應該是異步的,在一個事件循環中屢次執行數據更新操做,最終 dom 應該只須要渲染一次便可

<template>
	<div>index</div>
</template>
<script> export default { data () { return { index: 0 } }, mounted () { for (let i=0; i<100; i++) { this.index = i } } } </script>
複製代碼

在數據更新時會觸發 dep.notify ,這會將 watcher 放入隊列等待下一次事件循環

// observer/index.js
function defineReactive () {
  // ...
  Object.defineProperty({
    set () {
      // ...
      dep.notify()
    }
  })
}
// dep.js
class Dep {
	notify () {
    // stabilize the subscriber list first
    const subs = this.subs.slice()
    if (process.env.NODE_ENV !== 'production' && !config.async) {
      // subs aren't sorted in scheduler if not running async
      // we need to sort them now to make sure they fire in correct
      // order
      subs.sort((a, b) => a.id - b.id)
    }
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }
}
// watcher.js
class Watcher {
  // ...
  update () {
    /* istanbul ignore else */
    if (this.lazy) {
      this.dirty = true
    } else if (this.sync) {
      this.run()
    } else {
      queueWatcher(this)
    }
  }
}
複製代碼

queueWatcher 會將當前 watcher push 到更新隊列中,而後開始異步更新,以及更新後調觸發響應的生命週期事件

function queueWatcher (watcher) {
	nextTick(flushSchedulerQueue)
}
function flushSchedulerQueue () {
  for (index = 0; index < queue.length; index++) {
    watcher = queue[index]
   	watcher.run()
    // ...
		const vm = watcher.vm
    if (vm._watcher === watcher && vm._isMounted && !vm._isDestroyed) {
      callHook(vm, 'updated')
    }
  }
}
複製代碼

3 Proxy 在 Vue3 中的運用

// TODO

相關文章
相關標籤/搜索