Vue原理解析(六):全面深刻理解響應式原理(上)-對象基礎篇

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

vue之因此能數據驅動視圖發生變動的關鍵,就是依賴它的響應式系統了。響應式系統若是根據數據類型區分,對象和數組它們的實現會有所不一樣;解釋響應式原理,若是隻是爲了說明響應式原理而說,但不是從總體流程出發,不在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)時就會對內部使用到的一些狀態,如propsdatacomputedwatchmethods分別進行初始化,再對data進行初始化的最後有這麼一句:編程

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

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

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

簡單理解這個observe方法就是Observer這個類的工廠方法,因此仍是要看下Observer這個類的定義:bash

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這個方法,看下它的定義:app

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.defineProperty將對象的每個屬性都變成響應式數據:函數

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

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

這個時候getset只是定義了,並不會觸發。什麼是依賴咱們接下來講明,首先仍是用一張圖幫你們理清響應式數據的建立過程:oop

依賴收集

什麼是依賴了?咱們看下以前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,它們又是怎麼辦到的了?原理以後章節分析。

最後慣例的面試問答就扯扯最近工做中遇到趣事吧。對於一個數據不會變動的列表,筆者把它定義再了created鉤子內,不多結對編程,此次例外。

created() {
  this.list = [...]
}
複製代碼

旁邊的妹子接事後:

妹子: 這個列表怎麼data裏沒有阿?在哪定義的?
我:我定義在created鉤子裏了。
妹子:你怎麼定義在這了?
我:由於它是不會被變動的,因此不須要... 算了,那你移到data裏吧。
妹子:嗯!? 好。 小聲說道:我仍是第一次看見這麼寫的。
我:...有種被嫌棄了的感受
複製代碼

面試官微笑而又不失禮貌的問道:

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

懟回去:

  • data中的變量都會被代理到當前this下,因此咱們也能夠在this下掛載屬性,只要不重名便可。並且定義在data中的變量在vue的內部會將它包裝成響應式的數據,讓它擁有變動便可驅動視圖變化的能力。可是若是這個數據不須要驅動視圖,定義在createdmounted鉤子內也是能夠的,由於不會執行響應式的包裝方法,對性能也是一種提高。

下一篇:Vue原理解析(七):全面深刻理解響應式原理(下)-數組進階篇

順手點個贊或關注唄,找起來也方便~

參考:

Vue.js源碼全方位深刻解析

Vue.js深刻淺出

Vue.js組件精講

剖析 Vue.js 內部運行機制

相關文章
相關標籤/搜索