最前端|详解VUE源码初始化流程以及响应式原理
本期摘要
VUE开发是指使用VUE.js框架进行前端开发的过程。VUE.js是一个流行的JavaScript前端框架,用于构建用户界面和单页应用程序。在VUE开发中,开发者可以使用VUE.js提供的各种功能和特性,如组件化、响应式数据绑定、路由管理、状态管理等,来快速构建交互性强、用户体验良好的Web应用。
本次分享着重介绍VUE源码初始化流程以及响应式原理
作者
匿名 | 前端开发工程师
给大家来一点干货,简单易懂,希望对大家在 vue 的开发中有所帮助,废话不多说:
vue 源码的入口就是 src/core/instance/index.js, 这个文件的工作是在 Vue 的 prototype 上注册函数属性等,并执行initMixin中注册的 _init 函数
function Vue (options) { if (process.env.NODE_ENV !== 'production' &&
!(this instanceof Vue)
) {
warn('Vue is a constructor and should be called with the `new` keyword')
} this._init(options)
}
initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)
顺着流程往下看, _init方法就是我们的初始化流程,主体代码如下:
Vue.prototype._init = function (options?: Object) { const vm: Component = this vm._isVue = true if (options && options._isComponent) {
initInternalComponent(vm, options)
} else {
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor),
options || {},
vm
)
}
vm._self = vm
initLifecycle(vm)
initEvents(vm)
initRender(vm)
callHook(vm, 'beforeCreate')
initInjections(vm) // resolve injections before data/props initState(vm)
initProvide(vm) // resolve provide after data/props callHook(vm, 'created') if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
}
如果是组件,则_isComponent才会为 true,其他情况下都会执行 resolveConstructorOptions,这个函数的作用是将用户设置的options 和默认的 options 进行合并。再然后会执行一系列初始化函数,见名知意。 initLifecycle是初始化生命周期, initEvent是初始化事件处理机制, initRender则是对 vnode,插槽以及属性等进行一些初始化。然后就会调用 beforeCreate 的钩子函数。然后是 initInjections和 initProvide这两个通信相关的组件。
这个地方涉及到到了我们非常熟悉的两个生命周期函数, beforeCreate和 created。可以对比 Vue 的流程图,我们就可以明确知道这两个钩子函数执行的时机。
他们中间实际上就是差了三个初始化的过程。其中重点是 initState 方法:
function initState (vm: Component) {
vm._watchers = [] const opts = vm.$options if (opts.props) initProps(vm, opts.props) if (opts.methods) initMethods(vm, opts.methods) if (opts.data) {
initData(vm)
} else {
observe(vm._data = {}, true /* asRootData */)
} if (opts.computed) initComputed(vm, opts.computed) if (opts.watch && opts.watch !== nativeWatch) {
initWatch(vm, opts.watch)
}
}
在这个方法中,如果传入了 data 则会执行 initData,否则会初始化一个空对象。下面可以看到 computed和 watch也是在这里初始化的。
简化之后的 initData 代码:
function initData (vm: Component) { let data = vm.$options.data
data = vm._data = typeof data === 'function' ? getData(data, vm)
: data || {} 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)) {
process.env.NODE_ENV !== 'production' && warn( `The data property "${key}" is already declared as a prop. ` + `Use prop default value instead.`,
vm
)
} else if (!isReserved(key)) {
proxy(vm, `_data`, key)
}
} // observe data observe(data, true /* asRootData */)
}
这个方法中先判断 data 是不是函数,如果是则执行,不是则直接取值,所以我们的 data 既可以函数,也可以是对象。接下来是循环 data 的 key 值,通过 hasOwn(就是hasOwnProperty的简写函数)判断属性是否有重复。
isReserved方法是判断变量名是否是用_或者$开头,这意味着我们不能取_和$开头的属性名。然后进入到了 proxy方法中, proxy方法其实作用很简单,通过 Object.defineProperty设置 get 和 set 来将 data 的属性代理到 vm 上,使我们可以通过 this[propName]来访问到 data 上的属性,而不用通过 this.data[propName], 最后会执行 observe,如下:
function observe (value: any, asRootData: ?boolean): Observer | void { 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
}
前面都是在做一些初始化等必要的判断,核心只有一句:
ob = new Observer(value)
从这里开始,我们暂时中止 init 的流程,开始响应式流程这条线。看源码的时候,你总是会被各种支线打断,这是没办法的事情,只要你还记得之前在做什么就好。
Observer类是 vue 实现响应式最重要的三环之一,代码如下
export class Observer {
value: any;
dep: Dep;
vmCount: number; constructor (value: any) { this.value = value this.dep = new Dep()
def(value, '__ob__', this) if (Array.isArray(value)) { if (hasProto) {
protoAugment(value, arrayMethods)
} else {
copyAugment(value, arrayMethods, arrayKeys)
} this.observeArray(value)
} else { this.walk(value)
}
}
walk (obj: Object) { const keys = Object.keys(obj) for (let i = 0; i < keys.length; i++) { defineReactive(obj, keys[i]) } } observeArray (items: Array) { for (let i = 0, l = items.length; i < l; i++) { observe(items[i]) } } }
这里介绍一下 def 函数,这是 vue 封装的方法,在源码中大量使用到,我们可以稍微分析一下,代码如下:
function def (obj: Object, key: string, val: any, enumerable?: boolean) { Object.defineProperty(obj, key, { value: val, enumerable: !!enumerable, writable: true, configurable: true })
}
可以看到,也是使用了 Object.defineProperty方法,上文我们提到过。这个方法是一个非常强大的方法,可以说 vue 的双向绑定就是通过它实现的。它有三个配置项, configurable是配置是否可以重新赋值和删除, writable标识是否可以改写, enumerable表明该属性是否会被遍历到。vue 通过 def 方法来定义哪些属性是不可更改的,哪些属性是不暴露给用户的。这里通过 def方法将 Observer类绑定在了 data 的 __ob__属性上,有兴趣的同学可以去 debugger 看看 data 和 prop 中的 __ob__属性到底是什么样的格式。
再说回 Observer,如果传入的数据是数组,则会调用 observeArray,这个函数会遍历数组,然后每个数组项又会去执行 observe方法,这里显然是一个递归,目的是将所有的属性都调用 observe。这个 observe方法实际上是 vue 实现观察者模式的核心,不光在初始化 data 的时候用到。最终,data 上的每个属性都会走到 defineReactive里面来,重点就在这里:
function defineReactive ( obj: Object,
key: string,
val: any,
customSetter?: ?Function,
shallow?: boolean ) { const dep = new Dep() const property = Object.getOwnPropertyDescriptor(obj, key) if (property && property.configurable === false) { return } const getter = property && property.get const setter = property && property.set if ((!getter || setter) && arguments.length === 2) {
val = obj[key]
} let childOb = !shallow && observe(val) Object.defineProperty(obj, key, { enumerable: true, configurable: true, get: function reactiveGetter () { const value = getter ? getter.call(obj) : val if (Dep.target) {
dep.depend() if (childOb) {
childOb.dep.depend() if (Array.isArray(value)) {
dependArray(value)
}
}
} return value
}, 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 (getter && !setter) return if (setter) {
setter.call(obj, newVal)
} else {
val = newVal
}
childOb = !shallow && observe(newVal)
dep.notify()
}
})
}
这个方法的作用就是将普通数据处理成响应式数据,这里的 get 和 set 就是 vue 中依赖收集和派发更新的源头。这里又涉及到了响应式另外一个重要的类: Dep。
在这段代码中,通过 Object.getOwnPropertyDescriptor 方法获取对象的属性描述符,如果不存在,则会用过 Object.defineProperty创建。这里的 get 和 set 都是函数,所以 data 和 prop 中所有的值都会由于闭包而缓存在内存中,并且都关联了一个 Dep 对象。
当用户通过 this[propName] 访问属性的时候,就会触发get,并调用 dep.depend 方法(下面的 dependArray 实际上就是递归遍历数组,然后去调用那个数据上的 __ob__.dep.depend 方法),当赋值更新的时候,则会触发 set,并调用 observe去对新的值创建 observer对象,最后调用了 dep.notify方法。
总结起来就是,当赋值时调用 dep.notify;当取值时调用 dep.depend。这个方法的作用就在于此,剩下的工作交给了Dep类。
接下来我们可以看一下Dep类中做了什么。
class Dep { static target: ?Watcher;
id: number;
subs: Array; constructor () { this.id = uid++ this.subs = []
}
addSub (sub: Watcher) { this.subs.push(sub)
}
removeSub (sub: Watcher) {
remove(this.subs, sub)
}
depend () { if (Dep.target) {
Dep.target.addDep(this)
}
}
notify () { const subs = this.subs.slice() if (process.env.NODE_ENV !== 'production' && !config.async) {
subs.sort((a, b) => a.id - b.id)
} for (let i = 0, l = subs.length; i < l; i++) { subs[i].update() } } } Dep.target = null const targetStack = [] export function pushTarget (target: ?Watcher) {
targetStack.push(target)
Dep.target = target
} export function popTarget () {
targetStack.pop()
Dep.target = targetStack[targetStack.length - 1]
}
这里多贴了一些代码,虽然不属于同一个类,但是非常重要。这段代码初始化了一个 subs数组,这个非常眼熟的数组就是我们经常在 vue 的属性中看到的,它是一个观察者列表。
前文说到,当 key 的 getter 触发时会调用 depend,将 Dep.target 添加到观察者列表中。这样,在 set 的时候我们才能 notify 去通知 update。
另外,还要提一点,前面在设置getter时的代码中有这样一段:
if (Dep.target) {
dep.depend() if (childOb) {
childOb.dep.depend() if (Array.isArray(value)) {
dependArray(value)
}
}
}
那么既然已经执行了 dep.depend,为什么还要执行 childOb.dep.depend,这又是什么东西呢?
实际上,在数据的增删改查中,响应式的实现方式是不同的。setter 和 getter 只能检测到数据的修改和读取操作,因此这部分是由 dep.depend来实现的。而 data 的新增删除的属性,并不能直接实现响应式,这部分是由 childOb.dep.depend来完成的,这就是我们常用的Vue.set和Vue.delete的实现方式。
接着往下看,我们发现 depend方法将 Dep.target推入 subs中。在上面定义可以看到,它是一个 Watcher类的实例,这个类就是响应式系统中的最后一环。
不过,我们暂时不管它,在这里还有一个重要的点: targetStack 。可以看到有 pushTarget和 popTarget这两个方法,它们遵循着栈的原则,后进先出。因此,Vue中的更新也是按照这个原则进行的。另外,大家可能注意到,这里似乎没有实例化 Watcher对象,那么它是在什么地方执行的呢?下文会提到。
Watcher的代码很长,我们这里只看一小段。当 notify被触发时,会调用 update方法。需要注意的是,这部分已经不是在init的流程中了,而是在数据更新时调用的。
update () { if (this.lazy) { this.dirty = true } else if (this.sync) { this.run()
} else {
queueWatcher(this)
}
}
这里正常情况下会执行 queueWatcher:
function queueWatcher (watcher: Watcher) { const id = watcher.id if (has[id] == null) {
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 (process.env.NODE_ENV !== 'production' && !config.async) {
flushSchedulerQueue() return }
nextTick(flushSchedulerQueue)
}
}
}
可以看到,当 data 更新时会将 watcher push 到 queue 中,然后等到 nextTick 执行 flushSchedulerQueue, nextTick也是一个大家很熟悉的东西,vue 当然不会蠢到每有一个更新就更新一遍 dom。它就是通过 nextTick来实现优化的,所有的改动都会被 push 到一个 callbacks队列中,然后等待全部完成之后一次清空,一起更新。这就是一轮 tick。
言归正传,接着来看 flushSchedulerQueue
function flushSchedulerQueue () {
currentFlushTimestamp = getNow()
flushing = true let watcher, id
queue.sort((a, b) => a.id - b.id) for (index = 0; index < queue.length; index++) { watcher = queue[index] if (watcher.before) {
watcher.before()
}
id = watcher.id
has[id] = null watcher.run() 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 }
}
} const activatedQueue = activatedChildren.slice() const updatedQueue = queue.slice()
resetSchedulerState()
callActivatedHooks(activatedQueue)
callUpdatedHooks(updatedQueue) if (devtools && config.devtools) {
devtools.emit('flush')
}
}
实际核心代码就是遍历所有的 queue,然后执行 watcher.run,最后发出 actived和 updated两个hook。
watcher.run会更新值然后调用 updateComponent方法去更新 dom。至此,响应式原理的主体流程结束。说了这么多,其实下面这个流程图就能完整概括。
我们回到 init 的流程,上文中 init 的流程并没有执行完,还差这最后一句
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
即通过传入进来的 options 将 dom 给渲染出来,我们来看$mount 的代码
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component { el = el && query(el) if (el === document.body || el === document.documentElement) {
process.env.NODE_ENV !== 'production' && warn(
`Do not mount Vue to or - mount to normal elements instead.`
) return this
}
const options = this.$options if (!options.render) { let template = options.template if (template) { if (typeof template === 'string') { if (template.charAt(0) === '#') {
template = idToTemplate(template) if (process.env.NODE_ENV !== 'production' && !template) {
warn(
`Template element not found or is empty: ${options.template}`,
this
)
}
}
} else if (template.nodeType) {
template = template.innerHTML
} else { if (process.env.NODE_ENV !== 'production') {
warn('invalid template option:' + template, this)
} return this
}
} else if (el) {
template = getOuterHTML(el)
} if (template) { if (process.env.NODE_ENV !== 'production' && config.performance && mark) { mark('compile')
}
const { render, staticRenderFns } = compileToFunctions(template, {
outputSourceRange: process.env.NODE_ENV !== 'production',
shouldDecodeNewlines,
shouldDecodeNewlinesForHref,
delimiters: options.delimiters,
comments: options.comments
}, this) options.render = render options.staticRenderFns = staticRenderFns if (process.env.NODE_ENV !== 'production' && config.performance && mark) { mark('compile end')
measure(`vue ${this._name} compile`, 'compile', 'compile end')
}
}
} return mount.call(this, el, hydrating)
}
前面是在获取元素以及进行一系列的类型检查判断,核心就在 compileToFunctions这个方法上
const createCompiler = createCompilerCreator(function baseCompile(
template: string,
options: CompilerOptions
): CompiledResult { const ast = parse(template.trim(), options); if (options.optimize !== false) {
optimize(ast, options);
} const code = generate(ast, options); return {
ast,
render: code.render,
staticRenderFns: code.staticRenderFns,
};
});
看到这个 ast 我们就应该知道这个函数的作用了,通过 templete 获取 AST 抽象语法树,然后根据定义的模板规则生成 render 函数。
这个方法执行完之后返回了 render 函数,之后被赋值在了 options 上面,最后调用了 mount.call(this, el, hydrating)
const createCompiler = createCompilerCreator(function baseCompile(
template: string,
options: CompilerOptions
): CompiledResult { const ast = parse(template.trim(), options); if (options.optimize !== false) {
optimize(ast, options);
} const code = generate(ast, options); return {
ast,
render: code.render,
staticRenderFns: code.staticRenderFns,
};
});
这个方法很简单,就是调用 mountComponent 函数。
export function mountComponent (
vm: Component,
el: ?Element,
hydrating?: boolean
): Component { vm.$el = el if (!vm.$options.render) { vm.$options.render = createEmptyVNode if (process.env.NODE_ENV !== 'production') {
/* istanbul ignore if */ if ((vm.$options.template && vm.$options.template.charAt(0) !== '#') || vm.$options.el || el) {
warn( 'You are using the runtime-only build of Vue where the template ' + 'compiler is not available. Either pre-compile the templates into ' + 'render functions, or use the compiler-included build.', vm )
} else {
warn( 'Failed to mount component: template or render function not defined.', vm )
}
}
}
callHook(vm, 'beforeMount') let updateComponent if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
updateComponent = () => {
const name = vm._name
const id = vm._uid
const startTag = `vue-perf-start:${id}`
const endTag = `vue-perf-end:${id}` mark(startTag)
const vnode = vm._render() mark(endTag)
measure(`vue ${name} render`, startTag, endTag) mark(startTag) vm._update(vnode, hydrating) mark(endTag)
measure(`vue ${name} patch`, startTag, endTag)
}
} else {
updateComponent = () => { vm._update(vm._render(), hydrating)
}
} new Watcher(vm, updateComponent, noop, {
before () { if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate')
}
}
}, true /* isRenderWatcher */)
hydrating = false
components in its inserted hook if (vm.$vnode == null) { vm._isMounted = true
callHook(vm, 'mounted')
} return vm }
这里的流程很容易理解。首先触发beforeMount钩子函数,然后通过vm._render生成虚拟DOM(vnode)。这个vnode就是我们常说的虚拟DOM。生成vnode后,再调用update方法将其更新为真实的DOM。在update方法中,会实现diff算法。最后执行mounted钩子函数。需要注意的是,这里的updateComponent只是定义出来了,然后将其作为参数传递给了Watcher。之前提到的Watcher就是在这个地方实例化的。
至此,init的主体流程也结束了。当然,其中还有很多细节没有提到。我也还没有深入研究这些细节,之后有时间会进一步理解和梳理。这篇文章主要是为了自己做个笔记,也分享给大家,希望能有所帮助。如果文中有任何错误之处,请大家指正。