vnode 到真实 DOM 是如何转变的?
何为vnode
vnode 本质上是用来描述 DOM 的 JavaScript 对象,它在 Vue.js 中可以描述不同类型的节点,比如普通元素节点、组件节点等。
普通元素节点我们很熟悉,例如,我们在HTML定义一个 button 标签来写一个按钮:
<button class="btn" style="color: blue">我是个按钮</button>
相对应我们可以用 vnode 来表示 button 标签
const vnode = {
type: 'button',
props: {
class: 'btn',
style: {
color: 'blue',
}
},
children: '我是个按钮'
}
其中,type 属性来表示 DOM 的标签类型,props 属性来表示 DOM 的一些附件信息,比如 style、class等等,children属性表示 DOM 的子节点,它也可以是一个 vnode 数组。
何为组件
组件是一个抽象的概念,它是对一颗 DOM 树的抽象。
举个例子,我们现在在页面定义一个组件节点:
<my-component></my-componnet>
这段代码,并不会在页面渲染一个 my-component 标签,而它具体渲染成什么,取决于你怎么编写 MyComponent 组件的模板。比如,MyComponent 组件内部的模板定义是这样定义的:
<template>
<div>
<h2>我是个组件</h2>
</div>
</template>
可以看出,模板内部最终会在页面上渲染一个 div,内部包含一个 h2 标签,用来显示 我是个组件 文本。
所以,从表现上来看,组件的模板决定了组件生成的 DOM 标签,而在 Vue.js 内部,一个组件想要真正的渲染生成 DOM,还需要经历“创建 vnode - 渲染 vnode - 生成 DOM” 这几个步骤
那么问题来了,vnode 和组件有什么关系呢?
我们现在知道 vnode 可以用于描述一个真实的 DOM 的对象,它同样也可以用来描述组件。例如,我们在模板中引入一个组件标签 custom-component:
<custom-component msg="test"></custom-component>
我们可以用 vnode 这样表示 custom-component 组件标签:
const CustomComponent = {
// 在这里定义组件对象
}
const vnode = {
type: CustomComponent,
props: {
msg: 'test'
}
}
组件 vnode 其实是对抽象事物的描述,这是因为我们并不会在页面上真正渲染一个 custom-component 标签,而是渲染组件内部定义的 HTML 标签。
核心渲染流程:创建 vnode 和渲染 vnode
我们在 createApp 一文中讲述了,vue3在初始化应用的时候,会创建一个 app 对象,其中 app.mount 函数内部流程大致如下:
mount(rootContainer, isHydrate, isSVG) {
// 创建根组件的 vnode
const vnode = createVNode(rootComponent, rootProps)
// 渲染 vnode
render(vnode, rootContainer, isSVG);
}
创建 vnode
vnode 是通过 函数 createVNode 创建的,我们来看一下这个函数的大致实现:
function createVNode(type, props = null ,children = null) {
// 如果 传入的 本身是 vnode 类型,则克隆这个 vnode, 并且返回
if (isVNode(type)) {
const cloned = cloneVNode(type, props, true /* mergeRef: true */);
if (children) {
// 标准化子节点,把不同数据类型的 children 转成数组或者文本类型
normalizeChildren(cloned, children);
}
return cloned;
}
if (props) {
// 处理 props 相关逻辑,标准化 class 和 style
}
// 对 vnode 类型信息编码
const shapeFlag = isString(type)
? 1 /* ELEMENT */
: isSuspense(type)
? 128 /* SUSPENSE */
: isTeleport(type)
? 64 /* TELEPORT */
: isObject(type)
? 4 /* STATEFUL_COMPONENT */
: isFunction(type)
? 2 /* FUNCTIONAL_COMPONENT */
: 0
const vnode = {
type,
props,
shapeFlag,
// 一些其他属性
}
// 标准化子节点,把不同数据类型的 children 转成数组或者文本类型
normalizeChildren(vnode, children)
return vnode
}
通过上述代码可以看到,其实 createVNode 做的事情很简单,就是:对 props 做标准化处理、对 vnode 的类型信息编码、创建 vnode 对象,标准化子节点 children。
我们现在拥有了这个 vnode 对象,接下来要做的事情就是把它渲染到页面中去。
渲染 vnode
接下来,是渲染 vnode 的过程。我们来看一下 render 函数的实现:
const render = (vnode, container, isSVG) => {
if (vnode == null) {
// 销毁组件
if (container._vnode) {
unmount(container._vnode, null, null, true);
}
}
else {
// 创建或者更新组件
patch(container._vnode || null, vnode, container, null, null, null, isSVG);
}
// 调用回调 调度器
flushPostFlushCbs();
// 缓存 vnode 节点,表示已经渲染
container._vnode = vnode;
};
这个渲染函数 render 的实现很简单,如果它的第一个参数 vnode 为空,则执行销毁组件的逻辑,否则执行创建或者更新组件的逻辑。我们目前忽略 flushPostFlushCbs 这个方法,后面分析 nextTick 原理的时候,在来查看这个函数具体做了什么事情。
path vnode
接下来,我们看看创建或者更新组件节点的 path 函数的实现:
const patch = (n1, n2, container, anchor = null, parentComponent = null, parentSuspense = null, isSVG = false, slotScopeIds = null, optimized = isHmrUpdating ? false : !!n2.dynamicChildren) => {
// 相同节点
if (n1 === n2) {
return;
}
// 如果存在新旧节点, 且新旧节点类型不同,则销毁旧节点
if (n1 && !isSameVNodeType(n1, n2)) {
anchor = getNextHostNode(n1);
unmount(n1, parentComponent, parentSuspense, true);
n1 = null;
}
switch (type) {
case Text:
// 处理文本节点
processText(n1, n2, container, anchor);
break;
case Comment$1:
// 处理注释节点
processCommentNode(n1, n2, container, anchor);
break;
case Static:
// 处理静态节点
if (n1 == null) {
mountStaticNode(n2, container, anchor, isSVG);
}
else {
patchStaticNode(n1, n2, container, isSVG);
}
break;
case Fragment:
// 处理 Fragment 元素
processFragment(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized);
break;
default:
if (shapeFlag & 1 /* ELEMENT */) {
// 处理普通 DOM 元素
processElement(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized);
}
else if (shapeFlag & 6 /* COMPONENT */) {
// 处理组件
processComponent(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized);
}
else if (shapeFlag & 64 /* TELEPORT */) {
// 处理 TELEPORT
type.process(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized, internals);
}
else if (shapeFlag & 128 /* SUSPENSE */) {
// 处理 SUSPENSE
type.process(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized, internals);
}
else {
warn$1('Invalid VNode type:', type, `(${typeof type})`);
}
}
// 标识 ref
if (ref != null && parentComponent) {
setRef(ref, n1 && n1.ref, parentSuspense, n2 || n1, !n2);
}
};
patch 本意是打补丁的意思。这个函数有两个功能:
- 根据 vnode 挂载 DOM
- 根据新旧 vnode 更新 DOM
对于初次渲染,我们这里只分析创建过程,更新过程在后面的章节分析。
在创建的过程中,patch 函数接受多个参数,这里我们目前只重点关注前三个:
-
第一个参数 n1 表示旧的 vnode,当 n1 为 null 的时候,表示是一次挂载的过程;
-
第二个参数 n2 表示新的 vnode 节点,后续会根据这个 vnode 类型执行不同的处理逻辑;
-
第三个参数 container 表示 DOM 容器,也就是 vnode 渲染生成 DOM 后,会挂载到 container 下面。
对于渲染的节点,我们这里重点关注两种类型节点的渲染逻辑:对组件的处理和对普通 DOM 元素的处理。
path: 组件
我们来看看处理组件的 processComponent 函数的实现:
const processComponent = (n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized) => {
n2.slotScopeIds = slotScopeIds;
if (n1 == null) {
// 挂载组件
mountComponent(n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized);
} else {
// 更新组件
updateComponent(n1, n2, optimized);
}
};
该函数的逻辑很简单,如果 n1 为 null,则执行挂载组件的逻辑,否则执行更新组件的逻辑。
我们接着来看挂载组件的 mountComponent 函数的实现:
const mountComponent = (initialVNode, container, anchor, parentComponent, parentSuspense, isSVG, optimized) => {
// 创建组件实例
const instance = (initialVNode.component = createComponentInstance(initialVNode, parentComponent, parentSuspense));
if (isKeepAlive(initialVNode)) {
// 组件缓存
instance.ctx.renderer = internals;
}
// 设置组件实例,例如处理 props、slots
setupComponent(instance);
if (instance.asyncDep) {
// 异步组件
parentSuspense && parentSuspense.registerDep(instance, setupRenderEffect);
if (!initialVNode.el) {
const placeholder = (instance.subTree = createVNode(Comment$1));
processCommentNode(null, placeholder, container, anchor);
}
return;
}
// 设置并运行带副作用的渲染函数
setupRenderEffect(instance, initialVNode, container, anchor, parentSuspense, isSVG, optimized);
};
可以看出,挂载组件 mountComponent 这个函数主要做了三件事情:创建组件实例、设置组件实例、设置并运行带副作用的渲染函数。
其中,如果有组件缓存,则会替换实例的渲染器。目前需要了解即可,当后面分析组件缓存机制的时候,再深入探讨。异步组件处理也是类似的,等后面分析动态组件的时候,在深入分析。
创建组件实例
我们接下来看看 createComponentInstance 这个函数的实现:
function createComponentInstance(vnode, parent, suspense) {
const type = vnode.type;
// 如果是根组件的话的 组件的上下文是自身,否则继承父组件的上下文
const appContext = (parent ? parent.appContext : vnode.appContext) || emptyAppContext;
const instance = {
vnode,
type,
parent,
appContext,
root: null,
next: null,
subTree: null,
update: null,
scope: new EffectScope(true /* detached */),
render: null,
provides: parent ? parent.provides : Object.create(appContext.provides),
renderCache: [],
// local resovled assets
components: null,
directives: null,
// resolved props and emits options
propsOptions: normalizePropsOptions(type, appContext),
emitsOptions: normalizeEmitsOptions(type, appContext),
// emit
emit: null,
emitted: null,
// props default value
propsDefaults: EMPTY_OBJ,
// inheritAttrs
inheritAttrs: type.inheritAttrs,
// state
ctx: EMPTY_OBJ,
data: EMPTY_OBJ,
props: EMPTY_OBJ,
attrs: EMPTY_OBJ,
slots: EMPTY_OBJ,
refs: EMPTY_OBJ,
setupState: EMPTY_OBJ,
setupContext: null,
// suspense related
suspense,
suspenseId: suspense ? suspense.pendingId : 0,
asyncDep: null,
asyncResolved: false,
// lifecycle hooks
// not using enums here because it results in computed properties
isMounted: false,
isUnmounted: false,
isDeactivated: false,
bc: null,
c: null,
bm: null,
m: null,
bu: null,
u: null,
um: null,
bum: null,
da: null,
a: null,
rtg: null,
rtc: null,
ec: null,
sp: null
};
instance.root = parent ? parent.root : instance;
instance.emit = emit.bind(null, instance);
// apply custom element special handling
if (vnode.ce) {
vnode.ce(instance);
}
return instance;
}
可以看出组件的实例就是个JS对象,这个包含了很多属性,这与我们前面介绍组件这个抽象概念是一致的。
设置组件实例
组件实例创建完后,我再来看看 setupComponent 函数的实现:
function setupComponent(instance, isSSR = false) {
isInSSRComponentSetup = isSSR;
const { props, children } = instance.vnode;
const isStateful = isStatefulComponent(instance);
// 处理 组件的props
initProps(instance, props, isStateful, isSSR);
// 处理 组件的slots
initSlots(instance, children);
// 处理 组件其他属性、例如 创建 render 函数
const setupResult = isStateful
? setupStatefulComponent(instance, isSSR)
: undefined;
isInSSRComponentSetup = false;
return setupResult;
}
setupComponent 用来设置 组件初始化数据,例如props、slots、render等等。
设置并运行带副作用的渲染函数
最后我们来看看 运行带副作用的渲染函数 setupRenderEffect 的实现:
const setupRenderEffect = (instance, initialVNode, container, anchor, parentSuspense, isSVG, optimized) => {
const componentUpdateFn = () => {
// 省略生命钩子
if (!instance.isMounted) {
if (el && hydrateNode) {
// 省略 hydrateNode
} else {
// 渲染组件生成子树 vnode
const subTree = (instance.subTree = renderComponentRoot(instance))
// 把子树 vnode 挂载到 container 中
patch(null,subTree,container,anchor,instance,parentSuspense,sSVG)
// 保留渲染生成的子树根 DOM 节点
initialVNode.el = subTree.el
}
// 省略生命钩子
instance.isMounted = true
} else {
// 更新组件
}
}
// 创建响应式的副作用渲染函数
const effect = new ReactiveEffect(
componentUpdateFn,
() => queueJob(instance.update),
instance.scope // track it in component's effect scope
)
// 手动绑定 副作用渲染函数 的this
const update = (instance.update = effect.run.bind(effect) as SchedulerJob)
// 执行 副作用渲染函数
update()
}
setupRenderEffect 这个函数职责非常多,我们现在只分析初始渲染流程,省略了其他逻辑。例如组件的生命周期、hydrateNode、更新组件的逻辑等等。
另外,创建响应式的副作用函数,会很抽象。我们可以简单理解 实例化ReactiveEffect会生成 副作用 effcet 函数。副作用,这里你可以简单地理解为,当组件的数据发生变化时,effect 函数包裹的内部渲染函数 componentEffect 会重新执行一遍,从而达到重新渲染组件的目的。关于 ReactiveEffect 我们在后面阅读响应式相关的代码时候在深入分析。
初始渲染主要做两件事情:渲染组件生成 subTree、把 subTree 挂载到 container 中。
从组件的实例可以看出,每个组件都会有对应的 render 函数,即使你写 template,也会编译成 render 函数,而 renderComponentRoot 函数就是去执行 render 函数创建整个组件树内部的 vnode,把这个 vnode 再经过内部一层标准化,就得到了该函数的返回结果:子树vnode。
渲染生成子树 vnode 后,接下来就是继续调用 patch 函数把子树 vnode 挂载到 container 中了。
path: 普通 DOM 元素
我们分析完 path 组件的流程,接下来,我们再看看 path 普通 DOM 元素的 processElement 函数的实现:
const processElement = (n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized) => {
isSVG = isSVG || n2.type === 'svg';
if (n1 == null) {
// //挂载元素节点
mountElement(n2, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized);
} else {
//更新元素节点
patchElement(n1, n2, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized);
}
};
这个函数逻辑与 processComponent 函数的处理逻辑类似:如果 n1 为 null,走挂载元素节点的逻辑,否则走更新元素节点逻辑。
我们接着来看挂载元素的 mountElement 函数的实现:
const mountElement = (vnode, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized) => {
let el;
let vnodeHook;
const { type, props, shapeFlag, transition, patchFlag, dirs } = vnode;
{
// 创建 DOM 元素节点
el = vnode.el = hostCreateElement(vnode.type, isSVG, props && props.is, props);
if (shapeFlag & 8 /* TEXT_CHILDREN */) {
// 处理子节点是纯文本的情况
hostSetElementText(el, vnode.children);
} else if (shapeFlag & 16 /* ARRAY_CHILDREN */) {
// 处理子节点是数组的情况
mountChildren(vnode.children, el, null, parentComponent, parentSuspense, isSVG && type !== 'foreignObject', slotScopeIds, optimized);
}
// 处理 props,比如 class、style、event 等属性
if (props) {
for (const key in props) {
if (key !== 'value' && !isReservedProp(key)) {
hostPatchProp(el, key, null, props[key], isSVG, vnode.children, parentComponent, parentSuspense, unmountChildren);
}
}
}
// 处理 css作用域
setScopeId(el, vnode, vnode.scopeId, slotScopeIds, parentComponent);
}
// 把创建的 DOM 元素节点挂载到 container 上
hostInsert(el, container, anchor);
};
可以看出,挂载元素主要做了五件事情:创建 DOM 元素节点、处理文本或者数组的子节点、处理props、处理css作用域、挂载 DOM 元素到 container 上。
DOM 元素是通过 hostCreateElement 方法创建的,这一个平台相关的方法,在 Web 环境中对应的的是:
function createElement(tag, isSVG, is) {
isSVG ? document.createElementNS(svgNS, tag)
: document.createElement(tag, is ? { is } : undefined)
}
它调用了底层的 DOM API document.createElement 创建元素。
同样的如果子节点是文本节点,则执行 hostSetElementText 方法,它在 Web 环境下通过设置 DOM 元素的 textContent 属性设置文本:
function setElementText(el, text) {
el.textContent = text
}
处理props、处理css作用域,我们目前不做分析。
我接下来看看,处理子节点是数组的情况,执行 mountChildren 方法:
const mountChildren = (children, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized, start = 0) => {
for (let i = start; i < children.length; i++) {
// 对子节点 预处理(优化)
const child = (children[i] = optimized
? cloneIfMounted(children[i])
: normalizeVNode(children[i]));
// 递归 patch 挂载 child
patch(null, child, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized);
}
};
子节点的挂载逻辑同样很简单,遍历 children 获取到每一个 child,然后递归执行 patch 方法挂载每一个 child。
这里需要注意的是,递归 patch 是深度优先遍历树的方式。
处理完所有子节点后,最后通过 hostInsert 方法把创建的 DOM 元素节点挂载到 container 上。