Vue源码解读
版本:vue@2.6.11 前言:本篇纯属个人对知识点的整理,非整体原创。一些题目和答案来源于其他文章的借鉴,并融入自己理解,最终产出这篇文章。
在这之前,偶尔的会去阅读Vue相关知识点的源码,比如看到一些面试:“nextTick的原理是什么?” ,或者是:“请说一下响应式的实现原理”,或者在开发中遇到了棘手的bug(耽误我下班啦),也就不得不去找资料 or 阅读相关源码啦,以上等等情况.... 大家也是不是跟我差不多?(say yes, pls)
I know, so, 基于这种情况所了解到的知识点,太过碎片化了
那有没有一种东西,能把我们所学到的东西去串起来?Of course!
本yu就整理了一份简单的阅读Vue源码思维导图,能够方便你去理解整个Vue运行的机制,也方便知识回顾时,思维导图能够帮助你更快更直接的找到那种feel~~
废话不多唠,先上干货:
想要自由缩放的点击 Vue源码思维导图
这一切的开始,还得从......
算了,还是从new Vue()
说起吧
生命周期
我是不是该在这扯些东西???
Vue组件有哪些生命周期?
-
beforeCreate
在实例初始化之后,数据观测 (data observer) 和 event/watcher 事件配置之前被调用。
-
created
在实例创建完成后被立即调用。在这一步,实例已完成以下的配置:数据观测 (data observer),property 和方法的运算,watch/event 事件回调。然而,挂载阶段还没开始,
$el
property 目前尚不可用。 -
beforeMount
在挂载开始之前被调用:相关的
render
函数首次被调用。 该钩子在服务器端渲染期间不被调用。 -
mounted
实例被挂载后调用,这时
el
被新创建的vm.$el
替换了。如果根实例挂载到了一个文档内的元素上,当mounted
被调用时vm.$el
也在文档内。 注意mounted
不会保证所有的子组件也都一起被挂载。如果你希望等到整个视图都渲染完毕,可以在mounted
内部使用 vm.$nextTick: 该钩子在服务器端渲染期间不被调用。 -
beforeUpdate
数据更新时调用,发生在虚拟 DOM 打补丁之前。这里适合在更新之前访问现有的 DOM,比如手动移除已添加的事件监听器。 该钩子在服务器端渲染期间不被调用,因为只有初次渲染会在服务端进行。
-
updated
由于数据更改导致的虚拟 DOM 重新渲染和打补丁,在这之后会调用该钩子。 该钩子在服务器端渲染期间不被调用。
-
activated
被 keep-alive 缓存的组件激活时调用。 该钩子在服务器端渲染期间不被调用。
-
deactivated
官网原文:被 keep-alive 缓存的组件停用时调用。 该钩子在服务器端渲染期间不被调用。
-
beforeDestroy
实例销毁之前调用。在这一步,实例仍然完全可用。 该钩子在服务器端渲染期间不被调用。
-
destroyed
实例销毁后调用。该钩子被调用后,对应 Vue 实例的所有指令都被解绑,所有的事件监听器被移除,所有的子实例也都被销毁。 该钩子在服务器端渲染期间不被调用。
Vue中组件生命周期调用顺序是什么样的?
-
组件的调用顺序都是先父后子,渲染完成的顺序是先子后父。
-
组件的销毁操作是先父后子,销毁完成的顺序是先子后父。
说明:
这里有一个父组件parent
,和2个子组件child1
、child2
,child1有子组件child1child
那么此时的声明周期顺序应该是:
组件的调用顺序都是先父后子,为什么?
这个我没啥好说,难道要先子后父?
渲染完成的顺序是先子后父,为什么?
insertedVnodeQueue
被插入的虚拟节点队列
function patch(vnode) {
// 1.虚拟节点队列
const insertedVnodeQueue = [];
// 2.创建新节点,具体查看下面函数的具体内容
// create new node
createElm(
vnode,
insertedVnodeQueue,
// extremely rare edge case: do not insert if old element is in a
// leaving transition. Only happens when combining transition +
// keep-alive + HOCs. (#4590)
oldElm._leaveCb ? null : parentElm,
nodeOps.nextSibling(oldElm)
)
// 3.清空insertedVnodeQueue队列
invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
}
/** 创建DOM元素,并且append到父元素 */
function createElm (
vnode,
insertedVnodeQueue,
parentElm,
refElm,
nested,
ownerArray,
index
) {
const data = vnode.data
const children = vnode.children
const tag = vnode.tag
// 1.创建了DOM
vnode.elm = vnode.ns
? nodeOps.createElementNS(vnode.ns, tag)
: nodeOps.createElement(tag, vnode)
setScope(vnode)
// 2.递归创建子vnode的DOM
createChildren(vnode, children, insertedVnodeQueue)
// 重点!!!
// 3.递归创建好了子vnode,才把自己的vnode推到虚拟节点队列,此时,父虚拟节点在子虚拟节点后面
if (isDef(data)) {
// 实质:insertedVnodeQueue.push(vnode)
invokeCreateHooks(vnode, insertedVnodeQueue)
}
/** DOM操作了 将生成的DOM append到target DOM(parentVnode.elm) */
insert(parentElm, vnode.elm, refElm)
}
/** 将DOM append到父元素 */
function insert (parent, elm, ref) {
if (isDef(parent)) {
if (isDef(ref)) {
if (nodeOps.parentNode(ref) === parent) {
nodeOps.insertBefore(parent, elm, ref)
}
} else {
nodeOps.appendChild(parent, elm)
}
}
}
// 清空insertedVnodeQueue队列
function invokeInsertHook (vnode, queue, initial) {
// delay insert hooks for component root nodes, invoke them after the
// element is really inserted
if (isTrue(initial) && isDef(vnode.parent)) {
vnode.parent.data.pendingInsert = queue
} else {
for (let i = 0; i < queue.length; ++i) {
queue[i].data.hook.insert(queue[i])
}
}
}
这里再次解释下上面代码的逻辑:
-
在第一次patch,创建新的vnode节点时,会先定义一个
insertedVnodeQueue
被插入的虚拟节点队列 然后调用createElm
创建DOM元素,并且收集了vnode到insertedVnodeQueue
队列收集到顺序如何?
请查看
createEle
函数第2点和第3点可以发现,如果有子vnode,就会先完成创建子vnode的DOM生成(即:优先挂载),最后才是父vnode进行挂载 故,若parent、child、grandson三个组件它们的关系是:-- parent
-- child
-- grandson
那么第一步会生成parent的DOM,第二步递归式的生成child的DOM,以此类推,生成grandson的DOM
grandson组件没有子节点了,那么就会将grandson 的vnode添加到
insertedVnodeQueue
队列,接下来是child,parent,此时insertedVnodeQueue
是[grandsonVnode, childVnode, parentVnode]然后parent将生成的DOM插入到目标元素下 最后patch的末尾调用了
invokeInsertHook
,主要是清空insertedVnodeQueue队列综合以上:渲染完成的顺序是先子后父
组件的销毁操作是先父后子,销毁完成的顺序是先子后父。
销毁组件的方式是调用组件实例的销毁方法vm.$destroy()
Vue.prototype.$destroy = function () {
const vm: Component = this
// 开始销毁组件
callHook(vm, 'beforeDestroy')
// call the last hook...
vm._isDestroyed = true
// invoke destroy hooks on current rendered tree
vm.__patch__(vm._vnode, null)
// 销毁完成
callHook(vm, 'destroyed')
}
销毁方法先调用beforeDestroy
钩子
然后调用patch
函数来销毁vnode
最后调用销毁完成钩子destroyed
那么,调用patch
函数来销毁vnode具体是怎样的一个过程?
首先来看下patch
函数
function patch() {
/** if -> 销毁: 如果 newVnode传了`null`和oldVnode有传 说明:销毁oldVnode节点 */
if (isUndef(vnode)) {
if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
return
}
}
所以patch函数销毁vnode主要调用了invokeDestroyHook
那么下面看下invokeDestroyHook
主要做了什么?
/**
* 销毁vnode和子vnode
* */
function invokeDestroyHook (vnode) {
let i, j
const data = vnode.data
if (isDef(data)) {
/**
* 销毁vnode
**/
if (isDef(i = data.hook) && isDef(i = i.destroy)) i(vnode)
}
/**
* 递归子节点vnode,进行销毁操作
**/
if (isDef(i = vnode.children)) {
for (j = 0; j < vnode.children.length; ++j) {
invokeDestroyHook(vnode.children[j])
}
}
}
根据上面可以看的出来,invokeDestroyHook
函数先是调用当前的vnode的destroy hook,
vnode destory hook
componentVNodeHooks = {
destroy (vnode: MountedComponentVNode) {
const { componentInstance } = vnode
if (!componentInstance._isDestroyed) {
// 调用的是实例的销毁方法
componentInstance.$destroy()
}
}
}
然后在递归子节点vnode,一一的进行实例销毁。
但是父vnode只有等子vnode销毁完成后,才会调用destoryed
钩子,
所以组件销毁操作是先父后子,销毁完成的顺序是先子后父。
在什么阶段才能访问操作DOM?
在mounted
阶段就可以访问操作DOM了。
说明:
在组件执行挂载$mount
方法时,会调用beforeMount
钩子,此时DOM还未生成
然后开始准备生成DOM的一些准备:
- 执行
_render
方法,生成vnode - 执行
__patch__
方法生产了DOM,并被insert到父元素(具体参考别人的:VirtualDOM与diff 中的patch
),DOM已挂载
调用mounted
钩子。
你的接口请求一般放在哪个生命周期中?
在created、beforeMount和mounted都可以。
说明:因为在这三个钩子函数中,data 已经创建,可以将服务端端返回的数据进行赋值。
但我开发中习惯于用created
,原因有以下:
- 可以尽可能早的获取服务端到数据
beforeMount
、mounted
在服务器端渲染期间不被调用,created
是会被调用的,故放在created有助于一致性。
继续问:那为啥不使用beforeCreate
钩子,接口请求是异步的,应该不会影响到对服务端返回到数据进行赋值呀?
回答:如果单纯的只是接口请求,我想你说的是没啥毛病的;
但具体的情况也并非如你所说的简单,在请求接口前,往往都是需要做些数据的获取或者初始化,这个过程就很可能需要对data数据进行操作,基于此,我还是觉得使用beforeCreate
钩子是不够稳定的。
基于发布订阅模式的响应系统
什么是发布订阅模式?可查看Vue源码思维导图中的左下角的发布订阅模式思维图
组件中的data为什么是一个函数?
在官方文档中:data的类型是
Object
|Function
, 但给出了一个限制:组件的定义只接受function
。
我的简单回答:就是不是通过函数返回的对象,相同组件的多个实例公用一个数据对象,就会存在引用类型的隐患。
官方回答:当一个组件被定义,
data
必须声明为返回一个初始数据对象的函数,因为组件可能被用来创建多个实例。如果data
仍然是一个纯粹的对象,则所有的实例将共享引用同一个数据对象!通过提供data
函数,每次创建一个新实例后,我们能够调用data
函数,从而返回初始数据的一个全新副本数据对象。如果需要,可以通过将
vm.$data
传入JSON.parse(JSON.stringify(...))
得到深拷贝的原始数据对象。
说说Vue的双向数据绑定原理
根据Vue源码思维导图中可发现:Vue的数据的可响应性是发生在
beforeCreate
和created
之间的
响应式原理
主要原理是基于es5的API:Object.defineProperty
让data的property拥有getter
&setter
,从而是可响应性的
function initData (vm: Component) {
let data = vm.$options.data
// 1.代理每个属性到实例vm上
// proxy data on instance
const keys = Object.keys(data)
let i = keys.length
while (i--) {
proxy(vm, `_data`, key)
}
// 观察数据data
// observe data
observe(data, true /* asRootData */)
}
export function observe (value: any) {
Object.keys(value).forEach((key) => defineReactive(value, key, value[key]))
}
/**
* Define a reactive property on an Object.
*/
export function defineReactive (
obj: Object,
key: string,
val: any,
customSetter?: ?Function,
shallow?: boolean
) {
// 获取到data的属性的描述器,只有可配置性的才能操作修改
const property = Object.getOwnPropertyDescriptor(obj, key)
if (property && property.configurable === false) {
return
}
// cater for pre-defined getter/setters
const getter = property && property.get
const setter = property && property.set
if ((!getter || setter) && arguments.length === 2) {
val = obj[key]
}
// 重新定义属性,让属性拥有`getter` & `setter`
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
const value = getter ? getter.call(obj) : val
return value
},
set: function reactiveSetter (newVal) {
const value = getter ? getter.call(obj) : val
/* eslint-disable no-self-compare */
if (newVal === value || (newVal !== newVal && value !== value)) {
return
}
// #7981: for accessor properties without setter
if (getter && !setter) return
if (setter) {
setter.call(obj, newVal)
} else {
val = newVal
}
}
})
}
依赖收集
依赖收集是怎样的一个过程? 谁收集谁? 是怎样收集的? 收集的目的是什么?
脑子一下蹦出这么多的疑问,那我就凭着这些疑问一一的找出答案吧,let's go!
这一切的一切还得从app.$mount('#app')
说起:
为啥?follow me.
app.$mount('#app')
大概做了这个操作简约版,后续源码相关都只会挑重点来展示
Vue.prototype.$mount = function (el) {
const vm = this;
// ...
callHook(vm, 'beforeMount')
// ...
// 核心点:在这创建了一个render `Watcher`
new Watcher(vm, () => {
// 1.调用渲染函数返回一个虚拟节点
const vnode = vm._render();
// 2.调用patch生成真实的DOM
// 渲染真实DOM,就会使用到数据进行模版填充
// 使用到数据就会被监听器`Observer`给监听到
// 由此触发了数据data的property的`getter`
vm.__patch__(vm.$el, vnode);
})
// ...
if (vm.$vnode == null) {
vm._isMounted = true
callHook(vm, 'mounted')
}
// ...
}
监听器 Observer
上面提到了Observer
,这是什么东西?之前怎么没提到呢?
好吧....Observer的创建应该是在上面的响应式原理中的observe
方法创建的,这里在补充一下:
export function observe (value: any, asRootData: ?boolean): Observer | void {
let ob: Observer | void
// ...
// 在这里就创建了监听器
ob = new Observer(value)
// ...
return ob
}
我们来看看监听器Observer
类:
export class Observer {
value: any;
constructor (value: any) {
this.value = value
// 1.给对象定一个监听器实例
def(value, '__ob__', this)
// 2.对数据进行深度观察
// 这里对数据的一些原型方法进行了复写
if (Array.isArray(value)) {
if (hasProto) {
protoAugment(value, arrayMethods)
} else {
copyAugment(value, arrayMethods, arrayKeys)
}
this.observeArray(value)
} else {
this.walk(value)
}
}
/**
* Walk through all properties and convert them into
* getter/setters. This method should only be called when
* value type is Object.
*/
walk (obj: Object) {
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i])
}
}
/**
* Observe a list of Array items.
*/
observeArray (items: Array<any>) {
for (let i = 0, l = items.length; i < l; i++) {
observe(items[i])
}
}
}
那么这个renderWatcher
具体有什么东东?
观察者 Watcher
class Watcher {
value: any;
getter: Function;
constructor (
vm: Component,
expOrFn: string | Function
) {
this.vm = vm
// getter 就是一个render函数
this.getter = expOrFn
this.value = this.get()
}
/**
* Evaluate the getter, and re-collect dependencies.
*/
get () {
let value;
// 把当前的render `Watcher`推到全局`Dep.target`
pushTarget(this)
// 执行了render函数,可以通过触发property.getter进行对观察(Watcher),也就是当前Watcher的实例this,property的发布者(Dep)就会把观察者(Dep.target)收集起来
value = this.getter();
// 弹出当前的`Watcher`
popTarget()
return value
}
}
发布者 Dep
在类Watcher
看到了Dep,那么发布者又是在哪里定义的?
哪来的你???
好吧....Dep是在定义响应式属性defineReactive
时候创建的,这里在补充一下:
export function defineReactive (
obj: Object,
key: string,
val: any,
customSetter?: ?Function,
shallow?: boolean
) {
// 1.这里创建了一个发布者
const dep = new Dep()
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
// 2.在`getter`时候,进行依赖收集
get: function reactiveGetter () {
if (Dep.target) {
dep.depend()
}
},
// 3.在`setter`时候,通知观察这更新
set: function reactiveSetter (newVal) {
dep.notify()
}
})
}
总结:
-
依赖收集是怎样的一个过程?
首先收集是发生在render时,渲染函数对数据的获取被监听器
Observer
拦截到了比如这里有个属性name,render的时候,对name进行值的获取
监听器
Observer
拦截获取操作,并触发name属性的getter
getter
对当前的观察者进行依赖收集:dep.depend()
->dep.addSub(watcher)
-
谁收集谁?
收集过程中提到了3个角色:
- 监听器
Observer
- 观察者
Watcher
- 发布者
Dep
根据第一点得出结论:发布者Dep收集观察者Watcher
- 监听器
等等....那收集的目的是啥?
往下看....
数据更新通知
<template>
<div>
My name is {{name}}.
</div>
</template>
<script>
export default {
data() {
return {
name: '?'
}
},
created() {
setTimeout(() => {
this.name = 'Yu';
}, 2000);
}
}
</script>
上面组件中,有一个属性值name,和一个钩子事件:在2s后更新name的值为Yu
在初次render时候,字符串模版渲染对name属性进行了获取,被name属性的监听器监听到,并触发了getter
,然后发布者dep将当前的观察者watcher收集作为了依赖。
2s后,name属性被更新了,这时候,同样被name属性的监听器监听到,但触发的是setter
,根据上面提到的:
// 2.在`setter`时候,通知观察这更新
set: function reactiveSetter (newVal) {
dep.notify()
}
class Dep {
subs: Array<Watcher>;
notify () {
// stabilize the subscriber list first
const subs = this.subs.slice()
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update()
}
}
}
所以,上面的答案就在此:
setter
里面做的就是发布者通知已被收集到的观察者进行更新,即:watcher.update()
由此进入数据更新过程
数据更新过程
下面就是更新函数,主要逻辑:
- 数据更新是不是惰性的
lazy
,是的话就标记为脏数据,在数据被获取的时候,在根据是否脏数据进行重新计算。我了解到的只有计算属性了computed
. - 数据更新是不是同步的
sync
,是的话立即执行更新watcher.run()
- 否则,将更新推到一个异步更新队列,后续统一更新
class Watcher {
/**
* Subscriber interface.
* Will be called when a dependency changes.
*/
update () {
/* istanbul ignore else */
if (this.lazy) {
this.dirty = true
} else if (this.sync) {
this.run()
} else {
queueWatcher(this)
}
}
}
整个过程粗略的看Vue源码思维导图中数据更新过程
在这里不再详细描述。
这里重点有个知识点异步更新队列,可前往异步更新队列
异步更新队列
/**
* 将观察者推入观察者队列。
* 具有重复ID的watcher将被跳过
* 在刷新队列时推送。
*/
export function queueWatcher (watcher: Watcher) {
const id = watcher.id
// 如果有重复ID的watcher将被忽略
if (has[id] == null) {
has[id] = true
// 如果没有在冲洗,即还没有开始清空队列
// 将任务watcher添加到队列中
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
}
// 不过这里是异步的,就是说:
// 在真正冲洗队列前,还有新的watcher被添加到队列中,
// 直到所有同步代码执行完毕,没有新的watcher了,才是真正开始冲洗
nextTick(flushSchedulerQueue)
}
}
}
冲洗队列
这块的代码会比较简单,就是遍历的去执行每个watcher的run方法,重新计算新的值,并执行watch的cb函数 还有就是对watcher的执行状态进行管理,保证每个watcher只会被执行一次。
function flushSchedulerQueue () {
for (index = 0; index < queue.length; index++) {
watcher = queue[index]
if (watcher.before) {
watcher.before()
}
id = watcher.id
has[id] = null
// 其实就执行观察者的run方法
watcher.run()
}
}
class Watcher {
run () {
if (this.active) {
const value = this.get()
if (
value !== this.value ||
// Deep watchers and watchers on Object/Arrays should fire even
// when the value is the same, because the value may
// have mutated.
isObject(value) ||
this.deep
) {
// set new value
const oldValue = this.value
this.value = value
if (this.user) {
try {
this.cb.call(this.vm, value, oldValue)
} catch (e) {
handleError(e, this.vm, `callback for watcher "${this.expression}"`)
}
} else {
this.cb.call(this.vm, value, oldValue)
}
}
}
}
}
nextTick
在下次 DOM 更新循环结束之后执行延迟回调,在修改数据之后立即使用 nextTick 来获取更新后的 DOM。
nextTick
有两种使用方式
- 传了
cb
函数回调,不会返回一个Promise
- 没有传cb函数回调,会返回一个
Promise
先上源码:
export function nextTick (cb?: Function, ctx?: Object) {
// promise的resolve控制器
let _resolve
// 根据不同参数,重新包装传进来的`cb`或者`_resolve`
callbacks.push(() => {
// 如果有cb回调,尝试的去调用
if (cb) {
try {
cb.call(ctx)
} catch (e) {
handleError(e, ctx, 'nextTick')
}
} else if (_resolve) {
// 不然的话,就是第二种使用方式,返回一个`Promise`
_resolve(ctx)
}
})
// 这里的代码目的也是保证一轮更新只会被启动一次,不会启动多次
if (!pending) {
pending = true
// 一个根据浏览器差异返回最终的一个异步方案
timerFunc()
}
// 如果传了没有传cb参数,并且客户端有`Promise`这个对象,就返回一个`Promise`
// 也就是第二种使用方式
// $flow-disable-line
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}
nextTick主要使用了宏任务和微任务
根据上面nextTick发现有个timerFunc
函数:
这个函数主要是根据执行环境分别尝试采用:
Promise、MutationObserver、setImmediate
如果以上都不行则采用setTimeout定义了一个异步方法,
多次调用nextTick会将方法存入队列中,通过这个异步方法清空当前队列。
结尾:好了,数据的整个大概的更新过程就是这样子的了,如果还有疑问或者有很重要的细节漏了,请反馈一下~