平常的工作就是以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相关的问题,即考点,希望能帮助大家更好的理解源码在实战中的应用。