关于前端:Vue3-源码解析三静态提升

5次阅读

共计 7716 个字符,预计需要花费 20 分钟才能阅读完成。

什么是动态晋升

Vue3 尚未公布正式版本前,尤大在一次对于 Vue3 的分享中提及了动态晋升,过后笔者就对这个亮点产生了好奇,所以在源码浏览时,动态晋升也是笔者的一个重点浏览点。

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

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

例如这段模板代码,毫无疑问,咱们能看进去 <span class=”foo”> 这个节点,不管 dynamic 表达式如何变,它都不会再扭转了。对于这样的节点,就能够打上标记进行动态晋升。

而 Vue3 也能够对 props 属性进行动态晋升。

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

例如这段模板代码,Vue3 会跳过节点,仅仅将将不再会变动的 id="foo"class="bar" 进行晋升。

编译后的代码字符串

下面的例子咱们只是简略的剖析了一些模板,当初咱们通过一个例子,来理解动态晋升前后的变动。

<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 中由原来的多个创立节点的函数的传入,变为了仅仅传入一个函数。性能的晋升天然显而易见。

在晓得了动态晋升的景象后,咱们就一起来看看源码中的实现。

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,再次谢谢各位可恶的看官老爷。

正文完
 0