Vue原理解析(六):理解響應式原理-對象

上一篇 Vue 原理解析(五): 虛擬Dom到真實Dom的生成過程

vue 之所以能數據驅動視圖發生變更的關鍵就是:依賴它的響應式系統了。 響應式系統如果根據數據類型區分: 對象和數組兩者的實現會有所不同。 解釋響應式原理,需要從整體流程出發, 不在vue 組件化的整體流程中找到響應式原理的位置,對深刻理解響應式原理不太好。 接下來我們從整體流程出發, 試着站在巨人的肩膀上分別說明對象和數組的實現原理。

對象的響應式原理

對象響應式數據的創建

  • 在數組的初始化階段, 將對傳入的狀態進行初始化, 以下以data爲例, 會將傳入數據包裝爲響應式的數據。
對象示例:

main.js
new Vue({  // 根組件
  render: h => h(App)
})

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

app.vue
<template>
  <div>{{info.name}}</div>  // 只用了info.name屬性
</template>
export default {  // app組件
  data() {
    return {
      info: {
        name: 'cc',
        sex: 'man'  //  **即使是響應式數據,沒被使用就不會進行依賴收集**
      }
    }
  }
}

接下來的分析將以上面代碼爲例, 這種結構其實是一個嵌套組件,只不過根組件一般定義的參數比較少而已,理解這個很重要的。

在組件=new Vue() 後執行vm._init() 初始化過程中, 當執行到initState(vm)時就會對內部使用到的一些狀態, 如: props, data, computed, watch, methods 分別進行初始化, 再對data 進行初始化的最後有這麼一句:

function initData(vm) {  //初始化data
  ...
  observe(data) //  info:{name:'cc',sex:'man'}
}

這個observer 就是將用戶定義的data變成響應式的數據, 接下來看看它的創建過程:

export function observe(value) {
  if(!isObject(value)) {  // 不是數組或對象,再見
    return
  }
  return new Observer(value)
}

簡單理解這個observer 方法就是Observer 這個類的工廠方法, 所以還是要看下Observer 這個類的定義:

export class Observer {
  constructor(value) {
    this.value = value
    this.walk(value)  // 遍歷value
  }
  
  walk(obj) {
    const keys = Object.keys(obj)
    for(let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])  // 只傳入了兩個參數
    }
  }
}

當執行new Observer 時, 首先將傳入的對象掛載到當前this 下, 然後遍歷當前對象的每一項, 執行defineReactive 這個方法, 看看它的定義:

export function defineReactive(obj, key, val) {

  const dep = new Dep()  // 依賴管理器
  
  val = obj[key]  // 計算出對應key的值
  observe(val)  // 遞歸包裝對象的嵌套屬性
  
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get() {
      ... 收集依賴
    },
    set(newVal) {
      ... 派發更新
    }
  })
}

這個方法的作用就是使用Object.defineProperty創建響應式數據。 首先根據傳入的objkey 計算出 val 具體的值; 如果val 還是對象, 那就使用observe 方法進行遞歸創建, 在遞歸的過程中使用Object.defindeProperty 將對象的每一個屬性都變成響應式數據:

...
data() {
  return {
    info: {
      name: 'cc',
      sex: 'man'
    } 
  }
}
這段代碼就會有三個響應式數據:
  info, info.name, info.sex

知識點: Object.defineProperty內的get 方法, 它的作用就是誰訪問到當前key 的值就用 defineReactive 內的dep 將它收集起來, 也就是依賴收集的意思。 set 方法的作用就是當前key 的值被賦值了, 就通知dep內收集到的依賴項, key的值發生了變更, 視圖請變更吧。

這個時候getset 只是定義了, 並不會觸發。 什麼是依賴,我們接下來說明,首先看看下圖幫大家理清響應式數據的創建過程(這裏先摘用網上的一張圖):

在這裏插入圖片描述

依賴收集

什麼是依賴? 看下之前mountComponent的定義:

function mountComponent(vm, el) {
  ...
  const updateComponent = function() {
    vm._update(vm._render())
  }
  
  new Watcher(vm, updateComponent, noop, {  // 渲染watcher
    ...
  }, true)  // true爲標誌,表示是否是渲染watcher
  ...
}

首先我們說明下這個Watcher 類, 它類似與之前的VNode 類, 根據傳入的參數不同, 可以分別實例化出三種不同的Watcher 實例, 它們分別是用戶watcher, 計算watcher 以及渲染watcher:

用戶 (user) watcher

  • 也就是用戶自己定義的, 如:
new Vue({
  data {
    msg: 'hello Vue!'
  }
  created() {
    this.$watch('msg', cb())  // 定義用戶watcher
  },
  watch: {
    msg() {...}  // 定義用戶watcher
  }
})

這裏的兩種方式內部都是使用Watcher 這個類實例化的, 只是參數不同, 具體實現我們之後章節說明, 這裏大家只是知道這個是用戶watcher即可。

計算 (computed) watcher

  • 顧名思義, 這個是當定義計算屬性實例化出來的一種:
new Vue({
  data: {
    msg: 'hello'  
  },
  computed() {
    sayHi() {  // 計算watcher
      return this.msg + 'vue!'
    }
  }
})

渲染屬性 (render) watcher

  • 只是用做視圖渲染而定義的Watcher 實例, 再組件實行vm.$mount 的最後會實例化Watcher 類, 這個時候就是以渲染watcher 的格式定義的, 收集的就是當前渲染watcher 的實例, 我們來看下它內部如何定義的:
class Watcher {
  constructor(vm, expOrFn, cb, options, isRenderWatcher) {
    this.vm = vm
    if(isRenderWatcher) {  // 是否是渲染watcher
      vm._watcher = this  // 當前組件下掛載vm._watcher屬性
    }
    vm._watchers.push(this)  //vm._watchers是之前初始化initState時定義的[]
    this.before = options.before  // 渲染watcher特有屬性
    this.getter = expOrFn  // 第二個參數
    this.get()  // 實例化就會執行this.get()方法
  }
  
  get() {
    pushTarget(this)  // 添加
    ...
    this.getter.call(this.vm, this.vm)  // 執行vm._update(vm._render())
    ...
    popTarget()  // 移除
  }
  
  addDep(dep) {
    ...
    dep.addSub(this)  // 將當前watcher收集到dep實例中
  }
}

當執行new Watcher 的時候內部會掛載一些屬性, 然後執行this.get()這個方法, 首先會執行一個全局的方法pushTarget(this) , 傳入當前watcher 的實例, 我們看下這個方法定義的地方:

Dep.target = null
const targetStack = []  // 組件從父到子對應的watcher實例集合

export function pushTarget (_target) {  // 添加
  if (Dep.target) {
    targetStack.push(Dep.target)  // 添加到集合內
  }
  Dep.target = _target  // 當前的watcher實例
}

export function popTarget() {  // 移除
  targetStack.pop()  // 移除數組最後一項
  Dep.target = targetStack[targetStack.length - 1]  // 賦值爲數組最後一項
}

首先會定義一個Dep 類的靜態屬性Dep.targetnull, 這是一個全局會用到的屬性, 保存的是當前組件對應渲染watcher 的實例; targetStack 內存儲的是再執行組件化的過程中每個組件對應的渲染watcher實例集合, 使用的是一個先進後出的形式來管理數組的數據, 這裏可能有點不太好懂, 稍等再看到最後的流程圖後自然就明白了;然後傳入的watcher實例賦值給全局屬性Dep.target , 再之後的依賴收集過程中就是收集的它。

watcherget 這個方法然後會執行getter 這個方法, 它是new Watcher 時傳入的第二個參數, 這個參數就是之前的updateComponent 變量:

function mountComponent(vm, el) {
  ...
  const updateComponent = function() {  //第二個參數
    vm._update(vm._render())
  }
  ...
}

只要一執行就會執行當前組件實例上的vm._update(vm.render())render 函數轉爲VNode, 這個時候如果render 函數內有使用到data 中已經轉爲了響應式的數據,就會觸發get方法進行依賴收集, 補全之前依賴收集的邏輯:

export function defineReactive(obj, key, val) {
  const dep = new Dep()  // 依賴管理器
  
  val = obj[key]  // 計算出對應key的值
  observe(val)  // 遞歸的轉化對象的嵌套屬性
  
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get() {  // 觸發依賴收集
      if(Dep.target) {  // 之前賦值的當前watcher實例
        dep.depend()  // 收集起來,放入到上面的dep依賴管理器內
        ...
      }
      return val
    },
    set(newVal) {
      ... 派發更新
    }
  })
}

這個時候我們知道watcher 是個什麼東西了, 簡單理解就是數據和組件之間一個通信工具的封裝, 當某個數據被組件讀取時, 就將依賴數據的組件使用Dep 這個類給收集起來。

當前例子data 內的屬性是隻有一個渲染watcher 的, 因爲沒有被其它組件所使用。 但如果該屬性被其它組件使用到,又會將使用它的組件收集起來。 例如作爲了props傳遞給子組件, 再dep的數組內就會存在多個渲染watcher。 我們來看下Dep類這個依賴管理器的定義:

let uid = 0
export default class Dep {
  constructor() {
    this.id = uid++
    this.subs = []  // 對象某個key的依賴集合
  }
  
  addSub(sub) {  // 添加watcher實例到數組內
    this.subs.push(sub)
  }
  
  depend() {
    if(Dep.target) {  // 已經被賦值爲了watcher的實例
      Dep.target.addDep(this)  // 執行watcher的addDep方法
    }
  }
}

----------------------------------------------------------
class Watcher{
  ...
  addDep(dep) {  // 將當前watcher實例添加到dep內
    ...
    dep.addSub(this)  // 執行dep的addSub方法
  }
}

這個Dep 類的作用就是管理屬性對應watcher, 如添加/刪除/通知。 至此, 依賴收集的過程就算是完成了, 還是以一張圖片加深對過程的理解:
在這裏插入圖片描述

派發更新

如果只是收集依賴, 那其實是沒有任何意義的, 將收集到的依賴在數據發生變化時通知並引起視圖變化, 這樣纔有意義。 現在我們對數據重新賦值:

app.vue
export default {  // app組件
  ...
  methods: {
    changeInfo() {
      this.info.name = 'ww';
    }
  }
}

這個時候就會觸發創建響應式數據時的set方法了, 我們再補全那裏的邏輯:

export function defineReactive(obj, key, val) {
  const dep = new Dep()  // 依賴管理器
  
  val = obj[key]  // 計算出對應key的值
  observe(val)  // 遞歸轉化對象的嵌套屬性
  
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get() {
      ... 依賴收集
    },
    set(newVal) {  // 派發更新
      if(newVal === val) {  // 相同
        return
      }
      val = newVal  // 賦值
      observer(newVal)  // 如果新值是對象也遞歸包裝
      dep.notify()  // 通知更新
    }
  })
}

當賦值觸發set 時, 首先會檢測新值和舊值, 不能相同; 然後將新值賦值給舊值; 如果新值是對象則將它變成響應式的; 最後讓對應屬性的依賴管理器使用dep.notify發出更新視圖的通知。來看下它的實現:

let uid = 0
class Dep{
  constructor() {
    this.id = uid++
    this.subs = []
  }
  
  notify() {  // 通知
    const subs = this.subs.slice()
    for(let i = 0, i < subs.length; i++) {
      subs[i].update()  // 挨個觸發watcher的update方法
    }
  }
}

這裏做的事情只有一件, 將收集起來的watcher 挨個遍歷觸發update方法:

class Watcher{
  ...
  update() {
    queueWatcher(this)
  }
}

---------------------------------------------------------
const queue = []
let has = {}

function queueWatcher(watcher) {
  const id = watcher.id
  if(has[id] == null) {  // 如果某個watcher沒有被推入隊列
    ...
    has[id] = true  // 已經推入
    queue.push(watcher)  // 推入到隊列
  }
  ...
  nextTick(flushSchedulerQueue)  // 下一個tick更新
}

執行update 方法時將當前watcher 實例傳入到定義的queueWatcher 方法內, 這個方法的作用是把將要執行更新的watcher收集到一個隊列queue之內,保證如果同一個watcher 內觸發了多次更新, 只會更新一次對應的watcher ,我們舉兩個小實例:

export default {
  data() {
    return {  // 都被模板引用了
      num: 0,
      name: 'cc',
      sex: 'man'
    }
  },
  methods: {
    changeNum() {  // 賦值100次
      for(let i = 0; i < 100; i++) {
        this.num++
      }
    },
    changeInfo() {  // 一次賦值多個屬性的值
      this.name = 'ww'
      this.sex = 'woman'
    }
  }
}

這裏的三個響應式屬性他們收集都是同一個渲染watcher。 所以當賦值100次的情況出現時, 再將當前的渲染watcher 推入到的隊列之後, 之後賦值觸發的set隊列內並不會添加任何渲染watcher; 當同時賦值多個屬性時也是, 因爲他們收集的都是同一個渲染watcher , 所以推入到隊列一次之後就不會添加了。

知識點: vue 還是很聰明的, 通過上面實例大家應該看出來, 派發更新通知的粒度是組件級別, 至於組件內是哪個屬性賦值了,派發更新並不關心, 而且怎麼高效更新這個視圖, 那是之後diff對比做的事情。

隊列有了, 執行nextTick(flushSchedulerQueue) 再下一次tick時更新它, 這裏的nextTick 就是我們經常使用的 this.$nextTick 方法的原始方法, 它們作用一致,實現原理之後章節說明。 看下參數flushSchedulerQueue是啥?

let index = 0

function flushSchedulerQueue() {
  let watcher, id
  queue.sort((a, b) => a.id - b.id)  // watcher 排序
  
  for(index = 0; index < queue.length; index++) {  // 遍歷隊列
    watcher = queue[index]  
    if(watcher.before) {  // 渲染watcher獨有屬性
      watcher.before()  // 觸發 beforeUpdate 鉤子
    }
    id = watcher.id
    has[id] = null
    watcher.run()  // 真正的更新方法
    ...
  }
}

原來是個函數, 再nextTick方法的內部會執行第一個參數。 首先會將queue這個隊列進行一次排序,依次是每次new Watcher 生成的 id, 以從小到大的順序。 當前示例只是做渲染, 而且隊列內只存在了一個渲染watcher, 所以是不存在順序的。 但是如果有定義user watchercomputed watcher 加上 render watcher 後 , 它們之間就會存在一個執行順序的問題了。

知識點: watcher 的執行順序是先父後子, 然後是從computed watcheruser watcher 最後 render watcher , 這從它們的初始化順序就能看出來。

然後就是遍歷這個隊列, 因爲是渲染watcher, 所有是有before 屬性的, 執行傳入的before方法觸發beforeUpdate 鉤子。 最後執行watcher.run()方法, 執行真正的派發更新方法。 我們看下run幹了啥:

class Watcher {
  ...
  run () {  
    if (this.active) {
      this.getAndInvoke(this.cb) // 有一種要抓狂的感覺
    }
  }
  
  getAndInvoke(cb) {  // 渲染watcher的cb爲noop空函數
    const value = this.get()
    
    ... 後面是用戶watcher邏輯
  }
}

執行run 就是執行getAndInvoke方法, 因爲是渲染watcher, 參數cbnoop空函數。 看了這麼多, 其實… 就是重新執行一次 this.get()方法, 讓 vm._update(vm._render())在走一遍而已。 然後生成新舊VNode , 最後進行diff比對以更新視圖。

最後說下vue 基於Object.defineProperty響應式系統的一些不足。 比如:只能監聽到數據的變化, 所以有時data中要是定義一堆的初始值, 因爲加入了響應式系統後才能被感知到; 還有就是常規JavaScript操作對象的方式, 並不能監聽到增加以及刪除。如:

export default {
  data() {
    return {
      info: {
        name: 'cc'
      }
    }
  },
  methods: {
    addInfo() {  // 增加屬性
      this.info.sex = 'man'
    },
    delInfo() {  // 刪除屬性
      delete info.name
    }
  }
}

數據是被賦值了, 但是視圖並不會發生變更。 vue爲了解決這個問題,提供了兩個API: $set 和 $delete, 它們又是怎麼辦到的? 原理我們之後章節分享。

最後我們以一個問題結束本章內容:

  • 當前組件模板中用到的變量一定要定義在data裏麼?

解答:

  • data 中的變量都會被代理到this下, 所以我們也可以在this下掛載屬性, 只要不重名即可。 而且定義在data中的變量在vue的內部會將它包裝成響應式的數據, 讓它擁有變更即可驅動視圖變化的能力。 但是如果這個數據不需要驅動視圖, 定義在created 或者 mounted 鉤子內也是可以的, 因爲不會執行響應式的包裝方法,對性能也是一種提升。

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

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