深度解析 Vue 響應式原理

該文章內容節選自團隊的開源項目 InterviewMap。項目目前內容包含了 JS、網絡、瀏覽器相關、性能優化、安全、框架、Git、數據結構、算法等內容,不管是基礎仍是進階,亦或是源碼解讀,你都能在本圖譜中獲得滿意的答案,但願這個面試圖譜可以幫助到你們更好的準備面試。html

Vue 初始化

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

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 和 datareact

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 的事件。git

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 中派發更新。接下來咱們先看如何進行依賴收集。github

依賴收集

依賴收集是經過 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 的部分實現express

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

公衆號

最後

若是你有不清楚的地方或者認爲我有寫錯的地方,歡迎評論區交流。

相關文章

相關文章
相關標籤/搜索