出处:Soon Wang 个人博客

原作者:Soon Wang


什么是 Optimizer

上一章我们探索了 Parser 解析器部分,其解析得到的 AST 会作为输入传递给 Optimizer 继续处理,Optimizer 是优化器的意思,所以肯定是会对 AST 进行优化,那具体要优化什么呢? 这里需要先认识 Vue 模板中的一个重要概念:“静态节点”

静态节点

在 Vue 中,静态节点是指在模板中不会随数据变化而改变的内容。例如,纯文本节点或未绑定任何动态属性的元素都属 静态节点。相反,包含数据绑定或动态属性的节点则被称为动态节点

我们知道 Vue 的核心理念是数据驱动的视图更新,即视图层会根据数据层的变化自动进行更新,这得益于 Vue 响应式系统,它能追踪数据变化并更新对应 DOM。然而,对于静态节点来说,其渲染内容并不依赖于数据变化,频繁地检查和更新这些节点实际上是不必要且浪费性能

优化目的

Optimizer 优化器的目的就是识别并标记出 AST 中的静态节点。这样一来,在运行时 patch 阶段,可以跳过对静态节点的虚拟 DOM Diff 对比,避免不必要的计算和 DOM 操作,提升运行时渲染性能

Optimizer 的工作流程

Optimizer 的核心流程并不复杂,可以简单概括为以下两步:

  1. 标记静态节点:第一次遍历 AST,标记当前节点是否为静态节点
  2. 标记静态根节点:第二次遍历 AST,标记当前节点是否为静态根节点

Optimizer 源码架构流程如下图所示:

源码解析

optimize 入口方法

export function optimize(
  root: ASTElement | null | undefined,
  options: CompilerOptions,
) {
  if (!root) return
  // 生成静态 key 的映射函数,用于判断静态节点
  isStaticKey = genStaticKeysCached(options.staticKeys || '')
  // 生成平台保留标签的映射函数,用于判断静态节点
  isPlatformReservedTag = options.isReservedTag || no
  
  // 1. 第一次遍历:标记所有非静态节点
  markStatic(root)
  // 2. 第二次遍历:标记静态根节点
  markStaticRoots(root, false)
}

入口方法很简洁,正如前面整体流程中的介绍,其主要包含两个方法 markStatic 和 markStaticRoots

markStatic 标记静态节点

function markStatic(node: ASTNode) {
  // 首先执行 isStatic 判断一次是否静态节点
  node.static = isStatic(node)
  if (node.type === 1) {
    // 不要将组件插槽内容标记为静态,从而避免:
    // 1. 组件无法更改插槽节点
    // 2. 静态插槽内容在热重载时失败
    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
        }
      }
    }
  }
}

markStatic 标记方法主要分为三步骤:

  1. 通过 isStatic 方法判断是否是静态节点(具体判断见下文)
  2. 递归标记子节点:如果任一子节点不是静态的,则回溯时候父节点(当前节点)也标记为非静态
  3. 递归标记条件块中的所有子节点:如果任一子节点不是静态的,则父节点(当前节点)也标记为非静态

isStatic 静态节点判断标准

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))
  )
}
 
/**
 * 判断节点是否是模板 for 的直接子节点
 * 如果一个节点是 v-for 指令的直接子节点,
 * 它的内容可能是动态的,不能被错误地标记为静态节点
 */
function isDirectChildOfTemplateFor(node: ASTElement): boolean {
  while (node.parent) {
    node = node.parent
    if (node.tag !== 'template') {
      return false
    }
    if (node.for) {
      return true
    }
  }
  return false
}

可见,判断静态节点主要有以下几个标准:

  • 表达式节点(type === 2)不是静态节点
  • 纯文本节点(type === 3)是静态节点
  • 对于元素节点(type === 1),使用 v-pre 指令的节点被视为静态节点
  • 对于元素节点(type === 1),且满足以下条件才是静态节点:
    • 没有动态绑定(hasBindings 为 false
    • 没有 v-ifv-forv-else 等指令
    • 不是内置标签(如 slotcomponent
    • 是平台保留标签(如 divspan 等 HTML 标签)
    • 不是带有 v-for 指令的 template 标签的直接子节点
    • 节点的所有属性都属于静态键(isStaticKey

markStaticRoot 标记静态根节点

function markStaticRoots(node: ASTNode, isInFor: boolean) {
  // 剪枝:只处理 type === 1 元素类型节点,因为只有元素节点可以有子节点
  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)
      }
    }
  }
}

markStaticRoot 标记方法同样可以分为三步:

  1. 判断当前节点是否是静态根节点
  2. 递归标记子节点,但子节点不会回溯影响父节点
  3. 递归标记条件块中的所有子节点,但子节点不会回溯影响父节点

其中,对于静态根节点的判断标准是同时满足以下三个条件:

  • 当前节点是静态节点
  • 有至少一个子节点
  • 子节点不能只是一个纯文本节点

举个例子:

  • 非静态根节点: <div></div> 、<div>world</div> ,因为前者没有子节点,而后者只有一个纯文本子节点,均不满足上述条件
  • 属于静态根节点: <div><span>world</span></div>,满足上述条件

另外,还有两点值得注意的是:

  • markStaticRoot 方法开头的 if (node.type === 1) 条件判断其实是剪枝优化:只需要处理 node.type === 1 元素类型节点,因为只有元素节点可以有子节点,否则不需要标记可以剪枝减少计算
  • markStaticRoot 方法除了标记静态根节点,还会标记 staticInFor ,判断依据是:
    • 对于已经标记为静态或者包含 v-once 指令的节点,node.staticInFor = isInFor,其中 isInFor 是方法第二个入参

实践

现有模板如下,预期只有 <span> 和 word 两个节点是静态节点,不存在静态根节点

<div>
  <span>world</span><h1>{{ msg }}</h1>
</div>

经过 Optimizer 优化前的 AST (Parser 解析输出)如下:

{
    "type": 1,
    "tag": "div",
    "attrsList": [],
    "attrsMap": {},
    "rawAttrsMap": {},
    "parent": undefined,
    "children": [
        {
            "type": 1,
            "tag": "span",
            "attrsList": [],
            "attrsMap": {},
            "rawAttrsMap": {},
            "parent": [Circular *1],
            "children": [
                {
                    "type": 3,
                    "text": "world",
                    "static": true
                }
            ],
            "plain": true,
        }
        {
            "type": 1,
            "tag": "h1",
            "attrsList": [],
            "attrsMap": {},
            "rawAttrsMap": {},
            "parent": [Circular *1],
            "children": [
                {
                    "type": 2,
                    "expression": "_s(msg)",
                    "tokens": [],
                    "text": "{{ msg }}",
                    "static": false
                }
            ],
            "plain": true,
        }
    ],
    "plain": true,
}

经过 Optimizer 优化后的 AST 如下,注意其中添加了 staticstaticRootstaticInFor 属性

{
    "type": 1,
    "tag": "div",
    // 省略属性 ..
    "children": [
        {
            "type": 1,
            "tag": "span",
            // 省略属性 ..
            "children": [
                {
                    "type": 3,
                    "text": "world",
                    "static": true
                }
            ],
            "static": true,
            "staticInFor": false,
            "staticRoot": false
        }
        {
            "type": 1,
            "tag": "h1",
            // 省略属性 ..
            "children": [
                {
                    "type": 2,
                    "expression": "_s(msg)",
                    "tokens": [],
                    "text": "{{ msg }}",
                    "static": false
                }
            ],
            "static": false,
            "staticRoot": false
        }
    ],
    "static": false,
  "staticRoot": false
}

总结

通过对优化器(Optimizer)的源码解析,我们了解到 Vue2 是如何在编译阶段识别和标记静态节点的,优化器的工作为后续的代码生成阶段奠定了基础,在下一章的生成器(Generator)部分,我们将进一步探讨 Vue2 是如何具体优化这些静态节点的,实现更高效的渲染性能