之前两章讲到组件更新,期间一直都绕不开的就是虚拟DOM,这一章我们就来看看虚拟DOM是如何生成的。
在之前的两章中,我们编写的demo都会使用到h函数,这个函数就是用来生成虚拟DOM的,我们来看看h函数的定义:
现在我们就来深入h函数的实现,看看它是如何生成虚拟DOM的。
h 函数
在官网上可以看到对h函数的介绍和函数签名;

可以先去看看官网的介绍,然后再来看看源码的实现,传送门:
h 函数的实现
还是跟着我们之前的节奏,可以直接在h函数调用上面打上断点,然后开始调试进入源码:
js
const {h} = Vue;
debugger;
h('div');
直接就这样进入了h函数的实现,我们来看看h函数的实现:
js
function h(type, propsOrChildren, children) {
// 通过参数数量来进行重载
const l = arguments.length;
// 如果参数数量为2,那么就有两种情况
if (l === 2) {
// 如果第二个参数是对象,并且不是数组
if (isObject(propsOrChildren) && !isArray(propsOrChildren)) {
// 如果第二个参数是虚拟dom,那么就将第二个参数作为子节点进行处理
if (isVNode(propsOrChildren)) {
return createVNode(type, null, [propsOrChildren]);
}
// 如果第二个参数是对象,那么就将第二个参数作为props进行处理
return createVNode(type, propsOrChildren);
} else {
// 如果第二个参数是数组,那么就将第二个参数作为子节点进行处理
return createVNode(type, null, propsOrChildren);
}
} else {
// 如果参数数量不是2
if (l > 3) {
// 并且参数数量大于3,那么就将第三个参数以及后面的参数作为子节点进行处理
children = Array.prototype.slice.call(arguments, 2);
} else if (l === 3 && isVNode(children)) {
// 如果参数数量等于3,并且第三个参数是虚拟dom,那么就将第三个参数作为子节点进行处理
children = [children];
}
// 最后将第二个参数作为props,其余的参数作为子节点进行处理
return createVNode(type, propsOrChildren, children);
}
}
h函数就是一个重载函数,根据参数的不同,会有不同的处理逻辑,其实没有什么好看的;
它最后将所有的参数都传递给了createVNode函数,也就是核心是createVNode函数;
createVNode 函数
由于我们上面的示例代码中,只传入了一个参数,所以会跳过很多逻辑,简化后的createVNode函数如下:
js
function _createVNode(type, props = null, children = null, patchFlag = 0, dynamicProps = null, isBlockNode = false) {
// 获取 shapeFlag
const shapeFlag = isString(type) ? 1 :
isSuspense(type) ? 128 :
isTeleport(type) ? 64 :
isObject(type) ? 4 :
isFunction(type) ? 2 : 0;
// 如果是一个组件,并且还被设置成响应式的了,则会提示并解包
if (shapeFlag & 4 && isProxy(type)) {
type = toRaw(type);
warn(
`Vue received a Component which was made a reactive object. This can lead to unnecessary performance overhead, and should be avoided by marking the component with \`markRaw\` or using \`shallowRef\` instead of \`ref\`.`,
`
Component that was made reactive: `,
type
);
}
// 最后调用 createBaseVNode 创建 VNode
return createBaseVNode(
type,
props,
children,
patchFlag,
dynamicProps,
shapeFlag,
isBlockNode,
true
);
}
这里主要是获取了shapeFlag,我们上面传入了一个字符串的div,所以shapeFlag的值为1;
这里的shapeFlag其实是一个二进制的值,它的值是由type的类型来决定的,在ts的源码中有他们的定义:
ts
// packages\shared\src\shapeFlags.ts
export const enum ShapeFlags {
ELEMENT = 1, // 普通dom元素 二进制:0000 0001 十进制:1
FUNCTIONAL_COMPONENT = 1 << 1, // 函数组件 二进制:0000 0010 十进制:2
STATEFUL_COMPONENT = 1 << 2, // 有状态组件 二进制:0000 0100 十进制:4
TEXT_CHILDREN = 1 << 3, // 文本子节点 二进制:0000 1000 十进制:8
ARRAY_CHILDREN = 1 << 4, // 数组子节点 二进制:0001 0000 十进制:16
SLOTS_CHILDREN = 1 << 5, // 插槽 二进制:0010 0000 十进制:32
TELEPORT = 1 << 6, // TELEPORT组件 二进制:0100 0000 十进制:64
SUSPENSE = 1 << 7, // SUSPENSE组件 二进制:1000 0000 十进制:128
COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8, // 没弄清 二进制:0001 0000 0000 十进制:256
COMPONENT_KEPT_ALIVE = 1 << 9, // 没弄清 二进制:0010 0000 0000 十进制:512
COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT // 普通组件,应该是有状态组件和函数组件的并集
}
这里我们可以验证一下这些值,写个demo来看看:
js
const {h} = Vue;
// 普通元素
const element = h('div');
console.log('ELEMENT', element.shapeFlag);
// 函数式组件
const functionalComponent = h(() => h('div'));
console.log('FUNCTIONAL_COMPONENT', functionalComponent.shapeFlag);
// 有状态组件
const statefulComponent = h({
render() {
return h('div');
}
});
console.log('STATEFUL_COMPONENT', statefulComponent.shapeFlag);
// 文本子节点
const textChildren = h('div', 'text');
console.log('TEXT_CHILDREN', textChildren.shapeFlag);
// 数组子节点
const arrayChildren = h('div', [h('span'), h('span')]);
console.log('ARRAY_CHILDREN', arrayChildren.shapeFlag);
// 插槽子节点
const slotsChildren = h({
render() {
return h('div', this.$slots.default());
}
}, null, () => 'slotChildren');
console.log('SLOTS_CHILDREN', slotsChildren.shapeFlag);
// teleport组件
const teleport = h(Vue.Teleport);
console.log('TELEPORT', teleport.shapeFlag);
// suspense组件
const suspense = h(Vue.Suspense);
console.log('SUSPENSE', suspense.shapeFlag);
可以看到的是验证结果和我们上面的定义是一致的:

这里的文本子节点和数组子节点的值是9和17,这里的值是由shapeFlag的值和TEXT_CHILDREN和ARRAY_CHILDREN的值进行或运算得到,这就要进入到createBaseVNode函数中去看看了。
createBaseVNode 函数
这里的createBaseVNode函数就是定义了VNode的一些属性,我们拿文本子节点来做示例看看运行逻辑(删除不会执行的逻辑的简化版代码):
js
function createBaseVNode(type, props = null, children = null, patchFlag = 0, dynamicProps = null, shapeFlag = type === Fragment ? 0 : 1, isBlockNode = false, needFullChildrenNormalization = false) {
// 定义 vnode
const vnode = {
__v_isVNode: true,
__v_skip: true,
type,
props,
key: props && normalizeKey(props),
ref: props && normalizeRef(props),
scopeId: currentScopeId,
slotScopeIds: null,
children,
component: null,
suspense: null,
ssContent: null,
ssFallback: null,
dirs: null,
transition: null,
el: null,
anchor: null,
target: null,
targetAnchor: null,
staticCount: 0,
shapeFlag,
patchFlag,
dynamicProps,
dynamicChildren: null,
appContext: null,
ctx: currentRenderingInstance
};
// 普通节点固定走这个分支
if (needFullChildrenNormalization) {
// 使用 normalizeChildren 处理 children
normalizeChildren(vnode, children);
}
// 最后返回 vnode
return vnode;
}
这里的代码并不复杂,就是定义了vnode,然后对children进行了处理,最后返回了vnode;
我们当前测试的文本子节点,shapeFlag的值为9,这里就是通过normalizeChildren函数来处理的,我们来看看normalizeChildren函数的实现:
js
function normalizeChildren(vnode, children) {
let type = 0;
const { shapeFlag } = vnode;
if (children == null) {
// ...
} else if (isArray(children)) {
// ...
} else if (typeof children === "object") {
// ...
} else if (isFunction(children)) {
// ...
} else {
// 走到这里,说明 children 需要被规范为文本节点
// 直接转为字符串
children = String(children);
// 如果是 teleport ,子节点会被标记为 16,也就是数组节点
if (shapeFlag & 64) {
type = 16;
// 这里会将 children 转为数组
children = [createTextVNode(children)];
} else {
// 如果是普通节点,直接标记为文本节点,也就是 8
type = 8;
}
}
// 最后将 children 赋值给 vnode.children
vnode.children = children;
// 然后将 type 的值进行或运算,赋值给 vnode.shapeFlag
vnode.shapeFlag |= type;
}
可以看到这里写了一堆条件分支,来判断不同的子节点类型,最后将children赋值给vnode.children,然后将type的值进行或运算,赋值给vnode.shapeFlag;
或运算会得到什么结果呢?其实我们完全可以自己尝试一下:

1 | 8 的结果是9,这里的1就是ELEMENT,8就是TEXT_CHILDREN,所以最后的结果就是ELEMENT | TEXT_CHILDREN,也就是9;
位运算
这样做有什么意义呢?其实阅读了这么长时间的源码,不难发现经常会出现这样的代码:
js
if (shapeFlag & 8) {
// ...
}
这里就是一个位运算,这样写无疑是增加了阅读的难度,但是对代码的性能以及一些逻辑上的判断是有帮助的;
还是我们刚才的例子,我们来看看ELEMENT和TEXT_CHILDREN合并的值是9,ELEMENT和ARRAY_CHILDREN合并的值是17;
我们对它进行一个位运算,看看结果是什么:
ELEMENT和TEXT_CHILDREN合并的值,与所以类型进行与运算,结果如下:

ELEMENT和ARRAY_CHILDREN合并的值,与所有类型进行与运算,结果如下:

可以看到合并后的值,只会与参与合并的值进行与运算得到的结果是参与合并的值,这样就可以通过与运算来判断shapeFlag的值是否包含某个类型;
而将这个过程进行二进制来描述,就是这样的:
log
# 这是 ELEMENT 和 TEXT_CHILDREN 合并的值
0000 1001
# 这是 ELEMENT 的值
0000 0001
# 进行与运算
0000 1001
&&&& &&&&
0000 0001
= = = = =
0000 0001
通过上面的例子,其实与运算就是将两个值的二进制中的相同位置的值进行比较,如果都是1,那么结果就是1,否则就是0;
而Vue将每个节点的类型都定义成了2的n次方,这样就可以避免会出现相同位置的1,这样在进行或运算的时候,就可以将所有的类型进行合并,从而产生一个新的值;
如果是相同类型的节点,那么shapeFlag的值就是相同的,在进行或运算的时候会得到相同的值,新值和原来的值是相同的,因为本身就包含了这个类型;
这样新值就会包含所有参与合并的值的类型,就可以通过与运算来判断shapeFlag的值是否包含某个类型,设计非常的巧妙;
总结
这一篇主要学习了vnode的擦创建过程,其实一个vnode就是一个js对象,本身并没有什么特殊的;
特殊的是这个vnode自带的属性,例如这一章详细介绍的sahpeFlag,这个属性就是通过位运算来进行合并的,这样就可以通过与运算来判断shapeFlag的值是否包含某个类型;
而一个vnode中并不是只有一个shapeFlag属性,还有很多其他的属性,例如我们传入的props、children、slot等等;
这些属性在Vue的整个系统中又是如何使用的呢?这些将会在我们继续深入源码的过程中一一揭晓;
历史章节
- 【源码&库】跟着 Vue3 学习前端模块化
- 【源码&库】在调用 createApp 时,Vue 为我们做了那些工作?
- 【源码&库】细数 Vue3 的实例方法和属性背后的故事
- 【源码&库】Vue3 中的 nextTick 魔法背后的原理
- 【源码&库】Vue3 的响应式核心 reactive 和 effect 实现原理以及源码分析
- 【源码&库】跟着 Vue3 的源码学习 reactive 背后的实现原理
- 【源码&库】 Vue3 的依赖收集,这里的依赖指代的是什么?
- 【源码&库】 Vue3 的依赖收集和依赖触发是如何工作的
- 【源码&库】 Vue3 的组件是如何挂载的?
- 【源码&库】 Vue3 的组件是如何更新的?
- 【源码&库】 Vue3 的组件更新核心算法