让 Vue.js 3.2 创建节点提升 200% 速度的秘密
背景
上一篇文章,我分析了 Vue.js 3.2 关于响应式部分的优化,此外,在这次优化升级中,还有一个关于运行时的优化:
~200% faster creation of plain element VNodes
即针对普通元素类型 vnode
的创建,提升了约 200%
的性能。这也是一个非常伟大的优化,是 Vue 的官方核心开发者 HcySunYang 实现的,可以参考这个 PR。
那么具体是怎么做的呢,在分析实现前,我想先带你了解一些 vnode
的背景知识。
什么是 vnode
vnode
本质上是用来描述 DOM 的 JavaScript 对象,它在 Vue.js 中可以描述不同类型的节点,比如普通元素节点、组件节点等。
普通元素 vnode
什么是普通元素节点呢?举个例子,在 HTML 中我们使用 <button>
标签来写一个按钮:
<button class="btn" style="width:100px;height:50px">click me</button>
我们可以用 vnode
这样表示 <button>
标签:
const vnode = {
type: 'button',
props: {
'class': 'btn',
style: {
width: '100px',
height: '50px'
}
},
children: 'click me'
}
其中,type
属性表示 DOM 的标签类型;props
属性表示 DOM 的一些附加信息,比如 style
、class
等;children
属性表示 DOM 的子节点,在该示例中它是一个简单的文本字符串,当然,children
也可以是一个 vnode
数组。
组件 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
、注释 vnode
等等。
另外,Vue.js 3.x 内部还针对 vnode
的 type
,做了更详尽的分类,包括 Suspense
、Teleport
等,并且把 vnode
的类型信息做了编码,以便在后面 vnode
的挂载阶段,可以根据不同的类型执行相应的处理逻辑:
// runtime-core/src/vnode.ts
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;
vnode 的优势
知道什么是 vnode
后,你可能会好奇,那么 vnode
有什么优势呢?为什么一定要设计 vnode
这样的数据结构呢?
首先是抽象,引入 vnode
,可以把渲染过程抽象化,从而使得组件的抽象能力也得到提升。
其次是跨平台,因为 patch vnode
的过程不同平台可以有自己的实现,基于 vnode
再做服务端渲染、weex
平台、小程序平台的渲染都变得容易了很多。
不过这里要特别注意,在浏览器端使用 vnode
并不意味着不用操作 DOM 了,很多人会误以为 vnode
的性能一定比手动操作原生 DOM 好,这个其实是不一定的。
因为这种基于 vnode
实现的 MVVM 框架,在每次组件渲染生成 vnode
的过程中,会有一定的 JavaScript 耗时,尤其是是大组件。举个例子,一个 1000 * 10
的 Table 组件,组件渲染生成 vnode
的过程会遍历 1000 * 10
次去创建内部 cell vnode
,整个耗时就会变得比较长,再加上挂载 vnode
生成 DOM 的过程也会有一定的耗时,当我们去更新组件的时候,用户会感觉到明显的卡顿。
虽然 diff 算法在减少 DOM 操作方面足够优秀,但最终还是免不了操作 DOM,所以说性能并不是 vnode
的优势。
如何创建 vnode
通常我们开发组件都是编写组件的模板,并不会手写 vnode
,那么 vnode
是如何创建的呢?
我们知道,组件模板经过编译,会生成对应的 render
函数,在 render
函数内部,会执行 createVNode
函数创建 vnode
对象,我们来看一下 Vue.js 3.2 之前它的实现:
function createVNode(type, props = null, children = null, patchFlag = 0, dynamicProps = null, isBlockNode = false) {
if (!type || type === NULL_DYNAMIC_COMPONENT) {
if ((process.env.NODE_ENV !== 'production') && !type) {
warn(`Invalid vnode type when creating vnode: ${type}.`)
}
type = Comment
}
if (isVNode(type)) {
const cloned = cloneVNode(type, props, true /* mergeRef: true */)
if (children) {
normalizeChildren(cloned, children)
}
return cloned
}
// 类组件的标准化
if (isClassComponent(type)) {
type = type.__vccOpts
}
// class 和 style 标准化.
if (props) {
if (isProxy(props) || InternalObjectKey in props) {
props = extend({}, props)
}
let { class: klass, style } = props
if (klass && !isString(klass)) {
props.class = normalizeClass(klass)
}
if (isObject(style)) {
if (isProxy(style) && !isArray(style)) {
style = extend({}, style)
}
props.style = normalizeStyle(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
if ((process.env.NODE_ENV !== 'production') && shapeFlag & 4 /* STATEFUL_COMPONENT */ && 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\`.`, `\nComponent that was made reactive: `, type)
}
const vnode = {
__v_isVNode: true,
__v_skip: true,
type,
props,
key: props && normalizeKey(props),
ref: props && normalizeRef(props),
scopeId: currentScopeId,
slotScopeIds: null,
children: null,
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
}
if ((process.env.NODE_ENV !== 'production') && vnode.key !== vnode.key) {
warn(`VNode created with invalid key (NaN). VNode type:`, vnode.type)
}
normalizeChildren(vnode, children)
// 标准化 suspense 子节点
if (shapeFlag & 128 /* SUSPENSE */) {
type.normalize(vnode)
}
if (isBlockTreeEnabled > 0 &&
!isBlockNode &&
currentBlock &&
(patchFlag > 0 || shapeFlag & 6 /* COMPONENT */) &&
patchFlag !== 32 /* HYDRATE_EVENTS */) {
currentBlock.push(vnode)
}
return vnode
}
可以看到,创建 vnode
的过程做了很多事情,其中有很多判断的逻辑,比如判断 type
是否为空:
if (!type || type === NULL_DYNAMIC_COMPONENT) {
if ((process.env.NODE_ENV !== 'production') && !type) {
warn(`Invalid vnode type when creating vnode: ${type}.`)
}
type = Comment
}
判断 type
是不是一个 vnode
节点:
if (isVNode(type)) {
const cloned = cloneVNode(type, props, true /* mergeRef: true */)
if (children) {
normalizeChildren(cloned, children)
}
return cloned
}
判断 type
是不是一个 class
类型的组件:
if (isClassComponent(type)) {
type = type.__vccOpts
}
除此之外,还会对属性中的 style
和 class
执行标准化,其中也会有一些判断逻辑:
if (props) {
if (isProxy(props) || InternalObjectKey in props) {
props = extend({}, props)
}
let { class: klass, style } = props
if (klass && !isString(klass)) {
props.class = normalizeClass(klass)
}
if (isObject(style)) {
if (isProxy(style) && !isArray(style)) {
style = extend({}, style)
}
props.style = normalizeStyle(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
然后就是创建 vnode
对象,创建完后还会执行 normalizeChildren
去标准化子节点,这个过程也会有一系列的判断逻辑。
创建 vnode 过程的优化
仔细想想,vnode
本质上就是一个 JavaScript 对象,之所以在创建过程中做很多判断,是因为要处理各种各样的情况。然而对于普通元素 vnode
而言,完全不需要这么多的判断逻辑,因此对于普通元素 vnode
,使用 createVNode
函数创建就是一种浪费。
顺着这个思路,就可以在模板编译阶段,针对普通元素节点,使用新的函数来创建 vnode
,Vue.js 3.2 就是这么做的,举个例子:
<template>
<div class="home">
<img alt="Vue logo" src="../assets/logo.png">
<HelloWorld msg="Welcome to Your Vue.js App"/>
</div>
</template>
借助于模板导出工具,可以看到它编译后的 render
函数:
import { createElementVNode as _createElementVNode, resolveComponent as _resolveComponent, createVNode as _createVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
const _hoisted_1 = { class: "home" }
const _hoisted_2 = /*#__PURE__*/_createElementVNode("img", {
alt: "Vue logo",
src: "../assets/logo.png"
}, null, -1 /* HOISTED */)
export function render(_ctx, _cache, $props, $setup, $data, $options) {
const _component_HelloWorld = _resolveComponent("HelloWorld")
return (_openBlock(), _createElementBlock("template", null, [
_createElementVNode("div", _hoisted_1, [
_hoisted_2,
_createVNode(_component_HelloWorld, { msg: "Welcome to Your Vue.js App" })
])
]))
}
针对于 div
节点,这里使用了 createElementVNode
方法而并非 createVNode
方法,而 createElementVNode
在内部是 createBaseVNode
的别名,来看它的实现:
function createBaseVNode(type, props = null, children = null, patchFlag = 0, dynamicProps = null, shapeFlag = type === Fragment ? 0 : 1 /* ELEMENT */, isBlockNode = false, needFullChildrenNormalization = false) {
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
}
if (needFullChildrenNormalization) {
normalizeChildren(vnode, children)
if (shapeFlag & 128 /* SUSPENSE */) {
type.normalize(vnode)
}
}
else if (children) {
vnode.shapeFlag |= isString(children)
? 8 /* TEXT_CHILDREN */
: 16 /* ARRAY_CHILDREN */
}
if ((process.env.NODE_ENV !== 'production') && vnode.key !== vnode.key) {
warn(`VNode created with invalid key (NaN). VNode type:`, vnode.type)
}
if (isBlockTreeEnabled > 0 &&
!isBlockNode &&
currentBlock &&
(vnode.patchFlag > 0 || shapeFlag & 6 /* COMPONENT */) &&
vnode.patchFlag !== 32 /* HYDRATE_EVENTS */) {
currentBlock.push(vnode)
}
return vnode
}
可以看到,createBaseVNode
内部仅仅是创建了 vnode
对象,然后做了一些 block
逻辑的处理。相比于之前的 createVNode
的实现,createBaseVNode
少执行了很多判断逻辑,自然性能就获得了提升。
而 createVNode
的实现,是基于 createBaseVNode
做的一层封装:
function createVNode(type, props = null, children = null, patchFlag = 0, dynamicProps = null, isBlockNode = false) {
if (!type || type === NULL_DYNAMIC_COMPONENT) {
if ((process.env.NODE_ENV !== 'production') && !type) {
warn(`Invalid vnode type when creating vnode: ${type}.`)
}
type = Comment$1
}
if (isVNode(type)) {
const cloned = cloneVNode(type, props, true /* mergeRef: true */)
if (children) {
normalizeChildren(cloned, children)
}
return cloned
}
if (isClassComponent(type)) {
type = type.__vccOpts
}
if (props) {
props = guardReactiveProps(props)
let { class: klass, style } = props
if (klass && !isString(klass)) {
props.class = normalizeClass(klass)
}
if (isObject$1(style)) {
if (isProxy(style) && !isArray(style)) {
style = extend({}, style)
}
props.style = normalizeStyle(style)
}
}
const shapeFlag = isString(type)
? 1 /* ELEMENT */
: isSuspense(type)
? 128 /* SUSPENSE */
: isTeleport(type)
? 64 /* TELEPORT */
: isObject$1(type)
? 4 /* STATEFUL_COMPONENT */
: isFunction$1(type)
? 2 /* FUNCTIONAL_COMPONENT */
: 0
if ((process.env.NODE_ENV !== 'production') && shapeFlag & 4 /* STATEFUL_COMPONENT */ && 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\`.`, `\nComponent that was made reactive: `, type)
}
return createBaseVNode(type, props, children, patchFlag, dynamicProps, shapeFlag, isBlockNode, true)
}
createVNode
的实现还是和之前类似,需要执行一堆判断逻辑,最终执行 createBaseVNode
函数创建 vnode
,注意这里 createBaseVNode
函数最后一个参数传 true
,也就是 needFullChildrenNormalization
为 true
,那么在 createBaseVNode
的内部,还需要多执行 normalizeChildren
的逻辑。
组件 vnode
还是通过 createVNode
函数来创建。
总结
虽然看上去只是少执行了几行代码,但由于大部分页面都是由很多普通 DOM 元素构成,创建普通元素 vnode
过程的优化,对整体页面的渲染和更新都会有很大的性能提升。
由于存在模板编译的过程,Vue.js 可以利用编译 + 运行时优化,来实现整体的性能优化。比如 Block Tree
的设计,就优化了 diff
过程的性能。
其实对一个框架越了解,你就会越有敬畏之情,Vue.js 在编译、运行时的实现都下了非常大的功夫,处理的细节很多,因此代码的体积也难免变大。而且在框架已经足够成熟,有大量用户使用的背景下还能从内部做这么多的性能优化,并且保证没有 regression bug,实属不易。
开源作品的用户越多,受到的挑战也会越大,需要考虑的细节就会越多,如果一个开源作品都没啥人用,玩具级别,就真的别来碰瓷 Vue 了,根本不是一个段位的。
长按图片加 ssh 为好友
,
我会第一时间和你分享前端行业趋势,学习途径等等。2021 陪你一起度过!
参考资料
[1] Vue.js 3.2 升级介绍: https://blog.vuejs.org/posts/vue-3.2.html
[2] 相关 PR: https://github.com/vuejs/vue-next/pull/3334