大名鼎鼎的虚拟 DOM
什么是虚拟 DOM
在浏览器中,HTML 页面由基本的 DOM 树组成,当其中一部分发生变化时,其实就是对应某个 DOM 节点发生了变化,当 DOM 节点发生变化时就会触发对应的重绘或者重排,当过多的重绘和重排在短时间内发生时,就可能会引起页面卡顿,所以改变 DOM 是有一些代价的,如何优化 DOM 变化的次数以及在合适的时机改变 DOM 就是开发者需要注意的事情。
虚拟 DOM 就是为了解决上述浏览器性能问题而被设计出来的。当一次操作中有 10 次更新 DOM 的动作时,虚拟 DOM 不会立即操作 DOM,而是和原本的 DOM 进行对比,将这 10 次更新的变化部分内容保存到内存中,最终一次性地应用在 DOM 树上,再进行后续操作,避免大量无谓的计算量。虚拟 DOM 实际上就是采用 JavaScript 对象来存储 DOM 节点的信息,将 DOM 的更新变成对象的修改,并且这些修改计算在内存中发生,当修改完成后,再将 JavaScript 转换成真实的 DOM 节点,交给浏览器,从而达到性能的提升。
例如下面一段 DOM 节点,代码如下:
<div id="app">
<p class="text">Hello</p>
</div>
转换成一般的虚拟 DOM 对象结构,代码如下:
{
tag: 'div',
props: {
id: 'app'
},
chidren: [
{
tag: 'p',
props: {
className: 'text'
},
chidren: [
'Hello'
]
}
]
}
上面这段代码是一个基本的虚拟 DOM,但是并非是 Vue 中使用的虚拟 DOM 结构,因为 Vue 要复杂得多。
Vue 3 虚拟 DOM
在 Vue 中,我们写在 <template>
标签内的内容都属于 DOM 节点,这部分内容最终会被转换成 Vue 中的虚拟 DOM 对象 VNode,其中的步骤比较复杂,主要有以下几个过程:
-
抽取
<template>
内容进行编译。 -
得到抽象语法树(Abstract Syntax Tree, AST),并生成
render
方法。 -
执行
render
方法得到 VNode 对象。 -
VNode 转换为真实 DOM 并渲染到页面中。
我们以一个简单的 demo
为例,demo
代码如下:
<div id="app">
<div>
{{name}}
</div>
<p>123</p>
</div>
Vue.createApp({
data(){
return {
name : 'abc'
}
}
}).mount("#app")
上面的代码中,data
中定义了一个响应式数据 name
,在 template
中使用插值表达式 {{name}}
进行展示,还有一个静态节点 <p>123</p>。
获取 <template> 的内容
在 demo
代码的第 1 行调用 createApp()
方法,会进入源码 packages/runtime-dom/src/index.ts
中的 createApp()
方法,代码如下:
export const createApp = ((...args) => {
const app = ensureRenderer().createApp(...args)
...
app.mount = (containerOrSelector: Element | ShadowRoot | string): any => {
if (!isFunction(component) && !component.render && !component.template) {
// 将#app绑定的HTML内容赋值该template项
component.template = container.innerHTML
// 调用mount方法渲染
const proxy = mount(container, false, container instanceof SVGElement)
return proxy
}
}
...
return app
}) as CreateAppFunction<Element>
对于根组件来说,<template>
的内容由挂载的 #app
元素里面的内容组成,如果项目采用 npm
和 Vue Cli+Webpack 这种前端工程化的方式,那么对于 <template>
的内容,主要由对应的 loader
在构建时对文件进行处理来获取,这和在浏览器运行时的处理方式是不一样的。
生成 AST
在得到 <template>
后,就依据内容生成 AST。AST 是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。之所以说语法是 “抽象” 的,是因为这里的语法并不会表示出真实语法中出现的每个细节。比如,嵌套括号被隐含在树的结构中,并没有以节点的形式呈现而类似于 if-condition-then
这样的条件跳转语句,可以使用带有三个分支的节点来表示。代码如下:
while b ≠ 0
if a > b
a := a ? b
else
b := b ? a
return a
将上述代码转换成广泛意义上的语法树,如图 11-2 所示。

对于 <template>
的内容,其大部分是由 DOM 组成的,但是也会有 if-condition-then
这样的条件语句,例如 v-if
、v-for
指令等。在 Vue 3 中,这部分逻辑在源码 packages\compiler-core\src\compile.ts
的 baseCompile
方法中,核心代码如下:
export function baseCompile(
template: string | RootNode,
options: CompilerOptions = {}
): CodegenResult {
...
// 通过template生成AST结构
const ast = isString(template) ? baseParse(template, options) : template
...
// 转换
transform(
ast,
...
)
return generate(
ast,
extend({}, options, {
prefixIdentifiers
})
)
}
baseCompile
方法主要做了以下事情:
-
生成 Vue 中的 AST 对象。
-
将 AST 对象作为参数传入
transform
函数,进行转换。 -
将转换后的 AST 对象作为参数传入
generate
函数,生成render
函数。
其中,baseParse
方法用来创建 AST 对象。在 Vue 3
中,AST 对象是一个 RootNode
类型的树状结构,在源码 packages\compiler-core\src\ast.ts
中,其结构如下:
export function createRoot (
children: TemplateChildNode[],
loc = locStub
): RootNode {
return {
type: NodeTypes.ROOT, // 元素类型
children, // 子元素
helpers: [], // 帮助函数
components: [], // 子组件
directives: [], // 指令
hoists: [], // 标识静态节点
imports: [],
cached: 0, // 缓存标志位
temps: 0,
codegenNode: undefined, // 存储生成 render 函数的字符串
loc // 描述元素在 AST 的位置信息
}
}
其中,children
存储的是后代元素节点的数据,这就构成一个 AST 结构,type
表示元素的类型 NodeType
,主要分为 HTML 普通类型和 Vue 指令类型等,常见的有以下几种:
ROOT, // 根元素 0
ELEMENT, // 普通元素 1
TEXT, // 文本元素 2
COMMENT, // 注释元素 3
SIMPLE_EXPRESSION, // 表达式 4
INTERPOLATION, // 插值表达式 {{ }} 5
ATTRIBUTE, // 属性 6
DIRECTIVE, // 指令 7
IF, // if节点 9
JS_CALL_EXPRESSION, // 方法调用 14
...
hoists
是一个数组,用来存储一些可以静态提升的元素,在后面的 transform
会将静态元素和响应式元素分开创建,这也是 Vue 3 中优化的体现;codegenNode
则用来存储最终生成的 render
方法的字符串;loc
表示元素在 AST 的位置信息。
在生成 AST 时,Vue 3 在解析 <template>
内容时会用一个栈来保存解析到的元素标签。当它遇到开始标签时,会将这个标签推入栈,遇到结束标签时,将刚才的标签弹出栈。它的作用是保存当前已经解析了但还没解析完的元素标签。这个栈还有另一个作用,在解析到某个字节点时,通过 stack[stack.length - 1]
可以获取它的父元素。
demo
代码中生成的 AST 如图 11-3 所示。

生成 render 方法字符串
在得到 AST 对象后,会进入 transform
方法,在源码 packages\compiler-core\src\transform.ts
中,其核心代码如下:
export function transform(root: RootNode, options: TransformOptions) {
// 数据组装
const context = createTransformContext(root, options)
// 转换代码
traverseNode(root, context)
// 静态提升
if (options.hoistStatic) {
hoistStatic(root, context)
}// 服务端渲染
if (!options.ssr) {
createRootCodegen(root, context)
}
// 透传元信息
root.helpers = [...context.helpers.keys()]
root.components = [...context.components]
root.directives = [...context.directives]
root.imports = context.imports
root.hoists = context.hoists
root.temps = context.temps
root.cached = context.cached
if (__COMPAT__) {
root.filters = [...context.filters!]
}
}
transform
方法主要是对 AST 进行进一步转化,为 generate
函数生成 render
方法做准备,主要做了以下事情:
-
traverseNode
方法将会递归地检查和解析 AST 元素节点的属性,例如结合helpers
方法对@click
等事件添加对应的方法和事件回调,对插值表达式、指令、props
添加动态绑定等。 -
处理类型逻辑包括静态提升逻辑,将静态节点赋值给
hoists
,以及为不同类型的节点打上不同的patchFlag
,以便于后续diff
使用。 -
在 AST 上绑定并透传一些元数据。
generate
方法主要是生成 render
方法的字符串 code
,在源码 packages\compiler-core\src\codegen.ts
中,其核心代码如下:
export function generate(
ast: RootNode,
options: CodegenOptions & {
onContextCreated?: (context: CodegenContext) => void
} = {}
): CodegenResult {
const context = createCodegenContext(ast, options)
if (options.onContextCreated) options.onContextCreated(context)
const {
mode,
push,
prefixIdentifiers,
indent,
deindent,
newline,
scopeId,
ssr
} = context
...
// 缩进处理
indent()
deindent()
// 单独处理component、directive、filters
genAssets()
// 处理NodeTypes中的所有类型
genNode(ast.codegenNode, context)
...
// 返回code字符串
return {
ast,
code: context.code,
preamble: isSetupInlined ? preambleContext.code : ``,
// SourceMapGenerator does have toJSON() method but it's not in the types
map: context.map ? (context.map as any).toJSON() : undefined
}
}
generate
方法的核心逻辑在 genNode
方法中,其逻辑是根据不同的 NodeTypes
类型构造出不同的 render
方法字符串,部分代码如下:
switch (node.type) {
case NodeTypes.ELEMENT:
case NodeTypes.IF:
case NodeTypes.FOR:// for关键字元素节点
genNode(node.codegenNode!, context)
break
case NodeTypes.TEXT:// 文本元素节点
genText(node, context)
break
case NodeTypes.VNODE_CALL:// 核心:VNode混合类型节点(AST节点)
genVNodeCall(node, context)
break
case NodeTypes.COMMENT: // 注释元素节点
genComment(node, context)
break
case NodeTypes.JS_FUNCTION_EXPRESSION:// 方法调用节点
genFunctionExpression(node, context)
break
...
其中:
-
节点类型 NodeTypes.VNODE_CALL 对应 genVNodeCall 方法和 ast.ts 文件中的 createVNodeCall 方法,后者用来返回 VNodeCall,前者生成对应的 VNodeCall 这部分 render 方法字符串,是整个 render 方法字符串的核心。
-
节点类型 NodeTypes.FOR 对应 for 关键字元素节点,其内部递归地调用了 genNode 方法。
-
节点类型 NodeTypes.TEXT 对应文本元素节点,负责静态文本的生成。
-
节点类型 NodeTypes.JS_FUNCTION_EXPRESSION 对应方法调用节点,负责方法表达式的生成。
经过一系列的加工,最终生成的 render 方法字符串结果如下:

上面的代码中,_createElementVNode 和 _openBlock 是上一步传进来的 helper 方法。其中 <p>123</p> 这种属于没有响应式绑定的静态节点会被单独区分,而动态节点会使用 createElementVNode 方法来创建,最终这两种节点都会进入 createElementBlock 方法进行 VNode 的创建。
在 render 方法中使用了 with 关键字,with 的作用如下:
const obj = {
a:1
}
with(obj){
console.log(a) // 打印1
}
在 with(_ctx) 包裹下,我们在 data 中定义的响应式变量才能正常使用,例如调用 _toDisplayString(name),其中 name 就是响应式变量。
得到最终的 VNode 对象
最终,这是一段可执行代码,会赋值给组件的 Component.render 方法,其源码在 packages\runtime-core\src\component.ts 中,代码如下:
...
Component.render = compile(template, finalCompilerOptions)
...
if (installWithProxy) { // 绑定代理
installWithProxy(instance)
}
...
compile 方法最初是 baseCompile 方法的入口,在完成赋值后,还需要绑定代理,执行 installWithProxy 方法,其源码在 runtime-core/src/component.ts 中,代码如下:

这主要是给 render 中 _ctx 的响应式变量添加绑定,当上面的 render 方法中的 name 被使用时,可以通过代理监听到调用,这样就会响应式地监听收集 track,当触发 trigger 监听时进行 diff。
在 runtime-core/src/componentRenderUtils.ts 源码中的 renderComponentRoot 方法中会执行 render 方法得到 VNode 对象,其核心代码如下:
export function renderComponentRoot(){
// 执行render
let result = normalizeVNode(render!.call(
proxyToUse,
proxyToUse!,
renderCache,
props,
setupState,
data,
ctx
))
...
return result
}
demo 代码中最终得到的 VNode 对象如图11-4所示。

图11-4就是通过 render 方法运行后得到的 VNode 对象,可以看到 children 和 dynamicChildren 的区别:前者包括两个子节点,分别是 <div> 和 <p> ,这个和在 <template> 中定义的内容是对应的;而后者只存储了动态节点,包括动态 props,即 data-a 属性。同时,VNode 也是树状结构,通过 children 和 dynamicChildren一层一层地递进下去。
在通过 render 方法得到 VNode 的过程也是对指令、插值表达式、响应式数据、插槽等一系列 Vue 语法的解析和构造过程,最终生成结构化的 VNode 对象,可以将整个过程总结成流程图,以便于读者理解,如图11-5所示。

另外,还有一个需要关注的属性是 patchFlag,这个是后面进行 VNode 的 diff 时所用到的标志位,数字 64 表示稳定不需要改变。最后得到 VNode 对象后,需要转换成真实的 DOM 节点,这部分逻辑是在虚拟 DOM 的 diff 中完成的,在后面的双向绑定原理解析中进行讲解。