跳到主要内容

Vue2 虚拟dom

· 阅读需 14 分钟

我们知道,当 Vue 模板编译完成后会生成一个 render 函数,该函数内部会通过 createElement 创建虚拟 dom 并返回。

本节将展示 vue 是如何创建虚拟 dom 的,以及如何进行虚拟 dompatch

在开始之前需要先明确下 VNode 的定义:

export default class VNode {
tag: string | void // 节点标签名
data: VNodeData | void // 节点数据
children: ?Array<VNode> // 节点的子节点
text: string | void // 文本节点的值
elm: Node | void // 真实的dom节点
ns: string | void // 命名空间
context: Component | void // 编译的作用域
key: string | number | void // 节点的key值
componentOptions: VNodeComponentOptions | void
componentInstance: Component | void // 组件的实例
parent: VNode | void // 父节点
raw: boolean // contains raw HTML? (server only)
isStatic: boolean // 是否是静态节点
isRootInsert: boolean // 是否作为根节点插入
isComment: boolean // 注释节点
isCloned: boolean // 克隆节点
isOnce: boolean // v-once指令节点
asyncFactory: Function | void // async component factory function
asyncMeta: Object | void
isAsyncPlaceholder: boolean
ssrContext: Object | void
fnContext: Component | void // real context vm for functional nodes
fnOptions: ?ComponentOptions // for SSR caching
devtoolsMeta: ?Object // used to store functional render context for devtools
fnScopeId: ?string // functional scope id support
}

VNode 对象就是虚拟 DOM 的节点对象,其内部属性很好的描述了一个 HTML 标签所包含的内容。

下面介绍下 Vue 是如何创建虚拟 DOM

创建虚拟 DOM

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()
}
}

当首次渲染时,会创建 Watcher 实例

new Watcher(
vm,
updateComponent,
noop,
{
before() {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate')
}
}
},
true /* isRenderWatcher */
)

updateComponent 首先会执行 render 方法得到 VNode ,之后执行_update方法将虚拟 Dom 渲染成真实DOM

虚拟 DOM 生成真实 DOM

渲染成真实DOM_update 方法定义在src/core/instance/lifecycle.js文件中

Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
const vm: Component = this
const prevEl = vm.$el
const prevVnode = vm._vnode
const restoreActiveInstance = setActiveInstance(vm)
vm._vnode = vnode
// based on the rendering backend used.
if (!prevVnode) {
// initial render
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// updates
vm.$el = vm.__patch__(prevVnode, vnode)
}
restoreActiveInstance()
}

_update 的核心就是调用 vm.__patch__ 方法。其定义在src/platforms/web/runtime/patch.js

import * as nodeOps from 'web/runtime/node-ops'
import { createPatchFunction } from 'core/vdom/patch'
import baseModules from 'core/vdom/modules/index'
import platformModules from 'web/runtime/modules/index'

// the directive module should be applied last, after all
// built-in modules have been applied.
const modules = platformModules.concat(baseModules)

export const patch: Function = createPatchFunction({ nodeOps, modules })

该方法的定义是调用 createPatchFunction 方法的返回值,这里传入了一个对象,包含 nodeOps 参数和 modules 参数。其中,nodeOps 封装了一系列 DOM 操作的方法,modules 定义了一些模块的钩子函数的实现。而createPatchFunction定义在 src/core/vdom/patch.js 中:

const hooks = ['create', 'activate', 'update', 'remove', 'destroy']

export function createPatchFunction(backend) {
let i, j
const cbs = {}

const { modules, nodeOps } = backend

for (i = 0; i < hooks.length; ++i) {
cbs[hooks[i]] = []
for (j = 0; j < modules.length; ++j) {
if (isDef(modules[j][hooks[i]])) {
cbs[hooks[i]].push(modules[j][hooks[i]])
}
}
}

// ...

return function patch(oldVnode, vnode, hydrating, removeOnly) {
if (isUndef(vnode)) {
if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
return
}

let isInitialPatch = false
const insertedVnodeQueue = []

if (isUndef(oldVnode)) {
// empty mount (likely as component), create new root element
isInitialPatch = true
createElm(vnode, insertedVnodeQueue)
} else {
const isRealElement = isDef(oldVnode.nodeType)
if (!isRealElement && sameVnode(oldVnode, vnode)) {
// patch existing root node
patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly)
} else {
if (isRealElement) {
// mounting to a real element
// check if this is server-rendered content and if we can perform
// a successful hydration.
if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
oldVnode.removeAttribute(SSR_ATTR)
hydrating = true
}
if (isTrue(hydrating)) {
if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {
invokeInsertHook(vnode, insertedVnodeQueue, true)
return oldVnode
} else if (process.env.NODE_ENV !== 'production') {
warn(
'The client-side rendered virtual DOM tree is not matching ' +
'server-rendered content. This is likely caused by incorrect ' +
'HTML markup, for example nesting block-level elements inside ' +
'<p>, or missing <tbody>. Bailing hydration and performing ' +
'full client-side render.'
)
}
}
// either not server-rendered, or hydration failed.
// create an empty node and replace it
oldVnode = emptyNodeAt(oldVnode)
}

// replacing existing element
const oldElm = oldVnode.elm
const parentElm = nodeOps.parentNode(oldElm)

// 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)
)

// update parent placeholder node element, recursively
if (isDef(vnode.parent)) {
let ancestor = vnode.parent
const patchable = isPatchable(vnode)
while (ancestor) {
for (let i = 0; i < cbs.destroy.length; ++i) {
cbs.destroy[i](ancestor)
}
ancestor.elm = vnode.elm
if (patchable) {
for (let i = 0; i < cbs.create.length; ++i) {
cbs.create[i](emptyNode, ancestor)
}
// #6513
// invoke insert hooks that may have been merged by create hooks.
// e.g. for directives that uses the "inserted" hook.
const insert = ancestor.data.hook.insert
if (insert.merged) {
// start at index 1 to avoid re-invoking component mounted hook
for (let i = 1; i < insert.fns.length; i++) {
insert.fns[i]()
}
}
} else {
registerRef(ancestor)
}
ancestor = ancestor.parent
}
}

// destroy old node
if (isDef(parentElm)) {
removeVnodes(parentElm, [oldVnode], 0, 0)
} else if (isDef(oldVnode.tag)) {
invokeDestroyHook(oldVnode)
}
}
}

invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
return vnode.elm
}
}

createPatchFunction 最终会返回了一个 patch 方法,这个方法就赋值给了 vm._update 函数里调用的 vm.patch

patch方法接收四个参数

  • oldVnode: 旧的虚拟节点或旧的真实 dom 节点
  • vnode: 新的虚拟节点
  • hydrating: 是否是服务端渲染
  • removeOnly: 用于 <transition-group> 组件

根据源码可以知道 patch 的过程

  • 如果vnode不存在且oldVnode存在,则说明要是销毁oldVnode
  • 如果vnode存在但oldVnode不存在,则说明需要创建新的节点直接调用createElm
  • 如果vnodeoldVnode同时存在
    • oldVnodevnode指向同一个对象且oldVnode不是真实的 dom 节点,则调用 patchVnode
    • oldVnodevnode不是同一个对象,如果oldVnode是真实 dom 节点且hydrating为 true,则调用hydrate函数将虚拟 dom 和真是 dom 进行映射
if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {
invokeInsertHook(vnode, insertedVnodeQueue, true)
return oldVnode
} else if (process.env.NODE_ENV !== 'production') {
warn(...)
}

明确流程后,就可以看看真正的patch算法,理解oldVnodevnode如何去对比的。该算法流程定义在patchVnode函数中

patch算法:

  • oldVnodevnode相同,则直接返回
  • oldVnodevnode都是静态节点,且key相同,且当vnode是克隆节点或是v-once指令控制的节点时,只需要把oldVnode.elmoldVnode.child都复制到 vnode 上
  • 否则,如果vnode不是文本节点或注释节点
    • 如果oldVnodevnode都有子节点,且他们的子节点指向的对象不一致,就执行更新子节点的操作,即执行 updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
      • 分别获取 oldVnodevnode firstChildlastChild,赋值给 oldStartVnodeoldEndVnodenewStartVnodenewEndVnode
      • 如果oldStartVnodenewStartVnode是同一节点,调用patchVnode进行patch,并将oldStartVnodenewStartVnode都设置为下一个子节点,重复上述流程
      • 如果oldEndVnodenewEndVnode是同一节点,调用patchVnode进行patch,并将oldEndVnodenewEndVnode都设置为上一个子节点,重复上述流程
      • 如果oldStartVnodenewEndVnode是同一节点,调用patchVnode进行patch,如果removeOnlyfalse,那么可以把oldStartVnode.elm移动到oldEndVnode.elm之后,然后把oldStartVnode设置为下一个节点,newEndVnode设置为上一个节点,重复上述流程
      • 如果newStartVnodeoldEndVnode是同一节点,调用patchVnode进行patch,如果removeOnlyfalse,那么可以把oldEndVnode.elm移动到oldStartVnode.elm之前,然后把newStartVnode设置为下一个节点,oldEndVnode设置为上一个节点,重复上述流程
      • 如果以上都不匹配,就尝试在oldChildren中寻找跟newStartVnode具有相同key的节点,如果找不到相同key的节点,说明newStartVnode是一个新节点,就创建一个,然后把newStartVnode设置为下一个节点
      • 如果上一步找到了跟newStartVnode相同key的节点,那么通过其他属性的比较来判断这 2 个节点是否是同一个节点,如果是,就调用patchVnode进行patch,如果removeOnlyfalse,就把newStartVnode.elm插入到oldStartVnode.elm之前,把newStartVnode设置为下一个节点,重复上述流程
      • 如果在oldChildren中没有寻找到newStartVnode的同一节点,那就创建一个新节点,把newStartVnode设置为下一个节点,重复上述流程
      • 如果oldStartVnodeoldEndVnode重合了,并且newStartVnodenewEndVnode也重合了,这个循环就结束了
    • 如果只有oldVnode有子节点,那就把这些节点都删除
    • 如果只有vnode有子节点,那就创建这些子节点
    • 如果oldVnodevnode都没有子节点,但是oldVnode是文本节点或注释节点,就把vnode.elm的文本设置为空字符串
  • 如果vnode文本节点或注释节点,但是vnode.text != oldVnode.text,只需要更新node.elm文本内容就可以

以上便是 dom 更新的流程中