模板编译的优化和render函数的生成——vue2源码探究(6)

94 阅读4分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第18天,点击查看活动详情

虚拟DOM这篇文章中,我们了解到当响应性起作用,通知到变化后,虚拟DOM会调用patch方法对虚拟DOM进行比对,找出有变化的节点进行渲染,而渲染的时候为了提高性能,减少不必要的比对开销,在上一步生成的AST抽象树之后,我们还要对这个树进行优化,在优化之后,即可生成render函数最终将模板转化为虚拟DOM。

抽象树的优化

静态节点和静态根节点

优化抽象树来提高性能的主要操作就是标记静态节点和静态根节点,什么是静态节点和静态根节点呢,我们来一个例子:

<div>
    文字
    <span>文字</span>
    <span>文字</span>
    <span>文字</span>
    <span>文字</span>
 </div>

如果说上面这段代码是vue的模板,那么文字节点文字,以及元素节点<span>文字</span>中不包含任何变量和vue的标记,也就是说,无论数据如何变化,它们都不会发生任何改变,因此它们就被称作静态节点。而顺着静态节点往上找,这个<div>中的所有子元素都是静态节点,因此称之为静态根节点。

所以说,抽象树的优化主要只做了两件事:

  • 标记静态节点
  • 标记静态根节点

方法入口

抽象树优化的代码在src\compiler\optimizer.ts文件中,主要方法入口是optimize方法:

// 源码文件src\compiler\optimizer.ts
export function optimize(
  root: ASTElement | null | undefined,
  options: CompilerOptions
) {
  if (!root) return
  // 处理传入的根节点判断方法或默认
  isStaticKey = genStaticKeysCached(options.staticKeys || '')
  // 处理传入的保留标签或留空
  isPlatformReservedTag = options.isReservedTag || no
  // 先标记静态节点
  markStatic(root)
  // 再标记静态根节点
  markStaticRoots(root, false)
}

标记静态节点

标记静态节点主要使用了markStaticisStatic方法:

// 源码文件src\compiler\optimizer.ts
function markStatic(node: ASTNode) {
  node.static = isStatic(node)
  if (node.type === 1) {
    // do not make component slot content static. this avoids
    // 1. components not able to mutate slot nodes
    // 2. static slot content fails for hot-reloading
    if (
      !isPlatformReservedTag(node.tag) &&
      node.tag !== 'slot' &&
      node.attrsMap['inline-template'] == null
    ) {
      return
    }
    for (let i = 0, l = node.children.length; i < l; i++) {
      const child = node.children[i]
      markStatic(child)
      if (!child.static) {
        node.static = false
      }
    }
    if (node.ifConditions) {
      for (let i = 1, l = node.ifConditions.length; i < l; i++) {
        const block = node.ifConditions[i].block
        markStatic(block)
        if (!block.static) {
          node.static = false
        }
      }
    }
  }
}
function isStatic(node: ASTNode): boolean {
  if (node.type === 2) {
    // expression
    return false
  }
  if (node.type === 3) {
    // text
    return true
  }
  return !!(
    node.pre ||
    (!node.hasBindings && // no dynamic bindings
      !node.if &&
      !node.for && // not v-if or v-for or v-else
      !isBuiltInTag(node.tag) && // not a built-in
      isPlatformReservedTag(node.tag) && // not a component
      !isDirectChildOfTemplateFor(node) &&
      Object.keys(node).every(isStaticKey))
  )
}

首先直接调用isStatic方法对当前节点进行判断,判断逻辑如下:

  • 动态文本节点标记为非静态节点
  • 静态文本节点标记为静态节点
  • 标记了pre的元素节点标记为静态节点
  • 其他情况必须以下条件全部符合才为静态节点,否则为非静态节点
    • 没有动态属性绑定
    • 没有v-if,v-for,v-else命令存在
    • 不是内部保留组件
    • 不是组件
    • 父节点不带有v-for命令
    • 所包含的属性必须在isStatickey

isStatickey定义了静态节点包含的有限个属性,分别是:type,tag,attrsList,attrsMap,plain,parent,children,attrs,start,end,rawAttrsMap

之后再递归遍历节点的子节点,一旦发现了非静态节点,则整个父元素就要被标记为非静态节点,因为父元素中存在了“变化”,任何一个元素节点只要有一个部分能够发生变化,那就不能被当作静态节点来处理,比如:

<div>
    123
    <span>145</span>
    <span>{{num}}</span>
</div>

根据一开始的判断,我们标记这个div为静态节点,然后开始遍历子节点,前两个子节点均为静态节点,而到第三个节点的时候,第三个节点为非静态节点,此时我们无法将div整体做为静态节点处理,因此将div重新标记为非静态节点。

之后,还要对v-if,v-else,v-else-if的组合内部进行处理,依然是“连坐”制度,只要有一个子节点为非静态节点,则整体都要被标记为非静态节点。

标记静态根节点

有人可能要问了,既然静态节点都标记好了,直接进行下一步生成render就好了呗,为什么还要进一步标记根节点呢,实际上这是进一步对性能的优化,我们在后面的生成方法中可以看到,优先判断静态节点时首先判断的是其是否为静态根节点的属性staticRoot,因为这样可以把整个静态根节点整体进行处理,而不用再次去挨个处理里面的每个静态节点。

标记根节点的方式跟标记静态节点大同小异:

// 源码文件src\compiler\optimizer.ts
function markStaticRoots(node: ASTNode, isInFor: boolean) {
  if (node.type === 1) {
    if (node.static || node.once) {
      node.staticInFor = isInFor
    }
    if (
      node.static &&
      node.children.length &&
      !(node.children.length === 1 && node.children[0].type === 3)
    ) {
      node.staticRoot = true
      return
    } else {
      node.staticRoot = false
    }
    if (node.children) {
      for (let i = 0, l = node.children.length; i < l; i++) {
        markStaticRoots(node.children[i], isInFor || !!node.for)
      }
    }
    if (node.ifConditions) {
      for (let i = 1, l = node.ifConditions.length; i < l; i++) {
        markStaticRoots(node.ifConditions[i].block, isInFor)
      }
    }
  }
}

其中,判断静态根节点有三个先决条件:

  • 自身为静态节点
  • 内部包含子节点
  • 如果内部只有一个子节点,这个节点不能是静态文本节点

可能会有人觉得突兀,前两条还好理解,为什么要加入第三条判断呢,源代码注释是这么写的:

    // For a node to qualify as a static root, it should have children that
    // are not just static text. Otherwise the cost of hoisting out will
    // outweigh the benefits and it's better off to just always render it fresh.

是说这种节点如果被标记为静态根节点反而会影响性能,实际上原因是这个节点既然只包含一个静态文本节点,那他完全可以当作一个整体,继续向上寻找静态根节点。我们寻找静态根节点的目的,是找到最大的静态节点集合,以便整体处理这些静态节点,如果向上查找根节点的工作止步于此,反而会产生了负优化。

之后,继续对子节点、判断集合进行处理,标记所有静态根节点,优化工作就完成了。

render函数的生成

最后,我们获得了优化过的AST抽象树之后,我们就可以开始进行render函数的生成了,render函数的生成实际上就是一个递归的过程,通过对抽象树进行递归和遍历,首先生成抽象函数,再将抽象函数传入到createFunction中生成最后的render函数,抽象函数转化的一个例子:

<div id="app" v-if="a" class="app" attr="data">
  2{{ msg + 1 }}1
  <span v-for="item in list" :key="item.id"></span>
</div>

将会被转化为:

  with(this) {
    return (a) ? _c('div', {
      staticClass: "app",
      attrs: {
        "id": "app",
        "attr": "data"
      }
    }, [_v("\n  2" + _s(msg + 1) + "1\n  "), _l((list), function (item) {
      return _c('span', {
        key: item.id
      })
    })], 2) : _e()
  }

这个转化的过程主要处理了以下几件事:

  • 使用_c来生成元素节点并传入相应参数
  • 使用_v来生成文本节点并填入文本
  • 使用_e来生成注释节点并填入注释
  • 使用_m来处理静态根节点
  • 使用_o来处理v-once
  • v-if,v-for等命令进行判断和遍历处理
  • 处理slot

代码有点多,这就不贴了。

最后,将生成的抽象函数交给createFunction,最终将抽象树编译成为这样的代码:

import { createElementVNode as _createElementVNode, toDisplayString as _toDisplayString, createTextVNode as _createTextVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _createTextVNode(" 123 "),
    _createElementVNode("span", null, "145"),
    _createElementVNode("span", null, _toDisplayString(_ctx.num), 1 /* TEXT */)
  ]))
}