普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月23日首页

Vue 3.x 模板编译优化:静态提升、预字符串化与 Block Tree

作者 米丘
2026年4月23日 12:21

Vue 3 在性能上的飞跃,很大程度上归功于编译时(compile-time)的深度优化。Vue 3 的编译器会尽可能多地分析模板,生成更高效的渲染函数代码。本文将从三个核心优化入手——静态提升(含静态节点缓存、静态属性提升、动态属性列表提升)、预字符串化 和 Block Tree

静态提升

静态节点缓存(CACHED /v-once)

  • 完全静态元素 → PatchFlags.CACHED 标记
  • 加入 toCache 列表 → 调用 context.cache()
  • 编译结果:生成 _cache(0)运行时缓存
  • 不是提升到 render 外(不是 _hoisted_1
<template>
  <div>
    <div class="title">show1</div>
    <div>show1</div>
  </div>
</template>
import { createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [...(_cache[0] || (_cache[0] = [
    _createElementVNode("div", { class: "title" }, "show1", -1 /* CACHED */),
    _createElementVNode("div", null, "show1", -1 /* CACHED */)
  ]))]))
}

静态属性提升(Hoisted Props)

触发条件:节点动态(有文本 / 子节点更新),但 props 全静态

<template>
  <div>
    <div class="title" style="color: red" id="dom" title="show">
      {{ message }}
    </div>
    <div>show1</div>
  </div>
</template>
import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

const _hoisted_1 = {
  class: "title",
  style: {"color":"red"},
  id: "dom",
  title: "show"
}

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _createElementVNode("div", _hoisted_1, _toDisplayString(_ctx.message), 1 /* TEXT */),
    _cache[0] || (_cache[0] = _createElementVNode("div", null, "show1", -1 /* CACHED */))
  ]))
}

动态属性列表提升(Hoisted dynamicProps)

触发条件:任何有动态绑定的节点

<template>
  <div>
    <div
      :title="title"
      :class="title"
      :style="{ color: 'red', borderWidth: borderWidth }"
      :id="dom"
      :data-dom="dom"
    >
      show1
    </div>
    <div>show1</div>
  </div>
</template>
import { normalizeClass as _normalizeClass, normalizeStyle as _normalizeStyle, createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

const _hoisted_1 = ["title", "id", "data-dom"]

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _createElementVNode("div", {
      title: _ctx.title,
      class: _normalizeClass(_ctx.title),
      style: _normalizeStyle({ color: 'red', borderWidth: _ctx.borderWidth }),
      id: _ctx.dom,
      "data-dom": _ctx.dom
    }, " show1 ", 14 /* CLASS, STYLE, PROPS */, _hoisted_1),
    _cache[0] || (_cache[0] = _createElementVNode("div", null, "show1", -1 /* CACHED */))
  ]))
}

第四个参数 patchFlag计算 = 2(class) + 4 (style) + 8(非class 、非style)

image.png

源码 walk

vue3-core/packages/compiler-core/src/transforms/cacheStatic.ts

function walk(
  node: ParentNode,
  parent: ParentNode | undefined,
  context: TransformContext,
  doNotHoistNode: boolean = false,
  inFor = false,
) {
  const { children } = node // 获取子节点列表
  // 收集可缓存的静态节点(最终编译为 _cache 缓存)
  const toCache: (PlainElementNode | TextCallNode)[] = []

  for (let i = 0; i < children.length; i++) {
    const child = children[i]
    // only plain elements & text calls are eligible for caching.

    // 一、普通元素节点
    // 只处理普通元素(非组件、非插槽等)
    if (
      child.type === NodeTypes.ELEMENT &&
      child.tagType === ElementTypes.ELEMENT
    ) {
      // 计算节点的常量类型
      const constantType = doNotHoistNode
        ? // 如果 doNotHoistNode 为 true(表示该节点不应被提升)
          ConstantTypes.NOT_CONSTANT
        : getConstantType(child, context)

      /**
          NOT_CONSTANT = 0, // 非常量表达式,在编译时无法确定其值
          CAN_SKIP_PATCH, // 1 可以跳过补丁的常量,通常是静态节点
          CAN_CACHE, // 2 可以缓存的常量,其值在编译时已知
          CAN_STRINGIFY, // 3 可以字符串化的常量,其值可以在编译时转换为字符串
         */
      // ============== 场景1:节点是静态节点 ==============
      if (constantType > ConstantTypes.NOT_CONSTANT) {
        if (constantType >= ConstantTypes.CAN_CACHE) {
          // 如果常量类型达到 CAN_CACHE(意味着节点极其稳定,可被 v-once 缓存)

          // 设置 patchFlag 为 PatchFlags.CACHED (即 -1,表示完全静态)
          ;(child.codegenNode as VNodeCall).patchFlag = PatchFlags.CACHED
          // 节点添加到 toCache 数组中
          toCache.push(child)
          continue
        }

        // ============== 场景2:节点非整体静态,但属性可提升 ==============
      } else {
        // node may contain dynamic children, but its props may be eligible for
        // hoisting.
        // 获取节点的生成节点
        const codegenNode = child.codegenNode!

        // 节点是VNODE_CALL 类型(虚拟节点调用)
        if (codegenNode.type === NodeTypes.VNODE_CALL) {
          const flag = codegenNode.patchFlag

          if (
            (flag === undefined || // 未标记的节点
              flag === PatchFlags.NEED_PATCH || // 需要比对的节点
              flag === PatchFlags.TEXT) && // 只有文本内容会变化的节点
            // 检查节点属性的常量类型
            getGeneratedPropsConstantType(child, context) >=
              ConstantTypes.CAN_CACHE
          ) {
            // 获取节点的属性对象
            const props = getNodeProps(child)
            if (props) {
              // 将属性对象提升到渲染函数外部
              // 更新代码生成节点
              codegenNode.props = context.hoist(props)
            }
          }
          // 动态属性列表(dynamicProps):它是编译器生成的一个静态字符串数组,仅用于记录哪些属性名是动态绑定的。
          // 这个数组本身不依赖任何响应式数据,所以可以被提升。
          if (codegenNode.dynamicProps) {
            codegenNode.dynamicProps = context.hoist(codegenNode.dynamicProps)
          }
        }
      }

      // 处理文本调用节点
    } else if (child.type === NodeTypes.TEXT_CALL) {
      const constantType = doNotHoistNode
        ? ConstantTypes.NOT_CONSTANT
        : // 计算节点的常量类型
          getConstantType(child, context)

      // 纯静态文本节点 → 加入缓存,避免重复生成文本 VNode。
      if (constantType >= ConstantTypes.CAN_CACHE) {
        if (
          // 代码生成节点类型为 JavaScript 调用表达式
          child.codegenNode.type === NodeTypes.JS_CALL_EXPRESSION &&
          child.codegenNode.arguments.length > 0 // 参数大于0
        ) {
          child.codegenNode.arguments.push(
            // 添加 PatchFlags.CACHED 标记到参数列表中
            PatchFlags.CACHED +
              (__DEV__ ? ` /* ${PatchFlagNames[PatchFlags.CACHED]} */` : ``),
          )
        }
        toCache.push(child) // 存储可缓存的节点
        continue
      }
    }

    // walk further
    // 递归处理元素节点
    // 表示元素节点,包括普通 HTML 元素和组件
    if (child.type === NodeTypes.ELEMENT) {
      const isComponent = child.tagType === ElementTypes.COMPONENT

      if (isComponent) {
        // 跟踪当前 v-slot 作用域的深度
        // 进入组件时增加计数
        context.scopes.vSlot++
      }
      walk(child, node, context, false, inFor)

      if (isComponent) {
        // 退出时减少计数
        context.scopes.vSlot--
      }

      // 递归处理 v-for 循环节点
    } else if (child.type === NodeTypes.FOR) {
      // Do not hoist v-for single child because it has to be a block
      walk(
        child,
        node,
        context,
        // 只有一个子节点,如果是则禁止提升
        // 原因:v-for 的单个子节点必须是一个块(block),因为 v-for 指令需要在 DOM 中创建和管理多个元素
        child.children.length === 1,
        true,
      )

      // 递归处理 v-if 条件判断节点
    } else if (child.type === NodeTypes.IF) {
      // 遍历 v-if 节点的所有分支,包括 if、else-if 和 else
      for (let i = 0; i < child.branches.length; i++) {
        // Do not hoist v-if single child because it has to be a block
        walk(
          child.branches[i],
          node,
          context,
          // 只有一个子节点,禁止提升
          // 原因:v-if 的单个子节点必须是一个块(block),因为 v-if 指令需要在 DOM 中创建和销毁元素
          child.branches[i].children.length === 1,
          inFor,
        )
      }
    }
  }

  let cachedAsArray = false // 缓存标识

  // 所有子节点都可缓存 并且 当前节点是元素节点
  if (toCache.length === children.length && node.type === NodeTypes.ELEMENT) {
    if (
      node.tagType === ElementTypes.ELEMENT && // 普通 HTML 元素
      node.codegenNode &&
      node.codegenNode.type === NodeTypes.VNODE_CALL && // 代码生成节点是虚拟节点调用
      isArray(node.codegenNode.children) // 节点是数组形式
    ) {
      // all children were hoisted - the entire children array is cacheable.
      // 对整个子节点数组进行缓存
      node.codegenNode.children = getCacheExpression(
        createArrayExpression(node.codegenNode.children),
      )
      cachedAsArray = true // 标记为已缓存
    } else if (
      node.tagType === ElementTypes.COMPONENT && // 组件
      node.codegenNode &&
      node.codegenNode.type === NodeTypes.VNODE_CALL && // 代码生成节点是虚拟节点调用
      node.codegenNode.children && // 子节点存在
      !isArray(node.codegenNode.children) && // 子节点不是数组形式
      // 子节点是 JavaScript 对象表达式
      node.codegenNode.children.type === NodeTypes.JS_OBJECT_EXPRESSION
    ) {
      // default slot
      // 获取名称为 'default' 的默认插槽
      const slot = getSlotNode(node.codegenNode, 'default')
      if (slot) {
        slot.returns = getCacheExpression(
          createArrayExpression(slot.returns as TemplateChildNode[]),
        )
        cachedAsArray = true // 标记已缓存
      }
    } else if (
      node.tagType === ElementTypes.TEMPLATE && // 模板
      parent &&
      parent.type === NodeTypes.ELEMENT && // 父节点是元素节点
      parent.tagType === ElementTypes.COMPONENT && // 父节点是组件
      parent.codegenNode &&
      parent.codegenNode.type === NodeTypes.VNODE_CALL && // 代码生成节点是虚拟节点调用
      parent.codegenNode.children && // 子节点存在
      !isArray(parent.codegenNode.children) && // 子节点不是数组形式
      // 子节点是 JavaScript 对象表达式
      parent.codegenNode.children.type === NodeTypes.JS_OBJECT_EXPRESSION
    ) {
      // named <template> slot
      // 获取名称为 'slot' 的插槽名称
      const slotName = findDir(node, 'slot', true)
      const slot =
        slotName &&
        slotName.arg &&
        getSlotNode(parent.codegenNode, slotName.arg)

      if (slot) {
        slot.returns = getCacheExpression(
          createArrayExpression(slot.returns as TemplateChildNode[]),
        )
        cachedAsArray = true // 标记已缓存
      }
    }
  }

  // 未标记缓存
  if (!cachedAsArray) {
    for (const child of toCache) {
      // 对每个子节点进行缓存
      child.codegenNode = context.cache(child.codegenNode!)
    }
  }

  /**
   * 缓存表达式
   * @param value 要缓存的表达式
   * @returns 缓存后的表达式
   */
  function getCacheExpression(value: JSChildNode): CacheExpression {
    // 创建缓存表达式,将传入的 value(通常是静态内容)进行缓存
    const exp = context.cache(value)
    // #6978, #7138, #7114
    // a cached children array inside v-for can caused HMR errors since
    // it might be mutated when mounting the first item
    // 问题:在 v-for 循环中使用缓存的子数组可能导致热模块替换(HMR)错误
    // 原因:当挂载第一个项目时,缓存的数组可能会被修改
    // 解决方法:通过数组展开避免直接修改原始缓存数组
    // #13221
    // fix memory leak in cached array:
    // cached vnodes get replaced by cloned ones during mountChildren,
    // which bind DOM elements. These DOM references persist after unmount,
    // preventing garbage collection. Array spread avoids mutating cached
    // array, preventing memory leaks.
    // 问题:缓存的 vnode 数组可能导致内存泄漏
    // 原因:
    // 1、在 mountChildren 期间,缓存的 vnode 会被克隆的 vnode 替换
    // 2、克隆的 vnode 会绑定 DOM 元素
    // 3、这些 DOM 引用在组件卸载后仍然存在,阻止垃圾回收
    // 解决方法:使用数组展开语法创建新数组,避免修改原始缓存数组,从而防止内存泄漏
    exp.needArraySpread = true // 设置数组展开标志
    return exp
  }

  /**
   * 获取插槽节点
   * @param node 生成代码节点
   * @param name 插槽名称
   * @returns 插槽节点
   */
  function getSlotNode(
    node: VNodeCall,
    name: string | ExpressionNode,
  ): SlotFunctionExpression | undefined {
    if (
      node.children && // 子节点存在
      !isArray(node.children) && // 子节点不是数组形式
      // 子节点是 JavaScript 对象表达式
      node.children.type === NodeTypes.JS_OBJECT_EXPRESSION
    ) {
      const slot = node.children.properties.find(
        // 属性的 key 直接等于 name
        // 属性的 key 是 SimpleExpressionNode 类型,且其 content 属性等于 name
        p => p.key === name || (p.key as SimpleExpressionNode).content === name,
      )
      // 返回其 value 属性(即插槽函数表达式)
      return slot && slot.value
    }
  }

  if (toCache.length && context.transformHoist) {
    // 静态提升
    context.transformHoist(children, context, node)
  }
}

PatchFlags 枚举

标志 (Flag) 数值 (Value) 含义说明
TEXT 1 元素文本内容是动态的(如 {{ msg }}
CLASS 1 << 1 = 2 元素的 class 绑定是动态的(如 :class="active"
STYLE 1 << 2 = 4 元素的 style 绑定是动态的(如 :style="{ color: red }"
PROPS 1 << 3 = 8 元素除 class/style 外,有其他动态属性(如 :id="userId"
FULL_PROPS 1 << 4 = 16 属性键(key)本身是动态的(如 :[propName]="value"),需要全量对比属性
HYDRATE_EVENTS 1 << 5 = 32 元素绑定了事件监听器(如 @click="handle"),主要用于服务端渲染后的“注水”(hydration)阶段
STABLE_FRAGMENT 1 << 6 = 64 片段(Fragment)的子节点顺序稳定,不会改变
KEYED_FRAGMENT 1 << 7 = 128 片段有带 key 的子节点,用于优化 v-for 列表渲染
UNKEYED_FRAGMENT 1 << 8 = 256 片段有无 key 的子节点,更新性能较差
NEED_PATCH 1 << 9 = 512 节点需要进行非属性(non-props)的补丁操作,如对 ref 或指令的处理
DYNAMIC_SLOTS 1 << 10 = 1024 组件含有动态插槽内容
DEV_ROOT_FRAGMENT 1 << 11 = 2048 仅在开发模式下,用于标记根片段
标志 (Flag) 数值 (Value) 含义说明
HOISTED -1 节点是静态的,已被提升,完全不需要参与 diff 对比
BAIL -2 渲染器应退出优化模式,进行完整的 diff 对比

预字符串化(Pre-stringification

Vue3 预字符串化(Pre-stringification) 是编译器在编译时针对大量连续静态节点的深度优化,将其直接合并为一个 HTML 字符串,大幅减少虚拟 DOM(VNode)数量与运行时开销。

触发条件

  • 节点数(NODE_COUNT):连续 ≥ 20 个纯静态节点
  • 带绑定元素数(ELEMENT_WITH_BINDING_COUNT):连续 ≥ 5 个含静态绑定(如 class="xxx")的元素

【示例】

<div>
    <p>段落1.</p>
    <p>段落2.</p>
    <p>段落3.</p>
    <p>段落4.</p>
    <p>段落5.</p>
    <p>段落6.</p>
    <p>段落7.</p>
    <p>段落8.</p>
    <p>段落9.</p>
    <p>段落10.</p>
    <p>段落11.</p>
</div>
import { createElementVNode as _createElementVNode, createStaticVNode as _createStaticVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [...(_cache[0] || (_cache[0] = [
    _createStaticVNode("<p>段落1.</p><p>段落2.</p><p>段落3.</p><p>段落4.</p><p>段落5.</p><p>段落6.</p><p>段落7.</p><p>段落8.</p><p>段落9.</p><p>段落10.</p><p>段落11.</p>", 11)
  ]))]))
}

源码 stringifyStatic

image.png

image.png

//  Vue3 预字符串化(Pre-stringification)
// 把连续的纯静态节点 → 直接编译成 HTML 字符串 → 运行时 innerHTML 插入,彻底跳过 VNode 创建、Diff、DOM 逐个生成流程
export const stringifyStatic: HoistTransform = (children, context, parent) => {
  // bail stringification for slot content
  // 插槽内容不做字符串化(插槽有作用域、动态性)
  if (context.scopes.vSlot > 0) {
    return
  }

  // 判断父节点是否已缓存
  const isParentCached =
    parent.type === NodeTypes.ELEMENT &&
    parent.codegenNode &&
    parent.codegenNode.type === NodeTypes.VNODE_CALL &&
    parent.codegenNode.children &&
    !isArray(parent.codegenNode.children) &&
    parent.codegenNode.children.type === NodeTypes.JS_CACHE_EXPRESSION

  let nc = 0 // current node count 当前连续静态节点总数量
  let ec = 0 // current element with binding count 当前带绑定的静态元素数量
  const currentChunk: StringifiableNode[] = [] // 待合并的静态节点队列

  // 执行合并
  const stringifyCurrentChunk = (currentIndex: number): number => {
    if (
      nc >= StringifyThresholds.NODE_COUNT || // 大于20
      ec >= StringifyThresholds.ELEMENT_WITH_BINDING_COUNT // 大于5
    ) {
      // combine all currently eligible nodes into a single static vnode call
      // 创建静态 VNode 调用
      const staticCall = createCallExpression(context.helper(CREATE_STATIC), [
        JSON.stringify(
          currentChunk.map(node => stringifyNode(node, context)).join(''),
        ).replace(expReplaceRE, `" + $1 + "`),
        // the 2nd argument indicates the number of DOM nodes this static vnode
        // will insert / hydrate
        String(currentChunk.length),
      ])

      const deleteCount = currentChunk.length - 1

      // 父节点已缓存:直接替换 children
      if (isParentCached) {
        // if the parent is cached, then `children` is also the value of the
        // CacheExpression. Just replace the corresponding range in the cached
        // list with staticCall.
        children.splice(
          currentIndex - currentChunk.length,
          currentChunk.length,
          // @ts-expect-error
          staticCall,
        )

        // 父节点未缓存:用第一个节点承载,删除剩下节点
      } else {
        // replace the first node's hoisted expression with the static vnode call
        ;(currentChunk[0].codegenNode as CacheExpression).value = staticCall
        if (currentChunk.length > 1) {
          // remove merged nodes from children
          children.splice(currentIndex - currentChunk.length + 1, deleteCount)
          // also adjust index for the remaining cache items
          const cacheIndex = context.cached.indexOf(
            currentChunk[currentChunk.length - 1]
              .codegenNode as CacheExpression,
          )
          if (cacheIndex > -1) {
            for (let i = cacheIndex; i < context.cached.length; i++) {
              const c = context.cached[i]
              if (c) c.index -= deleteCount
            }
            context.cached.splice(cacheIndex - deleteCount + 1, deleteCount)
          }
        }
      }
      return deleteCount
    }
    return 0
  }

  // 遍历子节点 → 收集连续静态节点 → 达到阈值就合并成 HTML 字符串 → 替换原节点
  let i = 0
  for (; i < children.length; i++) {
    const child = children[i]
    const isCached = isParentCached || getCachedNode(child)
    if (isCached) {
      // presence of cached means child must be a stringifiable node
      const result = analyzeNode(child as StringifiableNode)
      if (result) {
        // node is stringifiable, record state
        nc += result[0]
        ec += result[1]
        currentChunk.push(child as StringifiableNode)
        continue
      }
    }
    // we only reach here if we ran into a node that is not stringifiable
    // check if currently analyzed nodes meet criteria for stringification.
    // adjust iteration index
    i -= stringifyCurrentChunk(i)
    // reset state
    nc = 0
    ec = 0
    currentChunk.length = 0
  }
  // in case the last node was also stringifiable
  // 处理最后可能剩下的连续静态节点
  stringifyCurrentChunk(i)
}
function analyzeNode(node: StringifiableNode): [number, number] | false {
  // 非可字符串化标签直接返回 false
  if (node.type === NodeTypes.ELEMENT && isNonStringifiable(node.tag)) {
    return false
  }

  // v-once nodes should not be stringified
  //  如果节点有 v-once 指令,返回 false(v-once 节点不应被字符串化)
  if (node.type === NodeTypes.ELEMENT && findDir(node, 'once', true)) {
    return false
  }

  // 如果节点是文本调用节点,直接返回 [1, 0](1个节点,0个带绑定的元素)
  if (node.type === NodeTypes.TEXT_CALL) {
    // 第一个数字:节点总数
    // 第二个数字:带有绑定的元素数量
    return [1, 0]
  }

  let nc = 1 // node count 节点计数
  let ec = node.props.length > 0 ? 1 : 0 // element w/ binding count 带有绑定的元素计数
  let bailed = false // 标记是否放弃分析
  const bail = (): false => {
    bailed = true // 标记为放弃分析
    return false
  }

  // TODO: check for cases where using innerHTML will result in different
  // output compared to imperative node insertions.
  // probably only need to check for most common case
  // i.e. non-phrasing-content tags inside `<p>`
  // 分析元素节点是否可以安全地被字符串化
  function walk(node: ElementNode): boolean {
    // 特殊标签处理
    const isOptionTag = node.tag === 'option' && node.ns === Namespaces.HTML

    // 属性检查
    for (let i = 0; i < node.props.length; i++) {
      const p = node.props[i]
      // bail on non-attr bindings
      // 普通属性并且不可字符串化,调用 bail() 放弃分析
      if (
        p.type === NodeTypes.ATTRIBUTE &&
        !isStringifiableAttr(p.name, node.ns)
      ) {
        return bail()
      }
      if (p.type === NodeTypes.DIRECTIVE && p.name === 'bind') {
        // bail on non-attr bindings
        // 指令参数并且不可字符串化,调用 bail() 放弃分析
        if (
          p.arg &&
          (p.arg.type === NodeTypes.COMPOUND_EXPRESSION ||
            (p.arg.isStatic && !isStringifiableAttr(p.arg.content, node.ns)))
        ) {
          return bail()
        }

        // 指令表达式并且不可字符串化,调用 bail() 放弃分析
        if (
          p.exp &&
          (p.exp.type === NodeTypes.COMPOUND_EXPRESSION ||
            p.exp.constType < ConstantTypes.CAN_STRINGIFY)
        ) {
          return bail()
        }
        // <option :value="1"> cannot be safely stringified
        // 对于 <option> 标签的 :value 绑定,特殊处理(非静态表达式不可字符串化)
        if (
          isOptionTag &&
          isStaticArgOf(p.arg, 'value') &&
          p.exp &&
          !p.exp.isStatic
        ) {
          return bail()
        }
      }
    }
    // 子节点检查
    for (let i = 0; i < node.children.length; i++) {
      nc++
      const child = node.children[i]
      if (child.type === NodeTypes.ELEMENT) {
        if (child.props.length > 0) {
          ec++
        }
        // 递归检查子节点
        walk(child)
        if (bailed) {
          return false
        }
      }
    }
    return true
  }

  return walk(node) ? [nc, ec] : false
}

事件缓存

【示例】

  <div>
    <button @click="console.log('xxx')">click</button>
    <button @click="handleClick">点击</button>
    <button @click="() => {}">点击</button>
  </div>

未开启事件缓存 cacheHandlers 设置 false

import { createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

const _hoisted_1 = ["onClick"]
const _hoisted_2 = ["onClick"]

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _createElementVNode("button", {
      onClick: $event => (console.log('xxx'))
    }, "click", 8 /* PROPS */, _hoisted_1),
    _createElementVNode("button", { onClick: _ctx.handleClick }, "点击", 8 /* PROPS */, _hoisted_2),
    _cache[0] || (_cache[0] = _createElementVNode("button", { onClick: () => {} }, "点击", -1 /* CACHED */))
  ]))
}

开启事件缓存 cacheHandlers 设置 true

import { createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _createElementVNode("button", {
      onClick: _cache[0] || (_cache[0] = $event => (console.log('xxx')))
    }, "click"),
    _createElementVNode("button", {
      onClick: _cache[1] || (_cache[1] = (...args) => (_ctx.handleClick && _ctx.handleClick(...args)))
    }, "点击"),
    _cache[2] || (_cache[2] = _createElementVNode("button", { onClick: () => {} }, "点击", -1 /* CACHED */))
  ]))
}

【示例】

  <div>
    <button @click="handleClick(message)">click</button>
    <button @click="handleClick('xx')">click</button>
  </div>
import { createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _createElementVNode("button", {
      onClick: _cache[0] || (_cache[0] = $event => (_ctx.handleClick(_ctx.message)))
    }, "click"),
    _createElementVNode("button", {
      onClick: _cache[1] || (_cache[1] = $event => (_ctx.handleClick('xx')))
    }, "click")
  ]))
}

image.png

【示例】

  <div>
    <input v-model="message" placeholder="请输入信息" />
  </div>
import { vModelText as _vModelText, createElementVNode as _createElementVNode, withDirectives as _withDirectives, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _withDirectives(_createElementVNode("input", {
      "onUpdate:modelValue": _cache[0] || (_cache[0] = $event => ((_ctx.message) = $event)),
      placeholder: "请输入信息"
    }, null, 512 /* NEED_PATCH */), [
      [_vModelText, _ctx.message]
    ])
  ]))
}

缓存的事件处理函数 $event => ((_ctx.message) = $event) 在组件整个生命周期中引用保持不变,但它却总能正确地将最新的输入值赋给响应式变量 _ctx.message

原因? 缓存的函数并没有“捕获” _ctx.message 的值,而是每次执行时动态地通过 _ctx 对象去访问 message 属性。而 _ctx 本身是一个组件实例的上下文代理对象,它在组件的整个生命周期中保持同一个引用,但其内部的属性(如 message)会随着响应式状态的变化而自动更新。

image.png

Block Tree

Block Tree 的核心理念是 将动态节点从静态节点中剥离出来,扁平化收集。它不是一个独立的运行时树形数据结构,而是编译器在模板编译阶段对 VNode 的一种标记和组织策略。

节点类型 原因说明
组件根节点 整个组件渲染的入口,天然形成一个 Block
带有 v-if / v-else / v-else-if 的节点 这些指令会导致节点的存在与否发生结构性变化,因此每个分支都会被包裹在一个独立的 Block 中
带有 v-for 的节点 列表渲染的节点结构可能会因数据变化而重排序或增删,所以会形成一个独立的 Block 来管理其动态子节点
多根节点模板(Fragment) 当模板有多个根节点时,这些根节点会被一个 Fragment 包裹,该 Fragment 节点也会成为一个 Block

【示例】

  <div>
    <p>段落</p>
    <p v-if="tag === 1">这里是header</p>
    <p v-else-if="tag === 2">这里是body</p>
    <p v-else>这里是footer</p>
  </div>
import { createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock, createCommentVNode as _createCommentVNode } from "vue"

const _hoisted_1 = { key: 0 }
const _hoisted_2 = { key: 1 }
const _hoisted_3 = { key: 2 }

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _cache[0] || (_cache[0] = _createElementVNode("p", null, "段落", -1 /* CACHED */)),
    (_ctx.tag === 1)
      ? (_openBlock(), _createElementBlock("p", _hoisted_1, "这里是header"))
      : (_ctx.tag === 2)
        ? (_openBlock(), _createElementBlock("p", _hoisted_2, "这里是body"))
        : (_openBlock(), _createElementBlock("p", _hoisted_3, "这里是footer"))
  ]))
}

【示例】

  <div>
    <ul>
      <li v-for="item in tag" :key="item">信息{{ item }} end</li>
    </ul>
  </div>
import { renderList as _renderList, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock, toDisplayString as _toDisplayString, createElementVNode as _createElementVNode } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _createElementVNode("ul", null, [
      (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.tag, (item) => {
        return (_openBlock(), _createElementBlock("li", { key: item }, "信息" + _toDisplayString(item) + " end", 1 /* TEXT */))
      }), 128 /* KEYED_FRAGMENT */))
    ])
  ]))
}

源码

render 函数执行:借助 openBlock 和 createBlock,将编译阶段标记出的动态节点,精准地收集到 dynamicChildren 数组中。

vue3-core/packages/runtime-core/src/vnode.ts

function setupBlock(vnode: VNode) {
  // save current block children on the block vnode
  // 保存动态子节点
  // 只有当 isBlockTreeEnabled > 0 时才启用跟踪
  vnode.dynamicChildren =
    isBlockTreeEnabled > 0 ? currentBlock || (EMPTY_ARR as any) : null
  // close block
  closeBlock()
  // a block is always going to be patched, so track it as a child of its
  // parent block
  // 只有当 isBlockTreeEnabled > 0 时才启用跟踪
  if (isBlockTreeEnabled > 0 && currentBlock) {
    currentBlock.push(vnode)
  }
  return vnode
}
function createBlock(
  type: VNodeTypes | ClassComponent, // 虚拟节点的类型,可以是标签名、组件等
  props?: Record<string, any> | null, // 节点的属性对象
  children?: any, // 节点的子节点
  patchFlag?: number, // 补丁标志,用于优化更新过程
  dynamicProps?: string[], // 动态属性数组,指定哪些属性是动态的
): VNode {
  return setupBlock(
    // 创建一个基础虚拟节点
    createVNode(
      type,
      props,
      children,
      patchFlag,
      dynamicProps,
      // 表示这是一个 block 节点,用于跟踪动态子节点
      true /* isBlock: prevent a block from tracking itself */,
    ),
  )
}
function createElementBlock(
  type: string | typeof Fragment, // 字符串或 Fragment 符号,表示元素的标签名或片段
  props?: Record<string, any> | null, // 可选的属性对象,包含元素的属性、事件等
  children?: any, // 选的子节点,可以是字符串、数字、VNode 数组等
  patchFlag?: number, // 可选的补丁标志,用于优化更新过程
  dynamicProps?: string[], // 可选的动态属性数组,指定哪些属性是动态的
  shapeFlag?: number, // 可选的形状标志,表示 VNode 的类型
): VNode {
  // 将基础 VNode 转换为块节点
  return setupBlock(
    // 创建基础 VNode,设置各种属性和标志
    createBaseVNode(
      type,
      props,
      children,
      patchFlag,
      dynamicProps,
      shapeFlag,
      true /* isBlock */,
    ),
  )
}

vue3.x 内置指令有哪些?

作者 米丘
2026年4月23日 11:19

Vue 3 提供了 14 个内置指令,用于在模板中实现响应式行为、DOM 操作和性能优化。

一 、v-cloak

v-cloak 是 Vue 的一个编译控制指令,用于解决 未编译的模板(Mustache 标签)短暂显示 的问题(即“闪烁”现象)。它不依赖于响应式数据,只在组件编译阶段起作用。

v-cloak 的原理非常简单,分为两个层面:

  1. CSS 隐藏:开发者需要编写 CSS 规则,例如 [v-cloak] { display: none; }。这样,带有 v-cloak 属性的元素会一开始就被隐藏。
  2. Vue 自动移除:当 Vue 编译完该组件的模板后,会自动移除元素上的 v-cloak 属性,从而让元素显示出来。

因此,用户看到的效果是:模板内容在编译完成前是隐藏的,编译完成后立即显示,不会出现未编译的 {{ message }} 一闪而过。

在典型的基于 SFC(单文件组件)和构建工具(Vite、Webpack)的 Vue 项目中,模板会在编译时被预编译为 render 函数,浏览器最终执行的是 render 函数生成的虚拟 DOM,不会包含原始的 {{ message }} 语法。因此不会看到未编译的插值闪烁现象。

二、v-pre

v-pre 是 Vue 的一个编译跳过指令,用于告诉 Vue 编译器跳过该元素及其所有子元素的编译过程,直接输出原始内容。

【示例】

<template>
  <div v-pre>
    {{ message }}
    <p v-text="message">这是一个段落</p>
  </div>
</template>

image.png

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock("div", null, [..._cache[0] || (_cache[0] = [_createTextVNode(
" {{ message }} ",
-1
/* CACHED */
), _createElementVNode(
"p",
{ "v-text": "message" },
"这是一个段落",
-1
/* CACHED */
)])]);
}

解析器遇到 v-pre 指令时,会将当前节点及其所有子节点标记的属性转为普通属性。

因为没有生成任何响应式相关的渲染代码,这些节点在组件更新时不会被重新渲染或 diff。们始终以静态 HTML 的形式存在。

三、v-on

在 Vue 3 的事件处理体系中,v-on 指令是连接用户操作与 JavaScript 代码的核心纽带。无论是点击按钮、输入文本还是按下键盘,v-on 都扮演着“信号接收器”的角色,让开发者能够以声明式的方式响应用户的每一次交互

v-on 是什么?

v-on 是 Vue 3 中用于监听 DOM 事件并在事件触发时执行指定代码的指令。它的简写形式是 @,这是开发中最常使用的写法

<!-- 完整写法 -->
<button v-on:click="handleClick">点击我</button>

<!-- 简写写法(最常用) -->
<button @click="handleClick">点击我</button>

v-on 基本使用方式

模板编译器通过检查 v-on 的值是否是合法的 JavaScript 标识符或属性访问路径来判断使用哪种处理器:

  1. 有括号的表达式(如 count++handleClick())→ 内联语句处理器
  2. 无括号的标识符(如 handleClickobj.method)→ 方法事件处理器

内联事件处理器

直接将 JavaScript 代码写在 v-on 的值中,适用于简单逻辑。当 v-on 的值是合法的 JavaScript 表达式或标识符时,Vue 会自动识别并处理。

【示例】简单内联表达式

<button @click="count++">点击我</button>
const count = ref(0);

image.png

【示例】多语句,用分号隔开

  <button
    @click="
      count--;
      console.log(count);
    "
  >
    点击我
  </button>

编译结果

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock("button", { onClick: _cache[0] || (_cache[0] = ($event) => {
$setup.count--;
console.log($setup.count);
}) }, " 点击我 ");
}

【示例】内联箭头函数

  <button
    @click="
      (event) => {
        count++;
        console.log(event, count);
      }
    "
  >
    点击我
  </button>

编译结果

import { openBlock as _openBlock, createElementBlock as _createElementBlock } from "/node_modules/.vite/deps/vue.js?v=b3e6ce82";
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock("button", { onClick: _cache[0] || (_cache[0] = (event) => {
$setup.count++;
console.log(event, $setup.count);
}) }, " 点击我 ");
}

【示例】在内联处理器中可以直接调用方法并传递参数

<button @click="handleClick($event)">点击我</button>

【示例】在内联处理器中可以直接调用方法并传递参数

<button @click="handleClick($event, 'click')">点击我</button>
const handleClick = (event: PointerEvent, type: string) => {
  count.value++;
  console.log("点击了按钮", event, type);
};

【示例】

<template>
  <div>
    <p>当前数字 {{ count }}</p>
    <button
      @click.stop="
        handleClick();
        handleClick2($event);
      "
    >
      增加
    </button>
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";
const count = ref(0);

const handleClick = () => {
  count.value++;
};

const handleClick2 = (event: Event) => {
  console.log(event);
};

defineOptions({
  name: "CloudView",
});
</script>

编译结果

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock("div", null, [_createElementVNode(
"p",
null,
"当前数字 " + _toDisplayString($setup.count),
1
/* TEXT */
), _createElementVNode("button", { onClick: _cache[0] || (_cache[0] = _withModifiers(($event) => {
$setup.handleClick();
$setup.handleClick2($event);
}, ["stop"])) }, " 增加 ")]);
}

【示例】

<template>
  <div>
    <p>当前数字 {{ count }}</p>
    <button
      @click="handleClick3"
      @click.stop="
        handleClick();
        handleClick2($event);
      "
    >
      增加
    </button>
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";
const count = ref(0);

const handleClick = () => {
  count.value++;
};

const handleClick2 = (event: Event) => {
  console.log(event);
};

const handleClick3 = () => {
  console.log("点击了按钮");
};

defineOptions({
  name: "CloudView",
});
</script>

编译结果

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock("div", null, [_createElementVNode(
"p",
null,
"当前数字 " + _toDisplayString($setup.count),
1
/* TEXT */
), _createElementVNode("button", { onClick: [$setup.handleClick3, _cache[0] || (_cache[0] = _withModifiers(($event) => {
$setup.handleClick();
$setup.handleClick2($event);
}, ["stop"]))] }, " 增加 ")]);
}

方法事件处理器

对于复杂逻辑,推荐使用方法作为事件处理器。方法事件处理器会自动接收原生 DOM 事件对象作为参数。

【示例】默认传递原生事件对象

<button @click="handleClick">点击我</button>

<button v-on:click="handleClick">点击我</button>
const handleClick = (event: PointerEvent) => {
  count.value++;
  console.log("点击了按钮", event);
};

编译结果

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock("button", { onClick: $setup.handleClick }, "点击我");
}

修饰符

一、事件修饰符

事件修饰符 是 Vue 为 v-on 指令(@)提供的特殊后缀,以声明式的方式解决事件处理中常见的底层 DOM 操作,避免在方法中手动调用 event.preventDefault()event.stopPropagation() 等。

【示例 一】捕获模式而非冒泡 capture

<template>
  <button @click.capture="handleClick">点击我</button>
</template>

编译结果

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock(
"button",
{ onClickCapture: $setup.handleClick },
"点击我",
32
/* NEED_HYDRATION */
);
}

【示例 二】事件只触发一次,然后自动解绑 once

<button v-on:click.once="handleClick4">点击我</button>

编译结果

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock(
"button",
{ onClickOnce: $setup.handleClick },
"点击我",
32
/* NEED_HYDRATION */
);
}

【示例 三】提示浏览器不会调用 preventDefault(),提升滚动性能

<template>
  <button @click.passive="handleClick">click</button>
</template>

编译结果

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock(
"button",
{ onClickPassive: $setup.handleClick },
"click",
32
/* NEED_HYDRATION */
);
}

【示例 四】阻止默认行为 prevent event.preventDefault()

<template>
  <a target="_blank" href="https://baidu.com" @click.prevent="handleClick">点击我</a>
</template>

编译结果

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock("a", {
target: "_blank",
href: "https://baidu.com",
onClick: _withModifiers($setup.handleClick, ["prevent"])
}, "点击我");
}

【示例 五】仅当 event.target === 当前元素 时触发 self

<template>
  <button @click.self="handleClick">click</button>
</template>

编译结果

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock("button", { onClick: _withModifiers($setup.handleClick, ["self"]) }, "click");
}

【示例 六】停止事件冒泡 stop event.stopPropagation()

<button v-on:click.stop="handleClick">点击我</button>

编译结果

import { withModifiers as _withModifiers, openBlock as _openBlock, createElementBlock as _createElementBlock } from "/node_modules/.vite/deps/vue.js?v=b3e6ce82";
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock("button", { onClick: _withModifiers($setup.handleClick, ["stop"]) }, "点击我");
}

【 Why?】oncepassivecapture修饰符会编译成onClickCapture这样的属性,而其他修饰符则通过_withModifiers辅助函数处理?

captureoncepassive 是 addEventListener 的底层配置项。它们是浏览器在事件绑定初期就需要确定的参数,可以在一个事件监听器上同时生效,所以 Vue 可以通过在编译阶段修改属性名(如 onClick -> onClickCapture),在最终生成的原生事件绑定中一次性配置。

而 stopprevent 则是对 事件回调函数行为的包装 。它们必须在事件触发时的 回调执行阶段 才能判断并生效,因此需要 Vue 在运行时(runtime)动态地创建一个包装函数(wrapper),在这个函数里按顺序执行 stopPropagation()preventDefault() 等操作,最后才调用你定义的回调。

二、鼠标修饰符

  1. .left左键(默认)
  2. .right右键
  3. .middle中键

【示例】

<template>
  <button @click.left="handleClick">click</button>
</template>
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock("button", { onClick: _withModifiers($setup.handleClick, ["left"]) }, "click");
}

【示例】

<template>
  <button @click.middle="handleClick">click</button>
</template>
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock(
"button",
{ onMouseup: _withModifiers($setup.handleClick, ["middle"]) },
"click",
32
/* NEED_HYDRATION */
);
}

【示例】

<template>
  <button @click.right="handleClick">click</button>
</template>
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock(
"button",
{ onContextmenu: _withModifiers($setup.handleClick, ["right"]) },
"click",
32
/* NEED_HYDRATION */
);
}

对比编译区别?

  • .left: 保留原有事件(如 click),修饰符仅用于运行时筛选。
  • .right: 事件名被替换为 contextmenu,这是一个专用于处理右键菜单的浏览器原生事件。
  • .middle: 事件名被替换为 mouseup,用于监听鼠标按键(包括中键)的释放动作

三、系统修饰符

Vue 为 v-on 指令提供了系统修饰符,用于实现仅在按下指定按键时才触发鼠标或键盘事件的监听器。

修饰符 对应按键 (Windows) 对应按键 (macOS)
.ctrl Ctrl 键 Control 键
.alt Alt 键 Option (⌥) 键
.shift Shift 键 Shift 键
.meta Windows (⊞) 键 Command (⌘) 键

【示例】ctrl

在 Windows 操作系统上,使用 Vue 的 @click.ctrl 修饰符时,click 事件会正常触发,并且事件处理函数会执行。 在 macOS 系统中,Control + 点击 的默认行为是触发右键菜单(上下文菜单) ,而不是 click 事件。因此,浏览器会优先派发 contextmenu 事件,而 click 事件根本不会被触发。

<template>
  <button @click.ctrl="handleClick">click</button>
</template>
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock("button", { onClick: _withModifiers($setup.handleClick, ["ctrl"]) }, "click");
}

【示例】 meta

在 Windows 系统中,.meta 修饰符对应的是代表 Win 键 (⊞); 在 macOS 系统中,Vue 的 .meta 修饰符对应的是 Command (⌘) 键。

<template>
  <button @click.meta="handleClick">click</button>
</template>
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock("button", { onClick: _withModifiers($setup.handleClick, ["meta"]) }, "click");
}

四、按键修饰符

修饰符 对应按键 说明
.enter Enter 键 回车
.tab Tab 键 制表符
.delete Delete 或 Backspace 删除键(两者都匹配)
.esc Escape 退出键
.space Space 空格键
.up 上箭头 ArrowUp
.down 下箭头 ArrowDown
.left 左箭头 ArrowLeft
.right 右箭头 ArrowRight
.page-up PageUp 上翻页
.page-down PageDown 下翻页
.home Home 行首
.end End 行尾

可以直接使用字母或数字作为修饰符(例如 .a.1),Vue 会将其转换为对应的 event.key 值。

【示例】

<template>
  <input @keyup.a="handleClick" />
</template>
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock(
"input",
{ onKeyup: _withKeys($setup.handleClick, ["a"]) },
null,
32
/* NEED_HYDRATION */
);
}

【示例】

<template>
  <input @keyup.1="handleClick" />
</template>

编译阶段

一、解析指令 (Parse)

<button @click="count++">这类模板解析为包含v-on指令信息的AST(抽象语法树)节点,包括事件名click、处理函数表达式count++

二、转换格式 (Transform)

这是核心环节,由transformOn函数完成。

  • 处理事件名:将静态的@click规范化为onClick,用于VNode的props
  • 处理动态事件名:处理@[eventName],生成可解析动态事件名的代码。
  • 包装处理函数:将如count++的内联语句,包装成接收$event参数的事件处理函数$event => (count++)

三、生成代码 (Codegen)

最终生成render函数,包含一个VNode,其props对象里有一个onClick属性,值为包装后的事件处理函数。

运行时阶段

当组件在浏览器中运行时,核心任务就是高效地将虚拟DOM映射到真实DOM上。

一、首次挂载

渲染器执行render函数生成VNode,patch过程中会为真实DOM绑定事件,主要依赖createInvoker这个工厂函数。

createInvoker:事件更新的性能关键createInvoker创建了一个特殊的函数(invoker),充当连接Vue虚拟DOM事件和真实浏览器事件的稳定桥梁

二、更新阶段

当父组件重新渲染导致事件处理函数改变时,渲染器会发现onClick属性变了,进入patchEvent逻辑。

  1. 发现改变:onClick属性的新值和旧值不同。
  2. 更新invoker:Vue不会调用removeEventListeneraddEventListener(传统方式性能差),而是找到该事件对应的invoker对象,直接更新其value属性。
  3. 自动生效:由于invoker函数本身没有变,DOM上的监听器无需任何改动。下次事件触发时,执行的invoker会调用其value属性上已指向的新的事件处理函数

四、v-once

在 Vue 3 的指令集中,v-once 是一个用于性能优化的内置指令。它的核心作用是告诉 Vue: “这个元素及其所有子元素,只渲染一次,后续无论数据如何变化,都不要再更新它们了。”

<p>更新{{ title }}</p>
<p v-once>静态 {{ title }}</p>

示例

<template>
  <div v-once>
    <p>当前计数: {{ count }}</p>
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";
const count = ref(0);
defineOptions({
  name: "CloudView",
});
</script>

编译结果

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _cache[0] || (_setBlockTracking(-1, true), (_cache[0] = _createElementVNode("div", null, [_createElementVNode(
"p",
null,
"当前计数: " + _toDisplayString($setup.count),
1
/* TEXT */
)])).cacheIndex = 0, _setBlockTracking(1), _cache[0]);
}

编译阶段

一、解析与标记

在编译的转换(Transform)阶段,transformOnce 函数会识别带有 v-once 指令的节点。 找到后,会为其打上“静态”标记,并在上下文中设置标记 context.inVOnce = true,同时将该指令从AST节点中移除。 该标记还会被递归地应用到其所有子节点上,确保整个子树都被视为静态内容。

二、代码生成

生成(Generate)阶段,被打上标记的节点会通过 context.cache() 方法进行缓存。 最终,生成的渲染函数会直接引用一个模块级常量(即被静态提升的节点),而不是在每次渲染时重新创建,从而最大程度地减少运行时开销。

运行阶段

  1. 首屏渲染:组件首次渲染时,render 函数会正常执行,并使用缓存的VNode来创建DOM。
  2. 跳过更新:当组件内的响应式数据发生变化,触发重新渲染时,Vue 的 diff 算法会检测到这些静态节点上的 PatchFlags.STATIC 标志。一旦识别到该标志,渲染器会完全跳过对该节点及其子树的所有更新流程,直接复用第一次渲染时缓存的VNode和对应的真实DOM

transformOnce

vue3-core/packages/compiler-core/src/transforms/vOnce.ts

transformOnce 是 Vue 3 编译器(compiler-core)中用于处理 v-once 指令的节点转换函数。它在 AST 转换阶段识别带有 v-once 指令的元素,并标记该子树为“一次性渲染”区域,最终通过缓存机制使其在后续渲染中直接被复用。

const transformOnce: NodeTransform = (node, context) => {
  // 只处理元素节点,查找节点上的 v-once 指令
  if (node.type === NodeTypes.ELEMENT && findDir(node, 'once', true)) {
    // 检查节点是否已经被处理过,避免重复处理
    if (seen.has(node) || context.inVOnce || context.inSSR) {
      return
    }
    seen.add(node)
    context.inVOnce = true // 标记为 v-once 处理中
    // 注入运行时辅助函数,用于暂时禁用块追踪(Block Tracking)
    context.helper(SET_BLOCK_TRACKING)
    return () => {
      context.inVOnce = false
      const cur = context.currentNode as ElementNode | IfNode | ForNode
      if (cur.codegenNode) {
        cur.codegenNode = context.cache(
          cur.codegenNode,
          true /* isVNode */, // 缓存 VNode 节点
          true /* inVOnce */, // 标记为 v-once 处理中,运行时会在首次渲染后永久复用该 VNode
        )
      }
    }
  }
}

五、v-memo

v-memo 的核心机制是:它接收一个依赖值数组,并缓存该元素及其子树的虚拟 DOM(VNode)。只有当数组中的某个依赖项的值与上一次渲染不同时,Vue 才会重新渲染该子树;否则,将直接复用缓存,跳过整个渲染和差异比对(diff)过程。

使用

<template>
  <div v-memo="[count]">
    {{ count }}
  </div>
</template>

编译结果

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _withMemo([$setup.count], () => (_openBlock(), _createElementBlock("div", null, [_createTextVNode(
_toDisplayString($setup.count),
1
/* TEXT */
)])), _cache, 0);
}

编译阶段

在编译阶段,Vue 的 transformMemo 转换器会识别并处理 v-memo 指令。它只对元素节点生效,并跳过服务端渲染(SSR)场景。

其核心任务是,将带有 v-memo 的节点(_createVNode 调用)包裹在一个名为 _withMemo 的运行时辅助函数调用中。

运行阶段

运行时,渲染函数开始执行。当执行到编译阶段生成的 _withMemo 函数时,核心逻辑如下:

1、执行 _withMemo 函数:该函数接收编译时传入的依赖数组、渲染函数、缓存对象和缓存索引。

2、判断是否命中缓存:它会根据索引查找 _cache 对象中是否已存在 VNode。如果存在,则通过 isMemoSame 函数,使用 Object.is 逐一比较新旧依赖数组中的每一项是否完全一致。

3、渲染或复用

  • 命中缓存:如果依赖数组各项均未改变,_withMemo 将直接返回缓存的 VNode,从而完全跳过了执行渲染函数、创建新 VNode 以及后续的 diff 和 DOM 更新。
  • 未命中缓存:如果依赖数组发生变化,_withMemo 则会执行传入的渲染函数,生成 新的 VNode,并将其更新到缓存中,以供下一次渲染使用

六 、v-if | v-else-if | v-else

Vue 3 中的 v-ifv-else-ifv-else 是用于条件渲染的指令,它们根据表达式的真假值,决定是否将元素或组件渲染到 DOM 中。

使用

【示例】 基本使用

<template>
  <div>
    <h3>v-if 指令</h3>
    <div v-if="show">{{ title }}</div>
    <div v-else>暂无数据</div>
    <button @click="show = !show">切换显示</button>
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";

const title = ref("列表");
const show = ref(false);
</script>

image.png

【示例】

<template>
  <div>
    <div v-if="type === 1">primary</div>
    <div v-else-if="type === 2">暂无数据</div>
    <div v-else>其他</div>
    <button @click="type++">切换显示</button>
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";

const type = ref(0);
</script>

编译结果

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
  return _openBlock(), _createElementBlock("div", null, [
    $setup.type === 1 ? (_openBlock(), _createElementBlock("div", _hoisted_1, "primary")) : $setup.type === 2 ? (_openBlock(), _createElementBlock("div", _hoisted_2, "\u6682\u65E0\u6570\u636E")) : (_openBlock(), _createElementBlock("div", _hoisted_3, "\u5176\u4ED6")),
    _createElementVNode("button", {
      onClick: _cache[0] || (_cache[0] = ($event) => $setup.type++)
    }, "\u5207\u6362\u663E\u793A")
  ]);
}

image.png

编译阶段

一、解析阶段:构建条件链表

编译器在解析模板时,会将连续的 v-ifv-else-ifv-else 节点合并为一个 条件节点NodeTypes.IF),其 branches 属性存储各个分支。

二、转换阶段:生成条件表达式

在 transformIf 函数(packages/compiler-core/src/transforms/vIf.ts)中,编译器将条件节点转换为一个 三元运算符链,并为每个分支包裹 openBlock() / createBlock() 调用,因为每个分支都是一个独立的 Block。

三、代码生成阶段:输出 JavaScript

最终生成的代码是一个嵌套的三元运算符,每个分支都使用 openBlock() / createBlock() 来创建 Block。

v-if 会被编译器转换为条件语句(三元运算符或 if 分支),在生成的渲染函数中,根据条件返回不同的虚拟 DOM。

运行阶段

  • 当条件改变时,Vue 的响应式系统触发重新渲染。
  • 渲染函数重新执行,如果条件从假变为真,则创建新的 VNode 并挂载到 DOM;如果从真变为假,则移除对应的 VNode。

七、v-show

v-show 是用于条件显示的内置指令。与 v-if 不同,v-show 不会销毁或重建元素,而是通过切换 CSS 的 display 属性来控制元素的可见性。这意味着元素始终存在于 DOM 中,只是被隐藏或显示。

v-show 的使用

【示例】基本使用

<template>
  <div>
    <div v-show="show">this 展示区</div>
    <button @click="show = !show">切换显示</button>
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";

const show = ref(true);
</script>

image.png

image.png

使用限制

  1. v-show 不支持 <template> 元素,因为 <template> 不会生成真实的 DOM 节点,无法应用 display 样式。

image.png

  1. v-show 没有 v-else 或 v-else-if 的配套指令,它仅单独控制单个元素的显隐。
  2. v-show 必须要有表达式。

image.png

  1. 不要与 v-for 同时使用。v-for 和 v-show 同时使用虽然不会报错,但会导致每次列表变化时重新计算显示状态,性能较差。推荐将 v-show 放在 v-for 内部的元素上,或者使用计算属性过滤后再用 v-for

编译阶段

v-show 会被编译为一个指令,生成一个用于控制 display 的绑定。

运行阶段

当绑定的值变化时,Vue 会直接更新该元素的 style.display 属性(设为 none 或移除/恢复原值)。

八、v-for

【示例】基础使用

<template>
  <div>
    <ul>
      <li v-for="item in books" :key="item">
        <span>
          {{ item }}
        </span>
      </li>
    </ul>
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";

const books = ref(["book1", "book2", "book3", "book4", "book5"]);
</script>

编译结果

import { renderList as _renderList, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock, toDisplayString as _toDisplayString, createElementVNode as _createElementVNode } from "/node_modules/.vite/deps/vue.js?v=efe42f93";
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
  return _openBlock(), _createElementBlock("div", null, [
    _createElementVNode("ul", null, [
      (_openBlock(true), _createElementBlock(
        _Fragment,
        null,
        _renderList($setup.books, (item) => {
          return _openBlock(), _createElementBlock("li", { key: item }, [
            _createElementVNode(
              "span",
              null,
              _toDisplayString(item),
              1
              /* TEXT */
            )
          ]);
        }),
        128
        /* KEYED_FRAGMENT */
      ))
    ])
  ]);
}

image.png

【示例】

<template>
  <div>
    <ul>
      <li v-for="(item, index) in books" :key="item + index">
        <span>
          {{ item }}
        </span>
      </li>
    </ul>
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";

const books = ref(["book1", "book2", "book3", "book4", "book5"]);
</script>

image.png

注意事项

  1. 必须为每个列表项提供一个唯一的 key,且不建议使用 index 作为 key(除非列表是静态的且不会重新排序)
  2. 在 Vue 3 中,v-if 的优先级高于 v-for(与 Vue 2 相反)。这意味着当它们同时用在同一个元素上时,v-if 会先执行,无法访问 v-for 作用域内的变量。
  3. v-for 可以遍历对象的属性,顺序基于 Object.keys() 的返回值。不建议直接遍历对象
  4. v-for 可以接受整数,渲染指定数量的元素。
  5. 避免在 v-for 内部使用复杂的计算属性或方法:每次重新渲染都会重新执行,建议将计算结果提前到列表数据源中。
  6. 使用 v-memo 缓存子树(Vue 3.2+):当子树的依赖很少变化时,使用 v-memo 跳过不必要的更新。

编译阶段

1、在 parse 阶段,v-for="..." 中的表达式会以原始的字符串形式被记录在 AST 节点的 props 属性中,尚未被处理。

2、转换。

解析 v-for="(item, index) in list" 字符串,提取出 source(数据源,如 list)、value(迭代项,如 item)和 key(索引,如 index),并存入一个专门的 ForParseResult 对象中。

构建 ForNode:基于解析结果,原始的节点会被替换成一个新的、类型为 ForNode 的 AST 节点。

3、生成代码。

最终生成的渲染函数会包含对 _renderList 这个运行时辅助函数的调用。

九、v-text

v-text 是 Vue 3 中用于更新元素文本内容的内置指令。它将数据绑定到 DOM 元素的 textContent 属性,确保视图与数据保持同步。与插值语法 {{ }} 相比,v-text 提供了一种更显式的方式来控制元素的全部文本内容,并且会完全覆盖元素原有的子节点

基本使用

<template>
  <div v-text="title"></div>
</template>

<script setup lang="ts">
import { ref } from "vue";

const title = ref("这是一段话题。<p>这是段落1。</p>");
</script>

展示效果

image.png

编译结果

image.png

【示例】

<template>
  <div v-text="count"></div>
</template>

<script setup lang="ts">
import { ref } from "vue";
const count = ref(0);
defineOptions({
  name: "CloudView",
});
</script>

编译结果

const _hoisted_1 = ["textContent"];
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock("div", { textContent: _toDisplayString($setup.count) }, null, 8, _hoisted_1);
}

【示例】插值{{}}

<template>
  <div>
    {{ count }}
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";
const count = ref(0);
defineOptions({
  name: "CloudView",
});
</script>

编译结果

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock(
"div",
null,
_toDisplayString($setup.count),
1
/* TEXT */
);
}

使用限制

  1. 避免与子节点共存:v-text 会覆盖元素的所有子内容,因此在同一个元素上,不应同时使用 v-text 并编写其他子节点

image.png

  1. 仅用于纯文本:v-text 会将 HTML 标签作为纯文本转义输出,不能解析 HTML 结构
  2. 相比于插值语法,v-text 避免了模板编译时的碎片化文本节点,性能上微乎其微,但可读性较差,通常推荐使用 {{ }}

编译阶段

v-text 会被解析为指令,生成代码直接设置 textContent

运行阶段

当绑定的数据变化时,Vue 会更新该元素的 textContent 属性。

十、v-html

v-html 是 Vue 3 中用于将原始 HTML 字符串渲染为真实 DOM 元素的内置指令。与 v-text 或插值语法不同,v-html 会将其内容作为 HTML 解析并插入到元素中,而不是作为纯文本。

【示例】基本使用

<template>
  <div v-html="title"></div>
</template>

<script setup lang="ts">
import { ref } from "vue";

const title = ref("这是一段话题。<p>这是段落1。</p>");
</script>

编译结果

image.png

十一、v-bind

v-bind 指令是数据驱动视图的核心桥梁。能够将 JavaScript 数据动态绑定到 HTML 属性、组件属性(props)甚至 CSS 样式上。当绑定的数据发生变化时,视图会自动更新——你只需关心数据的变化,Vue 会高效地完成 DOM 的更新工作,无需手动操作 DOM。

v-bind是什么?

v-bind 是 Vue 3 中用于动态绑定一个或多个属性的指令,可以绑定 HTML 元素的原生属性(如 srchreftitle 等),也可以绑定组件的 props,还能绑定 class 和 style 等特殊属性

v-bind 使用

语法

<!-- 完整语法 v-bind:属性名="JavaScript表达式" -->
<div v-bind:title="title">这是一个div</div>

<!-- 缩写语法(推荐使用) :属性名="JavaScript表达式"  -->
<div :title="title">这是一个div</div>

<!-- 当属性名与变量名完全相同时,可以省略表达式,直接写成 `:属性名` -->
<div :title>这是一个div</div>

基础使用

HTML 属性 和 DOM 属性的区别?

  1. HTML 属性只负责初始状态,不会自动同步到 DOM 属性。
  2. DOM 属性是当前状态,用户交互或 JS 修改后会更新。
    例如:用户在 <input> 中输入新内容,DOM 属性 value 改变,但 HTML 属性 value 不会变

【示例】绑定 HTML 属性

写在 HTML 标签上的静态文本,由浏览器解析后成为 DOM 节点的初始值。属性名通常是全小写。

<div :aria-label="title">hello</div>

因为 DOM 中不存在 ariaLabel 属性(虽然可以通过 setAttribute 设置),Vue 会将其作为 HTML 属性处理。

image.png

【示例】data-* 自定义属性

<div :data-id="title">ID</div>

【示例】 绑定 DOM 属性

浏览器解析 HTML 后生成的 DOM 对象上的动态属性,可以通过 JavaScript 读写,值会随用户交互变化。

<div v-bind:title="title">这是一个div</div>
<div :title="title">这是一个div</div>
<div :title>这是一个div</div>

image.png

const title = ref("hello vue3");

绑定 class 和 style

它们既是 HTML 属性,又是 DOM 属性,但 Vue 做了特殊增强(支持对象、数组语法),最终仍然通过 DOM 属性机制应用。

<!-- 根据 isActive 动态切换 active 类 -->
<div :class="{ active: isActive, 'text-danger': hasError }"></div>
<div :class="[activeClass, errorClass]"></div>
<div :class="[{ active: isActive }, errorClass]"></div>
<div :style="{ color: activeColor, fontSize: fontSize + 'px' }"></div>
<div :style="styleObject"></div>

const styleObject = reactive({
  color: 'red',
  fontSize: '13px'
})

修饰符

1、.camel将短横线命名的属性名转换为驼峰式

将 HTML 属性名中的短横线(kebab-case)转换为驼峰式(camelCase),以便绑定到使用驼峰命名的 DOM 属性。

2、.prop将绑定绑定为 DOM 属性而非 HTML 属性

强制将绑定值设置为 DOM 属性(Property),而不是 HTML 属性(Attribute)。

3、.attr将绑定强制绑定为 HTML 属性

强制将绑定值设置为 HTML 属性(Attribute),通过 setAttribute 设置。

十二、v-model

v-model 是 Vue.js 中用于实现双向数据绑定的核心指令。

v-model 的使用

【示例】在原生表单元素上:v-model 等价于 :value(或对应属性)加上 @input(或对应事件)事件监听。

<template>
  <div>
    <input type="text" v-model="roleName" placeholder="请输入角色名称" />
  </div>
</template>
<script lang="ts" setup>
import { ref } from "vue";

const roleName = ref("");
</script>

在原生元素上:v-model="username" 等价于 :value="username" @input="username = $event.target.value"

image.png

【示例】多个v-model

<template>
  <div>
    <input type="text" v-model="roleName" placeholder="请输入角色名称" /><br />
    <input type="text" v-model="roleID" placeholder="请输入角色ID" />
  </div>
</template>
<script lang="ts" setup>
import { ref } from "vue";

const roleName = ref("");
const roleID = ref("");
</script>

image.png

【示例】组件上使用

<template>
  <TabOne v-model:name="name" v-model:id="id" />
</template>

<script setup lang="ts">
import TabOne from "@/pages/cloud/components/tabOne.vue";
import { ref } from "vue";

const name = ref("CloudView");
const id = ref("ccd");
defineOptions({
  name: "CloudView",
});
</script>

image.png

【示例】组件上使用

// 父组件
<template>
  <TabOne v-model:tabName="name" v-model:tabId="id" />
</template>

<script setup lang="ts">
import TabOne from "@/pages/cloud/components/tabOne.vue";
import { ref } from "vue";

const name = ref("CloudView");
const id = ref("ccd");
defineOptions({
  name: "CloudView",
});
</script>
// 子组件
<template>
  <div>
    <input v-model="tabName" />
    <input v-model="tabId" />
  </div>
</template>
<script setup lang="ts">
const tabName = defineModel("tabName");
const tabId = defineModel("tabId");

defineOptions({
  name: "TabOneView",
});
</script>

image.png

原生元素修饰符

  1. .lazy,改为 change 事件同步。默认情况下,v-model 在 input 事件触发时同步数据(输入框每次按键都更新)。添加 .lazy 修饰符后,改为在 change 事件触发时同步(通常是 失焦回车时)。
  2. .number ,自动转为数字。将用户的输入自动转换为数字类型。如果输入无法被 parseFloat() 转换,则返回原始字符串。
  3. .trim ,自动去除首尾空格。自动过滤用户输入内容首尾的空白字符(空格、制表符、换行符等)。

【示例】

<template>
  <div>
    <input v-model.trim="tabName" />
    <input v-model.number="tabId" />
  </div>
</template>

image.png

组件修饰符

【示例】组件添加修饰符

// 父组件
<template>
  <TabOne v-model:tabName.max="name" v-model:tabId.upper="id" />
</template>

<script setup lang="ts">
import TabOne from "@/pages/cloud/components/tabOne.vue";
import { ref } from "vue";

const name = ref("CloudView");
const id = ref("ccd");

defineOptions({
  name: "CloudView",
});
</script>

image.png

// 子组件
<template>
  <div>
    <input v-model="tabName" />
    <input v-model="tabId" />
  </div>
</template>
<script setup lang="ts">
const [tabName, tabNameModifiers] = defineModel("tabName", {
  set(val: string) {
    console.log("tabName", tabNameModifiers, val);
    if (tabNameModifiers.max) {
      return val.slice(0, 10);
    }
    return val;
  },
});
const [tabId, tabIdModifiers] = defineModel("tabId", {
  set(val: string) {
    return tabIdModifiers.upper ? val.toUpperCase() : val.toLowerCase();
  },
});

defineOptions({
  name: "TabOneView",
});
</script>

image.png

使用限制

  1. 必须有表达式。
  2. 不能绑定 props
  3. 不能是常量

编译阶段:模板转化

一、 解析(Parse)

parse 函数将模板代码解析成抽象语法树(AST) 。此时,v-model 指令还是一个特殊的节点。

二、转换(Transform)

transform 函数会识别出 v-model 节点,并调用 transformModel 函数,把节点转换成两条 props

  • 对于原生元素:value 和 on:input
  • 对于自定义组件:modelValue 和 on:update:modelValue

三、生成(Generate)

generate 函数将转化后的 AST 生成最终的 render 函数。至此,v-model 指令已不复存在,AST 已被静态展开。

运行阶段:渲染与更新

  1. 执行 render 函数:浏览器执行编译阶段生成的 render 函数,生成虚拟 DOM(VNode)
  2. 处理 props:在生成 VNode 的过程中,render 函数会识别 modelValue 和 onUpdate:modelValue,并将其作为普通的 props 和 event 处理。
  3. 挂载与更新:Vue 的运行时系统会根据 VNode 创建或更新真实 DOM。当用户交互触发 update:modelValue 事件时,父组件中绑定的数据就会被更新,从而触发新一轮的渲染。

十三、v-slot

Vue 3 中的 v-slot 指令用于定义插槽(slot),它是 Vue 组件化体系中实现内容分发和组件复用的核心机制。

插槽的使用?

插槽允许父组件向子组件传递模板内容,子组件通过 <slot> 元素定义内容的放置位置。v-slot 指令用于在父组件中声明传递给子组件的内容。

  • 默认插槽:没有名字的插槽。
  • 具名插槽:有名字的插槽,用于多内容分发。
  • 作用域插槽:子组件可以将数据回传给父组件,父组件利用这些数据渲染内容。

默认插件

【示例】父组件直接嵌套内容

// 父组件
<template>
  <TabTwo>
    <p>插槽内容-默认</p>
  </TabTwo>
</template>

<script setup lang="ts">
import TabTwo from "./cloud/components/tabTwo.vue";

defineOptions({
  name: "CloudView",
});
</script>

编译结果

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createBlock($setup["TabTwo"], null, {
default: _withCtx(() => [..._cache[0] || (_cache[0] = [_createElementVNode(
"p",
null,
"插槽内容-默认",
-1
/* CACHED */
)])]),
_: 1
});
}
// 子组件
<template>
  <div>
    <slot></slot> <!-- 插槽出口 -->
  </div>
</template>
<script setup lang="ts">
defineOptions({
  name: "TabTwoView",
});
</script>

image.png

【示例】<template> 配合 v-slot:default 或简写 #default

v-slot:default

// 父组件
<template>
  <TabTwo>
    <template v-slot:default>
      <p>插槽内容-默认</p>
    </template>
  </TabTwo>
</template>

简写方式

// 父组件
<template>
  <TabTwo>
    <template #default>
      <p>插槽内容-默认</p>
    </template>
  </TabTwo>
</template>
<template>
  <div>
    <slot></slot>
  </div>
</template>
<script setup lang="ts">
defineOptions({
  name: "TabTwoView",
});
</script>

具名插槽

子组件定义多个 <slot>,用 name 属性区分。

【示例】

// 父组件
<template>
  <TabTwo>
    <template v-slot:default>
      <p>插槽内容-默认</p>
    </template>

    <template #header>
      <p>这里是头部</p>
    </template>

    <template #footer>
      <p>这里是脚部</p>
    </template>
  </TabTwo>
</template>

<script setup lang="ts">
import TabTwo from "./cloud/components/tabTwo.vue";

defineOptions({
  name: "CloudView",
});
</script>

image.png

// 子组件
<template>
  <div>
    <slot name="header"></slot>
    <slot></slot>
    <slot name="footer"></slot>
  </div>
</template>

image.png

【示例】条件插槽

// 子组件
<template>
  <div>
    <slot name="header" :header="info.header"></slot>
    <slot :list="info.list"></slot>
    <slot v-if="$slots.footer" name="footer" :footer="info.footer"></slot>
  </div>
</template>
<script setup lang="ts">
import { reactive } from "vue";

const info = reactive({
  header: "这里是头部",
  footer: "这里是脚部",
  list: ["item1", "item2", "item3"],
});
defineOptions({
  name: "TabTwoView",
});
</script>

编译结果

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock("div", null, [
_renderSlot(_ctx.$slots, "header", { header: $setup.info.header }),
_renderSlot(_ctx.$slots, "default", { list: $setup.info.list }),
_ctx.$slots.footer ? _renderSlot(_ctx.$slots, "footer", {
key: 0,
footer: $setup.info.footer
}) : _createCommentVNode("v-if", true)
]);
}

【示例】动态插槽名称

// 父组件
<template>
  <button @click="handleClick">切换</button>
  <TabTwo>
    <template v-slot:[slotName]="{ data }"> {{ data }} </template>
  </TabTwo>
</template>

<script setup lang="ts">
import TabTwo from "./cloud/components/tabTwo.vue";
import { ref } from "vue";
const slotName = ref("header");

const handleClick = () => {
  const flag = Math.random() > 0.5;
  slotName.value = flag ? "header" : "footer";
};
defineOptions({
  name: "CloudView",
});
</script>
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock(
_Fragment,
null,
[_createElementVNode("button", { onClick: $setup.handleClick }, "切换"), _createVNode(
$setup["TabTwo"],
null,
{
[$setup.slotName]: _withCtx(({ data }) => [_createTextVNode(
_toDisplayString(data),
1
/* TEXT */
)]),
_: 2
},
1024
/* DYNAMIC_SLOTS */
)],
64
/* STABLE_FRAGMENT */
);
}

作用域插槽

子组件在 <slot> 上绑定属性(称为插槽 prop),父组件通过 v-slot 接收这些数据,从而实现父组件模板使用子组件内部数据。

// 父组件
<template>
  <TabTwo>
    <template v-slot:default="{ list }">
      <p>插槽内容-默认</p>
      <ul v-for="item in list" :key="item">
        <li>{{ item }}</li>
      </ul>
    </template>

    <template #header="{ header }">
      <h1>这里是头部</h1>
      <p>{{ header }}</p>
    </template>

    <template #footer="{ footer }">
      <h1>这里是脚部</h1>
      <p>{{ footer }}</p>
    </template>
  </TabTwo>
</template>

编译结果

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createBlock($setup["TabTwo"], null, {
default: _withCtx(({ list }) => [_cache[0] || (_cache[0] = _createElementVNode(
"p",
null,
"插槽内容-默认",
-1
/* CACHED */
)), (_openBlock(true), _createElementBlock(
_Fragment,
null,
_renderList(list, (item) => {
return _openBlock(), _createElementBlock("ul", { key: item }, [_createElementVNode(
"li",
null,
_toDisplayString(item),
1
/* TEXT */
)]);
}),
128
/* KEYED_FRAGMENT */
))]),
header: _withCtx(({ header }) => [_cache[1] || (_cache[1] = _createElementVNode(
"h1",
null,
"这里是头部",
-1
/* CACHED */
)), _createElementVNode(
"p",
null,
_toDisplayString(header),
1
/* TEXT */
)]),
footer: _withCtx(({ footer }) => [_cache[2] || (_cache[2] = _createElementVNode(
"h1",
null,
"这里是脚部",
-1
/* CACHED */
)), _createElementVNode(
"p",
null,
_toDisplayString(footer),
1
/* TEXT */
)]),
_: 1
});
}
// 子组件
<template>
  <div>
    <slot name="header" :header="info.header"></slot>
    <slot :list="info.list"></slot>
    <slot name="footer" :footer="info.footer"></slot>
  </div>
</template>
<script setup lang="ts">
import { reactive } from "vue";

const info = reactive({
  header: "这里是头部",
  footer: "这里是脚部",
  list: ["item1", "item2", "item3"],
});
defineOptions({
  name: "TabTwoView",
});
</script>

image.png

注意事项

  1. v-slot 只能用在 template 标签 或组件上。
  2. v-slot 用在组件上,只能是默认的情况。
  3. 如果同时使用默认插槽和具名插槽,默认插槽的内容必须用 <template #default> 包裹(除非你只提供默认插槽且不与其他插槽混用)。

编译阶段

一、解析(Parse)

模板中的 <template v-slot:header="slotProps"> 会被解析成 AST 节点,其中包含:

  • slotName:插槽名(如 header
  • slotProps:作用域变量名(如 slotProps
  • 子节点:插槽内部的模板内容

二、转换(Transform)

编译器会对 AST 进行转换,将 v-slot 转换为 render 函数中的插槽定义。

转换的结果是:每个插槽会被编译成一个函数,该函数接收子组件传递的插槽 prop 作为参数,并返回插槽内容的虚拟 DOM。

三、生成(Generate)

最终生成可执行的 render 函数。

对于子组件,其 render 函数中会访问 $slots 对象;
对于父组件,render 函数会生成一个插槽对象作为子组件的第三个参数(即 children 或 slots)。

运行阶段

当父组件的 render 函数执行时,它会计算每个插槽的内容,并为每个插槽生成一个函数。这些函数被收集到一个对象中,作为子组件创建时的 slots 属性传递。

子组件在渲染时,会通过 $slots 属性访问父组件传递的插槽对象。

Vue 3.x 单文件组件(SFC)模板编译过程解析

作者 米丘
2026年4月23日 11:14

Vue 的单文件组件(Single-File Component,简称 SFC)以 .vue 为扩展名,将模板、逻辑和样式整合在一个文件中。但浏览器无法直接理解这种格式,因此需要经过编译工具(如 Vite、Webpack + vue-loader)将其转换为标准的 JavaScript 模块。这个过程由 @vue/compiler-sfc@vue/compiler-dom 和 @vue/compiler-core 协同完成。

编译流程概览:三部曲

Vue 3 的编译器采用了模块化设计,主要由三个部分组成:

  • @vue/compiler-sfc:专门处理 .vue 文件,负责将文件拆分成 <template><script> 和 <style> 三大块。
  • @vue/compiler-dom:专注于 DOM 平台相关的编译逻辑,是浏览器端的适配层。
  • @vue/compiler-core:平台无关的核心编译器,实现了编译的三大核心阶段:解析 (Parse)转换 (Transform)  和生成 (Generate)

image.png

模板编译

模板编译的核心流程,即 @vue/compiler-core 中发生的 Parse → Transform → Generate 三个阶段

解析(Parse):从模板字符串到 AST

解析器(Parser)的输入是模板字符串,输出是抽象语法树(AST)。AST 以树形结构描述模板的节点类型、属性、指令等信息。

转换(Transform):为运行时优化做准备

转换阶段遍历 AST,对节点进行修改、添加元数据,并生成代码生成节点(codegenNode)。

编译器会识别模板中的 v-ifv-forv-modelv-on 等指令,并将其转换为对应的代码生成节点。

对于插值表达式 {{ msg }} 或指令中的动态表达式,编译器会分析其内容,并在生成代码时添加上下文前缀。

静态提升是 Vue 3 最重要的优化之一。编译器会识别出不依赖任何响应式数据的纯静态节点(如没有绑定、没有插值、没有指令的 <div>),并将其提升到渲染函数之外。

转换器会为每个动态节点标记一个 PatchFlag(补丁标志),指示该节点在更新时哪些部分可能发生变化。这是一个位掩码,运行时可以快速判断需要比较的内容。

生成(Generate):从 AST 到 render 函数

生成阶段是编译的最后一步,它的任务是将转换后的 AST 转换成 JavaScript 代码字符串(即 render 函数)。

generate 函数会递归遍历 AST,调用不同的代码生成函数(genNodegenElementgenExpression 等)拼接字符串。

枚举 ConstantTypes

enum ConstantTypes {
  NOT_CONSTANT = 0, // 非常量表达式,在编译时无法确定其值
  CAN_SKIP_PATCH, // 1 可以跳过补丁的常量,通常是静态节点
  CAN_CACHE, // 2 可以缓存的常量,其值在编译时已知
  CAN_STRINGIFY, // 3 可以字符串化的常量,其值可以在编译时转换为字符串
}

枚举 NodeTypes

enum NodeTypes {
  ROOT, // 0 根节点,整个模板入口
  ELEMENT, // 1 元素节点 ,如 <div>、<span> 等 HTML 元素或 Vue 组件
  TEXT, // 2 文本节点,如普通文本内容
  COMMENT, // 3 注释节点,如 <!-- comment -->
  SIMPLE_EXPRESSION, // 4 简单表达式节点,如 message
  INTERPOLATION, // 5 插值节点,如 {{ message }}
  ATTRIBUTE, // 6 属性节点,如 v-model
  DIRECTIVE, // 7 指令节点,如 v-if

  // containers
  COMPOUND_EXPRESSION, // 8 复合表达式节点,由多个表达式组成
  IF, // 9 条件节点,如 v-if
  IF_BRANCH, // 10 条件分支节点,如 v-else、v-else-if
  FOR, // 11 循环节点,如 v-for
  TEXT_CALL, // 12 文本调用节点,用于处理带表达式的文本
  // codegen
  VNODE_CALL, // 13 VNode 调用节点,用于创建 VNode 实例
  JS_CALL_EXPRESSION, // 14 调用表达式节点,如 function() 或 method()
  JS_OBJECT_EXPRESSION, // 15 JS对象表达式
  JS_PROPERTY, // 16 JS属性表达式,如 obj.prop
  JS_ARRAY_EXPRESSION, // 17 JS数组表达式,如 [1, 2, 3]
  JS_FUNCTION_EXPRESSION, // 18 JS函数表达式,如 function() {}
  JS_CONDITIONAL_EXPRESSION, // 19 条件表达式,如 a ? b : c
  JS_CACHE_EXPRESSION, // 20 缓存表达式,用于缓存计算结果

  // ssr codegen
  JS_BLOCK_STATEMENT,
  JS_TEMPLATE_LITERAL,
  JS_IF_STATEMENT,
  JS_ASSIGNMENT_EXPRESSION,
  JS_SEQUENCE_EXPRESSION,
  JS_RETURN_STATEMENT,
}

示例 v-for

  <ul>
    <li v-for="item in tag" :key="item">信息{{ item }} end</li>
  </ul>

一、 parse 解析 @vue/compiler-core

image.png

image.png

image.png

二、转换

image.png

{
    "codegenNode": {
        "type": 13,
        "tag": "\"li\"",
        "props": {
            "type": 15,
            "properties": [
                {
                    "type": 16,
                    "key": {
                        "type": 4,
                        "content": "key",
                        "isStatic": true,
                        "constType": 3
                    },
                    "value": {
                        "type": 4,
                        "content": "item",
                        "isStatic": false,
                        "constType": 0,
                        "ast": null
                    }
                }
            ]
        },
        "children": {
            "type": 8,
            "children": [
                {
                    "type": 2,
                    "content": "信息"
                },
                " + ",
                {
                    "type": 5,
                    "content": {
                        "type": 4,
                        "content": "item",
                        "isStatic": false,
                        "constType": 0,
                        "ast": null
                    }
                },
                " + ",
                {
                    "type": 2,
                    "content": " end"
                }
            ]
        },
        "patchFlag": 1,
        "isBlock": true,
        "disableTracking": false,
        "isComponent": false
    }
}

三、生成代码

import { renderList as _renderList, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock, toDisplayString as _toDisplayString } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("ul", null, [
    (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.tag, (item) => {
      return (_openBlock(), _createElementBlock("li", { key: item }, "信息" + _toDisplayString(item) + " end", 1 /* TEXT */))
    }), 128 /* KEYED_FRAGMENT */))
  ]))
}

ForNode

interface ForNode extends Node {
  type: NodeTypes.FOR
  // 存储遍历的数据源表达式
  source: ExpressionNode
  // 存储遍历项的别名
  valueAlias: ExpressionNode | undefined
  // 存储键的别名(数组索引或对象键)
  // v-for="(value, key) in object":keyAlias 为 { type: NodeTypes.SIMPLE_EXPRESSION, content: 'key' }
  keyAlias: ExpressionNode | undefined
  // 存储对象索引的别名(仅在遍历对象时使用)
  objectIndexAlias: ExpressionNode | undefined
  // 存储 v-for 指令的解析结果
  parseResult: ForParseResult
  // 存储循环的子节点,即 v-for 循环的内容
  children: TemplateChildNode[]
  // 存储代码生成阶段的中间结果
  codegenNode?: ForCodegenNode
}

ForParseResult

interface ForParseResult {
  // 存储 v-for 指令中的数据源表达式
  source: ExpressionNode
  // 存储 v-for 指令中的值别名
  value: ExpressionNode | undefined
  // 存储 v-for 指令中的键别名
  key: ExpressionNode | undefined
  // 存储 v-for 指令中的索引别名(仅在遍历对象时使用)
  index: ExpressionNode | undefined
  // 标记解析是否已完成
  finalized: boolean
}

ForCodegenNode

interface ForCodegenNode extends VNodeCall {
  // 标记 v-for 生成的节点总是块级节点
  isBlock: true
  // 指定 v-for 生成的节点标签为 FRAGMENT
  tag: typeof FRAGMENT
  // 明确 v-for 生成的片段没有属性
  props: undefined
  // 存储列表渲染的表达式
  children: ForRenderListExpression
  // 存储补丁标志,用于虚拟 DOM 的更新优化
  patchFlag: PatchFlags
  // 标记是否禁用块跟踪
  disableTracking: boolean
}

ForRenderListExpression

interface ForRenderListExpression extends CallExpression {
  // 指定调用的函数为 RENDER_LIST 辅助函数
  callee: typeof RENDER_LIST
  // 第一个元素:ExpressionNode 类型,表示数据源表达式
  // 第二个元素:ForIteratorExpression 类型,表示迭代器表达式
  arguments: [ExpressionNode, ForIteratorExpression]
}

ForIteratorExpression

interface ForIteratorExpression extends FunctionExpression {
  // 存储迭代器函数的返回值类型
  returns?: BlockCodegenNode
}

示例 v-if

  <div>
    <p v-if="tag === 1">这里是header</p>
    <p v-else-if="tag === 2">这里是body</p>
    <p v-else>这里是footer</p>
  </div>

一、parse 解析

第一个 p 节点

image.png

第二个 p 节点

image.png

第三个 p 节点

image.png

二、转换

image.png

第一个分支

image.png

image.png

第二个分支

三、生成代码

import { openBlock as _openBlock, createElementBlock as _createElementBlock, createCommentVNode as _createCommentVNode } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    (_ctx.tag === 1)
      ? (_openBlock(), _createElementBlock("p", { key: 0 }, "这里是header"))
      : (_ctx.tag === 2)
        ? (_openBlock(), _createElementBlock("p", { key: 1 }, "这里是body"))
        : (_openBlock(), _createElementBlock("p", { key: 2 }, "这里是footer"))
  ]))
}

IfNode

interface IfNode extends Node {
  type: NodeTypes.IF
  // 存储条件分支数组,每个分支对应一个 v-if、v-else-if 或 v-else 指令
  branches: IfBranchNode[]
  // 存储代码生成阶段的中间结果
  codegenNode?: IfConditionalExpression | CacheExpression // <div v-if v-once>
}

IfBranchNode

interface IfBranchNode extends Node {
  type: NodeTypes.IF_BRANCH
  // 存储分支的条件表达式
  condition: ExpressionNode | undefined // else
  // 存储分支的子节点,即条件为真时要渲染的内容
  children: TemplateChildNode[]
  // 存储用户为条件分支提供的键,用于优化虚拟 DOM 更新
  userKey?: AttributeNode | DirectiveNode
  // 标记该分支是否来自 <template> 元素的 v-if 指令
  isTemplateIf?: boolean
}

ConditionalExpression

interface ConditionalExpression extends Node {
  // 节点类型,值为 19,标识这是一个条件表达式节点
  type: NodeTypes.JS_CONDITIONAL_EXPRESSION
  // 条件测试表达式,对应三元表达式中的 condition 部分
  test: JSChildNode
  // 条件为真时的表达式,对应三元表达式中的 trueValue 部分
  consequent: JSChildNode
  // 条件为假时的表达式,对应三元表达式中的 falseValue 部分
  alternate: JSChildNode
  // 代码生成时是否需要换行,用于格式化输出
  newline: boolean
}

示例 v-on

<button
  @click="handleClick3"
  @click.stop="
    handleClick();
    handleClick2($event);
  "
>
  增加
</button>

一、parse 解析

image.png

image.png

image.png

二、转换

image.png

image.png

image.png

三、生成代码

import { withModifiers as _withModifiers, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("button", {
    onClick: [
      _ctx.handleClick3,
      _withModifiers($event => {
    _ctx.handleClick();
    _ctx.handleClick2($event);
  }, ["stop"])
    ]
  }, " 增加 ", 8 /* PROPS */, ["onClick"]))
}

示例 v-text

<div>
  <p v-text="count"></p>
</div>

一、parse

image.png

二、转换

image.png

三、生成代码

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

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _createElementVNode("p", {
      textContent: _toDisplayString(_ctx.count)
    }, null, 8 /* PROPS */, ["textContent"])
  ]))
}

示例 插值

  <div>
   <p>{{ tag }}</p>
  </div>

一、parse 解析 @vue/compiler-core

image.png

二、transform 转换

image.png

三、generate 生成代码

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

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

InterpolationNode

interface InterpolationNode extends Node {
  type: NodeTypes.INTERPOLATION
  content: ExpressionNode
}

示例 v-model

  <div>
    <input v-model="message" type="text" placeholder="请输入" />
  </div>

一、parse 解析

image.png

二、转换

指令属性

image.png

普通属性

image.png

三、生成代码

import { vModelText as _vModelText, createElementVNode as _createElementVNode, withDirectives as _withDirectives, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _withDirectives(_createElementVNode("input", {
      "onUpdate:modelValue": $event => ((_ctx.message) = $event),
      type: "text",
      placeholder: "请输入"
    }, null, 8 /* PROPS */, ["onUpdate:modelValue"]), [
      [_vModelText, _ctx.message]
    ])
  ]))
}

示例 v-pre

  <div>
   <div v-pre>
    这是一个pre标签
    <p>这里是header</p>
    <button @click="handleClick">这里是body</button>
   </div>
  </div>

一、解析生成AST

image.png

image.png

二、转换

三、生成代码

image.png

示例 原生元素

<template>
  <p>这里是 tabTwo 组件</p>
</template>

编译结果

import { openBlock as _openBlock, createElementBlock as _createElementBlock } from "/node_modules/.vite/deps/vue.js?v=b3e6ce82";
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock("p", null, "这里是 tabTwo 组件");
}

【示例】

<template>
  <p>这里是 tabTwo 组件</p>
  <span>这里是 span 组件</span>
</template>

编译结果

import { createElementVNode as _createElementVNode, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock } from "/node_modules/.vite/deps/vue.js?v=b3e6ce82";
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock(
_Fragment,
null,
[_cache[0] || (_cache[0] = _createElementVNode(
"p",
null,
"这里是 tabTwo 组件",
-1
/* CACHED */
)), _cache[1] || (_cache[1] = _createElementVNode(
"span",
null,
"这里是 span 组件",
-1
/* CACHED */
))],
64
/* STABLE_FRAGMENT */
);
}

示例 元素img

  <div>
    <img src="@/assets/logo.svg" alt="" />
  </div>

一、转换

image.png

二、转换

image.png

image.png

静态资源转换

image.png

transformAssetUrl函数

image.png

getImportsExpressionExp函数

image.png

三、生成代码

import { createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "/node_modules/.vite/deps/vue.js?v=2d0c80e8";
import _imports_0 from "/src/assets/logo.svg?import";
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock("div", null, [..._cache[0] || (_cache[0] = [_createElementVNode(
"img",
{
src: _imports_0,
alt: ""
},
null,
-1
/* CACHED */
)])]);
}

示例 组件

<template>
  <div>
    <TabTwo :tabName="tabName" tabId="2" />
  </div>
</template>
<script setup lang="ts">
import TabTwo from "@/pages/cloud/components/tabTwo.vue";
import { ref } from "vue";

const tabName = ref("tabTwo");

defineOptions({
  name: "CloudIndexView",
});
</script>

一、解析

image.png

三、生成代码

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock("div", null, [_createVNode($setup["TabTwo"], {
tabName: $setup.tabName,
tabId: "2"
}, null, 8, ["tabName"])]);
}

示例 插槽

一、解析

父组件

image.png

二、转换

父组件

image.png

image.png

子组件

image.png

遍历节点

image.png

image.png

image.png

image.png

三、生成代码

父组件

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock("div", null, [_createVNode($setup["TabTwo"], {
tabName: $setup.tabName,
tabId: "2"
}, {
default: _withCtx(() => [..._cache[0] || (_cache[0] = [_createElementVNode(
"p",
null,
"插槽内容",
-1
/* CACHED */
)])]),
_: 1
}, 8, ["tabName"])]);
}

子组件

import { createElementVNode as _createElementVNode, renderSlot as _renderSlot, openBlock as _openBlock, createElementBlock as _createElementBlock } from "/node_modules/.vite/deps/vue.js?v=2d0c80e8"

function _sfc_render(_ctx, _cache) {
  return (_openBlock(), _createElementBlock("div", null, [
    _cache[0] || (_cache[0] = _createElementVNode("p", null, "这里是 tabTwo 组件", -1 /* CACHED */)),
    _renderSlot(_ctx.$slots, "default")
  ]))
}

示例 v-html

一、解析

image.png

二、转换

image.png

image.png

image.png

三、生成代码

image.png

import { openBlock as _openBlock, createElementBlock as _createElementBlock } from "/node_modules/.vite/deps/vue.js?v=2d0c80e8";
const _hoisted_1 = ["innerHTML"];
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock("div", { innerHTML: $setup.title }, null, 8, _hoisted_1);
}

最后

  1. 在线编译工具
❌
❌