vue2.x核心原始碼深入淺出,我還是去看原始碼了

2022-10-08 06:00:29

  平常的工作就是以vue2.x進行開發,因為我是個實用主義者,以前我就一直覺得,你既然選擇了這個框架開發你首先就要先弄懂這玩意怎麼用,也就是先熟悉vue語法和各種api,而不是去糾結實現它的原理是什麼。甚至我可以這麼說,你沒有看過原始碼,只通過官方檔案也能用這個框架解決絕大部分業務需要,解決大部分bug,而且大部分情況下,別人是不會管你知不知道原理的。但我不是說閱讀原始碼不好,至少在解決另一小部分bug的時候會讓你少走很多彎路,知道為什麼會導致這樣的bug,還有一點,至少在面試的時候還是很有用的,手動狗頭。

  先放上vue2.x版本官方檔案:https://v2.cn.vuejs.org/v2/guide/instance.html,然後gayhub上的vue2.x原始碼地址:https://github.com/vuejs/vue/tree/v2.7.10,由於vue2.x還在迭代更新中,目前最新tag是v2.7.10,所以我們這次分析此分支下的程式碼。本次分析的程式碼主要在src/core下,建議谷歌瀏覽器安裝Octo tree外掛,一款線上以樹形格式展示github專案程式碼結構的外掛(如圖左側),效果真的很棒。

1. 範例掛載

  大家都會在入口檔案main.js寫上

let app = new Vue({
  el: '#app',
  data: {
    message: 'Hello Vue!'
  }
})

  其實這塊程式碼套用官方的話呢,就是通過vue函數,給你建立一個vue範例。關於vue這個物件,原始碼定義在https://github.com/vuejs/vue/blob/v2.7.10/src/core/instance/index.ts#L9

function Vue(options) {
  if (__DEV__ && !(this instanceof Vue)) {
    warn('Vue is a constructor and should be called with the `new` keyword')
  }
  this._init(options)
}

  是不是感覺特別短,它只是說明了vue這個函數,必須要通過new關鍵字來進行初始化,而且,重頭戲在this._init(options)這行程式碼裡,這裡呼叫的_init方法原始碼是定義在https://github.com/vuejs/vue/blob/v2.7.10/src/core/instance/init.ts#L16中的initMixin(),但重點是下面這些程式碼(L38~L66):

// merge options 合併設定
if (options && options._isComponent) {
  // optimize internal component instantiation
  // since dynamic options merging is pretty slow, and none of the
  // internal component options needs special treatment.
  initInternalComponent(vm, options as any)
} else {
  vm.$options = mergeOptions(
    resolveConstructorOptions(vm.constructor as any),
    options || {},
    vm
  )
}
/* istanbul ignore else */
if (__DEV__) {
  initProxy(vm) // 初始化代理屬性
} else {
  vm._renderProxy = vm
}
// expose real self
vm._self = vm
initLifecycle(vm) // 初始化生命週期
initEvents(vm) // 初始化事件中心
initRender(vm) // 初始化渲染
callHook(vm, 'beforeCreate', undefined, false /* setContext */) // 初始化beforeCreate勾點
initInjections(vm) // resolve injections before data/props
initState(vm) // 初始化props、methods、data、computed、watch
initProvide(vm) // resolve provide after data/props
callHook(vm, 'created') // 初始化created勾點

 

2.雙向繫結 

  實現vue雙向繫結的3個核心類:observe類,dep類和watcher類,在src/core/observer資料夾下,分別對應index.ts檔案、dep.ts檔案、watcher.ts檔案,首先,我們先看index.ts中對observe類的定義,原始碼定義在https://github.com/vuejs/vue/blob/v2.7.10/src/core/observer/index.ts#L49

export class Observer {
  dep: Dep
  vmCount: number // number of vms that have this object as root $data

  constructor(public value: any, public shallow = false, public mock = false) {
    // this.value = value
    this.dep = mock ? mockDep : new Dep()
    this.vmCount = 0
    def(value, '__ob__', this)
    if (isArray(value)) {
      if (!mock) {
        if (hasProto) {
          /* eslint-disable no-proto */
          ;(value as any).__proto__ = arrayMethods
          /* eslint-enable no-proto */
        } else {
          for (let i = 0, l = arrayKeys.length; i < l; i++) {
            const key = arrayKeys[i]
            def(value, key, arrayMethods[key])
          }
        }
      }
      if (!shallow) {
        this.observeArray(value)
      }
    } else {
      /**
       * Walk through all properties and convert them into
       * getter/setters. This method should only be called when
       * value type is Object.
       */
      const keys = Object.keys(value)
      for (let i = 0; i < keys.length; i++) {
        const key = keys[i]
        defineReactive(value, key, NO_INIITIAL_VALUE, undefined, shallow, mock)
      }
    }
  }

  observe類會在vue範例被建立的時候,去遍歷data裡的每一個屬性,先呼叫Array.isArray()判斷是不是陣列。第一個考點就來了,vue是怎麼監控陣列的?可以看到,如果屬性是陣列,就會直接將arrayMethods直接賦值給監控陣列的_proto_上以達到重寫陣列方法的目的,所以實際上我們呼叫的這幾個陣列方法已經是經過mutator()重寫過了的(所以官方稱這些為陣列變更方法),在這裡重寫陣列方法的好處是隻對想要監控的陣列生效,不用擔心會汙染到全域性的Array方法。還有一點,雖然現在的瀏覽器基本都支援這種非標準屬性(_proto_)的寫法,因為這種寫法本身就是早期瀏覽器自身廠商對原型屬性規範的實現,但是為了以防有些瀏覽器不支援,原始碼這裡還是對瀏覽器做了相容,如果不支援,就將這些變異方法一個個繫結到監控的陣列上。

  arrayMethods定義在https://github.com/vuejs/vue/blob/v2.7.10/src/core/observer/array.ts,這裡寫了一個攔截器methodsToPatch用來攔截陣列原有的7個方法並進行重寫,這就是為什麼vue只能通過變異方法來改變data裡的陣列,而不能使用array[0]=newValue的原因。官網檔案說是由於 JavaScript 的限制,Vue 不能檢測陣列和物件的變化其實就是因為defineProperty方法只能監控物件,不能監控陣列。

const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)

const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]

/**
 * Intercept mutating methods and emit events
 */
methodsToPatch.forEach(function (method) {
  // cache original 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)
    // notify change
    if (__DEV__) {
      ob.dep.notify({
        type: TriggerOpTypes.ARRAY_MUTATION,
        target: this,
        key: method
      })
    } else {
      ob.dep.notify()
    }
    return result
  })
})

  繼續接上上面的observe類原始碼說,如果是屬性是物件的話,則會對物件的每一個屬性呼叫defineReactive()。原始碼定義在https://github.com/vuejs/vue/blob/v2.7.10/src/core/observer/index.ts#L131。其重點是下面這些程式碼(L157~L213)。

Object.defineProperty(obj, key, {
  enumerable: true,
  configurable: true,
  get: function reactiveGetter() {
    const value = getter ? getter.call(obj) : val
    if (Dep.target) {
      if (__DEV__) {
        dep.depend({
          target: obj,
          type: TrackOpTypes.GET,
          key
        })
      } else {
        dep.depend()
      }
      if (childOb) {
        childOb.dep.depend()
        if (isArray(value)) {
          dependArray(value)
        }
      }
    }
    return isRef(value) && !shallow ? value.value : value
  },
  set: function reactiveSetter(newVal) {
    const value = getter ? getter.call(obj) : val
    if (!hasChanged(value, newVal)) {
      return
    }
    if (__DEV__ && customSetter) {
      customSetter()
    }
    if (setter) {
      setter.call(obj, newVal)
    } else if (getter) {
      // #7981: for accessor properties without setter
      return
    } else if (!shallow && isRef(value) && !isRef(newVal)) {
      value.value = newVal
      return
    } else {
      val = newVal
    }
    childOb = !shallow && observe(newVal, false, mock)
    if (__DEV__) {
      dep.notify({
        type: TriggerOpTypes.SET,
        target: obj,
        key,
        newValue: newVal,
        oldValue: value
      })
    } else {
      dep.notify()
    }
  }
})

  這就是雙向繫結最核心的部分了,利用object.defineProperty()給每個屬性新增getter和setter。getter裡主要是呼叫了Dep類的depend(),Dep類的原始碼定義在https://github.com/vuejs/vue/blob/v2.7.10/src/core/observer/dep.ts#L21,depend()主要是呼叫了Dep.target.addDep(),可以看到Dep類下有個靜態型別target,它就是一個DepTarget,這個DepTarget介面是定義在#L10,而Watcher類則是對DepTarget介面的實現,所以addDep()的定義需要在Watcher類中去尋找,原始碼定義在https://github.com/vuejs/vue/blob/v2.7.10/src/core/observer/watcher.ts#L160,它又呼叫回dep.addSub(),其作用是將與當前屬性相關的watcher範例之間的依賴關係存進一個叫subs的陣列裡,這個過程就是依賴收集。那麼問題來了:為什麼這裡要調過來調過去,直接呼叫不行麼,這也是考點之一,vue的雙向繫結採用的是什麼設計模式?看了這段程式碼,你就知道了,它採用的是釋出者-訂閱者模式,而不是觀察者模式,因為Dep類就充當了釋出者訂閱者中的一個訊息中轉站,就是所謂的排程中心,這樣釋出者和訂閱者就不受對方干擾,實現解耦。

  然後setter裡主要是呼叫了dep.notify(),notify()原始碼定義在https://github.com/vuejs/vue/blob/v2.7.10/src/core/observer/dep.ts#L51,其作用是遍歷subs陣列,然後通知到與當前屬性相關的每個watcher範例,呼叫watcher.update()觸發檢視更新,這個過程叫做派發更新

export default class Dep {
  static target?: DepTarget | null
  id: number
  subs: Array<DepTarget>

  constructor() {
    this.id = uid++
    this.subs = []
  }

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

  removeSub(sub: DepTarget) {
    remove(this.subs, sub)
  }

  depend(info?: DebuggerEventExtraInfo) {
    if (Dep.target) {
      Dep.target.addDep(this)
      if (__DEV__ && info && Dep.target.onTrack) {
        Dep.target.onTrack({
          effect: Dep.target,
          ...info
        })
      }
    }
  }

  notify(info?: DebuggerEventExtraInfo) {
    // stabilize the subscriber list first
    const subs = this.subs.slice()
    if (__DEV__ && !config.async) {
      // subs aren't sorted in scheduler if not running async
      // we need to sort them now to make sure they fire in correct
      // order
      subs.sort((a, b) => a.id - b.id)
    }
    for (let i = 0, l = subs.length; i < l; i++) {
      if (__DEV__ && info) {
        const sub = subs[i]
        sub.onTrigger &&
          sub.onTrigger({
            effect: subs[i],
            ...info
          })
      }
      subs[i].update()
    }
  }
}

   然後,我們再看看Watcher類,原始碼定義在https://github.com/vuejs/vue/blob/v2.7.10/src/core/observer/watcher.ts。主要看下列程式碼,在#L196。

  update() {
    /* istanbul ignore else */
    if (this.lazy) {
      this.dirty = true
    } else if (this.sync) {
      this.run()
    } else {
      queueWatcher(this)
    }
  }

  前面說到,派發更新會觸發相關watcher範例的update(),而update()主要是執行了queueWatcher(),這個queueWatcher()定義在https://github.com/vuejs/vue/blob/v2.7.10/src/core/observer/scheduler.ts#L166,程式碼如下,主要是起到了對watcher範例去重,然後會在flushSchedulerQueue佇列中進行排序,並一個個呼叫了佇列中的watcher.run(),最後用nextTick去非同步執行flushSchedulerQueue使檢視產生更新。

export function queueWatcher(watcher: Watcher) {
  const id = watcher.id
  if (has[id] != null) {
    return
  }

  if (watcher === Dep.target && watcher.noRecurse) {
    return
  }

  has[id] = true
  if (!flushing) {
    queue.push(watcher)
  } else {
    // if already flushing, splice the watcher based on its id
    // if already past its id, it will be run next immediately.
    let i = queue.length - 1
    while (i > index && queue[i].id > watcher.id) {
      i--
    }
    queue.splice(i + 1, 0, watcher)
  }
  // queue the flush
  if (!waiting) {
    waiting = true

    if (__DEV__ && !config.async) {
      flushSchedulerQueue()
      return
    }
    nextTick(flushSchedulerQueue)
  }
}

   這裡可以看下wacther.run()的程式碼,原始碼定義在https://github.com/vuejs/vue/blob/v2.7.10/src/core/observer/watcher.ts#L211,其重點是它呼叫了Watcher類自身的get(),本質是呼叫data中的get() ,其作用是開啟新一輪的依賴收集。

    pushTarget(this)
    let value
    const vm = this.vm
    try {
      value = this.getter.call(vm, vm)
    } catch (e: any) {
      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)
      }
      popTarget()
      this.cleanupDeps()
    }
    return value
  }

 

3.diff演演算法

  vue更新節點並不是直接暴力一個個節點全部更新,而是對新舊節點進行比較,然後進行按需更新:建立新增的節點,刪除廢除不用的節點,然後對有差異的節點進行修改或移動。diff演演算法主要是靠patch()實現的,主要呼叫的是patchVnode()和updateChildren()這兩個方法,原始碼分別定義在https://github.com/vuejs/vue/blob/v2.7.10/src/core/vdom/patch.ts#L584和#L413,前者的作用是先對比了新老節點,然後對一些非同步預留位置節點(#603的oldVnode.isAsyncPlaceholder,這個屬性在vnode.ts中沒有註釋,vnode原始碼定義在https://github.com/vuejs/vue/blob/v2.7.10/src/core/vdom/vnode.ts#L8,應該是可以理解為非同步元件的預留位置)或是靜態節點(#617的vnode.isStatic)且含有一樣key值的節點且是【克隆節點(#620的vnode.isCloned)或v-once指令繫結的節點(#620的vnode.isOnce,只渲染一次)】不予更新,以提升效能。不是文位元組點(#638的vnode.text)的話,就需要對比新舊子節點,對新舊子節點進行按需更新:新子節點有舊子節點沒有則新建addVnodes(),新子節點沒有舊子節點有則刪除removeVnodes(),其他的更新updateChildren()。如果節點是文位元組點且文字不一樣的,直接將舊節點的文字設定為新節點的文字。

if (isTrue(oldVnode.isAsyncPlaceholder)) {
  if (isDef(vnode.asyncFactory.resolved)) {
    hydrate(oldVnode.elm, vnode, insertedVnodeQueue)
  } else {
    vnode.isAsyncPlaceholder = true
  }
  return
}

// reuse element for static trees.
// note we only do this if the vnode is cloned -
// if the new node is not cloned it means the render functions have been
// reset by the hot-reload-api and we need to do a proper re-render.
if (
  isTrue(vnode.isStatic) &&
  isTrue(oldVnode.isStatic) &&
  vnode.key === oldVnode.key &&
  (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
) {
  vnode.componentInstance = oldVnode.componentInstance
  return
}

  而後者的作用是進行是定義了新舊子節點陣列的頭和尾,然後新舊子節點陣列頭尾交叉對比,它只在同層級進行比較,不會跨層級比較,這是有考量的,因為前端實際的操作中,很少會把dom元素移到其他層級去。比較完子節點之後,就開始遞迴呼叫patchVnode()更新子節點了,這裡考點就來了:vue的diff演演算法是深度優先演演算法還是廣度優先演演算法?從這個更新流程可以看出來,正常呼叫順序是patch()->patchVnode()->updateChildren()->patchVnode()->updateChildren()->....這是深度優先演演算法,同層比較,深度優先。

 function updateChildren(
    parentElm,
    oldCh,
    newCh,
    insertedVnodeQueue,
    removeOnly
  ) {
    let oldStartIdx = 0
    let newStartIdx = 0
    let oldEndIdx = oldCh.length - 1
    let oldStartVnode = oldCh[0]
    let oldEndVnode = oldCh[oldEndIdx]
    let newEndIdx = newCh.length - 1
    let newStartVnode = newCh[0]
    let newEndVnode = newCh[newEndIdx]
    let oldKeyToIdx, idxInOld, vnodeToMove, refElm

    // removeOnly is a special flag used only by <transition-group>
    // to ensure removed elements stay in correct relative positions
    // during leaving transitions
    const canMove = !removeOnly

    if (__DEV__) {
      checkDuplicateKeys(newCh)
    }

    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      if (isUndef(oldStartVnode)) {
        oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
      } else if (isUndef(oldEndVnode)) {
        oldEndVnode = oldCh[--oldEndIdx]
      } else if (sameVnode(oldStartVnode, newStartVnode)) {
        patchVnode(
          oldStartVnode,
          newStartVnode,
          insertedVnodeQueue,
          newCh,
          newStartIdx
        )
        oldStartVnode = oldCh[++oldStartIdx]
        newStartVnode = newCh[++newStartIdx]
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        patchVnode(
          oldEndVnode,
          newEndVnode,
          insertedVnodeQueue,
          newCh,
          newEndIdx
        )
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = newCh[--newEndIdx]
      } else if (sameVnode(oldStartVnode, newEndVnode)) {
        // Vnode moved right
        patchVnode(
          oldStartVnode,
          newEndVnode,
          insertedVnodeQueue,
          newCh,
          newEndIdx
        )
        canMove &&
          nodeOps.insertBefore(
            parentElm,
            oldStartVnode.elm,
            nodeOps.nextSibling(oldEndVnode.elm)
          )
        oldStartVnode = oldCh[++oldStartIdx]
        newEndVnode = newCh[--newEndIdx]
      } else if (sameVnode(oldEndVnode, newStartVnode)) {
        // Vnode moved left
        patchVnode(
          oldEndVnode,
          newStartVnode,
          insertedVnodeQueue,
          newCh,
          newStartIdx
        )
        canMove &&
          nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
        oldEndVnode = oldCh[--oldEndIdx]
        newStartVnode = newCh[++newStartIdx]
      } else {
        if (isUndef(oldKeyToIdx))
          oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
        idxInOld = isDef(newStartVnode.key)
          ? oldKeyToIdx[newStartVnode.key]
          : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
        if (isUndef(idxInOld)) {
          // New element
          createElm(
            newStartVnode,
            insertedVnodeQueue,
            parentElm,
            oldStartVnode.elm,
            false,
            newCh,
            newStartIdx
          )
        } else {
          vnodeToMove = oldCh[idxInOld]
          if (sameVnode(vnodeToMove, newStartVnode)) {
            patchVnode(
              vnodeToMove,
              newStartVnode,
              insertedVnodeQueue,
              newCh,
              newStartIdx
            )
            oldCh[idxInOld] = undefined
            canMove &&
              nodeOps.insertBefore(
                parentElm,
                vnodeToMove.elm,
                oldStartVnode.elm
              )
          } else {
            // same key but different element. treat as new element
            createElm(
              newStartVnode,
              insertedVnodeQueue,
              parentElm,
              oldStartVnode.elm,
              false,
              newCh,
              newStartIdx
            )
          }
        }
        newStartVnode = newCh[++newStartIdx]
      }
    }
    if (oldStartIdx > oldEndIdx) {
      refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
      addVnodes(
        parentElm,
        refElm,
        newCh,
        newStartIdx,
        newEndIdx,
        insertedVnodeQueue
      )
    } else if (newStartIdx > newEndIdx) {
      removeVnodes(oldCh, oldStartIdx, oldEndIdx)
    }
  }

  上面updateChildren()原始碼其實還有一個考點:vue中key的作用,或是說v-for的時候為什麼推薦寫上key?在#495中,有一個createKeyToOldIdx(),這個方法是建立key=>index的map對映,原始碼定義在https://github.com/vuejs/vue/blob/v2.7.10/src/core/vdom/patch.ts#L56,對於新節點,可以看到如果沒有key值得話,它會通過findIdxInOld()遍歷舊的節點,而有key值的話,它會直接從map結構中取到對應的節點資料,相對於遍歷,map結構明顯會更有效率。

  

  對於core資料夾下的原始碼就分析到這裡,完結。

  最後說點題外話,這篇文章躺在我的隨筆列表裡好久了,其實一年半前就開始寫這篇文章了,一直縫縫補補,還好現在是寫完了。一是因為確實東西很多,不知道從何寫起,原本我打算寫的是src下所有的資料夾的主要原始碼分析,現在看來光是這src/core資料夾下的主要原始碼就花了這麼長的時間,當然有一部分原因是我比較懶,至於其他資料夾下的原始碼,如果以後有時間可能會新開文章寫;二是當時寫的時候是以main分支原始碼為基礎的寫的,但是vue2.x還是有一直更新的,剛開始寫的時候vue版本還在v2.6.10+,現在最新版本都到v2.7.10了,更沒想到vue2.x也會投入ts的懷抱,這就導致了之前寫的文章裡的原始碼與所在連結和行數是不對應的,有種錯亂的感覺,所以這次我將v2.7.10作為版本快照固定下來,在最新的tag上進行原始碼分析,放上對應的原始碼連結。三就是我前幾個月不是有一段面試經歷,加入了一點我面試中經常遇到和vue相關的問題,即考點,希望能幫助大家更好的理解原始碼在實戰中的應用。