平常的工作就是以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相關的問題,即考點,希望能幫助大家更好的理解原始碼在實戰中的應用。