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

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

vue之所以能數據驅動視圖發(fā)生變更的關鍵荣恐,就是依賴它的響應式系統了。響應式系統如果根據數據類型區(qū)分,對象和數組它們的實現會有所不同;解釋響應式原理响逢,如果只是為了說明響應式原理而說,但不是從整體流程出發(fā)棕孙,不在vue組件化的整體流程中找到響應式原理的位置舔亭,對深刻理解響應式原理并不太好。接下來筆者會從整體流程出發(fā)蟀俊,試著站在巨人的肩膀上分別說明對象和數組的實現原理钦铺。

對象的響應式原理

對象響應式數據的創(chuàng)建

  • 在組件的初始化階段,將對傳入的狀態(tài)進行初始化肢预,以下以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()后的執(zhí)行vm._init()初始化過程中壕探,當執(zhí)行到initState(vm)時就會對內部使用到的一些狀態(tài)冈钦,如propsdata李请、computed瞧筛、watchmethods分別進行初始化导盅,再對data進行初始化的最后有這么一句:

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

這個observe就是將用戶定義的data變成響應式的數據较幌,接下來看下它的創(chuàng)建過程:

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

簡單理解這個observe方法就是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])  // 只傳入了兩個參數
    }
  }
}

當執(zhí)行new Observer時乍炉,首先將傳入的對象掛載到當前this下绢片,然后遍歷當前對象的每一項,執(zhí)行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) {
      ... 派發(fā)更新
    }
  })
}

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

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

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

這個時候getset只是定義了,并不會觸發(fā)糟趾。什么是依賴我們接下來說明软瞎,首先還是用一張圖幫大家理清響應式數據的創(chuàng)建過程:

image

依賴收集

什么是依賴了?我們看下之前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這個類實例化的蜻展,只是參數不同,具體實現我們之后章節(jié)說明邀摆,這里大家只用知道這個是用戶watcher即可纵顾。

計算(computed) watcher

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

渲染(render) watcher

  • 只是用做視圖渲染而定義的Watcher實例栋盹,再組件執(zhí)行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()  // 實例化就會執(zhí)行this.get()方法
  }
  
  get() {
    pushTarget(this)  // 添加
    ...
    this.getter.call(this.vm, this.vm)  // 執(zhí)行vm._update(vm._render())
    ...
    popTarget()  // 移除
  }
  
  addDep(dep) {
    ...
    dep.addSub(this)  // 將當前watcher收集到dep實例中
  }
}

當執(zhí)行new Watcher的時候內部會掛載一些屬性汉额,然后執(zhí)行this.get()這個方法,首先會執(zhí)行一個全局的方法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類的靜態(tài)屬性Dep.targetnull,這是一個全局會用到的屬性收壕,保存的是當前組件對應渲染watcher的實例妓灌;targetStack內存儲的是再執(zhí)行組件化的過程中每個組件對應的渲染watcher實例集合轨蛤,使用的是一個先進后出的形式來管理數組的數據,這里可能有點不太好懂虫埂,稍等再看到最后的流程圖后自然就明白了俱萍;然后將傳入的watcher實例賦值給全局屬性Dep.target,再之后的依賴收集過程中就是收集的它告丢。

watcherget這個方法然后會執(zhí)行getter這個方法枪蘑,它是new Watcher時傳入的第二個參數,這個參數就是之前的updateComponent變量:

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

只要一執(zhí)行就會執(zhí)行當前組件實例上的vm._update(vm._render())render函數轉為VNode岖免,這個時候如果render函數內有使用到data中已經轉為了響應式的數據岳颇,就會觸發(fā)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() {  // 觸發(fā)依賴收集
      if(Dep.target) {  // 之前賦值的當前watcher實例
        dep.depend()  // 收集起來颅湘,放入到上面的dep依賴管理器內
        ...
      }
      return val
    },
    set(newVal) {
      ... 派發(fā)更新
    }
  })
}

這個時候我們知道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)  // 執(zhí)行watcher的addDep方法
    }
  }
}

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

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

image

派發(fā)更新

如果只是收集依賴写隶,那其實是沒任何意義的倔撞,將收集到的依賴在數據發(fā)生變化時通知到并引起視圖變化,這樣才有意義慕趴。如現在我們對數據重新賦值:

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

這個時候就會觸發(fā)創(chuàng)建響應式數據時的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) {  // 派發(fā)更新
      if(newVal === val) {  // 相同
        return
      }
      val = newVal  // 賦值
      observer(newVal)  // 如果新值是對象也遞歸包裝
      dep.notify()  // 通知更新
    }
  })
}

當賦值觸發(fā)set時,首先會檢測新值和舊值秩贰,不能相同霹俺;然后將新值賦值給舊值柔吼;如果新值是對象則將它變成響應式的毒费;最后讓對應屬性的依賴管理器使用dep.notify發(fā)出更新視圖的通知。我們看下它的實現:

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()  // 挨個觸發(fā)watcher的update方法
    }
  }
}

這里做的事情只有一件愈魏,將收集起來的watcher挨個遍歷觸發(fā)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更新
}

執(zhí)行update方法時將當前watcher實例傳入到定義的queueWatcher方法內觅玻,這個方法的作用是把將要執(zhí)行更新的watcher收集到一個隊列queue之內想际,保證如果同一個watcher內觸發(fā)了多次更新,只會更新一次對應的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推入到的隊列之后畸悬,之后賦值觸發(fā)的set隊列內并不會添加任何渲染watcher侧甫;當同時賦值多個屬性時也是,因為它們收集的都是同一個渲染watcher蹋宦,所以推入到隊列一次之后就不會添加了披粟。

知識點:vue還是挺聰明的,通過這兩個實例大家也看出來了冷冗,派發(fā)更新通知的粒度是組件級別守屉,至于組件內是哪個屬性賦值了,派發(fā)更新并不關心蒿辙,而且怎么高效更新這個視圖拇泛,那是之后diff比對做的事情。

隊列有了思灌,執(zhí)行nextTick(flushSchedulerQueue)再下一次tick時更新它俺叭,這里的nextTick就是我們經常使用的this.$nextTick方法的原始方法,它們作用一致泰偿,實現原理之后章節(jié)說明绪颖。看下參數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()  // 觸發(fā) beforeUpdate 鉤子
    }
    id = watcher.id
    has[id] = null
    watcher.run()  // 真正的更新方法
    ...
  }
}

原來是個函數柠横,再nextTick方法的內部會執(zhí)行第一個參數。首先會將queue這個隊列進行一次排序课兄,依據是每次new Watcher生成的id牍氛,以從小到大的順序。當前示例只是做渲染烟阐,而且隊列內只存在了一個渲染watcher搬俊,所以是不存在順序的。但是如果有定義user watchercomputed watcher加上render watcher后蜒茄,它們之間就會存在一個執(zhí)行順序的問題了唉擂。

知識點:watcher的執(zhí)行順序是先父后子,然后是從computed watcheruser watcher最后render watcher檀葛,這從它們的初始化順序就能看出玩祟。

然后就是遍歷這個隊列,因為是渲染watcher屿聋,所有是有before屬性的空扎,執(zhí)行傳入的before方法觸發(fā)beforeUpdate鉤子藏鹊。最后執(zhí)行watcher.run()方法,執(zhí)行真正的派發(fā)更新方法转锈。我們去看下run干了啥:

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

執(zhí)行run就是執(zhí)行getAndInvoke方法盘寡,因為是渲染watcher,參數cbnoop空函數撮慨「吞担看了這么多,其實...就是重新執(zhí)行一次this.get()方法砌溺,讓vm._update(vm._render())再走一遍而已菇曲。然后生成新舊VNode,最后進行diff比對以更新視圖抚吠。

最后我們來說下vue基于Object.defineProperty響應式系統的一些不足常潮。如只能監(jiān)聽到數據的變化,所以有時data中要定義一堆的初始值楷力,因為加入了響應式系統后才能被感知到喊式;還有就是常規(guī)JavaScript操作對象的方式,并不能監(jiān)聽到增加以及刪除萧朝,例如:

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

數據是被賦值了岔留,但是視圖并不會發(fā)生變更。vue為了解決這個問題检柬,提供了兩個API$set$delete献联,它們又是怎么辦到的了?原理之后章節(jié)分析何址。

最后慣例的面試問答就扯扯最近工作中遇到趣事吧里逆。對于一個數據不會變更的列表,筆者把它定義再了created鉤子內用爪,很少結對編程原押,這次例外。

created() {
  this.list = [...]
}

旁邊的妹子接過后:

妹子: 這個列表怎么data里沒有阿偎血?在哪定義的诸衔?
我:我定義在created鉤子里了。
妹子:你怎么定義在這了颇玷?
我:因為它是不會被變更的笨农,所以不需要... 算了,那你移到data里吧谒亦。
妹子:嗯!? 好羞延。 小聲說道:我還是第一次看見這么寫的渣淳。
我:...有種被嫌棄了的感覺

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

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

懟回去:

  • data中的變量都會被代理到當前this下入愧,所以我們也可以在this下掛載屬性,只要不重名即可棺蛛。而且定義在data中的變量在vue的內部會將它包裝成響應式的數據怔蚌,讓它擁有變更即可驅動視圖變化的能力。但是如果這個數據不需要驅動視圖旁赊,定義在createdmounted鉤子內也是可以的桦踊,因為不會執(zhí)行響應式的包裝方法,對性能也是一種提升终畅。

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

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

分享一個筆者自己寫的組件庫,哪天可能會用的上了 ~ ↓

你可能會用的上的一個vue功能組件庫离福,持續(xù)完善中...

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末杖狼,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子妖爷,更是在濱河造成了極大的恐慌蝶涩,老刑警劉巖,帶你破解...
    沈念sama閱讀 211,042評論 6 490
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件絮识,死亡現場離奇詭異绿聘,居然都是意外死亡,警方通過查閱死者的電腦和手機次舌,發(fā)現死者居然都...
    沈念sama閱讀 89,996評論 2 384
  • 文/潘曉璐 我一進店門斜友,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人垃它,你說我怎么就攤上這事鲜屏。” “怎么了国拇?”我有些...
    開封第一講書人閱讀 156,674評論 0 345
  • 文/不壞的土叔 我叫張陵洛史,是天一觀的道長。 經常有香客問我酱吝,道長也殖,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 56,340評論 1 283
  • 正文 為了忘掉前任,我火速辦了婚禮忆嗜,結果婚禮上己儒,老公的妹妹穿的比我還像新娘。我一直安慰自己捆毫,他們只是感情好闪湾,可當我...
    茶點故事閱讀 65,404評論 5 384
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著绩卤,像睡著了一般。 火紅的嫁衣襯著肌膚如雪何暇。 梳的紋絲不亂的頭發(fā)上凛驮,一...
    開封第一講書人閱讀 49,749評論 1 289
  • 那天黔夭,我揣著相機與錄音纠修,去河邊找鬼。 笑死扣草,一個胖子當著我的面吹牛,可吹牛的內容都是我干的辰妙。 我是一名探鬼主播,決...
    沈念sama閱讀 38,902評論 3 405
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼尔破!你這毒婦竟也來了懒构?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 37,662評論 0 266
  • 序言:老撾萬榮一對情侶失蹤絮姆,失蹤者是張志新(化名)和其女友劉穎篙悯,沒想到半個月后,有當地人在樹林里發(fā)現了一具尸體鸽照,經...
    沈念sama閱讀 44,110評論 1 303
  • 正文 獨居荒郊野嶺守林人離奇死亡归粉,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 36,451評論 2 325
  • 正文 我和宋清朗相戀三年漏峰,在試婚紗的時候發(fā)現自己被綠了浅乔。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片铝条。...
    茶點故事閱讀 38,577評論 1 340
  • 序言:一個原本活蹦亂跳的男人離奇死亡贤壁,死狀恐怖,靈堂內的尸體忽然破棺而出脾拆,到底是詐尸還是另有隱情名船,我是刑警寧澤旨怠,帶...
    沈念sama閱讀 34,258評論 4 328
  • 正文 年R本政府宣布鉴腻,位于F島的核電站,受9級特大地震影響蜓席,放射性物質發(fā)生泄漏瓮床。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 39,848評論 3 312
  • 文/蒙蒙 一踢步、第九天 我趴在偏房一處隱蔽的房頂上張望获印。 院中可真熱鬧兼丰,春花似錦唆缴、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,726評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽霎匈。三九已至铛嘱,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間饭入,已是汗流浹背肛真。 一陣腳步聲響...
    開封第一講書人閱讀 31,952評論 1 264
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留乾忱,地道東北人历极。 一個月前我還...
    沈念sama閱讀 46,271評論 2 360
  • 正文 我出身青樓蹄葱,卻偏偏與公主長得像,于是被迫代替她去往敵國和親惯悠。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 43,452評論 2 348

推薦閱讀更多精彩內容