深度解析 Vue 響應式原理

yck發表於1970-01-01
深度解析 Vue 響應式原理

該文章內容節選自團隊的開源專案 InterviewMap。專案目前內容包含了 JS、網路、瀏覽器相關、效能優化、安全、框架、Git、資料結構、演算法等內容,無論是基礎還是進階,亦或是原始碼解讀,你都能在本圖譜中得到滿意的答案,希望這個面試圖譜能夠幫助到大家更好的準備面試。

Vue 初始化

在 Vue 的初始化中,會先對 props 和 data 進行初始化

Vue.prototype._init = function(options?: Object) {
  // ...
  // 初始化 props 和 data
  initState(vm)
  initProvide(vm) 
  callHook(vm, 'created')

  if (vm.$options.el) {
    // 掛載元件
    vm.$mount(vm.$options.el)
  }
}
複製程式碼

接下來看下如何初始化 props 和 data

export function initState (vm: Component) {
  // 初始化 props
  if (opts.props) initProps(vm, opts.props)
  if (opts.data) {
  // 初始化 data
    initData(vm)
  }
}
function initProps (vm: Component, propsOptions: Object) {
  const propsData = vm.$options.propsData || {}
  const props = vm._props = {}
  // 快取 key
  const keys = vm.$options._propKeys = []
  const isRoot = !vm.$parent
  // 非根元件的 props 不需要觀測
  if (!isRoot) {
    toggleObserving(false)
  }
  for (const key in propsOptions) {
    keys.push(key)
    // 驗證 prop
    const value = validateProp(key, propsOptions, propsData, vm)
    // 通過 defineProperty 函式實現雙向繫結
    defineReactive(props, key, value)
    // 可以讓 vm._props.x 通過 vm.x 訪問
    if (!(key in vm)) {
      proxy(vm, `_props`, key)
    }
  }
  toggleObserving(true)
}

function initData (vm: Component) {
  let data = vm.$options.data
  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
    )
  }
  // proxy data on instance
  const keys = Object.keys(data)
  const props = vm.$options.props
  const methods = vm.$options.methods
  let i = keys.length
  while (i--) {
    const key = keys[i]
    if (props && hasOwn(props, key)) {
    } else if (!isReserved(key)) {
    // 可以讓 vm._data.x 通過 vm.x 訪問
      proxy(vm, `_data`, key)
    }
  }
  // 監聽 data
  observe(data, true /* asRootData */)
}
export function observe (value: any, asRootData: ?boolean): Observer | void {
  // 如果 value 不是物件或者使 VNode 型別就返回
  if (!isObject(value) || value instanceof VNode) {
    return
  }
  let ob: Observer | void
  // 使用快取的物件
  if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
    ob = value.__ob__
  } else if (
    shouldObserve &&
    !isServerRendering() &&
    (Array.isArray(value) || isPlainObject(value)) &&
    Object.isExtensible(value) &&
    !value._isVue
  ) {
    // 建立一個監聽者
    ob = new Observer(value)
  }
  if (asRootData && ob) {
    ob.vmCount++
  }
  return ob
}
export class Observer {
  value: any;
  dep: Dep;
  vmCount: number; // number of vms that has this object as root $data

  constructor (value: any) {
    this.value = value
    this.dep = new Dep()
    this.vmCount = 0
    // 通過 defineProperty 為物件新增 __ob__ 屬性,並且配置為不可列舉
    // 這樣做的意義是物件遍歷時不會遍歷到 __ob__ 屬性
    def(value, '__ob__', this)
    // 判斷型別,不同的型別不同處理
    if (Array.isArray(value)) {
    // 判斷陣列是否有原型
    // 在該處重寫陣列的一些方法,因為 Object.defineProperty 函式
    // 對於陣列的資料變化支援的不好,這部分內容會在下面講到
      const augment = hasProto
        ? protoAugment
        : copyAugment
      augment(value, arrayMethods, arrayKeys)
      this.observeArray(value)
    } else {
      this.walk(value)
    }
  }
  // 遍歷物件,通過 defineProperty 函式實現雙向繫結
  walk (obj: Object) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])
    }
  }
  // 遍歷陣列,對每一個元素進行觀測
  observeArray (items: Array<any>) {
    for (let i = 0, l = items.length; i < l; i++) {
      observe(items[i])
    }
  }
}
複製程式碼

Object.defineProperty

無論是物件還是陣列,需要實現雙向繫結的話最終都會執行這個函式,該函式可以監聽到 setget 的事件。

export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  // 建立依賴例項,通過閉包的方式讓
  // set get 函式使用
  const dep = new Dep()
  // 獲得屬性物件
  const property = Object.getOwnPropertyDescriptor(obj, key)
  if (property && property.configurable === false) {
    return
  }

  // 獲取自定義的 getter 和 setter
  const getter = property && property.get
  const setter = property && property.set
  if ((!getter || setter) && arguments.length === 2) {
    val = obj[key]
  }
  // 如果 val 是物件的話遞迴監聽
  let childOb = !shallow && observe(val)
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    // 攔截 getter,當取值時會觸發該函式
    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val
      // 進行依賴收集
      // 初始化時會在初始化渲染 Watcher 時訪問到需要雙向繫結的物件
      // 從而觸發 get 函式
      if (Dep.target) {
        dep.depend()
        if (childOb) {
          childOb.dep.depend()
          if (Array.isArray(value)) {
            dependArray(value)
          }
        }
      }
      return value
    },
    // 攔截 setter,當賦值時會觸發該函式
    set: function reactiveSetter (newVal) {
      const value = getter ? getter.call(obj) : val
      // 判斷值是否發生變化
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      if (process.env.NODE_ENV !== 'production' && customSetter) {
        customSetter()
      }
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      // 如果新值是物件的話遞迴監聽
      childOb = !shallow && observe(newVal)
      // 派發更新
      dep.notify()
    }
  })
}
複製程式碼

Object.defineProperty 中自定義 getset 函式,並在 get 中進行依賴收集,在 set 中派發更新。接下來我們先看如何進行依賴收集。

依賴收集

依賴收集是通過 Dep 來實現的,但是也與 Watcher 息息相關

export default class Dep {
  static target: ?Watcher;
  id: number;
  subs: Array<Watcher>;

  constructor () {
    this.id = uid++
    this.subs = []
  }
  // 新增觀察者
  addSub (sub: Watcher) {
    this.subs.push(sub)
  }
  // 移除觀察者
  removeSub (sub: Watcher) {
    remove(this.subs, sub)
  }

  depend () {
    if (Dep.target) {、
      // 呼叫 Watcher 的 addDep 函式
      Dep.target.addDep(this)
    }
  }
  // 派發更新
  notify () {
    const subs = this.subs.slice()
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }
}
// 同一時間只有一個觀察者使用,賦值觀察者
Dep.target = null
複製程式碼

對於 Watcher 來說,分為兩種 Watcher,分別為渲染 Watcher 和使用者寫的 Watcher。渲染 Watcher 是在初始化中例項化的。

export function mountComponent(
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  // ...
  let updateComponent
  if (process.env.NODE_ENV !== 'production' && config.performance && mark) {} else {
    // 元件渲染,該回撥會在初始化和資料變化時呼叫
    updateComponent = () => {
      vm._update(vm._render(), hydrating)
    }
  }
  // 例項化渲染 Watcher
  new Watcher(
    vm,
    updateComponent,
    noop,
    {
      before() {
        if (vm._isMounted) {
          callHook(vm, 'beforeUpdate')
        }
      }
    },
    true /* isRenderWatcher */
  )
  return vm
}
複製程式碼

接下來看一下 Watcher 的部分實現

export default class Watcher {
  constructor (
    vm: Component,
    expOrFn: string | Function,
    cb: Function,
    options?: ?Object,
    isRenderWatcher?: boolean
  ) {
    // ...
    if (this.computed) {
      this.value = undefined
      this.dep = new Dep()
    } else {
      this.value = this.get()
    }
  }

  get () {
  // 該函式用於快取 Watcher
  // 因為在元件含有巢狀元件的情況下,需要恢復父元件的 Watcher
    pushTarget(this)
    let value
    const vm = this.vm
    try {
    // 呼叫回撥函式,也就是 updateComponent 函式
    // 在這個函式中會對需要雙向繫結的物件求值,從而觸發依賴收集
      value = this.getter.call(vm, vm)
    } catch (e) {
      if (this.user) {
        handleError(e, vm, `getter for watcher "${this.expression}"`)
      } else {
        throw e
      }
    } finally {
      // "touch" every property so they are all tracked as
      // dependencies for deep watching
      if (this.deep) {
        traverse(value)
      }
      // 恢復 Watcher
      popTarget()
      // 清理依賴,判斷是否還需要某些依賴,不需要的清除
      // 這是為了效能優化
      this.cleanupDeps()
    }
    return value
  }
  // 在依賴收集中呼叫
  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 函式
      // 將當前 Watcher push 進陣列
        dep.addSub(this)
      }
    }
  }
}
export function pushTarget (_target: ?Watcher) {
// 設定全域性的 target
  if (Dep.target) targetStack.push(Dep.target)
  Dep.target = _target
}
export function popTarget () {
  Dep.target = targetStack.pop()
}
複製程式碼

以上就是依賴收集的全過程。核心流程是先對配置中的 props 和 data 中的每一個值呼叫 Obeject.defineProperty() 來攔截 setget 函式,再在渲染 Watcher 中訪問到模板中需要雙向繫結的物件的值觸發依賴收集。

派發更新

改變物件的資料時,會觸發派發更新,呼叫 Depnotify 函式

notify () {
  // 執行 Watcher 的 update
  const subs = this.subs.slice()
  for (let i = 0, l = subs.length; i < l; i++) {
    subs[i].update()
  }
}
update () {
  if (this.computed) {
    // ...
  } else if (this.sync) {
    // ...
  } else {
  // 一般會進入這個條件
    queueWatcher(this)
  }
}
export function queueWatcher(watcher: Watcher) {
// 獲得 id
  const id = watcher.id
  // 判斷 Watcher 是否 push 過
  // 因為存在改變了多個資料,多個資料的 Watch 是同一個
  if (has[id] == null) {
    has[id] = true
    if (!flushing) {
    // 最初會進入這個條件
      queue.push(watcher)
    } else {
      // 在執行 flushSchedulerQueue 函式時,如果有新的派發更新會進入這裡
      // 插入新的 watcher
      let i = queue.length - 1
      while (i > index && queue[i].id > watcher.id) {
        i--
      }
      queue.splice(i + 1, 0, watcher)
    }
    // 最初會進入這個條件
    if (!waiting) {
      waiting = true
      // 將所有 Watcher 統一放入 nextTick 呼叫
      // 因為每次派發更新都會引發渲染
      nextTick(flushSchedulerQueue)
    }
  }
}
function flushSchedulerQueue() {
  flushing = true
  let watcher, id

  // 根據 id 排序 watch,確保如下條件
  // 1. 元件更新從父到子
  // 2. 使用者寫的 Watch 先於渲染 Watch
  // 3. 如果在父元件 watch run 的時候有元件銷燬了,這個 Watch 可以被跳過
  queue.sort((a, b) => a.id - b.id)

  // 不快取佇列長度,因為在遍歷的過程中可能佇列的長度發生變化
  for (index = 0; index < queue.length; index++) {
    watcher = queue[index]
    if (watcher.before) {
    // 執行 beforeUpdate 鉤子函式
      watcher.before()
    }
    id = watcher.id
    has[id] = null
    // 在這裡執行使用者寫的 Watch 的回撥函式並且渲染元件
    watcher.run()
    // 判斷無限迴圈
    // 比如在 watch 中又重新給物件賦值了,就會出現這個情況
    if (process.env.NODE_ENV !== 'production' && has[id] != null) {
      circular[id] = (circular[id] || 0) + 1
      if (circular[id] > MAX_UPDATE_COUNT) {
        warn(
          'You may have an infinite update loop ' +
            (watcher.user
              ? `in watcher with expression "${watcher.expression}"`
              : `in a component render function.`),
          watcher.vm
        )
        break
      }
    }
  }
    // ...
}
複製程式碼

以上就是派發更新的全過程。核心流程就是給物件賦值,觸發 set 中的派發更新函式。將所有 Watcher 都放入 nextTick 中進行更新,nextTick 回撥中執行使用者 Watch 的回撥函式並且渲染元件。

Object.defineProperty 的缺陷

以上已經分析完了 Vue 的響應式原理,接下來說一點 Object.defineProperty 中的缺陷。

如果通過下標方式修改陣列資料或者給物件新增屬性並不會觸發元件的重新渲染,因為 Object.defineProperty 不能攔截到這些操作,更精確的來說,對於陣列而言,大部分操作都是攔截不到的,只是 Vue 內部通過重寫函式的方式解決了這個問題。

對於第一個問題,Vue 提供了一個 API 解決

export function set (target: Array<any> | Object, key: any, val: any): any {
// 判斷是否為陣列且下標是否有效
  if (Array.isArray(target) && isValidArrayIndex(key)) {
  // 呼叫 splice 函式觸發派發更新
  // 該函式已被重寫
    target.length = Math.max(target.length, key)
    target.splice(key, 1, val)
    return val
  }
  // 判斷 key 是否已經存在
  if (key in target && !(key in Object.prototype)) {
    target[key] = val
    return val
  }
  const ob = (target: any).__ob__
  if (target._isVue || (ob && ob.vmCount)) {
    process.env.NODE_ENV !== 'production' && warn(
      'Avoid adding reactive properties to a Vue instance or its root $data ' +
      'at runtime - declare it upfront in the data option.'
    )
    return val
  }
  // 如果物件不是響應式物件,就賦值返回
  if (!ob) {
    target[key] = val
    return val
  }
  // 進行雙向繫結
  defineReactive(ob.value, key, val)
  // 手動派發更新
  ob.dep.notify()
  return val
}
複製程式碼

對於陣列而言,Vue 內部重寫了以下函式實現派發更新

// 獲得陣列原型
const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)
// 重寫以下函式
const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]
methodsToPatch.forEach(function (method) {
  // 快取原生函式
  const original = arrayProto[method]
  // 重寫函式
  def(arrayMethods, method, function mutator (...args) {
  // 先呼叫原生函式獲得結果
    const result = original.apply(this, args)
    const ob = this.__ob__
    let inserted
    // 呼叫以下幾個函式時,監聽新資料
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args
        break
      case 'splice':
        inserted = args.slice(2)
        break
    }
    if (inserted) ob.observeArray(inserted)
    // 手動派發更新
    ob.dep.notify()
    return result
  })
})
複製程式碼

求職

最近本人在尋找工作機會,如果有杭州的不錯崗位的話,歡迎聯絡我 zx597813039@gmail.com

公眾號

深度解析 Vue 響應式原理

最後

如果你有不清楚的地方或者認為我有寫錯的地方,歡迎評論區交流。

相關文章

相關文章