Categories
程式開發

Vue的數據響應式原理


1. 什麼是數據響應式

Vue.js 一個核心思想是數據驅動。所謂數據驅動,是指視圖是由數據驅動生成的,我們對視圖的修改,不會直接操作DOM,而是通過修改數據。 ——《Vue.js技術揭秘》

在沒有Vue 和React 這些具有數據響應式特性的前端框架之前,我們從服務端提供過的接口獲取到數據要渲染在html 頁面上時,抑或是需要獲取表單的值進行計算回顯到頁面上時,都需要建立很多DOM 事件監聽器,並進行許多的DOM 操作。舉個最簡單的例子:用戶在 html 頁面的表單中輸入兩個加數 a 和 b ,計算得到兩個加數之和,並展示到頁面上。這時我們需要監聽表單中 a 和 b 兩個輸入框的變化,拿到變化值相加然後通過修改指定 id 或者 class 的選擇器對應的 DOM 來修改兩數之和。但是當輸入表單不止兩個時,就會令人捉急。

而 Vue 的數據響應式大大地優化了這一整套的流程。面對大量的 UI 交互和數據更新,數據響應式讓我們做到從容不迫——我們需要去了解數據的改變是怎樣被觸發,更新觸發後的數據改變又是怎樣反饋到視圖。接下來我們通過對部分 Vue 源碼(本文主要針對v2.6.11的 Vue 源碼進行分析)的簡單分析和學習來深入了解 Vue 中的數據響應式是怎樣實現的。

我們將這一部分的代碼分析大致分為三部分:讓數據變成響應式依賴收集派發更新

2. 數據響應式的中心思想

Vue的數據響應式原理 1

該原理圖源自 Vue 官方教程的深入響應式原理,這張圖向我們展示了 Vue 的數據響應式的中心思想。

我們先來介紹圖中的四個模塊,黃色部分是 Vue 的渲染方法,視圖初始化和視圖更新時都會調用 vm._render 方法進行重新渲染。渲染時不可避免地會 touch 到每個需要展示到視圖上的數據(紫色部分),觸發這些數據的 get 方法從而收集到本次渲染的所有依賴。收集依賴和更新派發都是基於藍色部分的 Watcher 觀察者。

而當我們在修改這些收集到依賴的數據時,會觸發數據中的 set 屬性方法,該方法會修改數據的值並 notify 到依賴到它的觀察者,從而觸發視圖的重新渲染。綠色部分是渲染過程中生成的 Virtual DOM Tree,這棵樹不僅關係到視圖渲染,更是 Vue 優化視圖更新過程的基礎。

簡言之,數據響應式的中心思想,是通過重寫數據的get 和set 屬性方法,讓數據在被渲染時把所有用到自己的訂閱者存放在自己的訂閱者列表中,當數據發生變化時將該變化通知到所有訂閱了自己的訂閱者,達到重新渲染的目的。

3. 讓數據變成響應式

Vue 的數據響應式原理是由JS 標準內置對象方法Object.defineProperty 來實現的(這個方法不兼容IE8 和FF22 及以下版本瀏覽器,這也是為什麼Vue 只能在這些版本之上的瀏覽器中才能運行)。這個方法的作用是在一個對像上定義一個新屬性,或者修改一個對象的現有屬性。那麼數據響應式用這個方法為什麼對象添加或修改了什麼屬性呢?我們從 Vue 的初始化講起。

Vue的數據響應式原理 2

Vue 的初始化

function Vue (options) {
    // ...
    this._init(options)
}
initMixin(Vue)     
stateMixin(Vue)     
// ...
export default Vue

在 src/core/index.js 中我們可以看到一個 Vue 方法被導出,這個 Vue 方法是在 src/core/instance/index 中定義的,參數是 options。進行vue 實例化時主要調用了_init,它對當前傳入的options 進行了一些處理(主要是判斷當前實例化的是否為組件,使用mergeOptions 方法對options 進行加工,此處不做贅述),然後又調用了一系列方法進行了生命週期、事件、渲染器的初始化,我們主要來關注initState 這個方法(src/core/instance/state.js):

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)
    // a*
    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)
    }
}

將 Vue 實例上的 data 變成響應式數據

上面的方法中對 props、methods、data、computed 和 watch 進行了初始化,這些都是 Vue 實例化方法中傳入參數 options 對象的一些屬性,這些屬性都需要被響應式化。而針對於 data 的初始化分了兩種情況[a*],一種是 options 中沒有 data 屬性的,該方法會給 data 賦值一個空對象並進行 observe(該方法之後我們會詳細講述),如果有 data 屬性,則調用 initData 方法進行初始化。我們主要通過對 data 屬性的初始化來分析Vue中的數據響應式原理。

function initData (vm: Component) {
    let data = vm.$options.data
    // a*
    data = vm._data = typeof data === 'function'
    ? getData(data, vm)
    : data || {}
    if (!isPlainObject(data)) {
        data = {}
        process.env.NODE_ENV !== 'production' && warn(
          'data functions should return an object:n' +
          'https://vuejs.org/v2/guide/components.html#data-Must-Be-a-Function',
          vm
        )
    }
    // b*
    const keys = Object.keys(data)
    const props = vm.$options.props
    const methods = vm.$options.methods
    let i = keys.length
    while (i--) {
        // c*
        const key = keys[i]
        if (process.env.NODE_ENV !== 'production') {
          if (methods && hasOwn(methods, key)) {
            warn(
              `Method "${key}" has already been defined as a data property.`,
              vm
            )
          }
        }
        if (props && hasOwn(props, key)) {
          process.env.NODE_ENV !== 'production' && warn(
            `The data property "${key}" is already declared as a prop. ` +
            `Use prop default value instead.`,
            vm
          )
        } else if (!isReserved(key)) {
          proxy(vm, `_data`, key)
        }
    }
    // d*
    observe(data, true)
}

initData 方法對 options 中的 data 進行處理,主要是有兩個目的:

  • 將 data 代理到 Vue 實例上,同時檢查 data 中是否使用了 Vue 中的保留字、是否與 props、methods 屬性中的數據同名。
  • 使用 observe 方法將data中的屬性變成響應式的。之前我們提到在調用initState 方法之前會有對Vue 實例化傳入的options 參數進行處理的環節(mergeOptions),這個過程會將data 處理成一個函數(例如我們在vue 組件中的data 屬性),之後會調用callHook(vm, ‘beforeCreate’) 方法來觸發beforeCreate 的生命週期鉤子,這個方法的調用可能會對data 屬性進行進一步處理,所以方法一開始會對data 統一做處理使其成為一個function[a*]。

接下來會對 data 中的每一個數據進行遍歷[b*],遍歷過程將會使用hasOwn(methods, key)、hasOwn(props, key)、!isReserved(key) 方法對該數據是否佔用保留字、是否與props 和methods 中的屬性重名進行判斷,然後調用proxy方法將其代理到Vue 實例上。

此處需要注意的是:如果 data 中的屬性與 props、methods 中的屬性重名,那麼在 Vue 實例上調用這個屬性時的優先級順序是 props > methods > data。

最後對每一個 data 中的屬性調用 observe 方法,該方法的功能是賦予 data 中的屬性可被監測的特性。這個方法和其中使用到的 Observe 類是在src/core/observer/index.js文件中,observe 方法主要是調用 Observer 類構造方法,將每一個 data 中的 value 變成可觀察、響應式的:

constructor (value: any) {
    this.value = value
    this.dep = new Dep()
    this.vmCount = 0

    def(value, '__ob__', this)

    if (Array.isArray(value)) {
      //...
      this.observeArray(value)
    } else {
      this.walk(value)
    }
  }

可以看到該構造函數有以下幾個目的:

  • 針對當前的數據對像新建一個訂閱器;
  • 為每個數據的 value 都添加一個__ob__屬性,該屬性不可枚舉並指向自身;
  • 針對數組類型的數據進行單獨處理(包括賦予其數組的屬性和方法,以及 observeArray 進行的數組類型數據的響應式);
  • this.walk(value),遍歷對象的 key 調用 defineReactive 方法;

defineReactive是真正為數據添加get 和set 屬性方法的方法,它將data 中的數據定義一個響應式對象,並給該對象設置get 和set 屬性方法,其中get 方法是對依賴進行收集, set 方法是當數據改變時通知Watcher 派發更新。

4. 依賴收集

依賴收集的原理是:當視圖被渲染時,會觸發渲染中所使用到的數據的get 屬性方法,通過 get 方法進行依賴收集。

我們先看下 get 屬性方法:

get: function reactiveGetter () {
  const value = getter ? getter.call(obj) : val
  if (Dep.target) {
    dep.depend()
    if (childOb) {
      childOb.dep.depend()
      if (Array.isArray(value)) {
        dependArray(value)
      }
    }
  }
  return value
}

可以看到 get 屬性方法中調用了一個 dep.depend 方法,該方法正是依賴收集的開始。我們通過下圖來簡要概述依賴收集的一個整體過程:

Vue的數據響應式原理 3

此處涉及到了兩個重要的部分——依賴到底是什麼?依賴要存放在哪裡?這兩部分剛好對應 Vue 中兩個類,一個是Watcher 類,而依賴就是Watcher 類的實例;另一個是 Dep 類,而依賴就是存放在 Dep 實例的 subs 屬性(數組類型)中進行管理的。

其真實的邏輯我們通過舉例來說明:當前正在渲染組件ComponentA,會將​​當前全局唯一的監聽器置為當前正在渲染組件Component A 的watcher,這個組件中使用到了數據data () { return { a: b + 1} },此時觸發b 的getter 會將當前的watcher 添加到b 的訂閱者列表subs 中。也就是說如果 ComponentA 依賴 b,則將該組件的渲染 Watcher 作為訂閱者加入b的訂閱者列表中。換言之,組件 A 用到了數據 b,則組件 A 依賴數據 b;反饋在依賴收集過程中,就是組件 A 的 watcher 會被添加到數據 b 的依賴數組 subs 中。接下來我們就針對 Dep 類和 Watcher 類進行簡要分析。

其真實的邏輯我們舉例來說明:當前正在渲染組件ComponentA,會將​​當前全局唯一的監聽器置為這個Watcher,這個組件中使用到了數據data () { return { a: b + 1} },此時觸發b 的getter 會將當前的watcher 添加到b 的訂閱者列表subs 中。也就是說如ComponentA 依賴 b,則將該組件的渲染 Watcher 作為訂閱者加入 b 的訂閱者列表中。

Vue的數據響應式原理 3

Dep – 訂閱器

數據對像中的 get 方法主要使用 depend 方法進行依賴收集,depend 是 Dep 類中的屬性方法,繼續來看 Dep 類是怎樣實現的:

export default class Dep {
    // a*
    static target: ?Watcher;
    id: number;
    subs: Array;
    constructor () {
        this.id = uid++
        this.subs = []
    }

    addSub (sub: Watcher) {
        this.subs.push(sub)
    }

    depend () {
        if (Dep.target) {
            Dep.target.addDep(this)
        }
    }
}

Dep.target = null
const targetStack = []

export function pushTarget (target: ?Watcher) {
    targetStack.push(target)
    Dep.target = target
}

export function popTarget () {
    targetStack.pop()
    Dep.target = targetStack[targetStack.length - 1]
}

Dep 類中有三個屬性:target、id 和 subs,分別表示當前全局唯一的靜態數據依賴的監聽器 Watcher、該屬性的 id 以及訂閱這個屬性數據的訂閱者列表[a*],其中subs其實就是存放了所有訂閱了該數據的訂閱者們。另外還提供了將訂閱者添加到訂閱者列表的 addSub方法、從訂閱者列表刪除訂閱者的 removeSub 方法。

Dep.target 是當前全局唯一的訂閱者,這是因為同一時間只允許一個訂閱者被處理。 target 的意思就是指當前正在處理的目標訂閱者,而這個訂閱者是有一個訂閱者棧targetStack,當某一個組件執行到某個生命週期的hook 時(例如mountComponent),會將當前目標訂閱者target置為這個watcher,並將其壓入targetStack 棧頂。

接下來是添加當前數據依賴的 depend方法,Dep.target 對像是一個 Watcher 類的實例,調用 Watcher 類的 addDep 方法,我們看下 addDep 方法將當前的依賴 Dep 實例放在了哪裡:

addDep (dep: Dep) {
    const id = dep.id
    if (!this.newDepIds.has(id)) {
        this.newDepIds.add(id)
        this.newDeps.push(dep)
        if (!this.depIds.has(id)) {
            dep.addSub(this)
        }
    }
}

我們稍後會對 Watcher 類進行分析,目前先來簡單看下 addDep 這個屬性方法做了什麼。可以看到入參是一個Dep 類實例,這個實例實際上是當前全局唯一的訂閱者,newDepIds 是當前數據依賴dep 的id 列表,newDeps 是當前數據依賴dep 列表,depsId 則是上一個tick 的數據依賴的id 列表。這個方法主要的邏輯就是調用當前數據依賴 dep 的類方法 addSub,而這個方法在上面Dep 類方法中可以看到,就是將當前全局唯一的 watcher 實例放入這個數據依賴的訂閱者列表中。

target Watcher的產生

我們以生命週期中的 mountComponent 這一個階段為例來分析上面提到的 Dep.target 這個全局唯一的當前訂閱者 Watcher 是怎樣產生的。

export function mountComponent (
    vm: Component, 
    el: ?Element, hydrating?: boolean
): Component {
    // ...
    let updateComponent
    if (process.env.NODE_ENV !== 'production' && config.performance && mark) { 
        updateComponent = () => { // 首次渲染视图会进入,生成虚拟Node,更新DOM}
    } else {
        updateComponent = () => { // 数据更新时会进入,调用渲染函数对视图进行更新
        vm._update(vm._render(), hydrating)
        }
    }
    new Watcher(vm, updateComponent, noop, {
        before () {
            if (vm._isMounted && !vm._isDestroyed) {
                callHook(vm, 'beforeUpdate')
            }
        }
    }, true /* isRenderWatcher */)
    // ...
    return vm
}

通過mountComponent的代碼可以看到在觸發了beforeMount生命週期鉤子後,緊接著創建了一個用於渲染Watcher實例。我們通過Watcher類的構造函數來看創建 Watcher 實例時做了哪些工作:

constructor (
    vm: Component,              // 当前渲染的组件
    expOrFn: string | Function,  // 当前Watcher实例的getter属性方法
    cb: Function,               // 回调函数
    options?: ?Object,          // 手动传入watch时的option
    isRenderWatcher?: boolean    // 是否为渲染watcher的标识
  ) {
    this.vm = vm
    if (isRenderWatcher) {
        vm._watcher = this
    }
    vm._watchers.push(this)
    // 此处处理我们传入的watcher参数(在组件内手动新建watch属性)
    if (options) {} else {}
    // ...
    // parse expression for getter
    if (typeof expOrFn === 'function') {
        this.getter = expOrFn
    } else {
        this.getter = parsePath(expOrFn)
        if (!this.getter) {
            this.getter = noop
        }
    }
    this.value = this.lazy ? undefined : this.get()
}

通過Watcher 類的構造函數傳參可以看到mountComponent 生命週期中創建的Watcher 是一個渲染Watcher,將當前的getter 設置為了updateComponent 方法(也就是重新渲染視圖的方法),最後調用了get 屬性方法,我們接下來看get 方法做了什麼:

get () {
    pushTarget(this)
    let value
    const vm = this.vm
    try {
        value = this.getter.call(vm, vm)
    } catch (e) {
        if (this.user) {
            handleError(e, vm, `getter for watcher "${this.expression}"`)
        } else { throw e }
    } finally {
        if (this.deep) {
            traverse(value)
        }
        popTarget()
        this.cleanupDeps()
    }
    return value
}

get 方法首先調用了在 Dep類文件中定義的全局方法 pushTarget,將 Dep.target 置為當前正在執行的渲染 Watcher,並將這個 watcher 壓到了 targetStack。接下來調用wathcer 的getter 方法:由於此時的getter 就是updateComponent 方法,執行updateComponent 方法,也就是vm._update(vm._render(), hydrating) 進行渲染,渲染過程中必然會touch 到data 中相關依賴的屬性,此時就會觸發各個屬性中的get 方法(也就是將當前的渲染Watcher 添加到所有渲染中使用到的數據的依賴列表subs 中)。

渲染完之後會將當前的渲染 Watcher 從 targetStack 推出,進行下一個 watcher 的任務。最後會進行依賴的清空,每次數據的變化都會引起視圖重新渲染,每一次渲染又會進行依賴收集,又會觸發 data 中每個屬性的 get 方法,這時會有一種情況發生:

{{ a }}
{{ b }}

第一次當 someBool 為真時,進行渲染,當我們把 someBool 的值 update 為 false 時,這時候屬性 a 不會被使用,所以如果不進行依賴清空,會導致不必要的依賴通知。依賴清空主要是對 newDeps 進行更新,通過對比本次收集的依賴和之前的依賴進行對比,把不需要的依賴清除。

5. 派發更新

Vue的數據響應式原理 5

當我們修改一個存在 data 屬性上的值時,會觸發數據中的 set 屬性方法,首先會判斷並修改該屬性數據的值,並觸發自身的 dep.notify 方法開始更新派發:

set: function reactiveSetter (newVal) {
  const value = getter ? getter.call(obj) : val
  if (newVal === value || (newVal !== newVal && value !== value)) {
    return
  }
  // ...
  childOb = !shallow && observe(newVal)
  dep.notify()
}

接下來讓我們進入dep.notify這個方法:

notify () {
    const subs = this.subs.slice()
    if (process.env.NODE_ENV !== 'production' && !config.async) {
        subs.sort((a, b) => a.id - b.id)
    }
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }

notify 這個方法是每一個數據在響應式化之後自己內部閉包的dep 實例的方法,還記得之前講過的subs 保存著所有訂閱該數據的訂閱者,所以在notify 方法中首先對subs 這個訂閱者序列按照其id 進行了排序。接下來就是調用每一個訂閱者 watcher 實例的 update 方法進行更新的派發。 update 方法是在 Watcher 類文件中,使用了隊列的方式來管理訂閱者的更新派發,其中主要調用了 queueWatcher 這個方法來實現該邏輯:

export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  if (has[id] == null) {    // a*
    has[id] = true
    if (!flushing) {        // b*
      queue.push(watcher)
    } else {
      let i = queue.length - 1
      while (i > index && queue[i].id > watcher.id) {
        i--
      }
      queue.splice(i + 1, 0, watcher)
    }
    if (!waiting) {         // c*
      waiting = true
      // ...
      nextTick(flushSchedulerQueue)
    }
  }
}

進入該方法後,首先使用一個名為 has 的 Map 來保證每一個 watcher 僅推入隊列一次[a*]。 flushing 這個標識符表示目前這個隊列是否正在進行更新派發,如果是,那麼將這個 id 對應的訂閱者進行替換,如果已經路過這個 id,那麼就立刻將這個 id 對應的 watcher 放在下一個排入隊列[b*]。接下來根據waiting 這個標識符來表示當前是否正在對這個隊列進行更新派發[c*],如果沒有的話,就可以調用 nextTick(flushSchedulerQueue) 方法進行真正的更新派發了。

function flushSchedulerQueue () {
  flushing = true
  let watcher, id

  queue.sort((a, b) => a.id - b.id)

  for (index = 0; index < queue.length; index++) {
    watcher = queue[index]
    if (watcher.before) {
      watcher.before()
    }
    id = watcher.id
    has[id] = null
    watcher.run()
    // ...
  }

這個方法首先對隊列中的 watcher 按照其 id 進行了排序,排序的主要目的有三(Vue.js技術揭秘):

  • 組件的更新由父到子;因為父組件的創建過程是先於子的,所以 watcher 的創建也是先父後子,執行順序也應該保持先父後子。
  • 用戶的自定義 watcher 要優先於渲染 watcher 執行;因為用戶自定義 watcher 是在渲染 watcher 之前創建的。
  • 如果一個組件在父組件的 watcher 執行期間被銷毀,那麼它對應的 watcher 執行都可以被跳過,所以父組件的 watcher 應該先執行。

排序結束之後,便對這個隊列進行遍歷,並執行watcher.run()方法去實現數據更新的通知,run 方法的邏輯是:

  • 新的值與老的值不同時會觸發通知;
  • 但是當值是對像或者 deep 為 true 時無論如何都會進行通知

所以 watcher 有兩個功能,一個是將屬性的值進行更新,另一個就是可以執行 watch 中的回調函數 handler(newVal, oldVal),這也是為何我們可以在 watcher 中拿到新舊兩個值的原因。

至此,數據的更新派發也通知到了各個組件,對應的視圖需要進一步的更新渲染,依賴收集和更新派發……

通過對數據響應式這一部分的源碼分析,可以看到Vue 為了實現監聽數據動態變化來進行對應的視圖渲染和其他操作,為每一個數據都閉包了一個訂閱者管理器,即Dep 實例,並且為每一個對數據的依賴都創建了一個Wathcer 實例作為訂閱者存放在數據自己的訂閱者列表subs 中。渲染時通過數據中的 get 屬性方法來收集依賴,數據更新時通過其 set 屬性方法來通知到對應的 watcher 去做相應的更新操作或執行某個回調函數。

6. 總結

本文僅針對對像類型數據展開了數據響應式原理和代碼的分析,但是對於對像類型數據中新增屬性的響應式以及數組類型數據的響應式都未涉及,而這些更能夠體現出Vue 的數據響應式的整體性設計。

在 Vue3 推出後,Vue 的數據響應式的實現過程中,數據劫持的方式和所支持的數據類型都有更多優化,依賴收集的方式和邏輯與之前也有所不同,具有了更易維護的特性。通過對這些源碼的分析,不僅能夠了解其內部的實現原理,讓我們在使用這些框架的過程中得心應手,更能夠通過其代碼的組織和版本演進去理解作者的創作思路。

作者介紹

王奇峰,滴滴軟件開發工程師

主要負責滴滴星雲平台的問卷系統與其商業化工具桔研問卷系統的開發與維護,兼職系統客服。關注用戶體驗與系統穩定性,在技術驅動的項目中獲得了快速成長。

本文轉載自公眾號普惠出行產品技術(ID:pzcxtech)。

原文鏈接

https://mp.weixin.qq.com/s/jh5-Iv5TiZBUSzqRs6iYVw