首頁 > 軟體

圖解Vue 響應式流程及原理

2022-07-08 18:01:25

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

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

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

這篇是進階的 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
    • popTargettargetStack最後一個元素彈出(.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改為namecomputed 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 響應式的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com