Vue3 源码解析(三):静态提高

什么是静态提高

Vue3 还没有发布正式版本前,尤大在一次关于 Vue3 的分享中说起了静态提高,当时笔者就对这个亮点产生了好奇,因此在源码阅读时,静态提高也是笔者的一个重点阅读点。html

那么什么是静态提高呢?当 Vue 的编译器在编译过程当中,发现了一些不会变的节点或者属性,就会给这些节点打上标记。而后编译器在生成代码字符串的过程当中,会发现这些静态的节点,并提高它们,将他们序列化成字符串,以此减小编译及渲染成本。有时能够跳过一整棵树。vue

<div>
  <span class="foo">
    Static
  </span>
  <span>
    {{ dynamic }}
  </span>
</div>

例如这段模板代码,毫无疑问,咱们能看出来 <span class="foo"> 这个节点,不论 dynamic 表达式如何变,它都不会再改变了。对于这样的节点,就能够打上标记进行静态提高。node

而 Vue3 也能够对 props 属性进行静态提高。git

<div id="foo" class="bar">
    {{ text }}
</div>

例如这段模板代码,Vue3 会跳过节点,仅仅将将再也不会变更的 id="foo"class="bar" 进行提高。github

编译后的代码字符串

上面的例子咱们只是简单的分析了一些模板,如今咱们经过一个例子,来了解静态提高先后的变化。dom

<div>
  <div>
    <span class="foo"></span>
    <span class="foo"></span>
    <span class="foo"></span>
    <span class="foo"></span>
    <span class="foo"></span>
  </div>
</div>

来看这样一个模板,符合静态提高的条件,可是若是没有静态提高的机制,它会被编译成以下代码:函数

const { createVNode: _createVNode, openBlock: _openBlock, createBlock: _createBlock } = Vue

return function render(_ctx, _cache) {
  return (_openBlock(), _createBlock("div", null, [
    _createVNode("div", null, [
      _createVNode("span", { class: "foo" }),
      _createVNode("span", { class: "foo" }),
      _createVNode("span", { class: "foo" }),
      _createVNode("span", { class: "foo" }),
      _createVNode("span", { class: "foo" })
    ])
  ]))
}

编译后生成的 render 函数很清晰,是一个柯里化的函数,返回一个函数,建立一个根节点的 div,children 里有再建立一个 div 元素,最后在最里面的 div 节点里建立五个 span 子元素。性能

若是进行静态提高,那么它会被编译成这样:优化

const { createVNode: _createVNode, createStaticVNode: _createStaticVNode, openBlock: _openBlock, createBlock: _createBlock } = Vue

const _hoisted_1 = /*#__PURE__*/_createStaticVNode("<div><span class=\"foo\"></span><span class=\"foo\"></span><span class=\"foo\"></span><span class=\"foo\"></span><span class=\"foo\"></span></div>", 1)

return function render(_ctx, _cache) {
  return (_openBlock(), _createBlock("div", null, [
    _hoisted_1
  ]))
}

静态提高之后生成的代码,咱们能够看出有明显区别,它会生成一个变量: _hoisted_1,并打上 /*#__PURE__*/ 标记。 _hoisted_1 经过字符串的传参,调用 createStaticVNode 建立了静态节点。而 _createBlock 中由原来的多个建立节点的函数的传入,变为了仅仅传入一个函数。性能的提高天然不言而喻。spa

在知道了静态提高的现象后,咱们就一块儿来看看源码中的实现。

transform 转换器

在上一篇文章中笔者提到编译时会调用 compiler-core 模块中 @vue/compiler-core/src/compile.ts 文件下的 baseCompile 函数。在这个函数的执行过程当中会执行 transform 函数,传入解析出来的 AST 抽象语法树。那么咱们首先一块儿看一下 transform 函数作了什么。

export function transform(root: RootNode, options: TransformOptions) {
  // 建立转换上下文
  const context = createTransformContext(root, options)
  // 遍历全部节点,执行转换
  traverseNode(root, context)
  // 若是编译选项中打开了 hoistStatic 开关,则进行静态提高
  if (options.hoistStatic) {
    hoistStatic(root, context)
  }
  if (!options.ssr) {
    createRootCodegen(root, context)
  }
  // 肯定最终的元信息 
  root.helpers = [...context.helpers.keys()]
  root.components = [...context.components]
  root.directives = [...context.directives]
  root.imports = context.imports
  root.hoists = context.hoists
  root.temps = context.temps
  root.cached = context.cached
}

transform 函数很简短,而且从中文注释中,咱们能够关注到在第 7 行代码的位置,转换器判断了编译时是否有开启静态提高的开关,如果打开的话则对节点进行静态提高。今天笔者的文章主要是介绍静态提高,那么就围绕静态提高的代码往下探索下去,而其他部分代码则不展开来细究了。

hoistStatic 静态提高转换

hoistStatic 的函数源码以下:

export function hoistStatic(root: RootNode, context: TransformContext) {
  walk(
    root,
    context,
    // 很不幸,根节点是不能被静态提高的
    isSingleElementRoot(root, root.children[0])
  )
}

从函数的声明中咱们可以得知,静态提高转换器接收根节点以及转换器上下文做为参数。而且仅仅是调用了 walk 函数。

walk 函数很长,因此在咱们讲解 walk 函数以前,我先将 walk 函数的函数签名写出来给你们讲一讲。

(node: ParentNode, context: TransformContext, doNotHoistNode: boolean) => void

从函数签名中能够看出,walk 函数的参数中须要一个 node 节点,context 转换器的上下文,以及 doNotHoistNode 这样一个布尔值来从外部告知该节点是否能够被提高。在 hoistStatic 函数中,传入了根节点,而且根节点是不能够被提高的。

walk 函数

接下来笔者会分段的给你们解析 walk 函数。

function walk(
  node: ParentNode,
  context: TransformContext,
  doNotHoistNode: boolean = false
) {
  let hasHoistedNode = false
  let canStringify = true

  const { children } = node
  for (let i = 0; i < children.length; i++) {
    const child = children[i]
    /* 省略逻辑 */
  }
   
  if (canStringify && hasHoistedNode && context.transformHoist) {
    context.transformHoist(children, context, node)
  }
}

walk 函数首先会声明两个标记,hasHoistedNode:记录该节点是否能够被提高; canStringify: 当前节点是否能够被字符序列化。

对于 canStringify 这个变量,源码是这样解释的:有一些转换,好比 @vue/compiler-sfc 中的 transformAssetUrls,用表达式代替静态的绑定。这些表达式是不可变的,因此它们依然是能够被合法的提高的,可是他们只有在运行时的时候才会被发现,所以不能提早评估。这只是字符串序列化以前的一个问题(经过 @vue/compiler-dom 的 transformHoist 功能),可是在这里容许咱们执行一次完整的 AST 解析,并容许 stringifyStatic 在知足其字符串阈值后当即中止执行 walk 函数。

以后会遍历当前节点的 children 全部子节点,而 for 内处理的逻辑咱们暂时忽略,后面再看。

执行完 for 循环以后,能够看到若是该节点能被提高且能被字符序列化,而且上下文中有 transformHoist 的转换器,则对当前节点经过提高转换器进行提高。由此能够推测出 for 循环主体内的工做就是遍历节点,而且判断是否能够被提高以及字符序列化,并将结果赋值给函数开头声明的这两个标记。这样的遍历行为跟函数名 walk 的意义也是一致的。

一块儿来看一下 for 循环体内的逻辑:

for (let i = 0; i < children.length; i++) {
  const child = children[i]
  // 只有简单的元素以及文本是能够被合法提高的
  if (
    child.type === NodeTypes.ELEMENT &&
    child.tagType === ElementTypes.ELEMENT
  ) {
    // 若是不容许被提高,则赋值 constantType NOT_CONSTANT 不可被提高的标记
    // 不然调用 getConstantType 获取子节点的静态类型
    const constantType = doNotHoistNode
      ? ConstantTypes.NOT_CONSTANT
      : getConstantType(child, context)
    // 若是获取到的 constantType 枚举值大于 NOT_CONSTANT
    if (constantType > ConstantTypes.NOT_CONSTANT) {
      // 根据 constantType 枚举值判断是否能够被字符序列化
      if (constantType < ConstantTypes.CAN_STRINGIFY) {
        canStringify = false
      }
      // 若是能够被提高
      if (constantType >= ConstantTypes.CAN_HOIST) {
        // 则将子节点的 codegenNode 属性的 patchFlag 标记为 HOISTED 可提高
        ;(child.codegenNode as VNodeCall).patchFlag =
          PatchFlags.HOISTED + (__DEV__ ? ` /* HOISTED */` : ``)
        child.codegenNode = context.hoist(child.codegenNode!)
        // hasHoistedNode 记录为 true
        hasHoistedNode = true
        continue
      }
    } else {
      // 节点可能包含动态的子节点,可是它的 props 属性也可能能被合法提高
      const codegenNode = child.codegenNode!
      if (codegenNode.type === NodeTypes.VNODE_CALL) {
        // 获取 patchFlag
        const flag = getPatchFlag(codegenNode)
        // 若是不存在 flag,或者 flag 是文本类型
        // 而且该节点 props 的 constantType 值判断出能够被提高
        if (
          (!flag ||
            flag === PatchFlags.NEED_PATCH ||
            flag === PatchFlags.TEXT) &&
          getGeneratedPropsConstantType(child, context) >=
            ConstantTypes.CAN_HOIST
        ) {
          // 获取节点的 props,并在转换器上下文中执行提高操做
          const props = getNodeProps(child)
          if (props) {
            codegenNode.props = context.hoist(props)
          }
        }
      }
    }
  // 若是节点类型为 TEXT_CALL,则一样进行检查,逻辑与前面一致
  } else if (child.type === NodeTypes.TEXT_CALL) {
    const contentType = getConstantType(child.content, context)
    if (contentType > 0) {
      if (contentType < ConstantTypes.CAN_STRINGIFY) {
        canStringify = false
      }
      if (contentType >= ConstantTypes.CAN_HOIST) {
        child.codegenNode = context.hoist(child.codegenNode)
        hasHoistedNode = true
      }
    }
  }

  // walk further
  /* 暂时忽略 */
}

循环体内的函数较长,因此咱们先不关注底部 walk further 的部分,为了便于理解,我逐行添加了注释。

经过最外层 if 分支顶部的注释,咱们能够知道只有简单的元素和文本类型是能够被提高的,因此会先判断该节点是不是一个元素类型。若是该节点是一个元素,那么会检查 walk 函数的 doNotHoistNode 参数确认该节点是否能被提高,若是 doNotHoistNode 不为真,则调用 getConstantType 函数获取当前节点的 constantType。

export const enum ConstantTypes {
  NOT_CONSTANT = 0,
  CAN_SKIP_PATCH,
  CAN_HOIST,
  CAN_STRINGIFY
}

这是 ConstantType 枚举的声明,经过这个枚举能够将静态类型分为 4 个等级,而静态类型更高等级的节点涵盖了更小值的节点是全部能力。例如当一个节点被标记了 CAN_STRINGIFY,意味着它可以被字符序列化,因此它永远也是一个能够被静态提高(CAN_HOIST)以及跳过 PATCH 检查的节点。

在搞明白了 ConstantType 类型后,再接着看后续的判断,获取了元素类型节点的静态类型后,会判断静态类型的值是否大于 NOT_CONSTANT,若是条件为 true,则说明该节点可能能被提高或字符序列化。接着往下判断该静态类型可否被字符序列化,若是不能则修改 canStringify 的标记。以后判断静态类型可否被提高,若是能够被提高,则将子节点的 codegenNode 对象的 patchFlag 属性标记为 PatchFlags.HOISTED,执行转换器上下文中的 context.hoist 操做,并修改 hasHoistedNode 的标记。

至此元素类型节点的提高判断完毕,咱们有发现有一个 PatchFlags 标记的存在,你们只要知道 Patch Flag 是在编译过程当中生成的一些优化记号就行。

后续的代码是在判断当该节点不是简单元素时,尝试提高该节点的 props 中的静态属性,以及当节点为文本类型时,确认是否须要提高。限于篇幅缘由,请你们自行查看上方代码。

在前面我隐藏了一段 walk further 的逻辑,从注释中来理解,这段代码的做用是继续查看一些分支状况,看看是否还有可能进行静态提高,代码以下:

// walk further
  if (child.type === NodeTypes.ELEMENT) {
    // 若是子节点的 tagType 是组件,则继续遍历子节点
    // 以便判断插槽中的状况
    const isComponent = child.tagType === ElementTypes.COMPONENT
    if (isComponent) {
      context.scopes.vSlot++
    }
    walk(child, context)
    if (isComponent) {
      context.scopes.vSlot--
    }
  } else if (child.type === NodeTypes.FOR) {
    // 查看 v-for 类型的节点是否可以被提高
    // 可是若是 v-for 的节点中是只有一个子节点,则不能被提高
    walk(child, context, child.children.length === 1)
  } else if (child.type === NodeTypes.IF) {
    // 若是子节点是 v-if 类型,判断它全部的分支状况
    for (let i = 0; i < child.branches.length; i++) {
            // 若是只有一个分支条件,则不进行提高
      walk(
        child.branches[i],
        context,
        child.branches[i].children.length === 1
      )
    }
  }

walk futher 的部分会尝试判断元素为组件、v-for、v-if 的状况。再一次遍历组件的目的是为了检查其中的插槽是否能被静态提高。v-for 和 v-if 也是同样,检查 v-for 循环生成的节点以及 v-if 的分支条件可否被静态提高。可是这里须要注意,若是 v-for 是单一节点或者 v-if 的分支中只有一个分支判断那么均不会进行提高,由于它们会是一个 block 类型。

至此,walk 函数就给你们讲解完了。

总结

今天的这篇文章,带你们一块儿阅读了 Vue 源码中静态提高的部分,笔者经过编译后代码的区别给你们直观的举例了静态提高到底有什么做用,它让编译后的代码产生了怎样的区别。而且咱们从 transform 函数一路向下深究,直至 walk 函数,咱们在 walk 函数中看到了 Vue3 如何去遍历各个节点,并给他们打上静态类型的标记,以便于编译时进行针对性的优化。

因为篇幅限制,笔者并无展开讲解 getConstantType 这个函数是如何区分各个节点类型来返回静态类型的,也没有讲解当一个节点能够被字符序列化时,context.transformHoist(children, context, node) 这行代码是如何将节点字符序列化的,这些都留给感兴趣的读者继续深刻阅读。

若是这篇文章可以帮助到你再深一点的理解 Vue3 的特性,但愿能给本文点一个喜欢❤️。若是想继续追踪后续文章,也能够关注个人帐号或 follow 个人 github,再次谢谢各位可爱的看官老爷。