Appearance
VNode 如何成为真实 DOM
当我们编写 <div id="app"></div> 时,Vue 会把它解释并渲染成真实的界面。而这个过程就要从一个js对象VNode开始。
VNode 的结构与创建
VNode 是一个用于描述 DOM 结构的 JavaScript 对象。它包含了 Vue 在创建真实 DOM 节点时所需的所有信息,例如标签类型、属性和子节点。
VNode 的核心结构定义位于 @vue/runtime-core/src/vnode.ts:
typescript
// core/packages/runtime-core/src/vnode.ts
export interface VNode {
// 核心标识,用于 isVNode() 判断
__v_isVNode: true
// 节点的类型,如 'div', 'p',或一个组件对象
type: VNodeTypes
// 节点的属性,如 id, class, style, 事件监听器等
props: (VNodeProps & ExtraProps) | null
// 子节点,可以是文本,也可以是 VNode 数组
children: VNodeNormalizedChildren
// 渲染后会指向真实的 DOM 元素
el: HostNode | null
// --- 优化的关键 ---
// 形状标记:用位运算快速判断 VNode 的类型
shapeFlag: number
// 补丁标记:编译器提供的优化提示
patchFlag: number
// 仅包含动态子节点的数组,用于 Block 优化
dynamicChildren: VNode[] | null
// ... 其他属性如 key, ref, component instance 等
}优化标记:ShapeFlags 与 PatchFlags
初看之下,shapeFlag 和 patchFlag 可能有些晦涩,但shapeFlag 和 patchFlag 是 Vue 3 实现高性能渲染的关键。
shapeFlag(形状标记): Vue 需要频繁判断 VNode 的类型(是元素、组件、还是文本?)。shapeFlag使用位运算来存储这些类型信息,这比使用if/else或typeof进行多次判断效率更高。typescriptexport const enum ShapeFlags { ELEMENT = 1, // 00000001 FUNCTIONAL_COMPONENT = 1 << 1, // 00000010 STATEFUL_COMPONENT = 1 << 2, // 00000100 TEXT_CHILDREN = 1 << 3, // 00001000 ARRAY_CHILDREN = 1 << 4, // 00010000 // ... }判断时,
if (vnode.shapeFlag & ShapeFlags.ELEMENT)这样的位运算操作非常快。patchFlag(补丁标记): 这是编译器在编译模板时,分析出动态内容后附加到 VNode 上的优化提示。例如,对于
<div :class="cls" style="color: red"></div>:class是动态的。style是静态的。- 文本
msg是动态的。
编译器会附加一个
patchFlag,如PatchFlags.CLASS | PatchFlags.TEXT。在更新(patch)时,运行时(runtime)会读取这个标记,只对比class和文本内容,跳过对style等静态部分的检查,实现靶向更新。
VNode 的创建:createVNode 函数
createVNode 是创建 VNode 的入口函数。其核心逻辑(位于 @vue/runtime-core/src/vnode.ts)如下:
typescript
// core/packages/runtime-core/src/vnode.ts
function _createVNode(
type: VNodeTypes,
props: (Data & VNodeProps) | null = null,
children: unknown = null,
patchFlag: number = 0,
// ...
): VNode {
// 1. 对 props 中的 class 和 style 进行归一化处理
if (props) {
props.class = normalizeClass(props.class)
props.style = normalizeStyle(props.style)
}
// 2. 根据 type 计算出 shapeFlag,确定 VNode 的基本类型
const shapeFlag = isString(type)
? ShapeFlags.ELEMENT // e.g., 'div'
: isObject(type)
? ShapeFlags.STATEFUL_COMPONENT // e.g., { setup() ... }
: isFunction(type)
? ShapeFlags.FUNCTIONAL_COMPONENT // e.g., (props) => {}
: 0
// 3. 调用 createBaseVNode 创建基础 VNode 对象
return createBaseVNode(
type,
props,
children,
patchFlag,
dynamicProps,
shapeFlag,
// ...
)
}createVNode 的职责是接收原始参数,进行预处理(如归一化),计算 shapeFlag,最后组装成一个结构统一的 VNode 对象,供后续的 patch 流程使用。
Patch 算法:VNode 的对比与更新
patch 函数是 Vue 渲染器的核心,位于 @vue/runtime-core/src/renderer.ts。
patch 函数接收 n1 (旧 VNode) 和 n2 (新 VNode)。它的核心任务是对比新旧 VNode,并以最小的 DOM 操作将 n1(旧 DOM)更新为 n2(新 DOM)所描述的状态。
typescript
// core/packages/runtime-core/src/renderer.ts
const patch: PatchFn = (
n1, // old vnode
n2, // new vnode
container,
// ...
) => {
// 场景1: VNode 相同,无需操作
if (n1 === n2) {
return
}
// 场景2: VNode 类型不同,卸载旧节点,挂载新节点
if (n1 && !isSameVNodeType(n1, n2)) {
unmount(n1, parentComponent, parentSuspense, true)
n1 = null // n1 置为 null,后续会执行挂载 n2
}
// 根据新 VNode (n2) 的类型进行分支处理
const { type, ref, shapeFlag } = n2
switch (type) {
case Text:
processText(n1, n2, container, anchor)
break
// ... 其他 case: Comment, Static, Fragment
default:
// 根据 shapeFlag 分发任务
if (shapeFlag & ShapeFlags.ELEMENT) {
// 处理普通 HTML 元素
processElement(n1, n2, container, anchor, ...)
} else if (shapeFlag & ShapeFlags.COMPONENT) {
// 处理 Vue 组件
processComponent(n1, n2, container, anchor, ...)
}
// ...处理其他类型如 Teleport, Suspense
}
}patch 函数体现了 Diff 算法的核心思想:同层比较。它首先进行宏观判断(节点是否相同、类型是否改变),然后根据 shapeFlag,将具体任务分发给 processElement 或 processComponent 等函数处理。
组件的挂载与更新
处理组件比处理普通元素更复杂,因为它涉及实例创建、生命周期和响应式系统的建立。
当 patch 将任务分派给 processComponent 时,它会根据 n1 是否为 null 来判断是挂载还是更新。挂载流程由 mountComponent 函数负责。
typescript
// core/packages/runtime-core/src/renderer.ts
const mountComponent: MountComponentFn = (
initialVNode,
container,
// ...
) => {
// 1. 创建组件实例
const instance: ComponentInternalInstance =
(initialVNode.component = createComponentInstance(
initialVNode,
parentComponent,
parentSuspense,
))
// 2. 初始化实例
// (解析 props, slots, 并执行 setup() 函数)
setupComponent(instance)
// 3. 设置渲染副作用
setupRenderEffect(
instance,
initialVNode,
container,
anchor,
parentSuspense,
namespace,
optimized,
)
}mountComponent 的执行分为三个关键步骤:
createComponentInstance:创建instance对象,它挂载了组件运行所需的所有上下文,如props,slots,emit和生命周期钩子。setupComponent:初始化实例,解析父组件传来的props和slots,然后执行开发者编写的setup()函数,获取组件的状态和方法。setupRenderEffect:这是连接响应式系统和渲染的核心。- 它内部会创建一个
effect(响应式副作用)。 - 在这个
effect内部,会立即执行组件的render()函数,生成组件的 VNode(称为subTree)。 - 然后,再次调用
patch函数,将这个subTree渲染成真实 DOM。 - 因为
render()在effect中执行,它会自动收集所有访问到的响应式数据作为依赖。当这些数据变化时,这个effect会被重新触发,从而实现组件的自动更新。
- 它内部会创建一个
nodeOps:跨平台 DOM 操作
Vue 的 patch 逻辑最终必须调用平台原生的 API(如 document.createElement)来操作 DOM。为了实现跨平台(例如渲染到 Canvas 或小程序),Vue 设计了 nodeOps 这一抽象层。
nodeOps 是一个包含所有底层平台操作方法的对象,它在创建渲染器时作为参数传入。
typescript
// core/packages/runtime-dom/src/nodeOps.ts
// (浏览器平台的 nodeOps 实现)
const doc = document;
export const nodeOps: Omit<RendererOptions<Node, Element>, 'patchProp'> = {
// 插入节点
insert: (child, parent, anchor) => {
parent.insertBefore(child, anchor || null)
},
// 移除节点
remove: child => {
const parent = child.parentNode
if (parent) {
parent.removeChild(child)
}
},
// 创建元素
createElement: (tag): Element => {
return doc.createElement(tag)
},
// 创建文本节点
createText: text => doc.createTextNode(text),
// ... 其他所有 DOM API 的封装
}当 patch 算法需要创建 div 时,它会调用 renderer.nodeOps.createElement('div'),而不是 document.createElement('div')。
这种方式使 Vue 的核心渲染逻辑与具体平台完全解耦。如果想让 Vue 渲染到其他平台,只需提供一套符合 nodeOps 接口规范的实现即可。
渲染流程时序图
为了方便大家能够看懂从应用启动到渲染的完整调用链路,下面是一个简化的时序图:
总结:系统协同工作
Vue 3 将 VNode 渲染成真实 DOM 的过程,是编译器、运行时和响应式系统之间协同工作的结果:
- VNode 创建 (编译时):编译器将
<template>转换为render函数,该函数运行时生成 VNode。编译器还会附带patchFlag等优化提示。 - Patch 调度 (运行时):
patch函数作为渲染核心,接收新旧 VNode,通过shapeFlag决策,将任务分派给不同的处理函数。 - 组件初始化 (运行时):
mountComponent和setupComponent创建实例、运行setup,并通过setupRenderEffect建立“状态 -> 视图”的响应式关联。 - 递归渲染 (运行时):
render函数生成子树 VNode,再次进入patch流程,形成递归,直到所有节点都处理完毕。 - 平台抽象 (平台层):所有 DOM 操作都通过
nodeOps这个抽象层执行,以支持跨平台。
理解这一过程,有助于深入掌握 Vue 的工作原理和进行性能优化。
