DOM
是文档对象模型(Document Object Model
)的简写,在浏览器中我们可以通过js来操作DOM
,但是这样的操作性能很差,于是Virtual Dom
应运而生。我的理解,Virtual Dom
就是在js中模拟DOM
对象树来优化DOM
操作的一种技术或思路。
本文将对于Vue框架2.1.8版本中使用的Virtual Dom
进行分析。
VNode对象
一个VNode的实例对象包含了以下属性
tag
: 当前节点的标签名data
: 当前节点的数据对象,具体包含哪些字段可以参考vue源码types/vnode.d.ts
中对VNodeData
的定义children
: 数组类型,包含了当前节点的子节点text
: 当前节点的文本,一般文本节点或注释节点会有该属性elm
: 当前虚拟节点对应的真实的dom节点ns
: 节点的namespacecontext
: 编译作用域functionalContext
: 函数化组件的作用域key
: 节点的key属性,用于作为节点的标识,有利于patch的优化componentOptions
: 创建组件实例时会用到的选项信息child
: 当前节点对应的组件实例parent
: 组件的占位节点raw
: raw htmlisStatic
: 静态节点的标识isRootInsert
: 是否作为根节点插入,被
包裹的节点,该属性的值为false
isComment
: 当前节点是否是注释节点isCloned
: 当前节点是否为克隆节点isOnce
: 当前节点是否有v-once
指令
VNode分类
VNode
可以理解为vue框架的虚拟dom的基类,通过new
实例化的VNode
大致可以分为几类
EmptyVNode
: 没有内容的注释节点TextVNode
: 文本节点ElementVNode
: 普通元素节点ComponentVNode
: 组件节点CloneVNode
: 克隆节点,可以是以上任意类型的节点,唯一的区别在于isCloned
属性为true
...
createElement解析
const SIMPLE_NORMALIZE = 1
const ALWAYS_NORMALIZE = 2
function createElement (context, tag, data, children, normalizationType, alwaysNormalize) {
// 兼容不传data的情况
if (Array.isArray(data) || isPrimitive(data)) {
normalizationType = children
children = data
data = undefined
}
// 如果alwaysNormalize是true
// 那么normalizationType应该设置为常量ALWAYS_NORMALIZE的值
if (alwaysNormalize) normalizationType = ALWAYS_NORMALIZE
// 调用_createElement创建虚拟节点
return _createElement(context, tag, data, children, normalizationType)
}
function _createElement (context, tag, data, children, normalizationType) {
/**
* 如果存在data.__ob__,说明data是被Observer观察的数据
* 不能用作虚拟节点的data
* 需要抛出警告,并返回一个空节点
*
* 被监控的data不能被用作vnode渲染的数据的原因是:
* data在vnode渲染过程中可能会被改变,这样会触发监控,导致不符合预期的操作
*/
if (data && data.__ob__) {
process.env.NODE_ENV !== 'production' && warn(
`Avoid using observed data object as vnode data: ${JSON.stringify(data)}\n` +
'Always create fresh vnode data objects in each render!',
context
)
return createEmptyVNode()
}
// 当组件的is属性被设置为一个falsy的值
// Vue将不会知道要把这个组件渲染成什么
// 所以渲染一个空节点
if (!tag) {
return createEmptyVNode()
}
// 作用域插槽
if (Array.isArray(children) &&
typeof children[0] === 'function') {
data = data || {}
data.scopedSlots = { default: children[0] }
children.length = 0
}
// 根据normalizationType的值,选择不同的处理方法
if (normalizationType === ALWAYS_NORMALIZE) {
children = normalizeChildren(children)
} else if (normalizationType === SIMPLE_NORMALIZE) {
children = simpleNormalizeChildren(children)
}
let vnode, ns
// 如果标签名是字符串类型
if (typeof tag === 'string') {
let Ctor
// 获取标签名的命名空间
ns = config.getTagNamespace(tag)
// 判断是否为保留标签
if (config.isReservedTag(tag)) {
// 如果是保留标签,就创建一个这样的vnode
vnode = new VNode(
config.parsePlatformTagName(tag), data, children,
undefined, undefined, context
)
// 如果不是保留标签,那么我们将尝试从vm的components上查找是否有这个标签的定义
} else if ((Ctor = resolveAsset(context.$options, 'components', tag))) {
// 如果找到了这个标签的定义,就以此创建虚拟组件节点
vnode = createComponent(Ctor, data, context, children, tag)
} else {
// 兜底方案,正常创建一个vnode
vnode = new VNode(
tag, data, children,
undefined, undefined, context
)
}
// 当tag不是字符串的时候,我们认为tag是组件的构造类
// 所以直接创建
} else {
vnode = createComponent(tag, data, context, children)
}
// 如果有vnode
if (vnode) {
// 如果有namespace,就应用下namespace,然后返回vnode
if (ns) applyNS(vnode, ns)
return vnode
// 否则,返回一个空节点
} else {
return createEmptyVNode()
}
}
简单的梳理了一个流程图,可以参考下
patch原理
patch
函数的定义在src/core/vdom/patch.js
中,我们先来看下这个函数的逻辑
patch
函数接收6个参数:
oldVnode
: 旧的虚拟节点或旧的真实dom节点vnode
: 新的虚拟节点hydrating
: 是否要跟真是dom混合removeOnly
: 特殊flag,用于
组件parentElm
: 父节点refElm
: 新节点将插入到refElm
之前
patch
的策略是:
如果
vnode
不存在但是oldVnode
存在,说明意图是要销毁老节点,那么就调用invokeDestroyHook(oldVnode)
来进行销毁如果
oldVnode
不存在但是vnode
存在,说明意图是要创建新节点,那么就调用createElm
来创建新节点-
当
vnode
和oldVnode
都存在时如果
oldVnode
和vnode
是同一个节点,就调用patchVnode
来进行patch
当
vnode
和oldVnode
不是同一个节点时,如果oldVnode
是真实dom节点或hydrating
设置为true
,需要用hydrate
函数将虚拟dom和真是dom进行映射,然后将oldVnode
设置为对应的虚拟dom,找到oldVnode.elm
的父节点,根据vnode创建一个真实dom节点并插入到该父节点中oldVnode.elm
的位置
这里面值得一提的是
patchVnode
函数,因为真正的patch算法是由它来实现的(patchVnode中更新子节点的算法其实是在updateChildren
函数中实现的,为了便于理解,我统一放到patchVnode
中来解释)。
patchVnode
算法是:
如果
oldVnode
跟vnode
完全一致,那么不需要做任何事情如果
oldVnode
跟vnode
都是静态节点,且具有相同的key
,当vnode
是克隆节点或是v-once
指令控制的节点时,只需要把oldVnode.elm
和oldVnode.child
都复制到vnode
上,也不用再有其他操作-
否则,如果
vnode
不是文本节点或注释节点-
如果
oldVnode
和vnode
都有子节点,且2方的子节点不完全一致,就执行更新子节点的操作(这一部分其实是在updateChildren
函数中实现),算法如下分别获取
oldVnode
和vnode
的firstChild
、lastChild
,赋值给oldStartVnode
、oldEndVnode
、newStartVnode
、newEndVnode
如果
oldStartVnode
和newStartVnode
是同一节点,调用patchVnode
进行patch
,然后将oldStartVnode
和newStartVnode
都设置为下一个子节点,重复上述流程如果
oldEndVnode
和newEndVnode
是同一节点,调用patchVnode
进行patch
,然后将oldEndVnode
和newEndVnode
都设置为上一个子节点,重复上述流程如果
oldStartVnode
和newEndVnode
是同一节点,调用patchVnode
进行patch
,如果removeOnly
是false
,那么可以把oldStartVnode.elm
移动到oldEndVnode.elm
之后,然后把oldStartVnode
设置为下一个节点,newEndVnode
设置为上一个节点,重复上述流程如果
newStartVnode
和oldEndVnode
是同一节点,调用patchVnode
进行patch
,如果removeOnly
是false
,那么可以把oldEndVnode.elm
移动到oldStartVnode.elm
之前,然后把newStartVnode
设置为下一个节点,oldEndVnode
设置为上一个节点,重复上述流程如果以上都不匹配,就尝试在
oldChildren
中寻找跟newStartVnode
具有相同key
的节点,如果找不到相同key
的节点,说明newStartVnode
是一个新节点,就创建一个,然后把newStartVnode
设置为下一个节点如果上一步找到了跟
newStartVnode
相同key
的节点,那么通过其他属性的比较来判断这2个节点是否是同一个节点,如果是,就调用patchVnode
进行patch
,如果removeOnly
是false
,就把newStartVnode.elm
插入到oldStartVnode.elm
之前,把newStartVnode
设置为下一个节点,重复上述流程如果在
oldChildren
中没有寻找到newStartVnode
的同一节点,那就创建一个新节点,把newStartVnode
设置为下一个节点,重复上述流程如果
oldStartVnode
跟oldEndVnode
重合了,并且newStartVnode
跟newEndVnode
也重合了,这个循环就结束了
如果只有
oldVnode
有子节点,那就把这些节点都删除如果只有
vnode
有子节点,那就创建这些子节点如果
oldVnode
和vnode
都没有子节点,但是oldVnode
是文本节点或注释节点,就把vnode.elm
的文本设置为空字符串
-
如果
vnode
是文本节点或注释节点,但是vnode.text != oldVnode.text
时,只需要更新vnode.elm
的文本内容就可以
生命周期
patch
提供了5个生命周期钩子,分别是
create
: 创建patch时activate
: 激活组件时update
: 更新节点时remove
: 移除节点时destroy
: 销毁节点时
这些钩子是提供给Vue内部的directives
/ref
/attrs
/style
等模块使用的,方便这些模块在patch的不同阶段进行相应的操作,这里模块定义在src/core/vdom/modules
和src/platforms/web/runtime/modules
2个目录中
vnode
也提供了生命周期钩子,分别是
init
: vdom初始化时create
: vdom创建时prepatch
: patch之前insert
: vdom插入后update
: vdom更新前postpatch
: patch之后remove
: vdom移除时destroy
: vdom销毁时
vue组件的生命周期底层其实就依赖于vnode的生命周期,在src/core/vdom/create-component.js
中我们可以看到,vue为自己的组件vnode已经写好了默认的init
/prepatch
/insert
/destroy
,而vue组件的mounted
/activated
就是在insert
中触发的,deactivated
就是在destroy
中触发的
实践
在Vue里面,Vue.prototype.$createElement
对应vdom的createElement
方法,Vue.prototype.__patch__
对应patch
方法,我写了个简单的demo来验证下功能
See the Pen Vue Virtual Dom by zhulei (@JoeRay) on CodePen.