vlambda博客
学习文章列表

让 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 的一些附加信息,比如 styleclass 等;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 内部还针对 vnodetype,做了更详尽的分类,包括 SuspenseTeleport 等,并且把 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_isVNodetrue,
    __v_skiptrue,
    type,
    props,
    key: props && normalizeKey(props),
    ref: props && normalizeRef(props),
    scopeId: currentScopeId,
    slotScopeIdsnull,
    childrennull,
    componentnull,
    suspensenull,
    ssContentnull,
    ssFallbacknull,
    dirsnull,
    transitionnull,
    elnull,
    anchornull,
    targetnull,
    targetAnchornull,
    staticCount0,
    shapeFlag,
    patchFlag,
    dynamicProps,
    dynamicChildrennull,
    appContextnull
  }
  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
  }

除此之外,还会对属性中的 styleclass 执行标准化,其中也会有一些判断逻辑:

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_isVNodetrue,
    __v_skiptrue,
    type,
    props,
    key: props && normalizeKey(props),
    ref: props && normalizeRef(props),
    scopeId: currentScopeId,
    slotScopeIdsnull,
    children,
    componentnull,
    suspensenull,
    ssContentnull,
    ssFallbacknull,
    dirsnull,
    transitionnull,
    elnull,
    anchornull,
    targetnull,
    targetAnchornull,
    staticCount0,
    shapeFlag,
    patchFlag,
    dynamicProps,
    dynamicChildrennull,
    appContextnull
  }
  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,也就是 needFullChildrenNormalizationtrue,那么在 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