博客 / 詳情

返回

Vue 響應式原理剖析 —— 從實例化、渲染到數據更新(上)

概況

最近對一個基於 Vue 項目的 Sentry Issue 進行治理時,發現了大量 Issue 都是 Vue 內部邏輯引起的,為了更好地去解決問題,因此也複習了一遍 Vue2 的原理。

相比起 Vue3 更清晰的項目結構和實現,Vue2 中各個部分的實現存在較多的耦合,也導致其邏輯梳理起來較為複雜。其中「響應式」的部分是最為複雜也是最重要的一環,實際項目中大部分的 Issue 也與其相關,如 Vue2 官網中所述的那樣:

“Vue 最獨特的特性之一,是其非侵入性的響應式系統。數據模型僅僅是普通的 JavaScript 對象。而當你修改它們時,視圖會進行更新。這使得狀態管理非常簡單直接,不過理解其工作原理同樣重要,這樣你可以避開一些常見的問題”。

在系統地梳理「響應式」工作原理的過程中,也參考了不少現有的文章,大部分都是圍繞“依賴收集”、“派發更新”或者“Watcher”,“Dep”這些響應式相關的概念邏輯展開講述,當然這些概念和邏輯是必不可少的要展開講述的內容,但是如果單純圍繞這些內容展開來編寫一篇文章,對於理解「響應式」在整個 Vue 中的工作過程可能會感到困惑。因此,本文會換一個角度,從 Vue 使用的過程展開説明「響應式」的工作原理,即從「實例化」、「渲染」、「數據更新」三條線講述「響應式」的工作過程,分別對應的是如何定義響應式數據、如何觸發響應式邏輯執行,以及如何觸發響應式數據更新。

在介紹了「響應式」的工作原理之後,也會基於工作原理解決一些常見的數據更新相關的問題。

從實例化到渲染

import Vue from 'vue'
import App from './App.vue'

Vue.config.productionTip = false

new Vue({
  render: h => h(App),
}).$mount('#app')

以上是一段大家應該都很熟悉的代碼,即 Vue Cli 創建的示例項目實例化 Vue 的代碼,雖然是實例化代碼,但實際上這裏做了兩件事:

  1. new Vue,即創建了一個 Vue 實例。
  2. 調用實例的 $mount 方法,即掛載 Vue 的渲染結果到 #app 這個節點上。

這裏是 Vue 中兩條重要的工作線,接下來看看在 Vue 內部這兩個操作具體做了什麼,當然會着重於「響應式」相關部分。

實例化過程

// 精簡了非 production 的邏輯
function Vue (options) {
  this._init(options)
}

initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)

export default Vue

首先是定義了 Vue 的構造函數,構造函數內會調用 _init 方法,定義構造函數後會調用 initMixinstateMixin 等方法,其中 initMixin 內會定義構造函數內的 _init 方法,因此先關注一下 initMixin

// 精簡了非 production 邏輯
export function initMixin (Vue: Class<Component>) {
  Vue.prototype._init = function (options?: Object) {
    const vm: Component = this
    vm._uid = uid++
    vm._isVue = true
    vm._self = vm
    initLifecycle(vm)
    initEvents(vm)
    initRender(vm)
    callHook(vm, 'beforeCreate')
    initInjections(vm) // resolve injections before data/props
    initState(vm)
    initProvide(vm) // resolve provide after data/props
    callHook(vm, 'created')

    if (vm.$options.el) {
      vm.$mount(vm.$options.el)
    }
  }
}

initMixin 方法內部會給 Vue 的原型擴展大量方法,其中初始的就是 _init 方法,包括生命週期、渲染函數(把模板構造成 render 函數,render 函數負責輸出虛擬節點)、data/props、調用 created hook 等,對數據進行響應式封裝的邏輯也是從這裏開始的。

export function initState (vm: Component) {
  vm._watchers = []
  const opts = vm.$options
  if (opts.props) initProps(vm, opts.props)
  if (opts.methods) initMethods(vm, opts.methods)
  if (opts.data) {
    initData(vm)
  } else {
    observe(vm._data = {}, true /* asRootData */)
  }
  if (opts.computed) initComputed(vm, opts.computed)
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch)
  }
}

initState 是負責處理 data 的核心,propsmethodscomputedwatch 這些常用的 Vue 的 options,也是在這裏進行處理,主要的處理內容包括做一些檢查,例如有名字衝突,比如比較常見的 warning:"Method xxx has already been defined as a prop.",就是在這個階段做的檢查,另外最重要的就是對數據進行響應式封裝,接下來會以最常用也是最直觀的 data 作為例子。

// 精簡了非 production 邏輯
function initData (vm: Component) {
  let data = vm.$options.data
  data = vm._data = typeof data === 'function'
    ? getData(data, vm)
    : data || {}
  const keys = Object.keys(data)
  const props = vm.$options.props
  let i = keys.length
  while (i--) {
    const key = keys[i]
    if (props && hasOwn(props, key)) {
    } else if (!isReserved(key)) {
      proxy(vm, `_data`, key)
    }
  }
  observe(data, true /* asRootData */)
}

上圖是 initData 的主體邏輯,主要的作用是對 data 的內容進行格式檢查,比如必須是一個 isPlainObject(至於這是什麼後面會詳細説明),另外就是如上面提到的,進行名字校檢防止衝突,例如如果有 datakeyprops 衝突了,就會報那個大家應該都很熟悉的 warning:"The data property xxx is already declared as a prop. Use prop default value instead.",最後就是真正的響應式邏輯 observe 方法。

到這裏,實例化的主線已經梳理出來了,可以看到 new Vue 之後 Vue 的處理步驟,以及 data 這類 options 是如何走到數據響應式處理的。

調用 $mount,掛載實例

在 Vue 的示例中,實例化之後會調用 $mount 把渲染出來的 DOM 掛載到頁面上,$mount 實際上是觸發渲染的入口。

// 精簡了非 production 邏輯
export function mountComponent (vm: Component, el: ?Element, hydrating?: boolean): Component {
  vm.$el = el
  if (!vm.$options.render) {
    vm.$options.render = createEmptyVNode
  }
  callHook(vm, 'beforeMount')

  let updateComponent
  updateComponent = () => {
    vm._update(vm._render(), hydrating)
  }
  new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted && !vm._isDestroyed) {
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)
  hydrating = false

  if (vm.$vnode == null) {
    vm._isMounted = true
    callHook(vm, 'mounted')
  }
  return vm
}

$mount 首先會調用 mountComponent 方法,這是渲染的核心主線邏輯,按順序分別做了以下的事情:

  1. 判斷是否有傳入 render 方法,render 方法是把 Vue 模板轉換成 VNode 的方法,在 Vue 內部,如果 new Vue 時有 render 會優先使用,上面 new Vue 的示例就傳入了 render 方法,也是大家比較熟悉的把 App.vue 傳入的邏輯。如果沒有傳入 render 則會把 render 賦值成創建一個空 VNode 節點的方法。
  2. 調用 beforeMount 的鈎子。
  3. 定義好 updateComponent 方法,該方法負責執行實例的渲染和更新,內部會調用 Vue 實例的 _update,而 _update 則傳入了 render 的調用結果,即計算好的 VNode。_update 方法的內最重要的就是調用了 patch,即把 VNode 轉換成真實 DOM 的方法,轉換過程跟「響應式」關聯不大,因此這裏不針對 patch 展開太多。
  4. 創建一個 Watcher 實例,傳入當前 Vue 的實例 vmupdateComponent,還有一些 options,例如 before 參數。
  5. 調用 mounted 鈎子。

在梳理了 $mount 的過程後,可以梳理出一個清晰的 Vue 實例渲染主線,調用 new Vue 實例化 Vue,然後把 dataprops 等 options 進行校檢和「響應式」封裝,接着調用 $mount 開始進行渲染,首先創建一個 Watcher 對象跟 Vue 實例關聯起來,並通過傳入 updateComponent 方法維護實例的渲染和更新,render 作為 updateComponent,負責把模板轉換成虛擬節點 VNode,後面的 patch 方法則把 VNode 轉換成真實 DOM,最後掛載到頁面上。而在這個過程中,實例化時定義好響應式數據,渲染時調用響應式數據的更新邏輯,最終實現整個更新邏輯。

訂閲者模式

在上面的整個更新邏輯中,核心的「響應式」邏輯,應用了訂閲者模式這種設計模式,在説明 Vue 具體是如何基於訂閲者模式實現「響應式」之前,先來介紹一下訂閲者模式。

什麼是訂閲者模式?

“一個目標對象管理所有相依於它的觀察者對象,並且在它本身的狀態改變時主動發出通知”。這是對訂閲者的簡單描述,在 JavaScript 中,訂閲者模式是最常用的模式之一,例如經常用到的 DOM 事件監聽也是一種訂閲者模式,比如:

document.body.addEventListener('click', () => {
    console.log('clicked1');
});
document.body.addEventListener('click', () => {
    console.log('clicked2');
});

body 作為觀察目標,訂閲了 click 事件,當 body 被點擊時就會向訂閲者發出通知,訂閲者依次輸出 clicked1 和 clicked2,完成了一個訂閲 - 通知 - 響應的過程。

訂閲者模式的基礎實現

根據上面的例子可以總結出訂閲者模式的基礎特徵:

  1. 一個觀察目標對象通常會有觀察者管理類,包括了添加、刪除、通知觀察者更新三個主要操作。
  2. 一個或多個觀察者,接收觀察目標的通知並作出處理。

觀察者模式示例

也就是説,觀察目標類觀察者管理類觀察者是訂閲者模式中的三個基本要素。基於以上特徵,這裏實現了一個簡單的訂閲者模式示例,其中觀察者集合類 ObserverList 作為一個工具類用於管理觀察者,觀察者目標類 Subject 調用 ObserverList 進行實際的觀察者(Observer)管理,以及在需要時發送更新通知給觀察者,示例中的更新通知是更新隨機數,觀察者接受通知把最新的隨機數輸出。

到這裏,Vue 實例化和渲染的基本邏輯已經梳理出來,下一篇文章會詳細説明 Vue「響應式」的具體實現。

user avatar lantianhaijiao 頭像 kevin_5d8582b6a85cd 頭像 nxmin 頭像
3 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.