圖解Vue 響應式流程及原理

閱讀本文能夠幫助你什麼?

  • 在學習vue源碼的時候發現組件化過程很繞?
  • 在響應式過程中ObserverDepWatcher三大對象傻傻分不清?
  • 搞不清楚對象、數組依賴收集、派發更新的流程?depwatcher互調造成混亂?
  • 學瞭一遍好像懂瞭又好像不全懂的感覺?而且缺乏大體流程概念?
  • 或者像我一樣,有段時間沒看vue源碼好像有點遺忘?但是想快速回顧卻無從下手?

本文主要分為1. 組件化;2. 響應式原理;3. 彩蛋(computed和watch)進行講解。本文調試源碼的vue版本是v2.6.14。整篇將采用源碼講解 + 流程圖的方式詳細還原整個Vue響應式原理的全過程。你可以瞭解到Dep.targetpushTargetpopTarget;響應式中的三大Watcher;DepWathcer多對多的,互相收集的關系。

這篇是進階的 Vue 響應式源碼解析,文章比較長,內容比較深,大傢可以先mark後看。看不懂的不要強行看,可以先看看其他作者的偏簡單一點的源碼解析文章,然後好好消化。等過段時間再回來看這篇,相信你由淺入深後再看本文,一定會有意想不到的收獲~

一、組件化流程

在講解整個響應式原理之前,先介紹一下Vue中另一個比較核心的概念——組件化,個人認為這也是學習響應式的前置核心。搞懂組件化,響應式學習如虎添翼!

1. 整個new Vue階段做瞭什麼?

  • 執行init操作。包括且不限制initLifecycleinitState
  • 執行mount。進行元素掛載
  • compiler步驟在runtime-only版本中沒有。
    • compiler步驟對template屬性進行編譯,生成render函數。
    • 一般在項目中是在.vue文件開發,通過vue-loader處理生成render函數。

執行render。生成vnode

<div id="app">{{ message }}</div>
render (h) {
  return h('div', {
     attrs: {
        id: 'app'
      },
  }, this.message)
}
  • render例子,如下
  • 對應手寫的render函數
  • patch。新舊vnode經過diff後,渲染到真實dom上

2. 普通dom元素如何渲染到頁面?

  • 執行$mount
    • 實際執行mountComponent
    • 這裡會實例化一個Watcher
    • Watcher中會執行get方法,觸發updateComponent
  • 執行updateComponent。執行vm._update(vm._render(), hydrating)
  • 執行vm.render()
    • render其實調用createElment(h函數)
    • 根據tag的不同,生成組件、原生VNode並返回
  • 執行vm.update()createElm() 到 createChildren() 遞歸調用
  • 將VNode轉化為真實的dom,並且最終渲染到頁面

3. 組件如何渲染到頁面?

這裡以如下代碼案例講解更加清晰~沒錯,就是這麼熟悉!就是一個初始化的Vue項目

// mian.js
import Vue from 'vue'
import App from './App.vue'
new Vue({
  render: h => h(App),
}).$mount('#app')
// App.vue
<template>
    <div id="app">
      <p>{{ msg }}</p>
    </div>
</template>
<script>
    export default {
        name: 'App',
        data () {
            return {
                msg: 'hello world'
            }
        }
    }
</script>

主要講解組件跟普通元素的不同之處,主要有2點:

如何生成VNode——創建組件VNodecreateComponent

如何patch——組件new Vue到patch流程createComponent

$vnode:占位符vnode。最終渲染vnode掛載的地方。所有的組件通過遞歸調用createComponent直至不再存在組件VNode,最終都會轉化成普通的dom。

{
    tag: 'vue-component-1-App',
    componentInstance: {組件實例},
    componentOptions: {Ctor, ..., }
}

_vnode:渲染vnode。

{
    tag: 'div',
    {
        "attrs": {
            "id": "app"
        }
    },
    // 對應占位符vnode: $vnode
    parent: {
        tag: 'vue-component-1-App',
        componentInstance: {組件實例},
        componentOptions: {Ctor, ..., }
    },
    children: [
        // 對應p標簽
        { 
            tag: 'p',
            // 對應p標簽內的文本節點{{ msg }}
            children: [{ text: 'hello world' }]
        }, {
          // 如果還有組件VNode其實也是一樣的
          tag: 'vue-component-2-xxx'
        }              
    ]
}

(註意:這一步對應上圖render流程的紫色塊的展開!!!)

區分普通元素VNode

  • 普通VNode:tag是html的保留標簽,如tag: 'div'
  • 組件VNode:tag是以vue-component開頭,如tag: 'vue-component-1-App'

(註意:這一步對應上圖patch流程的紫色塊的展開!!!)

4. Vue組件化簡化流程

相信你看完細粒度的Vue組件化過程可能已經暈頭轉向瞭,這裡會用一個簡化版的流程圖進行回顧,加深理解

二、響應式流程

案例代碼

// 案例
export default {
    name: 'App',
    data () {
        return {
            msg: 'hello world',
            arr = [1, 2, 3]
        }
    }
}

1. 依賴收集

這裡會從Observer、Dep、Watcher三個對象進行講解,分 objectarray 兩種依賴收集方式。

  • 一定要註意!數組 的依賴收集 跟 對象的屬性 是不一樣的。對象屬性經過深度遍歷後,最終就是以一個基本類型的數據為單位收集依賴,但是數組仍然是一個引用類型。
  • 如果這裡不懂,先想一個問題: 我們用 this.msg = 'xxx' 能觸發 setter 派發更新,但是我們修改數組並不是用 this.arr = xxx ,而是用 this.arr.push(xxx) 等修改數組的方法。很顯然,這時候並不是通過觸發 arr 的 setter 去派發更新的。那是怎麼做的呢?先帶著這個問題繼續往下看吧!

三個核心對象:Observer(藍)、Dep(綠)、Watcher(紫)

依賴收集準備階段——Observer、Dep的實例化

// 以下是initData調用的方法講解,排列遵循調用順序
function observe (value, asRootData) {
  if (!isObject(value)) return // 非對象則不處理
  // 實例化Observer對象
  var ob;
  ob = new Observer(value);
  return ob
}
function Observer (value) {
  this.value = value; // 保存當前的data
  this.dep = new Dep(); // 實例化dep,數組進行依賴收集的dep(對應案例中的arr)
  def(value, '__ob__', this);    
  if (Array.isArray(value)) {
    if (hasProto) {
      // 這裡會改寫數組原型。__proto__指向重寫數組方法的對象
      protoAugment(value, arrayMethods); 
    } else {
      copyAugment(value, arrayMethods, arrayKeys);
    }
    this.observeArray(value);
  } else {
    this.walk(value); 
  }
}
// 遍歷數組元素,執行對每一項調用observe,也就是說數組中有對象會轉成響應式對象
Observer.prototype.observeArray = function observeArray (items) {
  for (var i = 0, l = items.length; i < l; i++) {
    observe(items[i]);
  }
}
// 遍歷對象的全部屬性,調用defineReactive
Observer.prototype.walk = function walk (obj) {
  var keys = Object.keys(obj);
  // 如案例代碼,這裡的 keys = ['msg', 'arr']
  for (var i = 0; i < keys.length; i++) {        
    defineReactive(obj, keys[i]);
  }
}
function defineReactive (obj, key, val) {
  // 產生一個閉包dep
  var dep = new Dep();
  // 如果val是object類型,遞歸調用observe,案例代碼中的arr會走這個邏輯
  var childOb = !shallow && observe(val);
  Object.defineProperty(obj, key, {    
    get: function reactiveGetter () { 
      // 求value的值
      var value = getter ? getter.call(obj) : val;
      if (Dep.target) { // Dep.target就是當前的Watcher
        // 這裡是閉包dep
        dep.depend();
        if (childOb) {
          // 案例代碼中arr會走到這個邏輯
          childOb.dep.depend(); // 這裡是Observer裡的dep,數組arr在此依賴收集
          if (Array.isArray(value)) {
            dependArray(value);
          }
        }
      }
      return value
    },
    set: function reactiveSetter (newVal) {
      // 下文派發更新裡進行講解
    }
  });
}

註意 對象 、 數組 的不同處理方式。這裡以 核心代碼 + 圖 進行講解

接下來核心分析 defineReactive 做瞭什麼。註意 childOb ,這是數組進行依賴收集的地方(也就是為什麼我們 this.arr.push(4) 能找到 Watcher 進行派發更新)

依賴收集觸發階段——Wather實例化、訪問數據、觸發依賴收集

// new Wathcer核心
function Watcher (vm, expOrFn, cb, options, isRenderWatcher) {
  if (typeof expOrFn === 'function') {
  // 渲染watcher中,這裡傳入的expOrFn是updateComponent = vm.update(vm.render())
  // this.getter等價於vm.update(vm.render())
    this.getter = expOrFn; 
  } else {
    ...
  }
  // 這裡進行判斷,lazy為true時(計算屬性)則什麼都不執行,否則執行get
  this.value = this.lazy
    ? undefined
    : this.get(); // 本次為渲染Watcher,執行get,繼續往下看~
}
// Watcher的get方法
Watcher.prototype.get = function get () {
  // 這裡很關鍵,pushTarget就是把當前的Wather賦值給“Dep.target”
  pushTarget(this);
  var value;
  var vm = this.vm;
  try {
    // 1. 這裡調用getter,也就是執行vm.update(vm.render())
    // 2. 執行vm.render函數就會訪問到響應式數據,觸發get進行依賴收集
    // 3. 此時的Dep.target為當前的渲染Watcher,數據就可以理所應當的把Watcher加入自己的subs中
    // 4. 所以此時,Watcher就能監測到數據變化,實現響應式
    value = this.getter.call(vm, vm);
  } catch (e) {
    ...
  } finally {
    popTarget();
    /*
    * cleanupDeps是個優化操作,會移除Watcher對本次render沒被使用的數據的觀測
    * 效果:處於v-if為false中的響應式數據改變不會觸發Watcher的update
    * 感興趣的可以自己去debugger調試,這裡就不展開瞭
    */
    this.cleanupDeps(); 
  }
  return value
}

Dep.target相關講解

  • targetStack:棧結構,用來保存Watcher
  • pushTarget:往targetStackpush當前的Watcher(排在前一個Watcher的後面),並把Dep.target賦值給當前Watcher
  • popTarget:先把targetStack最後一個元素彈出(.pop),再把Dep.target賦值給最後一個Watcher(也就是還原瞭前一個Watcher)
  • 通過上述實現,vue保證瞭全局唯一的Watcher,準確賦值在Dep.target

細節太多繞暈瞭?來個整體流程,從宏觀角度再過一遍(computed部分可看完彩蛋後再回來重溫一下)

2. 派發更新

派發更新區分對象屬性、數組方法進行講解

如果想要深入瞭解組件的異步更新,戳這裡,瞭解Vue組件異步更新之nextTick。本文隻針對派發更新流程,不會對異步更新DOM進行展開講解~

這裡可以先想一下,以下操作會發生什麼?

this.msg = 'new val'

this.arr.push(4)

是的,毫無疑問都會先觸發他們之中的get,那再觸發什麼呢?我們接下來看

對象屬性修改觸發set,派發更新。this.msg = 'new val'

...
Object.defineProperty (obj, key, {
    get () {...},
    set: function reactiveSetter (newVal) {
      var value = getter ? getter.call(obj) : val;
      // 判斷新值相比舊值是否已經改變
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      // 如果新值是引用類型,則將其轉化為響應式
      childOb = !shallow && observe(newVal);
      // 這裡通知dep的所有watcher進行更新
      dep.notify();
    }
}        
...

數組調用方法。this.arr.push(4)

// 數組方法改寫是在 Observer 方法中
function Observer () {
    if (hasProto) { 
        // 用案例講解,也就是this.arr.__proto__ = arrayMethods
        protoAugment(value, arrayMethods); 
    }
}   
// 以下是數組方法重寫的實現
var arrayProto = Array.prototype; // 保存真實數組的原型
var arrayMethods = Object.create(arrayProto); // 以真數組為原型創建對象
// 可以看成:arrayMethods.__proto__ = Array.prototype
var methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
];
// 一個裝飾器模型,重寫7個數組方法
methodsToPatch.forEach(function (method) {
  // 保存原生的數組方法
  var original = arrayProto[method];
  // 劫持arrayMethods對象中的數組方法
  def(arrayMethods, method, function mutator () {
    var args = [], len = arguments.length;
    while ( len-- ) args[ len ] = arguments[ len ];
    var result = original.apply(this, args);
    var ob = this.__ob__; // 當我門調用this.arr.push(),這裡就能到數組對象的ob實例
    var inserted;
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args;
        break
      case 'splice':
        inserted = args.slice(2);
        break
    }
    if (inserted) { ob.observeArray(inserted); }
    // 由於數組對象在new Observer中實例化瞭一個dep,並通過childOb邏輯收集瞭依賴,這裡就能在ob實例中拿到dep屬性
    ob.dep.notify();
    return result
  });
})
  • 這裡可以聯合數組的依賴收集再看一遍,你就恍然大悟瞭。為什麼 對象的屬性 、數組 的依賴收集方式不一樣

整個new Vue階段、到依賴收集、派發更新的全部流程就到這裡結束瞭。可以縱觀流程圖看出,Vue應用就是一個個Vue組件組成的,雖然整個組件化、響應式流程很多,但核心的路徑一旦走通,你就會恍然大悟。

三、彩蛋篇

1. computed依賴收集

  • 案例代碼
<template>
    <div id="app">
        {{ name }}
    </div>
</template>
<script>
export default {
    name: 'App',
    computed: {
      name () {
        return this.firstName + this.secondName
      }
    },
    data () {
        return {
            firstName: 'jing',
            secondName: 'boran'
        }
    }
}
</script>
  • 我們先看流程圖。圖有點大~大傢可以放大看看,每個核心步驟都附有文字說明

根據案例概括一下,加深理解

// 訪問computed時觸發get的核心代碼 
function createComputedGetter (key) {
  return function computedGetter () {
    var watcher = this._computedWatchers && this._computedWatchers[key];
    if (watcher) {
      if (watcher.dirty) { // dirty第一次為true
        watcher.evaluate(); // 這裡是對computed進行求值,對computed watcher執行依賴收集
      }
      if (Dep.target) {
        watcher.depend(); // 這裡是對渲染Watcher進行依賴收集
      }
      return watcher.value
    }
  }
}

computed中的name其實就是一個computed Watcher,這個Watcher在init階段生成

當App組件render的階段,render函數會訪問到模版中的{{ name }},則會觸發computed的求值,也就是執行上面代碼computedGetter()。執行watcher.evaluate()。也就是執行wathcer.get。上文依賴收集的第3點:依賴收集觸發階段有對get方法進行講解,忘瞭的可以上去回顧一下執行watcher.depend()

Watcher.prototype.depend = function depend () {
  var i = this.deps.length;
  while (i--) {
    // 也就是調用Dep.depend => Watcher.addDep => dep.addSub
    this.deps[i].depend(); 
  }
}
// this.firstName和this.secondName的dep.subs
dep.subs: [name的computed watcher, App組件的渲染Watcher]

代碼中判斷watcher.dirty標志是什麼?有什麼用?

隻有computed的值發生改變(也就是其依賴的數據改變),watcher.dirty才會被設為true

隻有watcher.dirtytrue才會對computed進行 求值 或 重新求值

總結:也就是組件每次render,如果computed的值沒改變,直接返回value值(是不需要重新計算的),這也是computed的一個特點

  • 首先pushTargetDep.target從App組件的渲染Watcher改為name的computed Watcher
  • 其次執行cb:function() { return this.firstName + this.secondName }
  • 執行cb的過程中,必然會訪問到firstNamesecondName,這時候就是我們熟悉的依賴收集階段瞭。firstName、secondName都會把name這個computed watcher收集到自己的dep.subs[]
  • 最後popTarget把name的computed Watcher彈出棧,並恢復Dep.target為當前App組件的渲染Watcher
  • 遍歷computed watcher的deps。其實就是firstName、secondName實例的Dep
  • dep.depend也就是調用watcher.addDep(把Dep收集進watcher.deps中),再由watcher.appDep調用dep.addSub(把Watcher收集進dep.subs中)
  • 這樣一來,就完成瞭firstName、secondName對App組件的渲染watcher進行收集
  • 結果如下。響應式數據中會存在兩個Watcher
  • 至於為什麼響應式數據要收集2個watcher?下文computed派發更新會講解

講到這裡,我以自己的理解講解下文章開頭引言的問題:為什麼Watcher、Dep多對多且相互收集? 這可能也是大傢閱讀Vue源碼中一直存在的一個疑惑(包括我自己剛開始讀也是這樣)

對的,當然是為瞭computed中的響應式數據收集渲染Watcher啦!!!

還有!!! 還記得前文中依賴收集的第3點——依賴收集觸發階段的代碼講解中我寫瞭很多註釋的cleanupDeps嗎?

// 此時flag為true,也就是說msg2沒有渲染在頁面中
<div v-if="flag">{{ msg1 }}</div>
<div v-else>{{ msg2 }}</div>
<button @click=() => { this.msg2 = 'change' }>changeMsg2</button>
function cleanupDeps () {
  var i = this.deps.length;
  while (i--) {
    // 這裡對watcher所觀測的響應式數據的dep進行遍歷
    // 對的,這樣一來,是不是watcher中的deps就發揮作用瞭呢?
    var dep = this.deps[i];
    if (!this.newDepIds.has(dep.id)) {
      // 這裡對當前渲染中沒有訪問到的響應式數據進行依賴移除
      dep.removeSub(this); 
    }
  }
  ...
}
  • cleanupDeps的作用就是清除掉當前沒有使用到的響應式數據。怎麼清除?我們往下看
  • 首先看個案例回答個問題,代碼如下。當flag為true時,msg2並沒有渲染在頁面中,那麼此時我們點擊按鈕修改msg2的值會不會、或者應不應該觸發這個組件的重新渲染呢?
  • 答案肯定是不會、不應該。所以:cleanupDeps就是為此而存在的
  • cleanupDeps是怎麼工作的呢?接著看下面代碼
  • 到此,你是否已經懂得瞭watcher中為什麼要收集自己觀測的響應式數據對應的dep呢?

2. computed派發更新

派發相對來說比較簡單瞭~跟響應式的派發更新基本一致,繼續以案例來講解吧!

當我們修改firstName會發生什麼?this.firstName = 'change'

首先觸發firstName的set,最終會調用dep.notify()。firstName的dep.subs中有2個watcher,分別執行對應watcher的notify

Watcher.prototype.update = function update () {      
  if (this.lazy) {
    this.dirty = true; // computed會走到這裡,然後就結束瞭
  } else if (this.sync) {
    this.run();
  } else {
    queueWatcher(this); // 渲染watcher會走到這裡
  }
}

computed watcher:將dirty屬性置為true。

渲染watcher會執行派發更新流程(如本文響應式流程——2.派發更新一致)

nextTick階段執行flushSchedulerQueue,則會執行watcher.run()

watcher.run會執行watcher.get方法,也就是重新執行render、update的流程

執行render又會訪問到name的computed,從而又會執行computedGetter

此時的watcher.dirty在本步驟3已經置為true,又會執行watcher.evaluate()進行computed的求值,執行watcher.depend()……後續的流程就是派發更新的流程瞭~

3. user Watcher依賴收集

user Watcher的依賴收集相比computed會簡單一點,這裡不會贅述太多,隻說核心區別,還有watch的常用配置immediatedeepsync

user Watcher在init階段會執行一次watcher.get(),在這裡會訪問我們watch的響應式數據,從而進行依賴收集。回顧下computed,computed在這個階段什麼也沒做。

// 沒錯,又是這段熟悉的代碼
this.value = this.lazy
  ? undefined
  : this.get(); // user Watcher和渲染 Watcher都在new Watcher階段執行get()

如果userWatcher設置的immediate: true,則會在new Watcher後主動觸發一次cb的執行

Vue.prototype.$watch = function (expOrFn, cb, options) {
  ...
  var watcher = new Watcher(vm, expOrFn, cb, options);
  if (options.immediate) {
    // immediate則會執行我們傳入的callback
    try {
      cb.call(vm, watcher.value);
    } catch (error) {
    }
  }
  return function unwatchFn () {
    watcher.teardown();
  }
};

deep邏輯很簡單,大概講下:深度遍歷這個對象,訪問到該對象的所有屬性,以此來觸發所有屬性的getter。這樣,所有屬性都會把當前的user Watcher收集到自己的dep中。因此,深層的屬性值修改(觸發set派發更新能通知到user Watcher),watch自然就能監測到數據改變~感興趣的同學可以自己去看看源碼中traverse的實現。

sync。當前tick執行,以此能先於渲染Wathcer執行。不設置同步的watcher都會放到nextTick中執行。

Watcher.prototype.update = function update () {
  if (this.lazy) {
    this.dirty = true; // 計算屬性
  } else if (this.sync) {
    this.run(); // 同步的user Wathcer
  } else {
    queueWatcher(this); // 普通user Watcher和渲染Watcher
  }
}

總體來說,Vue的源碼其實是比較好上手的,整體代碼流程非常的清晰。但是想要深入某一塊邏輯,最好結合流程圖加debugger方式親自上手實踐。畢竟真正搞懂一門框架的源碼並非易事,我也是通過不斷debugger調試,一遍遍走核心流程,才能較好的學習理解vue的實現原理~

寫在最後,這篇文章也算是自己的一個知識沉淀吧,畢竟很早之前就學習過Vue的源碼瞭,但是也一直沒做筆記。現在回顧一下,發現很多都有點忘瞭,但是缺乏一個快速記憶、回顧的筆記。如果要直接硬磕源碼重新記憶,還是比較費時費力的~作為知識分享,希望可以幫助到想學習源碼,想要進階的你,大傢彼此共勉,一同進步!

以上就是圖解Vue 響應式原理的詳細內容,更多關於Vue 響應式的資料請關註WalkonNet其它相關文章!

推薦閱讀: