阅读视图

发现新文章,点击刷新页面。

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

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 集成 NProgress 进度条:从入门到精通

在前端应用开发中,用户体验至关重要。当页面加载或进行数据请求时,一个优雅的进度条不仅能告知用户系统正在工作,还能有效缓解用户的等待焦虑。NProgress 作为一款轻量级的进度条库,凭借其简洁的设计和良好的兼容性,被广泛应用于各类 Web 项目中。本文将详细介绍如何在 Vue3 项目中优雅地集成和使用 NProgress

一、环境准备

1.1 安装依赖

# 安装 NProgress 核心库和 lodash-es 工具库
pnpm i nprogress lodash-es

# 安装 TypeScript 类型定义(开发依赖)
pnpm i @types/nprogress @types/lodash-es -D

1.2 依赖说明

  • nprogress:进度条核心库,提供简单的进度控制 API
  • lodash-es:高效的 JavaScript 工具库,用于对象合并等操作
  • @types/nprogress:NProgress 的 TypeScript 类型定义文件
  • @types/lodash-es:lodash-es 的 TypeScript 类型定义文件

1.3 环境变量配置

.env 文件中配置进度条的开关:

# 路由进度条,默认开启(设置为 'false' 可关闭)
VITE_ROUTER_NPROGRESS = true

# 请求进度条,默认开启(设置为 'false' 可关闭)
VITE_REQUEST_NPROGRESS = true

二、核心实现

2.1 基础封装

// src/hooks/useProgress.ts

import { merge } from 'lodash-es'
import NProgress from 'nprogress'
import type { NProgressOptions } from 'nprogress'

interface ProgressConfig extends NProgressOptions {
  /** 是否显示进度条 */
  show: boolean
}

const DEFAULT_CONFIG: Partial<ProgressConfig> = {
  /** CSS3 缓冲动画字符串,支持 ease、linear、ease-in、ease-out、ease-in-out 以及自定义 cubic-bezier 等 */
  easing: 'ease',
  /** 指定进度条的父容器,默认为 body */
  parent: 'body',
  /** 是否显示进度条,可通过环境变量控制 */
  show: true,
  /** 是否显示右侧的环形进度动画 */
  showSpinner: false,
  /** 是否开启自动递增模式 */
  trickle: true,
  /** 设置开始时最低百分比,范围 0-1 */
  minimum: 0.08,
  /** 动画速度,单位毫秒 */
  speed: 200,
}

/**
 * 进度条控制工具 Hook
 * @param config 自定义配置,会与默认配置深度合并
 * @returns { start, done } 启动/结束进度条方法
 */
export function useProgress(config: Partial<ProgressConfig> = {}) {
  const mergeConfig = merge({}, DEFAULT_CONFIG, config)
  NProgress.configure(mergeConfig)

  /**
   * 启动进度条
   */
  function start() {
    if (!mergeConfig.show) return
    NProgress.start()
  }

  /**
   * 结束进度条
   */
  function done() {
    if (!mergeConfig.show || !NProgress.isStarted()) return
    NProgress.done()
  }

  return { start, done }
}

三、实际应用场景

3.1 Axios 请求拦截器集成

在实际项目中,我们通常需要为 API 请求自动添加进度条。以下是配合 Axios 使用的完整示例,通过环境变量控制是否显示:

// src/utils/request.ts

import axios, { AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios'

const NProgress = useProgress({ show: import.meta.env.VITE_REQUEST_NPROGRESS !== 'false' })

const instance: AxiosInstance = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 15000,
})

// 请求拦截器
instance.interceptors.request.use(
  (config: InternalAxiosRequestConfig) => {
    NProgress.start()
    return config
  },
  (error) => {
    NProgress.done()
    return Promise.reject(error)
  },
)

// 响应拦截器
instance.interceptors.response.use(
  (response: AxiosResponse) => {
    NProgress.done()
    return response
  },
  (error) => {
    NProgress.done()
    return Promise.reject(error)
  },
)

export const request = instance

3.2 Vue Router 路由守卫集成

结合 Vue Router,可以在页面切换时显示进度条,通过环境变量控制是否显示:

// src/router/index.ts

import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
  history: createWebHistory(),
  routes: [],
})

const NProgress = useProgress({ show: import.meta.env.VITE_ROUTER_NPROGRESS !== 'false' })

router.beforeEach((to, from, next) => {
  NProgress.start()
  next()
})

router.afterEach(() => {
  NProgress.done()
})

export default router

3.3 组合式使用示例

<template>
  <div class="app">
    <button @click="loadData">加载数据</button>
  </div>
</template>

<script setup lang="ts">
import { useProgress } from '@/hooks/useProgress'

const NProgress = useProgress({ show: import.meta.env.VITE_REQUEST_NPROGRESS !== 'false' })

async function loadData() {
  NProgress.start()
  try {
    await fetch('/api/data')
  } finally {
    NProgress.done()
  }
}
</script>

四、全局样式配置

4.1 全局样式入口文件

创建全局样式入口文件,统一管理项目样式:

// src/styles/index.scss

@use './variables.scss';
@use './transition.scss';
@use 'nprogress/nprogress.css';
@use './element-plus/el-table.scss';
@use './element-plus/el-dialog.scss';
@use './element-plus/el-dropdown.scss';

body {
  font-family: var(--el-font-family);
  background-color: var(--el-bg-color-page);
}

#nprogress .bar {
  background-color: var(--el-color-primary);
}

4.2 样式文件说明

  • variables.scss:Element Plus 主题变量定义
  • transition.scss:全局过渡动画样式
  • nprogress.css:NProgress 进度条基础样式
  • element-plus/*.scss:Element Plus 组件样式覆盖
  • 全局样式:包含进度条颜色等自定义样式

4.3 NProgress 主题样式覆盖

如果需要更详细的自定义 NProgress 样式,可以创建专门的样式文件:

// src/styles/nprogress.scss

#nprogress .bar {
  background-color: var(--el-color-primary);
  height: 3px;

  // 添加渐变效果
  background: linear-gradient(90deg, var(--el-color-primary-light-3) 0%, var(--el-color-primary) 100%);
}

#nprogress .peg {
  box-shadow: 0 0 10px var(--el-color-primary);
}

#nprogress .spinner-icon {
  border-top-color: var(--el-color-primary);
  border-left-color: var(--el-color-primary);
}

4.4 在入口文件中引入

// src/styles/index.scss

@use './variables.scss';
@use './transition.scss';
@use './nprogress.scss'; // 替换为自定义样式文件
@use './element-plus/el-table.scss';
@use './element-plus/el-dialog.scss';
@use './element-plus/el-dropdown.scss';

body {
  font-family: var(--el-font-family);
  background-color: var(--el-bg-color-page);
}

#nprogress .bar {
  background-color: var(--el-color-primary);
}

4.5 main.ts 中引入全局样式

// src/main.ts

import { createApp } from 'vue'
import App from './App.vue'
import './styles/index.scss' // 引入全局样式

const app = createApp(App)
app.mount('#app')

五、NProgress 配置详解

5.1 核心配置项

配置项 类型 默认值 说明
easing string 'ease' CSS3 缓动函数
speed number 200 动画速度(毫秒)
trickle boolean true 是否自动递增
trickleSpeed number 200 自动递增速度
minimum number 0.08 起始百分比
showSpinner boolean false 是否显示环形动画
showUI boolean false 是否显示进度条
parent string 'body' 父容器选择器
positionUsing string '' 定位方式

5.2 缓动函数推荐

const EASING_FUNCTIONS = {
  // 匀速运动
  linear: 'linear',

  // 标准缓动
  ease: 'ease',
  easeIn: 'ease-in',
  easeOut: 'ease-out',
  easeInOut: 'ease-in-out',

  // 自定义贝塞尔曲线
  smooth: 'cubic-bezier(0.4, 0, 0.2, 1)',
  gentle: 'cubic-bezier(0.25, 0.1, 0.25, 1)',
  swift: 'cubic-bezier(0.4, 0, 0.6, 1)',
}

五、总结

通过本文的学习,你应该已经掌握了:

  1. 基础集成:如何在 Vue3 项目中安装和配置 NProgress
  2. 封装技巧:如何封装通用的进度条 Hook,提高代码复用性
  3. 环境变量控制:如何通过环境变量灵活控制进度条的开关
  4. 实际应用:如何与 Axios、Vue Router 等常见库配合使用
  5. 全局样式配置:如何通过全局样式统一管理进度条外观

vue3.x 内置指令有哪些?

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)模板编译过程解析

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. 在线编译工具

Vue并发控制|几十个请求高效管控(实战方案+可运行代码)

在Vue项目开发中,经常会遇到需要同时发起几十个请求的场景(如批量数据查询、批量提交、页面初始化加载多接口数据)。若直接同时发起所有请求,会导致网络拥堵、接口超时、浏览器卡顿,甚至触发后端接口限流,影响用户体验和系统稳定性。本文结合Vue2/Vue3实战,提供4种主流并发控制方案,覆盖不同场景,可直接落地使用,轻松管控几十个请求的并发逻辑。

一、并发控制核心逻辑

并发控制的核心是:限制同一时间发起的请求数量,将几十个请求分批次、有序执行,避免一次性占用过多网络资源。核心要点的2个:

  • 控制并发数:根据后端接口承载能力和浏览器限制,合理设置并发数(通常4-6个,过多易拥堵,过少效率低);
  • 有序执行:分批次发起请求,上一批请求完成(成功/失败)后,再发起下一批,确保请求有序且不拥堵;
  • 异常兼容:处理单个请求失败、超时场景,避免单个请求失败导致整个并发流程中断。

以下方案均适配Vue2/Vue3,基于Axios请求库(Vue项目主流请求工具),可直接复制到项目中修改使用。

二、4种实战并发控制方案(按推荐度排序)

方案一:并发池控制(最推荐,灵活高效,适配所有场景)

核心思路:封装一个并发池工具,将所有请求放入队列,限制同时执行的请求数量,当某个请求完成后,自动从队列中取出下一个请求执行,循环直至所有请求完成。该方案灵活可控,可处理成功/失败回调、超时控制,是几十个请求并发管控的最优选择。

1. 封装并发池工具(Vue2/Vue3通用)

// utils/requestPool.js
import axios from 'axios';

// 极简版并发池(核心功能:限制并发、处理超时、返回结果)
export async function requestPool(requestList, limit = 4, timeout = 10000) {
  const result = [];
  let running = 0;
  let queue = [...requestList];

  const runRequest = async () => {
    if (queue.length === 0 && running === 0) return result;
    while (running < limit && queue.length > 0) {
      running++;
      const requestFn = queue.shift();
      const index = requestList.length - queue.length - running;
      try {
        const res = await Promise.race([
          requestFn(),
          new Promise((_, reject) => setTimeout(() => reject(new Error('请求超时')), timeout))
        ]);
        result[index] = { success: true, data: res.data };
      } catch (error) {
        result[index] = { success: false, error: error.message };
      } finally {
        running--;
        await runRequest();
      }
    }
  };
  await runRequest();
  return result;
}

// 极简请求函数(按需修改url和参数)
export function createRequestFn(id) {
  return () => axios({ url: `/api/data/${id}`, method: 'get', timeout: 10000 });
}

2. Vue组件中使用(Vue3示例,Vue2可直接适配)

<template>
  <div>
    <button @click="handleBatchRequest">发起30个并发请求</button>
    <div class="result">成功:{{ successCount }} 个 | 失败:{{ failCount }} 个</div>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import { requestPool, createRequestFn } from '@/utils/requestPool';

const successCount = ref(0);
const failCount = ref(0);

// 极简使用示例(30个请求,并发数5)
const handleBatchRequest = async () => {
  const requestList = Array.from({ length: 30 }, (_, i) => createRequestFn(i + 1));
  const results = await requestPool(requestList, 5);
  successCount.value = results.filter(item => item.success).length;
  failCount.value = results.filter(item => !item.success).length;
};
</script>

<style scoped>
.result { margin-top: 20px; font-size: 14px; color: #333; }
</style>

方案一优势与适配场景

  • 优势:灵活可控,可自定义并发数、超时时间;单个请求失败不影响整体流程;请求结果顺序与发起顺序一致;代码复用性强,可全局调用。
  • 适配场景:几十个请求的批量查询、批量提交、页面初始化多接口加载等所有场景(最推荐)。

方案二:分批次请求(简单易实现,适合对顺序要求高的场景)

核心思路:将几十个请求分成若干批次,每批次发起固定数量的请求(如每批5个),等待当前批次所有请求完成后,再发起下一批。实现简单,无需复杂封装,适合对请求顺序有严格要求的场景(如下一批请求依赖上一批请求结果)。

1. 封装分批次请求工具(Vue2/Vue3通用)

// utils/batchRequest.js
import axios from 'axios';

/**
 * 分批次请求控制
 * @param {Array} requestList - 请求列表(每个元素是请求参数,如id)
 * @param {Number} batchSize - 每批请求数量(默认5个)
 * @returns {Promise} - 所有请求结果数组
 */
export async function batchRequest(requestList, batchSize = 5) {
  const result = [];
  // 计算总批次
  const totalBatch = Math.ceil(requestList.length / batchSize);

  // 循环发起每批次请求
  for (let i = 0; i < totalBatch; i++) {
    // 截取当前批次的请求参数
    const currentBatch = requestList.slice(i * batchSize, (i + 1) * batchSize);
    // 发起当前批次的所有请求(并行)
    const batchResult = await Promise.allSettled(
      currentBatch.map(id => 
        axios({
          url: `/api/data/${id}`,
          method: 'get',
          timeout: 10000
        }).then(res => ({ success: true, data: res.data }))
        .catch(err => ({ success: false, error: err.message }))
      )
    );
    // 将当前批次结果存入总结果
    result.push(...batchResult);
  }

  return result;
}

2. Vue组件中使用

<script setup>
import { ref } from 'vue';
import { batchRequest } from '@/utils/batchRequest';

const successCount = ref(0);
const failCount = ref(0);

const handleBatchRequest = async () => {
  // 生成30个请求参数(如id数组)
  const requestList = Array.from({ length: 30 }, (_, i) => i + 1);
  
  // 分批次请求,每批5个
  const results = await batchRequest(requestList, 5);
  
  // 处理结果
  successCount.value = results.filter(item => item.success).length;
  failCount.value = results.filter(item => !item.success).length;
};
</script>

方案二优势与适配场景

  • 优势:实现简单,无需复杂封装;请求批次清晰,顺序可控;适合下一批请求依赖上一批结果的场景。
  • 适配场景:对请求顺序有要求、批量提交且需分批校验的场景(如分批提交表单数据,上一批通过再提交下一批)。

方案三:Axios拦截器控制并发(全局管控,适合简单场景)

核心思路:通过Axios的请求拦截器和响应拦截器,维护一个“正在执行的请求”计数器,当计数器达到并发限制时,将后续请求存入队列,等待正在执行的请求完成后,再依次发起。适合简单场景,无需在组件中单独处理,全局统一管控。

1. 全局配置Axios并发控制(Vue2/Vue3通用)

// utils/axiosConfig.js
import axios from 'axios';

// 并发限制数
const CONCURRENT_LIMIT = 4;
// 正在执行的请求计数器
let requestCount = 0;
// 请求队列
const requestQueue = [];

// 创建Axios实例
const service = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 10000
});

// 请求拦截器:控制并发
service.interceptors.request.use(
  config => {
    return new Promise(resolve => {
      // 若当前请求数未达限制,直接发起请求
      if (requestCount < CONCURRENT_LIMIT) {
        requestCount++;
        resolve(config);
      } else {
        // 达到限制,存入请求队列
        requestQueue.push(resolve);
      }
    });
  },
  error => {
    return Promise.reject(error);
  }
);

// 响应拦截器:请求完成后,从队列中取出下一个请求
service.interceptors.response.use(
  response => {
    // 请求完成,计数器减1
    requestCount--;
    // 若队列中有请求,取出并执行
    if (requestQueue.length > 0) {
      const resolve = requestQueue.shift();
      requestCount++;
      resolve(service.defaults);
    }
    return response;
  },
  error => {
    // 失败也需计数器减1,避免队列卡住
    requestCount--;
    if (requestQueue.length > 0) {
      const resolve = requestQueue.shift();
      requestCount++;
      resolve(service.defaults);
    }
    return Promise.reject(error);
  }
);

export default service;

2. Vue组件中使用

<script setup>
import { ref } from 'vue';
import request from '@/utils/axiosConfig';

const successCount = ref(0);
const failCount = ref(0);

// 直接发起30个请求,Axios拦截器自动控制并发
const handleBatchRequest = async () => {
  const requestList = [];
  for (let i = 1; i <= 30; i++) {
    requestList.push(
      request({
        url: `/api/data/${i}`,
        method: 'get'
      }).then(res => ({ success: true, data: res.data }))
      .catch(err => ({ success: false, error: err.message }))
    );
  }

  // 等待所有请求完成
  const results = await Promise.allSettled(requestList);
  successCount.value = results.filter(item => item.success).length;
  failCount.value = results.filter(item => !item.success).length;
};
</script>

方案三优势与适配场景

  • 优势:全局统一管控,组件中无需单独处理并发逻辑;侵入性低,原有请求代码无需修改;实现简单,适合快速落地。
  • 适配场景:项目中所有批量请求需统一控制并发、无需个性化并发配置的简单场景。

方案四:使用第三方库(高效快捷,适合复杂场景)

核心思路:借助成熟的第三方库(如p-limit),快速实现并发控制,无需自己封装工具,适合复杂场景(如并发数动态调整、请求优先级控制)。p-limit轻量、易用,是前端并发控制的常用库。

1. 安装与使用(Vue2/Vue3通用)

// 1. 安装依赖
// npm install p-limit --save

// 2. 组件中使用
<script setup>
import { ref } from 'vue';
import axios from 'axios';
import pLimit from 'p-limit';

// 设置并发限制数为4
const limit = pLimit(4);
const successCount = ref(0);
const failCount = ref(0);

const handleBatchRequest = async () => {
  // 生成30个请求函数,并用p-limit包装
  const requestList = [];
  for (let i = 1; i <= 30; i++) {
    // 用limit包装请求函数,限制并发
    requestList.push(
      limit(() => 
        axios({
          url: `/api/data/${i}`,
          method: 'get',
          timeout: 10000
        }).then(res => ({ success: true, data: res.data }))
        .catch(err => ({ success: false, error: err.message }))
      )
    );
  }

  // 等待所有请求完成
  const results = await Promise.allSettled(requestList);
  successCount.value = results.filter(item => item.success).length;
  failCount.value = results.filter(item => !item.success).length;
};
</script>

方案四优势与适配场景

  • 优势:无需自己封装,高效快捷;支持动态调整并发数、请求优先级;成熟稳定,适配复杂并发场景。
  • 适配场景:复杂并发需求(如动态调整并发数、设置请求优先级)、不想自己封装工具的场景。

三、Vue2与Vue3适配差异(关键注意点)

  • 请求工具:两者均使用Axios,配置方式完全一致,无差异;
  • 组件写法:Vue3使用组合式API(setup语法),Vue2使用选项式API(methods中编写逻辑),核心并发控制逻辑完全一致;
  • 环境变量:Vue3使用import.meta.env,Vue2使用process.env,修改Axios baseURL时需注意适配;
  • 第三方库:p-limit等库适配所有Vue版本,无差异。

四、避坑指南(高频问题)

  • 并发数设置:避免设置过大(如超过10个),否则会导致网络拥堵、后端限流;建议设置4-6个,根据后端接口承载能力调整;
  • 超时控制:必须为单个请求设置超时时间,避免某个请求长时间挂起,导致整个并发流程卡住;
  • 失败处理:使用Promise.allSettled(而非Promise.all),避免单个请求失败导致整个并发流程中断;
  • 请求顺序:并发池和分批次方案可保证请求结果顺序与发起顺序一致,Axios拦截器方案无法保证顺序(需额外处理);
  • 内存占用:几十个请求并发时,避免存储过多请求结果,可按需处理结果(如边请求边渲染),减少内存占用。

五、总结

针对Vue中几十个请求的并发控制,4种方案各有适配场景,可根据项目需求灵活选择:

  1. 并发池控制:最推荐,灵活可控、适配所有场景,兼顾易用性和扩展性;
  2. 分批次请求:适合对请求顺序有要求、下一批依赖上一批结果的场景;
  3. Axios拦截器:适合全局统一管控、无需个性化配置的简单场景;
  4. 第三方库:适合复杂场景、不想自己封装工具的场景。

实际开发中,建议优先使用“并发池控制”方案,既能灵活控制并发数、处理异常,又能保证请求顺序,可直接复制本文代码,修改请求地址和参数即可快速落地,轻松解决几十个请求的并发难题。

Vue大批量接口请求优化|告别卡顿、超时!前端落地实战指南

在Vue项目开发中,大批量接口请求(如列表批量查询、批量提交、多模块初始化请求)是常见场景,若不做优化,易出现请求阻塞、页面卡顿、接口超时、服务器压力过大等问题,严重影响用户体验和系统稳定性。本文结合Vue2/Vue3实战,从请求管控、缓存策略、代码优化、异常处理四大维度,提供可直接落地的优化方案,覆盖核心场景与避坑要点。

一、核心痛点分析(优化前提)

大批量接口请求的核心问题集中在4点,优化需针对性突破:

  • 请求并发过多:同时发起数十个甚至上百个接口请求,超出浏览器并发限制(通常浏览器同一域名并发数为6个),导致请求排队、阻塞,页面加载缓慢;
  • 重复请求浪费:同一接口短时间内多次发起(如页面刷新、组件重复渲染),重复获取相同数据,增加服务器压力和网络开销;
  • 数据处理低效:大批量请求返回后,频繁操作DOM或修改响应式数据,触发Vue多次重新渲染,导致页面卡顿;
  • 异常处理缺失:单个请求失败导致整体流程中断,或无超时控制、重试机制,影响用户操作体验。

二、核心优化方案(实战落地)

(一)请求层面优化:管控并发,减少无效请求

核心思路:通过“限制并发数、合并请求、取消无效请求”,降低服务器和浏览器的压力,提升请求响应效率。

1. 限制并发请求数量(最核心优化)

浏览器对同一域名的并发请求数有明确限制(Chrome为6个),超出部分会排队等待,导致请求延迟。通过“并发池”控制同时发起的请求数量,避免阻塞。

实战实现(通用封装,适配Vue2/Vue3):

// utils/requestPool.js
/**
 * 并发请求池:限制同时发起的请求数量
 * @param {Array} requests - 请求函数数组(每个函数返回Promise)
 * @param {Number} limit - 并发限制数(默认6)
 * @returns {Promise} - 所有请求完成后的Promise
 */
export function requestPool(requests, limit = 6) {
  let index = 0; // 当前执行的请求索引
  const results = []; // 存储所有请求结果
  let resolveAll; // 所有请求完成的回调

  // 创建Promise,用于等待所有请求完成
  const allPromise = new Promise(resolve => {
    resolveAll = resolve;
  });

  // 执行单个请求
  async function run() {
    if (index >= requests.length) {
      // 所有请求执行完毕,返回结果
      resolveAll(results);
      return;
    }
    // 取出当前请求函数
    const request = requests[index];
    index++;
    try {
      // 执行请求,存储结果
      const res = await request();
      results.push({ success: true, data: res, index: index - 1 });
    } catch (err) {
      // 捕获错误,存储错误信息
      results.push({ success: false, error: err, index: index - 1 });
    }
    // 递归执行下一个请求(当前请求完成后,再发起下一个)
    run();
  }

  // 初始化并发池,启动limit个请求
  for (let i = 0; i < limit; i++) {
    run();
  }

  return allPromise;
}

// 页面中使用(示例:批量获取列表数据)
import { requestPool } from '@/utils/requestPool';
import { getListData } from '@/api/list';

// 构造请求函数数组(每个请求函数返回Promise)
const requestList = [1,2,3,...,50].map(id => () => getListData(id));

// 限制并发数为5,执行批量请求
requestPool(requestList, 5).then(results => {
  // 处理所有请求结果(区分成功/失败)
  const successData = results.filter(item => item.success).map(item => item.data);
  const failIds = results.filter(item => !item.success).map(item => item.index + 1);
});

关键说明:并发数建议设置为4-6(匹配浏览器并发限制),避免设置过高导致服务器压力过大,过低影响请求效率。

2. 合并请求,减少接口调用次数

对于“批量查询、批量提交”类场景(如批量查询多个商品详情、批量提交多条数据),避免循环发起单个请求,改为“一次请求携带多个参数”,减少接口调用次数。

实战场景(Vue3示例):

// 优化前:循环发起单个请求(低效)
const ids = [1,2,3,...,20];
const list = [];
for (const id of ids) {
  const res = await getGoodsDetail(id); // 循环发起20次请求
  list.push(res.data);
}

// 优化后:合并请求(一次请求)
const ids = [1,2,3,...,20];
const res = await getBatchGoodsDetail({ ids: ids.join(',') }); // 一次请求,携带所有id
const list = res.data;

注意:需与后端配合,约定批量接口的参数格式(如用逗号分隔id、传递数组);若批量数据过多(如超过100条),可拆分多个合并请求(如每50条一次),避免请求参数过长导致接口报错。

3. 取消无效请求,避免资源浪费

场景:页面切换、组件销毁时,之前发起的请求未完成,会导致无用响应占用网络资源,甚至引发数据错乱。需在合适时机取消无效请求。

实战实现(结合Axios + Vue3生命周期):

// 1. 封装Axios,支持取消请求
import axios from 'axios';

// 创建取消令牌生成器
const CancelToken = axios.CancelToken;
let cancel;

// 封装请求函数
export function request(config) {
  return axios({
    ...config,
    // 创建取消令牌
    cancelToken: new CancelToken(c => {
      cancel = c; // 保存取消函数,用于后续取消请求
    })
  });
}

// 提供取消请求的方法
export function cancelRequest(msg = '请求已取消') {
  if (cancel) {
    cancel(msg);
    cancel = null; // 重置取消函数
  }
}

// 2. 组件中使用(Vue3)
import { onUnmounted } from 'vue';
import { request, cancelRequest } from '@/utils/request';

export default {
  setup() {
    // 组件销毁时,取消未完成的请求
    onUnmounted(() => {
      cancelRequest('组件已销毁,取消请求');
    });

    // 发起请求
    const fetchData = async () => {
      try {
        const res = await request({
          url: '/api/batch/data',
          method: 'get'
        });
        // 处理数据
      } catch (err) {
        // 捕获取消请求的异常(无需提示用户)
        if (axios.isCancel(err)) {
          console.log('请求已取消:', err.message);
          return;
        }
        // 处理其他错误
        ElMessage.error('请求失败,请重试');
      }
    };

    return { fetchData };
  }
};

Vue2适配:在beforeDestroy钩子中调用cancelRequest,逻辑一致。

(二)缓存层面优化:复用数据,减少重复请求

核心思路:对“不常变化、高频访问”的批量请求数据进行缓存,再次请求时直接复用缓存,避免重复调用接口。

1. 内存缓存(适合单页面会话内复用)

通过Vuex/Pinia或全局对象,缓存批量请求的结果,页面刷新前有效,适合临时复用数据(如页面内多个组件共用同一批量数据)。

实战实现(Pinia示例,Vue3):

// store/modules/dataCache.js
import { defineStore } from 'pinia';
import { getBatchData } from '@/api/data';

export const useDataCacheStore = defineStore('dataCache', {
  state: () => ({
    batchDataCache: new Map() // 用Map存储缓存,key为请求参数,value为数据
  }),
  actions: {
    // 批量请求并缓存数据
    async fetchBatchData(ids) {
      const cacheKey = ids.join(','); // 用ids拼接作为缓存key
      // 检查缓存,存在则直接返回
      if (this.batchDataCache.has(cacheKey)) {
        return this.batchDataCache.get(cacheKey);
      }
      // 缓存不存在,发起请求
      const res = await getBatchData({ ids: cacheKey });
      // 存入缓存(可设置过期时间,优化缓存有效性)
      this.batchDataCache.set(cacheKey, res.data);
      // 可选:设置缓存过期时间(如5分钟)
      setTimeout(() => {
        this.batchDataCache.delete(cacheKey);
      }, 5 * 60 * 1000);
      return res.data;
    }
  }
});

// 组件中使用
import { useDataCacheStore } from '@/store/modules/dataCache';

export default {
  setup() {
    const dataCacheStore = useDataCacheStore();
    const fetchData = async () => {
      const ids = [1,2,3,...,10];
      // 优先从缓存获取,无缓存则请求
      const data = await dataCacheStore.fetchBatchData(ids);
    };
    return { fetchData };
  }
};

2. 本地存储缓存(适合跨会话复用)

对于“长期不变、高频使用”的批量数据(如字典表、分类列表),使用localStorage/sessionStorage缓存,减少页面初始化时的批量请求。

注意:避免缓存敏感数据(如用户信息);设置合理的缓存过期时间,避免数据过时。

// 封装缓存工具
export const cacheUtil = {
  // 存入缓存(带过期时间)
  setCache(key, value, expire = 24 * 60 * 60 * 1000) {
    const cacheData = {
      data: value,
      expireTime: Date.now() + expire
    };
    localStorage.setItem(key, JSON.stringify(cacheData));
  },
  // 获取缓存(判断是否过期)
  getCache(key) {
    const cacheStr = localStorage.getItem(key);
    if (!cacheStr) return null;
    const cacheData = JSON.parse(cacheStr);
    // 过期则删除缓存,返回null
    if (Date.now() > cacheData.expireTime) {
      localStorage.removeItem(key);
      return null;
    }
    return cacheData.data;
  }
};

// 页面中使用
import { cacheUtil } from '@/utils/cacheUtil';
import { getDictList } from '@/api/dict';

async function fetchDictData() {
  const cacheKey = 'dict_batch_data';
  // 从本地缓存获取
  const cacheData = cacheUtil.getCache(cacheKey);
  if (cacheData) return cacheData;
  // 缓存不存在,发起批量请求
  const res = await getDictList({ type: 'all' });
  // 存入缓存(设置24小时过期)
  cacheUtil.setCache(cacheKey, res.data, 24 * 60 * 60 * 1000);
  return res.data;
}

(三)代码层面优化:减少渲染开销,提升执行效率

核心思路:大批量请求返回后,减少Vue响应式数据的修改频率和DOM操作,避免页面卡顿。

1. 批量修改响应式数据,减少重新渲染

Vue响应式数据每次修改都会触发依赖更新和页面渲染,大批量数据处理时,需避免循环修改响应式数据,改为“一次性赋值”。

实战对比(Vue3):

// 优化前:循环修改响应式数据(触发多次渲染,卡顿)
const { reactive } = Vue;
const list = reactive([]);
// 假设results是批量请求返回的50条数据
results.forEach(item => {
  list.push(item.data); // 每push一次,触发一次渲染
});

// 优化后:一次性赋值(仅触发一次渲染)
const { reactive } = Vue;
const list = reactive([]);
// 先将数据存入普通数组,处理完成后一次性赋值
const tempList = [];
results.forEach(item => {
  tempList.push(item.data);
});
list.push(...tempList); // 一次性添加所有数据,仅触发一次渲染

Vue2适配:使用Vue.set批量修改时,同样先处理普通数组,再一次性赋值给响应式数组。

2. 虚拟列表渲染,避免DOM过载

若批量请求返回大量数据(如1000+条),直接渲染所有数据会导致DOM节点过多,页面卡顿。使用虚拟列表(如vue-virtual-scroller),只渲染当前可视区域的内容,大幅减少DOM节点数量。

实战实现(Vue3 + vue-virtual-scroller):

<template>
  <virtual-scroller
    class="virtual-list"
    :items="batchData"
    :item-height="60"
    key-field="id"
  >
    <template #default="{ item }">
      <div class="list-item">{{ item.name }}</div>
    </template>
  </virtual-scroller>
</template>

<script setup>
import { ref } from 'vue';
import { VirtualScroller } from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
import { getBatchData } from '@/api/data';

const batchData = ref([]);

// 批量请求数据
async function fetchData() {
  const res = await getBatchData({ page: 1, size: 1000 });
  batchData.value = res.data; // 无需分页,直接赋值给虚拟列表
}

fetchData();
</script>

<style scoped>
.virtual-list {
  height: 500px; /* 固定可视区域高度 */
  overflow-y: auto;
}
.list-item {
  height: 60px; /* 与item-height一致 */
  line-height: 60px;
}
</style>

3. 防抖节流,避免重复触发请求

对于“搜索、筛选”类批量请求(如输入关键词后批量查询),使用防抖(debounce)或节流(throttle),避免用户频繁操作导致多次发起批量请求。

// 封装防抖函数
export function debounce(fn, delay = 300) {
  let timer = null;
  return function(...args) {
    clearTimeout(timer);
    timer = setTimeout(() => {
      fn.apply(this, args);
    }, delay);
  };
}

// 组件中使用
import { debounce } from '@/utils/tools';
import { getBatchSearch } from '@/api/search';

export default {
  setup() {
    // 防抖处理批量搜索请求,延迟300ms执行
    const handleSearch = debounce(async (keyword) => {
      const res = await getBatchSearch({ keyword, size: 50 });
      // 处理数据
    }, 300);

    return { handleSearch };
  }
};

(四)异常处理优化:提升稳定性,优化用户体验

核心思路:针对大批量请求的“超时、失败、部分成功”场景,做容错处理,避免整体流程中断,提升用户体验。

1. 超时控制,避免请求挂起

为批量请求设置合理的超时时间(如10-15秒),避免请求长时间挂起,占用网络资源,同时给用户明确的反馈。

// Axios全局配置超时时间
import axios from 'axios';

axios.defaults.timeout = 12000; // 全局超时12秒

// 单个批量请求单独设置超时(按需)
export function getBatchData(params) {
  return axios({
    url: '/api/batch/data',
    method: 'get',
    params,
    timeout: 15000 // 批量请求超时时间可适当延长
  });
}

2. 失败重试,提升成功率

对于临时网络波动导致的请求失败,设置自动重试机制(如重试2次),避免用户手动重试,提升批量请求的成功率。

// 封装带重试机制的请求函数
export async function requestWithRetry(config, retryCount = 2) {
  try {
    const res = await axios(config);
    return res;
  } catch (err) {
    // 若未超过重试次数,继续重试
    if (retryCount > 0) {
      console.log(`请求失败,剩余重试次数:${retryCount}`);
      return requestWithRetry(config, retryCount - 1);
    }
    // 重试次数耗尽,抛出错误
    throw new Error('请求失败,请检查网络后重试');
  }
}

// 批量请求中使用
const res = await requestWithRetry({
  url: '/api/batch/data',
  method: 'get',
  params: { ids: '1,2,3,...,50' }
}, 2); // 失败重试2次

3. 部分失败处理,不中断整体流程

大批量请求中,可能出现“部分请求成功、部分失败”的情况(如批量提交10条数据,2条失败),需单独处理失败项,不中断整体流程,同时提示用户失败原因。

// 结合并发池,处理部分失败场景
requestPool(requestList, 5).then(results => {
  const successData = [];
  const failList = [];
  results.forEach((item, index) => {
    if (item.success) {
      successData.push(item.data);
    } else {
      // 记录失败的请求索引和原因
      failList.push({
        id: index + 1,
        error: item.error.message
      });
    }
  });
  // 提示用户结果
  ElMessage.success(`批量请求完成,成功${successData.length}条,失败${failList.length}条`);
  // 若有失败项,可展示失败原因或提供重试按钮
  if (failList.length > 0) {
    console.log('失败详情:', failList);
    // 可选:自动重试失败项
    const failRequests = failList.map(item => () => requestList[item.id - 1]());
    requestPool(failRequests, 2).then(failResults => {
      // 处理重试结果
    });
  }
});

三、Vue2与Vue3优化差异(注意要点)

  • 响应式处理:Vue3使用reactive/ref,批量修改时可直接操作普通数组后一次性赋值;Vue2使用Vue.set,避免直接修改数组索引,同样建议一次性赋值。
  • 状态管理:Vue3推荐使用Pinia缓存数据,API更简洁;Vue2使用Vuex,需通过mutations/actions修改缓存。
  • 生命周期:Vue3使用onUnmounted取消请求;Vue2使用beforeDestroy,逻辑一致。
  • 虚拟列表:Vue3可使用vue-virtual-scroller@next版本;Vue2使用vue-virtual-scroller旧版本,配置略有差异。

四、优化总结与落地建议

Vue大批量接口请求优化,核心是“减少请求次数、控制并发数量、复用数据、减少渲染开销”,落地时需结合实际场景(请求类型、数据量、用户操作)灵活选择方案:

  1. 高频批量查询:优先使用“合并请求 + 缓存”,减少接口调用;
  2. 大量数据渲染:结合“虚拟列表”,避免DOM过载;
  3. 用户交互类批量请求(如筛选、搜索):使用“防抖 + 并发限制”,避免无效请求;
  4. 关键业务批量请求(如批量提交):添加“超时控制 + 失败重试 + 部分失败处理”,提升稳定性。

同时,需与后端密切配合(如提供批量接口、优化接口响应速度),前端优化与后端优化结合,才能最大化提升大批量接口请求的效率和用户体验。

五、优化落地清单(简洁版)

核心优化动作,直接对照落地,适配Vue2/Vue3:

  1. 请求管控:用并发池限制并发数(4-6个),合并批量请求(避免循环调用),组件销毁时取消无效请求;
  2. 缓存复用:高频不变数据用Pinia/Vuex做内存缓存,长期不变数据用localStorage做本地缓存(设过期时间);
  3. 渲染优化:批量修改响应式数据时先存普通数组再一次性赋值,1000+条数据用虚拟列表渲染;
  4. 异常处理:全局/单独设置超时(10-15秒),失败请求重试2次,部分失败单独处理不中断整体流程;
  5. 交互优化:搜索/筛选类请求加防抖(300ms),避免频繁触发批量请求;
  6. 版本适配:Vue3用Pinia+onUnmounted,Vue2用Vuex+beforeDestroy,虚拟列表按版本选择对应依赖。

Vue3 defineModel 完全不破坏单向数据流!底层原理+实战解析

结论先行:defineModel 不仅没有破坏 Vue3 的单向数据流,反而在简化代码的同时,严格遵循了单向数据流的核心原则。很多开发者产生“破坏”的误解,本质是混淆了“子组件直接修改父组件数据”与“子组件通过约定机制通知父组件更新数据”的区别,而 defineModel 的底层实现,恰恰是对单向数据流的合规封装与语法简化。

要搞懂这个问题,我们需要先明确两个核心前提:Vue3 单向数据流的定义,以及 defineModel 的底层工作机制,再通过对比验证其合规性,同时补充错误示范,清晰区分“合规写法”与“真正破坏数据流的写法”。

一、先明确:Vue3 单向数据流的核心原则

Vue3 单向数据流的核心规则只有两条,也是判断任何组件通信方式是否合规的标准:

  • 数据流向:父组件 → 子组件,数据只能由父组件通过 props 传递给子组件,子组件仅能读取 props 数据,不能直接修改 props 本身(props 是只读的);
  • 更新权限:只有父组件拥有数据的修改权,子组件若需修改父组件传递的数据,必须通过触发父组件的事件(emit),由父组件在事件回调中修改数据,再通过 props 将更新后的数据同步给子组件。

简单来说,单向数据流的核心是“数据只读(子组件)、更新可控(父组件)”,避免数据流向混乱,降低复杂应用的维护成本。这也是 Vue3 组件通信的核心设计理念,defineModel 作为 Vue3.4+ 新增的语法糖,完全遵循这一原则。反之,若子组件直接操作父组件实例、修改父组件数据,则会真正破坏单向数据流。

二、关键解析:defineModel 的底层实现(打破误解的核心)

defineModel 并非新增的“双向数据流”机制,而是 Vue3 提供的语法糖宏,其底层本质是对“props + emit”的自动封装——编译器会在构建阶段,将 defineModel 的代码自动展开为标准的 props 接收和 emit 触发逻辑,完全贴合单向数据流的规则。

很多开发者误以为“子组件能直接修改 defineModel 返回的值,就是修改了父组件数据”,实则是忽略了 defineModel 的编译过程。我们通过“原始写法”与“defineModel 写法”的对比,清晰看其底层逻辑,同时新增错误示范,强化区分:

1. 传统双向绑定写法(手动实现,完全遵循单向数据流)

在 defineModel 出现之前,组件间双向绑定需手动定义 props 和 emit,严格遵循“父传子、子通知父”的流程:

<!-- 父组件 Parent.vue -->
<template>
  <Child 
    :modelValue="count" 
    @update:modelValue="newVal => count = newVal" 
  />
</template>

<script setup lang="ts">
import { ref } from 'vue'
import Child from './Child.vue'

const count = ref(0) // 父组件拥有数据修改权
</script>

<!-- 子组件 Child.vue -->
<template>
  <button @click="handleClick">count: {{ modelValue }}</button>
</template>

<script setup lang="ts">
// 1. 手动接收父组件传递的 props(数据从父到子)
const props = defineProps({
  modelValue: {
    type: Number,
    required: true
  }
})

// 2. 手动定义 emit,用于通知父组件更新数据
const emit = defineEmits(['update:modelValue'])

// 3. 子组件不直接修改 props,而是触发 emit 通知父组件
const handleClick = () => {
  emit('update:modelValue', props.modelValue + 1)
}
</script>

这种写法完全符合单向数据流:子组件仅读取 props.modelValue,不直接修改;数据更新由父组件在 emit 回调中完成,数据流向清晰可控。

2. defineModel 写法(语法糖,底层与传统写法完全一致)

使用 defineModel 后,代码被大幅简化,但底层逻辑没有任何变化——编译器会自动帮我们生成 props 和 emit 相关代码,本质还是“props + emit”的组合:

<!-- 父组件 Parent.vue(不变) -->
<template>
  <Child v-model="count" /> <!-- v-model 是 :modelValue + @update:modelValue 的语法糖 -->
</template>

<script setup lang="ts">
import { ref } from 'vue'
import Child from './Child.vue'

const count = ref(0)
</script>

<!-- 子组件 Child.vue(defineModel 简化写法) -->
<template>
  <button @click="handleClick">count: {{ model.value }}</button>
</template>

<script setup lang="ts">
// 一行代码替代 props + emit 的手动定义
const model = defineModel({
  type: Number,
  required: true
})

const handleClick = () => {
  model.value++ // 看似直接修改,实则触发底层 emit
}
</script>

重点:defineModel 返回的是一个 ref 对象,而非直接指向父组件的 props 数据。当我们修改 model.value 时,并非直接修改父组件的 count,而是触发了底层自动生成的 emit('update:modelValue', 新值),由父组件接收事件后修改自身的 count,再通过 props 将新值同步给子组件的 model.value。

3. 错误示范:真正破坏单向数据流的写法(与合规写法对比)

以下写法直接违背单向数据流原则,属于“子组件直接修改父组件数据”,会导致数据流向混乱、维护困难,与 defineModel 的合规写法形成鲜明对比,开发中需严格规避:

<!-- 父组件 Parent.vue -->
<template>
  <Child :count="count" />
  <div>父组件 count: {{ count }}</div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import Child from './Child.vue'

const count = ref(0)
</script>

<!-- 子组件 Child.vue(错误写法:直接修改父组件数据) -->
<script setup lang="ts">
import { getCurrentInstance } from 'vue'
import type { ComponentInternalInstance } from 'vue'

// 错误1:通过 getCurrentInstance 获取父组件实例,直接修改父组件数据
const instance = getCurrentInstance() as ComponentInternalInstance
const handleClick = () => {
  // 直接修改父组件的 count,跳过 emit 通知,破坏单向数据流
  (instance.parent?.exposed as { count: { value: number } }).count.value++ 
}

// 错误2:直接修改 props(props 只读,TS 会报错,运行时也会失败)
const props = defineProps({
  count: { type: Number, required: true }
})
const wrongHandle = () => {
  props.count++ // ❌ TS 报错:Cannot assign to 'count' because it is a read-only property
}
</script>

关键提醒:上述错误写法的核心问题的是“子组件直接操作父组件数据/实例”,未通过 emit 通知父组件,完全违背“父组件拥有数据修改权”的原则,这才是真正破坏单向数据流的行为。而 defineModel 始终通过 emit 通知父组件更新,从未直接操作父组件数据,两者有本质区别。

核心差异点标注(合规写法 vs 错误写法)

为更清晰区分,以下明确两类写法的核心差异,结合前文代码场景总结,整理为对比表格如下:

对比维度 合规写法(defineModel/传统 props+emit) 错误写法(破坏单向数据流)
数据操作方式(核心) 子组件仅操作本地 ref 对象(defineModel 生成)或触发 emit,不直接触碰父组件数据 子组件通过 getCurrentInstance 获取父组件实例、直接修改 props,直接操作父组件数据
更新通知机制 必须通过 emit 事件通知父组件,由父组件执行数据修改,遵循“子通知、父更新” 跳过 emit 通知,子组件自主修改父组件数据,完全脱离父组件控制
props 操作 子组件仅读取 props,不修改 props(TS 会校验 props 只读) 试图直接修改 props 或通过父组件实例绕开 props 只读限制,违背 Vue 设计规则
数据流向 严格遵循“父→子”单向流向,更新时“子通知→父修改→子同步” 打破流向,子组件可直接修改父组件数据,导致数据流向混乱、难以调试

4. defineModel 的编译展开过程(核心证据)

Vue3 编译器会将 defineModel 代码自动展开为传统的“props + emit + 计算属性”逻辑,其展开后的代码如下(与我们手动编写的传统写法完全一致):

// defineModel 编译前(我们写的代码)
const model = defineModel({ type: Number, required: true })

// 编译后(编译器自动生成的代码)
const props = defineProps({ modelValue: { type: Number, required: true } })
const emit = defineEmits(['update:modelValue'])

// 生成一个 ref 对象,关联 props.modelValue 和 emit
const model = computed({
  get: () => props.modelValue, // 读取父组件传递的 props(数据父→子)
  set: (newVal) => emit('update:modelValue', newVal) // 修改时触发 emit,通知父组件更新
})

从编译结果可以明确:defineModel 本质是对“props 接收 + emit 触发”的封装,没有任何“子组件直接修改父组件数据”的操作,完全遵循单向数据流的核心原则。我们看到的“子组件修改 model.value”,只是语法层面的简化,底层依然是“子组件通知、父组件更新”的合规流程。

三、常见误解拆解(为什么会觉得“破坏”数据流?)

开发者产生误解,主要源于两个常见认知偏差,结合实战场景逐一拆解:

误解1:“子组件能修改 model.value,就是直接修改父组件数据”

核心澄清:model.value 是子组件本地的 ref 对象,并非父组件的 props 本身。

defineModel 生成的 ref 对象,内部维护了一个本地变量(localValue),该变量通过 watchSyncEffect 与父组件传递的 props.modelValue 保持同步——父组件数据更新时,子组件的 model.value 会自动同步;子组件修改 model.value 时,会触发 set 方法,通过 emit 通知父组件更新,而非直接修改父组件数据。

举个直观例子:父组件 count = 0,子组件 model.value 初始值 = 0(同步 props);子组件执行 model.value++ 后,先触发 emit 传递新值 1,父组件接收后将 count 改为 1,再通过 props 将 1 同步给子组件,子组件 model.value 才更新为 1。整个过程中,子组件从未直接操作父组件的 count。

误解2:“defineModel 实现了双向绑定,双向绑定就是破坏单向数据流”

核心澄清:Vue 中的“双向绑定”,本质是“单向数据流 + 事件回调”的语法糖,并非真正的“双向数据流”(如 AngularJS 的双向绑定)。

Vue3 的 v-model(包括 defineModel 配合 v-model 使用),底层始终是“父传子(props)+ 子通知父(emit)”的单向流程,所谓“双向同步”,只是语法层面的简化,让开发者无需手动编写 emit 回调,但其数据流向依然是单向的——父组件掌握数据的最终修改权,子组件仅负责触发更新通知,这与“双向数据流”(父、子组件可随意修改数据)有本质区别。

四、实战验证:defineModel 完全遵循单向数据流的场景

结合 TS 实战场景,进一步验证 defineModel 的合规性,同时补充开发中的关键细节:

场景1:基础双向绑定(单个 v-model)

<!-- 父组件 -->
<template>
  <div>父组件 count: {{ count }}</div>
  <Child v-model="count" />
</template>

<script setup lang="ts">
import { ref } from 'vue'
import Child from './Child.vue'

const count = ref(0)
// 父组件可主动修改数据,子组件仅能通过 emit 通知修改
const resetCount = () => {
  count.value = 0
}
</script>

<!-- 子组件 -->
<script setup lang="ts">
// 显式指定类型,TS 自动校验 props 规则
const model = defineModel<number>({
  required: true,
  validator: (val) => val >= 0 // 子组件可对 props 进行校验,无法修改
})

// 子组件只能通过修改 model.value 触发 emit,无法直接修改父组件 count
const increment = () => {
  model.value++ // 触发 emit('update:modelValue', model.value + 1)
}
</script>

关键细节:子组件中,若直接尝试修改 props(如 props.modelValue++),TS 会直接报错(props 只读);而修改 model.value 时,底层是触发 emit,完全符合单向数据流规则。同时需注意,避免像错误示范那样,通过 getCurrentInstance 直接操作父组件实例。

场景2:多 v-model 绑定(多个数据同步)

Vue3 支持多个 v-model 绑定,defineModel 可通过指定名称适配,底层依然是“props + emit”的封装,同样遵循单向数据流:

<!-- 父组件 -->
<template>
  <Form 
    v-model:name="form.name" 
    v-model:age="form.age" 
  />
</template>

<script setup lang="ts">
import { reactive } from 'vue'
import Form from './Form.vue'

// 父组件拥有所有数据的修改权
const form = reactive({
  name: '',
  age: 18
})
</script>

<!-- 子组件 Form.vue -->
<script setup lang="ts">
// 分别定义两个 model,对应父组件的两个 v-model
const nameModel = defineModel('name', { type: String })
const ageModel = defineModel('age', { type: Number, default: 18 })

// 修改时分别触发对应的 emit 事件
const handleNameChange = (val: string) => {
  nameModel.value = val // 触发 emit('update:name', val)
}

const handleAgeChange = (val: number) => {
  ageModel.value = val // 触发 emit('update:age', val)
}
</script>

说明:多个 v-model 绑定的底层,是生成多个对应的 props(name、age)和 emit 事件(update:name、update:age),每个数据的流向依然是“父→子”,更新依然是“子通知、父修改”,未破坏单向数据流。开发中需注意,即使多 v-model 绑定,也不能让子组件直接修改父组件的 form 对象。

场景3:带修饰符的 v-model(数据转换)

defineModel 支持 v-model 修饰符(如 .trim、.number),可通过解构获取修饰符并进行数据转换,底层依然遵循单向数据流:

<!-- 父组件 -->
<Child v-model.trim="username" />

<!-- 子组件 -->
<script setup lang="ts">
// 解构获取 model 和修饰符
const [model, modifiers] = defineModel({ type: String })

// 基于修饰符处理数据,修改时触发 emit
const handleInput = (e: Event) => {
  let value = (e.target as HTMLInputElement).value
  // 处理 .trim 修饰符
  if (modifiers.trim) {
    value = value.trim()
  }
  model.value = value // 触发 emit,由父组件更新数据
}
</script>

关键:子组件仅负责数据转换和通知,最终的数据更新依然由父组件完成,数据流向始终可控。需注意,数据转换仅在子组件本地完成,不直接修改父组件原始数据,符合单向数据流要求。

五、核心总结(彻底理清逻辑)

  1. 单向数据流的核心是“数据父→子、更新父控制”,defineModel 底层是“props + emit”的语法糖,完全遵循这一原则,没有任何“子组件直接修改父组件数据”的操作;

  2. 误解的核心是“把语法糖的简化写法,当成了底层逻辑”——子组件修改的是 defineModel 生成的本地 ref 对象,而非父组件数据,底层依然是“子通知、父更新”;

  3. 真正破坏单向数据流的行为,是子组件直接操作父组件实例(如通过 getCurrentInstance 修改父组件数据)、直接修改 props 等,这类写法需严格规避,而 defineModel 恰恰避免了这类问题;

  4. defineModel 的价值的是简化代码,减少手动编写 props 和 emit 的冗余操作,同时保留单向数据流的优势,让数据流向清晰、维护成本降低,尤其适配 Vue3+TS 的类型推导,提升开发效率和类型安全性;

  5. 开发中需注意:defineModel 生成的 ref 对象,其修改会触发 emit,若需避免误触发,可通过添加 props 验证、控制修改时机,进一步保障数据更新的可控性;同时,避免过度依赖 getCurrentInstance 等 API 直接操作父组件实例,否则可能真正破坏单向数据流。

综上,defineModel 不仅没有破坏 Vue3 的单向数据流,反而让单向数据流的实现更简洁、更高效,是 Vue3 对组件双向绑定场景的优化升级,而非对核心设计原则的突破。

Vue 转 React:揭秘 CSS Modules 是如何被 VuReact 编译的?

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue SFC 中的 <style module> CSS Modules 样式经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 3 中 CSS Modules 的用法。

编译对照

模块样式转换

Vue 的 CSS Modules 会转换为 React 兼容的模块导入形式,保持类名映射的完整性。

  • Vue 代码:
<!-- Component.vue -->
<template>
  <div :class="$style.container">Hello</div>
</template>

<style module>
.container {
  padding: 20px;
  background: #f5f5f5;
}
</style>
  • VuReact 编译后 React 代码:
// Component.jsx
import $style from './component-abc1234.module.css';

function Component() {
  return <div className={$style.container}>Hello</div>;
}
/* component-abc1234.module.css */
.container {
  padding: 20px;
  background: #f5f5f5;
}

从示例可以看到:Vue 的 <style module> 块被编译为 CSS Modules 文件,并在 React 组件中通过模块导入方式使用。VuReact 提供的CSS Modules 转换功能,可理解为「React 版的 Vue CSS Modules」,完全模拟 Vue SFC 的模块样式映射机制,例如通过 $style.container 访问编译后的类名,确保样式模块化的完整性。


模块名映射

CSS Modules 支持不同的模块名映射方式:

  1. 默认模块名$style$style
  2. 自定义模块名<style module="custom">custom

自定义模块名示例

  • Vue 代码:
<!-- Component.vue -->
<template>
  <div :class="custom.container">Custom Module</div>
</template>

<style module="custom">
.container {
  margin: 10px;
  border: 1px solid #ccc;
}
</style>
  • VuReact 编译后 React 代码:
// Component.jsx
import custom from './component-xyz123.module.css';

function Component() {
  return <div className={custom.container}>Custom Module</div>;
}

模块名映射特点

  1. 灵活性:支持自定义模块名,适应不同项目需求
  2. 一致性:保持 Vue 和 React 端的模块名一致
  3. 导入方式:使用 ES6 模块导入语法
  4. 类型安全:TypeScript 环境下有完整的类型提示

带 Scoped 的 CSS Modules

CSS Modules 可以与 Scoped 样式结合使用,提供更强的样式隔离。

  • Vue 代码:
<!-- Component.vue -->
<template>
  <div :class="$style.wrapper">
    <span :class="$style.text">Text Content</span>
  </div>
</template>

<style module scoped>
.wrapper {
  padding: 20px;
  background: #f8f8f8;
}

.text {
  color: #333;
  font-size: 16px;
}
</style>
  • VuReact 编译后 React 代码:
// Component.jsx
import $style from './component-abc123.module.css';

function Component() {
  return (
    <div className={$style.wrapper} data-css-abc123>
      <span className={$style.text} data-css-abc123>
        Text Content
      </span>
    </div>
  );
}
/* component-abc123.module.css */
.wrapper[data-css-abc123] {
  padding: 20px;
  background: #f8f8f8;
}

.text[data-css-abc123] {
  color: #333;
  font-size: 16px;
}

Scoped + Module 组合优势

  1. 双重隔离:模块化 + 作用域双重样式隔离
  2. 类名安全:避免类名冲突
  3. 开发体验:清晰的类名引用方式
  4. 维护性:易于维护和重构

编译策略总结

VuReact 的 CSS Modules 编译策略展示了完整的模块化样式转换能力

  1. 模块提取:将 Vue 的 CSS Modules 提取为独立的 .module.css 文件
  2. 类名映射:保持类名映射关系,支持 $style.className 语法
  3. 模块导入:转换为 React 兼容的模块导入方式

VuReact 的编译策略确保了从 Vue 到 React 的平滑迁移,开发者无需手动处理 CSS Modules 的兼容性问题。编译后的代码既保持了 Vue 的 CSS Modules 使用体验,又符合 React 的模块化设计模式,让迁移后的应用保持完整的样式模块化能力。

🔗 相关资源


✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

Vue 转 React:揭秘 scoped 样式是如何被 VuReact 编译的?

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue SFC 中的 <style scoped> 作用域样式经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 3 中 SFC 作用域样式块的用法。

编译对照

作用域样式转换

VuReact 会计算并生成带作用域标识的 CSS,并借助 PostCSS 处理,将样式选择器与 DOM 属性进行正确的关联注入。

  • Vue 代码:
<!-- Counter.vue -->
<template>
  <div class="card">
    <p>Header</p>
    <p class="content">Content</p>
  </div>
  <button>Submit</button>
</template>

<style scoped>
.card {
  border: 1px solid #e5e5e5;
  border-radius: 8px;
}
.card:hover {
  background: #2a8c5e;
}
.content {
  font-size: 12px;
}
</style>
  • VuReact 编译后 React 代码:
// Counter.jsx
import './counter-abc1234.css';

function Counter() {
  return (
    <div className="card" data-css-abc1234>
      <p>Header</p>
      <p className="content" data-css-abc1234>Content</p>
    </div>
    <button>Submit</button>
  );
}
/* counter-abc1234.css */
.card[data-css-abc1234] {
  border: 1px solid #e5e5e5;
  border-radius: 8px;
}
.card[data-css-abc1234]:hover {
  background: #2a8c5e;
}
.content[data-css-abc1234] {
  font-size: 12px;
}  

从示例可以看到:Vue 的 <style scoped> 块被编译为带作用域标识的 CSS 文件,并在 React 组件中只对有 class/id 属性的元素标签自动注入 data-css-{hash} 属性。VuReact 的作用域样式转换功能完全模拟 Vue SFC 的作用域样式隔离机制,确保样式只在当前组件内生效。


作用域注入规则

作用域属性的注入遵循以下规则:

  1. template 元素:不注入作用域属性
  2. slot 元素:不注入作用域属性
  3. 存在 class/id 属性的元素:自动注入 data-css-{hash} 属性

作用域隔离原理

  1. CSS 选择器增强:将 .card 转换为 .card[data-css-hash]
  2. DOM 属性注入:在对应元素上添加 data-css-hash 属性
  3. 样式隔离:确保样式只在具有相同作用域属性的元素上生效
  4. 避免冲突:防止组件间样式相互影响

编译策略总结

VuReact 的 Scoped 样式编译策略展示了完整的作用域样式转换能力

  1. PostCSS 处理:通过 PostCSS 处理 Scoped 样式,生成作用域标识
  2. CSS 选择器增强:将普通选择器转换为带作用域属性的选择器
  3. DOM 属性注入:在 React 组件元素中注入作用域属性
  4. 文件分离:生成独立的作用域样式文件

VuReact 的编译策略确保了从 Vue 到 React 的平滑迁移,开发者无需手动实现样式隔离。编译后的代码既保持了 Vue 的作用域样式隔离机制,又符合 React 的组件设计模式,让迁移后的应用保持完整的样式隔离能力。

🔗 相关资源


✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

7.响应式系统比对:手写一个响应式状态库并应用在 React 上

前言

我们通过第一篇文章总结出的结论是,基于依赖追踪的响应式系统的本质是在读取数据的时候收集依赖,在更新数据的时候触发依赖,在后续的文章中我们又知道了所谓的依赖收集在发布订阅模式中就是订阅者进行订阅操作,触发依赖则是发布者进行发布操作,那么基于此原理,我们只需要把数据的读写进行分离再结合发布订阅模式那么可以实现数据响应式了。为了实现数据读写劫持,Vue 中不同的版本采用了不同的 JavaScript 原生 API,具体就是 Vue2 中采用了 Object.defineProperty,Vue3 中采用了 Proxy + Object.defineProperty(ref 本质上是通过 Object.defineProperty 实现的,class 的 getter 方式只是一个语法糖)。同时我们在第一篇文章的也介绍到了可以通过沙箱模式实现数据的读写分离,从而实现数据的响应式,那么在这篇文章中就让我们通过沙箱模式来实现一个数据响应式系统,并把它应用到 React 上吧。

通过沙箱模式实现代理

JS 沙箱我们或多或少都接触过,只是可能我们不了解不多,接触过也不知道。在计算机领域中,沙箱技术(Sandbox)是一种用于隔离正在运行程序的安全机制,其目的是限制不可信进程或不可信代码运行时的访问权限。比如说我们如果开发过微信小程序,我们就有比较深刻的体验,很多在浏览器端可以访问的 API,在小程序上都不可以使用,这是因为小程序上的 JavaScript 代码被运行在一个 JS 沙箱中了,从而限制了一些访问权限,还有一些微前端框架的实现也是通过 JS 沙箱的机制来实现的,还有我们的 Vue 中的模板其实也是运行在一个 JS 沙箱中。

我们这里对 JS 沙箱的各种实现不过作过多深入的解析,JS 沙箱的本质是创建一个独立的运行环境,然后可以暴露一些方法给外部环境访问,然后当外部环境访问这些沙箱中暴露的方法时,在沙箱内部就可以对这些方法进行一些操作了。那么利用这个特点,那么我们就可以创建一个沙箱环境,在沙箱内部创建一个对象,然后暴露一个可以让外部环境访问该对象的方法和一个修改该对象的方法,这样我们就可以在访问该对象的时候进行依赖收集,修改该对象的时候进行依赖触发,从实现了该对象的代理了。

那么根据目的以及功能的不同创建一个 JS 沙箱环境的方式也有很多,其中比较简单一种方式就使用闭包或IIFE(立即执行函数表达式)来实现。通过闭包可以创建一个独立的作用域,然后暴露一些公开的方法,用于与外部环境进行通信。

// 创建作用域沙箱环境
function createSandbox(value) {
  // 创建一个与外部环境隔离的对象变量,并且接收一个外部环境传进来的值作为初始值 value
  const context = {
    value
  }
  // 创建一个外部环境可以访问 context 对象的方法
  function getter() {
    return context.value
  }
  // 创建一个外部环境可以修改 context 对象的方法
  function setter(val) {
    context.value = val
  }

  // 暴露外部环境可以访问 context 对象的方法
  return [getter, setter]
}

通过上述的方式,我们仅仅只是创建一个作用域沙箱,并不是一个独立的运行环境,但通过它可以实现我们想要代理一个对象的读写功能了。

const [count, setCount] = createSandbox(0)
// 访问对象的值
console.log('访问对象的值:', count())
// 修改对象的值
setCount(2)
console.log('修改后的值', count())

打印结果如下:

01.png

那么根据上文我们就可以在访问沙箱作用域中的对象的时候进行依赖收集,修改该对象的时候进行依赖触发,从实现了该对象的读写代理了。

通过发布订阅模式实现数据响应式

同时通过上文对我们前面所学的知识的总结我们又知道了所谓的依赖收集在发布订阅模式中就是订阅者进行订阅操作,触发依赖则是发布者进行发布操作,那么基于此原理,我们只需要把数据的读写进行分离再结合发布订阅模式那么可以实现数据响应式了。

通过前面文章的学习我们知道实现发布订阅模式需要一个变量来存储订阅者,那么在这里我们可以把这个变量设置在 context 对象中。

function createSandbox(value) {
  // 创建一个与外部环境隔离的对象变量,并且接收一个外部环境传进来的值作为初始值 value
  const context = {
    value,
+    observers: null, // 存储订阅者的变量
  }
}

然后在访问 context 对象的 value 值的时候我们可以去判断存不存在订阅者,如果存在就存储到 observers 变量中,同时为了去重,我们把 observers 设置成 Set 类型。同时根据前面文章我们知道需要一个全局订阅者中间变量,这样我们在判断存不存在订阅者的时候就方便很多了,在这里我们把这个全局订阅者中间变量命名为 Listener

代码迭代如下:

+ // 全局订阅者中间变量
+ let Listener
function createSandbox(value) {
  // 创建一个与外部环境隔离的对象变量,并且接收一个外部环境传进来的值作为初始值 value
  const context = {
    value,
    observers: null, // 存储订阅者的变量
  }
  // 创建一个外部环境可以访问 context 对象的方法
  function getter() {
+      // 进行订阅者添加
+      if (Listener) {
+          if (!context.observers) {
+              context.observers = new Set([Listener])
+          } else {
+              context.observers.add(Listener)
+          }
+      }
      return context.value 
  }
  // 省略...
}

通过上述迭代我们就实现订阅者的订阅,那么很自然的接下来迭代实现的功能就是触发依赖了,也就是发布者进行发布。实现也很简单,具体就是把存储订阅者的变量的订阅者全部通知一次。

代码迭代如下:

function createSandbox(value) {
  // 省略...
  // 创建一个外部环境可以访问 context 对象的方法
  function setter(val) {
    context.value = val
+    // 把存储订阅者的变量的订阅者全部通知一次
+    context.observers.forEach(fn => fn());
  }
}

这样我们就可以进行测试了:

const [count, setCount] = createSandbox(0)
// 订阅者小明
Listener = () => {
    console.log(`计算结果是:${count()}`)
}
// 初始化
Listener()
Listener = null
// 更改计算
setCount(2)

打印结果如下:

02.png

我们可以看到成功打印了如期的结果。

至此,我们就通过发布订阅模式实现数据响应式。

实现响应式副作用函数

根据我们前面所学的知识,我们知道不管是 Vue 还是 Mobx 都存在响应式副作用函数,例如 Vue3 中的 effect,Mobx 中的 autorun。那么这里我们实现一个满足上面响应式数据需求的副作用函数,其实它们的实现原理都是一致的。首先需要传递一个需要观察的函数,从发布订阅模式角度理解,这个函数就是一个订阅者,然后把这个函数赋值到一个中间变量上,然后执行这个函数,进行初始化,本质是在触发响应式数据的依赖收集。

function createEffect(fn) {
  // 把需要观察的函数赋值到一个中间变量中去
  Listener = fn
  // 初始化
  fn()
  Listener = null
}

接着我们就可以测试了

const [count, setCount] = createSandbox(0)
createEffect(() => {
  console.log(`计算结果是:${count()}`)
})
setCount(2)

打印结果如下:

02.png

我们可以看到成功打印了如期的结果。

我们通过前面的学习,我们知道不管 Mobx 还是 Vue 中的订阅者中介上都存在一个调度器的参数,在 Mobx 中是 Reaction 中的 onInvalidate 参数,在 Vue3 中则是 ReactiveEffect 的 scheduler 参数,它们的主要作用是在触发依赖的时候,如果存在调度器则调用调度器,从而改变程序的执行顺序。

在这里我们也可以给我们的手写的数据响应式系统简单实现一个调度器,其实很简单,我们给 createEffect 函数传递第二个参数作为调度器,那么当触发依赖的时候,就会去执行第二个参数,而不会执行第一个参数。

-function createEffect(fn) {
+function createEffect(fn, onInvalid) {
  // 把需要观察的函数赋值到一个中间变量中去
-  Listener = fn
+  Listener = {
+    fn,
+    onInvalid
+  }
  // 初始化
  fn()
  Listener = null
}

接着我们需要修改我们的触发依赖部分的代码

function createSandbox(value) {
  // 省略...
  // 创建一个外部环境可以访问 context 对象的方法
  function setter(val) {
    context.value = val
    // 把存储订阅者的变量的订阅者全部通知一次
-    context.observers.forEach(fn => fn())
+    // 如果存在调度器则执行调度器函数
+    context.observers.forEach(o => o.onInvalid ? o.onInvalid() : o.fn())
  }
}

接着我们就可以测试了

const [count, setCount] = createSandbox(0)
createEffect(() => {
  console.log(`计算结果是:${count()}`)
}, () => {
  console.log(`我是调度器,更新的时候先执行调度器`)
})
setCount(2)

打印结果如下:

03.png

应用到 React 上

我们有了前面的 Mobx 和 Vue3 数据响应式库 @vue/reactivity 应用在 React 上的经验,我们再来把我们的上面实现的数据响应式系统应用到 React 上也是非常容易的。我们通过前面的学习知道 Mobx 是通过 observer 函数实现与 React 进行链接结合的,那么我们也在这里实现一个类似 observer 函数则可,为了跟我们上面的副作用函数名称有关联,我们把这个函数命名为 createRenderEffect。根据我们前篇所学的知识知道 observer 是一个高阶函数,所以我们初步把 createRenderEffect 的基础架构搭建出来。

function createRenderEffect(baseComponent) {
  return (props) => {
    return baseComponent(props)
  }
}

通过前面学习我们知道需要通过 React 中的 useRef 来保存订阅者中介类的实例对象,而我们这里并没有实现订阅者中介类,所以我们只需要保存我们上面 createEffect 中的字面量的订阅者中介即可。代码实现如下:

function createRenderEffect(baseComponent) {
  return (props) => {
      const [, setState] = useState()
      const adm = useRef()
      let renderResult
      if (!adm.current) {
        // 保存字面量的订阅者中介
        adm.current = { 
            fn: baseComponent, 
            onInvalid: () => {
                setState(Symbol())
            }
        }
      }
      Listener = adm.current
      renderResult = Listener.fn(props)
      Listener = null
      return renderResult    
  }
}

同时为了顾名思义,我们将上面实现响应式数据的函数 createSandbox 重新命名为 createSignal

// 创建作用域沙箱环境
-function createSandbox(value) {
+function createSignal(value) {
  // 省略...
}

接着我们就可以测试了

const [count, setCount] = createSignal(1)

const TimerView = createRenderEffect(({ count }) => <span>this counter is: {count()}</span>)

function App() {
  return (
    <TimerView count={count}></TimerView>
  );
}

setInterval(() => {
  setCount(count() + 1)
}, 1000)

打印结果如下:

tutieshi_550x220_5s.gif

我们可以看到如期打印了结果,说明我们成功手写了一个数据响应式系统,并且应用到了 React 上。

总结

在本文章中我们成功通过沙箱模式实现了对数据的代理,再通过发布订阅模式实现了数据的响应式,再结合我们前面所学的知识成功把我们的数据响应式系统应用到了 React 上。可能有细心的同学就会发现了我们的所谓的手写响应式状态库,其实就是 SolidJS 的数据响应式的实现原理。

我是程序员Cobyte,现在已转向研究 AI Agent,欢迎添加 v: icobyte,学习交流 AI Agent 应用开发。

鼠标跟随倾斜动效

前言

最近在 gsap 上看到一个有趣的动效(Cursor-driven perspective tilt),于是决定自己实现一下,下面将介绍实现的过程,希望你能喜欢。

202604111231046.gif

观察动效

  1. 卡片的倾斜角度会随着鼠标的移入在 x 轴和 y 轴上向内进行倾斜。
  2. 卡片上的文字是悬浮在卡片,给人一种悬空在空中的错觉。

技术拆解

要实现这种 3D 的效果,在 css 中你首先想到的是什么?

在 CSS 中有三个属性实现 3D 效果至关重要。它们分别是 perspective、transform-style: preserve-3dtransform: rotateX() rotateY()。下面将详细的介绍他们在 3D 动效中的作用。

  1. perspective (透视/视距):它是 3D 的灵魂,如果没有它,你看到的效果看起来只像是在平面上进行拉伸和缩放。你可以理解它是3维空间中的z轴,定义观察者距离 z = 0平面的距离。通常设定在父容器上,数值越小(如500px),透视畸变越强烈(近大远小极度明显);数值越大(如 2000px),效果越平缓。
  2. transform-style: preserve-3d :它的作用是告诉子元素(文字层)也要保持在 3D 空间中,这样我们看到的容器的内容是有深度的,同时也可以在侧面看到元素与元素之间的距离。当父元素设置了transform-style: preserve-3d 的时候,同时子元素需要设置 transform: translateZ()。
  3. transform: rotateX() rotateY():这个属性相信大家都知道,这也是这次动效能实现的关键。rotateX 控制卡片绕水平轴转动,rotateY 控制卡片绕垂直轴转动。

总结一下

如果把 CSS 3D 比作一场电影:

  • perspective 是摄影机,决定了画面的纵深感。
  • transform-style: preserve-3d 是舞台搭建,决定了演员(元素)能不能在台前幕后来回走动,而不是画在背景板上。
  • transform: rotate / translate 是演员的动作,决定了物体怎么摆放和移动。

效果展示

如果你已经理解了上面属性,相信实现效果只是时间的问题,下面我就提前剧透一下效果吧!同时在浏览器中为你演示各个的属性的具体效果,让你更加深刻的理解上面的属性。

试想一下,如果没有设置 perspective 属性会怎么样呢?

为了更好的演示,我会将卡片绕着它的y轴固定旋转30度。然后对比设置了 perspective 属性和没有设置 perspective 的效果如下。

image.png

在对比了设置 perspective 的作用后,接下来为你演示 transform-style: preserve-3d 的效果,为了更好的演示,接下来调整一下卡片在y轴的旋转角度为-80度,同时对子元素设置 transform: translateZ(50px); 将背景调整为白色,让文字和背景不会重合。对比效果如下:

image.png

从上面的效果可以看出,设置了 transform-style: preserve-3d 的文字和背景卡片是分离的,没有设置 transform-style: preserve-3d 的文字被拍扁在卡片上面。

注意事项: 当容器设置了 transform-style: preserve-3d; 的时候,不能再设置 overflow: hidden; 不然 transform-style: preserve-3d; 不会生效。

经过上面的对比可以帮助我们更好的理解每个属性在具体场景中的使用,下面就使用 vue3 去实现具体的功能。

代码拆解

完整代码

<template>
  <div class="container">
    <div 
      class="card"
      ref="cardRef"
      :style="cardStyle"
      @mousemove="handleMouseMove"
      @mouseleave="handleMouseLeave"
    >
      <div class="content">
        <span>ANIMATION</span>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, reactive, computed } from 'vue';

const cardRef = ref(null);

// 存储旋转角度
const transform = reactive({
  rotateX: 0,
  rotateY: 0
});

// 计算最终的 CSS 样式
const cardStyle = computed(() => {
  const scale = 1;
  return {
    transform: `rotateX(${transform.rotateX}deg) rotateY(${transform.rotateY}deg)`,
    transition: 'transform 0.5s ease-out'
  };
});

const handleMouseMove = (e) => {
  if (!cardRef.value) return;

  const rect = cardRef.value.getBoundingClientRect();
  const centerX = rect.left + rect.width / 2;
  const centerY = rect.top + rect.height / 2;
  
  // 计算鼠标距离中心点的偏移量 (-1 到 1)
  const percentX = (e.clientX - centerX) / (rect.width / 2);
  const percentY = (e.clientY - centerY) / (rect.height / 2);

  const deg = 25; // 最大旋转角度
  transform.rotateY = percentX * deg;
  transform.rotateX = -percentY * deg; // 取反是因为鼠标向上移动时图片应向下倾斜
};

const handleMouseLeave = () => {
  transform.rotateX = 0;
  transform.rotateY = 0;
};
</script>

<style scoped>
.container {
  /* 3D 透视的关键 */
  perspective: 1000px; 
  display: flex;
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 100vh;
  background-color: #0f0f0f;
}

.card {
  position: relative;
  width: 320px;
  height: 200px;
  background: linear-gradient(135deg, #6ee7b7, #3b82f6);
  border-radius: 20px;
  transform-style: preserve-3d;
  display: flex;
  justify-content: center;
  align-items: center;
  cursor: pointer;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
  /* overflow: hidden; */
}

.content {
  font-family: 'Arial Black', sans-serif;
  font-size: 2.5rem;
  color: #000;
  /* 让文字在 3D 空间悬浮 */
  transform: translateZ(50px); 
  pointer-events: none;
}
</style>

简要分析:

  1. 绑定事件:鼠标移入卡片触发 mousemove 事件,设置卡片旋转。鼠标移除触发 mouseleave 事件将旋转的角度置为0。
  2. 样式动态计算:动态绑定 style,通过计算属性实时更新旋转的角度。
  3. 计算偏移量: 这里主要利用鼠标当前的位置减去卡片中心点计算出偏移距离,然后再除以卡片宽高的一半,等到一个-1到1的偏移值。
  4. 角度映射:通过得到的偏移值乘以 deg (25度),刚好可以映射到对应的角度,比如鼠标移动到最左边,卡片正好偏转 -25度。

优化补充

下面是一些优化的建议,有兴趣的同学可以自己实现一下:

  1. 增加光影变化,跟随鼠标移动的卡片增加渐变层的光影,让整体更加真实。
  2. mousemove 在移动端不支持,增加移动端的支持。

Vue3+TS 中 this 指向机制全解析(实战避坑版)

Vue3 结合 TypeScript 开发时,this 指向的核心逻辑的是:this 指向由代码编写场景(选项式API/组合式API)决定,TS 的类型校验会进一步约束 this 的可访问范围,其本质是 JavaScript this 绑定规则(隐式绑定、箭头函数无绑定等)在 Vue3 框架中的延伸,同时 Vue3 对不同 API 场景的 this 做了针对性优化,避免开发者踩坑。

与 Vue2+TS 不同,Vue3 支持选项式API和组合式API两种写法,两种写法中 this 指向差异极大,且 TS 的 strict 模式会直接影响 this 的类型推导,这也是开发中最易出错的点,下面分场景详细拆解,搭配 TS 实战代码说明。

一、核心前提:TS 配置对 this 指向的影响

Vue3+TS 项目中,tsconfig.json 的配置会直接决定 this 的类型校验逻辑,其中最关键的是 strict 相关配置,这是避免 this 类型模糊(any)的核心:

// tsconfig.json 关键配置
{
  "compilerOptions": {
    "strict": true, // 开启严格模式(推荐),会自动开启 noImplicitThis
    "noImplicitThis": true, // 禁止隐式 this(单独开启也可),避免 this 被推导为 any
    "isolatedModules": true, // Vite 项目必需,不影响 this 指向,但影响 TS 编译
    "verbatimModuleSyntax": true // 推荐,与 isolatedModules 兼容,优化类型推导
  }
}

strict: falsenoImplicitThis: false时,TS 会将未明确类型的 this 推导为 any,此时即使 this 指向错误,TS 也不会报错,容易引发运行时问题;开启严格模式后,TS 会强制校验 this 的指向和可访问属性,契合 Vue3 的 this 机制。

二、选项式API(Options API)中 this 指向机制(Vue3+TS)

Vue3 选项式API 的 this 指向与 Vue2 基本一致,核心是 this 始终指向当前组件实例(ComponentPublicInstance) ,TS 会自动推导 this 类型,无需手动声明,且所有组件选项(data、methods、computed、watch 等)中的 this 均指向同一实例。

Vue3 官方为选项式API 提供了完善的类型支持,通过 defineComponent 包裹组件,TS 可自动推导 this 的类型,包含组件的所有属性、方法、props、emit 等,无需手动定义。

1. 基础场景:组件选项中的 this 指向

在 data、methods、computed、watch、生命周期钩子(created、mounted 等)中,this 均指向当前组件实例,可直接访问实例上的所有属性和方法,TS 会自动校验属性的合法性。

<script lang="ts">
import { defineComponent } from 'vue'

// 用 defineComponent 包裹,TS 自动推导 this 类型
export default defineComponent({
  // props 定义(TS 会自动将 props 挂载到 this 上)
  props: {
    title: {
      type: String,
      required: true
    }
  },
  // data 函数:this 指向组件实例,TS 推导 this 为 ComponentPublicInstance
  data() {
    return {
      count: 0,
      message: 'Vue3+TS this 指向'
    }
  },
  // methods:this 指向组件实例,可访问 data、props、其他 methods
  methods: {
    increment() {
      this.count++ // TS 校验通过,可直接访问 data 中的 count
      console.log(this.title) // TS 校验通过,可直接访问 props 中的 title
      this.logMessage() // 可调用当前组件的其他方法
    },
    logMessage() {
      console.log(this.message)
    }
  },
  // 计算属性:this 指向组件实例
  computed: {
    fullMessage() {
      return `${this.title} - ${this.message}` // TS 自动校验 this 上的属性
    }
  },
  // 生命周期钩子:this 指向组件实例
  mounted() {
    this.increment() // 可直接调用 methods 中的方法
  },
  // watch:this 指向组件实例
  watch: {
    count(newVal) {
      console.log('count 变化:', newVal, this.count) // 可访问当前实例属性
    }
  }
})
</script>

关键说明:

  • data 函数中,this 指向组件实例,且 data 返回的响应式数据会被自动挂载到实例上,可通过 this.$data.xxx 访问,也可直接通过 this.xxx 访问(Vue 自动代理),以 _$ 开头的属性不会被代理,需通过 this.$data 访问。
  • methods、computed、watch 中的 this 均由 Vue 自动绑定为组件实例,即使在方法中嵌套普通函数,只要不修改 this 绑定,this 仍指向实例。
  • 通过 defineComponent 包裹后,TS 会自动推导 this 的类型为 ComponentPublicInstance,包含 Vue 内置的 $props$emit$refs 等属性,避免 this 为 any 类型。

2. 易错场景:this 指向丢失(选项式API)

选项式API 中,this 丢失的核心原因是 手动修改了函数的 this 绑定,常见于嵌套普通函数、定时器、Promise 回调等场景,TS 会在严格模式下报错,提示 this 类型不匹配。

<script lang="ts">
import { defineComponent } from 'vue'

export default defineComponent({
  data() {
    return {
      count: 0
    }
  },
  methods: {
    wrongDemo() {
      // 错误1:普通函数嵌套,this 指向 window(浏览器环境),TS 报错:this 类型为 Window,无 count 属性
      setTimeout(function() {
        this.count++ // ❌ TS 报错:Property 'count' does not exist on type 'Window & typeof globalThis'
      }, 1000)

      // 错误2:箭头函数定义 methods 方法,this 不绑定组件实例,指向外层作用域(undefined)
      const wrongMethod = () => {
        console.log(this.count) // ❌ TS 报错:this 为 undefined,无 count 属性
      }
      wrongMethod()

      // 正确写法1:使用箭头函数作为回调,继承外层 this(组件实例)
      setTimeout(() => {
        this.count++ // ✅ 正确,this 指向组件实例
      }, 1000)

      // 正确写法2:保存 this 到变量,避免绑定丢失
      const self = this
      setTimeout(function() {
        self.count++ // ✅ 正确,self 指向组件实例
      }, 1000)

      // 正确写法3:使用 bind 绑定 this 到组件实例
      setTimeout(function() {
        this.count++
      }.bind(this), 1000) // ✅ 正确,bind 强制绑定 this 为组件实例
    }
  }
})
</script>

补充说明:Vue3 选项式API 中,methods 中的方法会被 Vue 自动绑定 this 为组件实例,因此直接调用方法(如 this.increment())不会出现 this 丢失;但如果将方法作为回调传递(如 btn.addEventListener('click', this.increment)),会导致 this 丢失,需通过 this.increment.bind(this) 绑定。

三、组合式API(Composition API)中 this 指向机制(Vue3+TS)

组合式API(<script setup lang="ts"> 或 setup 函数)是 Vue3 的核心写法,其 this 指向与选项式API 完全不同,核心规则是:setup 函数及其中定义的函数、回调中,this 均为 undefined,TS 会明确推导 this 类型为 undefined,禁止通过 this 访问组件实例。

这是 Vue3 组合式API 的设计初衷——摒弃 this 依赖,通过显式导入 API(ref、reactive、onMounted 等)和返回值,实现逻辑复用和类型安全,避免 this 指向混乱。

1. 基础场景:setup 中的 this 指向

无论是 setup 函数(非语法糖)还是 <script setup lang="ts">(语法糖),this 均为 undefined,TS 会严格校验,禁止通过 this 访问任何属性,所有响应式数据、方法均需显式定义和使用。

<!-- 语法糖写法(推荐):<script setup lang="ts"> -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'

// 定义响应式数据
const count = ref(0)
const message = ref('Vue3+TS 组合式API')

// 定义方法
const increment = () => {
  count.value++ // 直接操作响应式数据,无需 this
  console.log(message.value)
}

// 生命周期钩子:无 this,直接调用方法、操作数据
onMounted(() => {
  increment()
  console.log(this) // undefined,TS 推导 this 为 undefined
})

// 错误写法:试图通过 this 访问数据,TS 报错
const wrongDemo = () => {
  console.log(this.count) // ❌ TS 报错:this is undefined
}
</script>
<!-- 非语法糖写法:setup 函数 -->
<script lang="ts">
import { defineComponent, ref, onMounted } from 'vue'

export default defineComponent({
  setup() {
    const count = ref(0)
    const increment = () => {
      count.value++
    }

    onMounted(() => {
      increment()
      console.log(this) // undefined
    })

    // 必须返回,模板才能访问
    return {
      count,
      increment
    }
  }
})
</script>

关键说明:

  • setup 函数在组件实例创建前(beforeCreate 之前)执行,此时组件实例尚未初始化,因此 this 为 undefined,这是 Vue3 的设计逻辑,目的是让开发者脱离 this 依赖。
  • <script setup lang="ts"> 语法糖中,无需手动返回数据和方法,TS 会自动推导其类型,模板可直接访问;非语法糖写法需手动返回,否则模板无法访问。
  • 组合式API 中,所有响应式数据(ref、reactive)、方法均为局部变量,无需挂载到 this 上,直接通过变量名访问即可,TS 会严格校验变量的类型和可用性。

2. 特殊场景:需访问组件实例的解决方案

组合式API 中禁止直接使用 this,但实际开发中可能需要访问组件实例的内置属性(如 $refs$emit$route 等),此时可通过 getCurrentInstance API 获取组件实例,而非使用 this,TS 需手动指定类型,避免类型报错。

<script setup lang="ts">
import { ref, getCurrentInstance } from 'vue'
// 导入组件内部实例类型,用于类型断言
import type { ComponentInternalInstance } from 'vue'

// 获取组件内部实例,通过类型断言指定类型
const instance = getCurrentInstance() as ComponentInternalInstance

// 访问实例内置属性(替代 this.$refs、this.$emit 等)
const handleClick = () => {
  // 替代 this.$emit
  instance.emit('change', 'hello')
  // 替代 this.$refs
  console.log(instance.refs)
  // 替代 this.$props
  console.log(instance.props)
}

// 注意:不推荐过度使用 getCurrentInstance,优先通过显式 API 实现需求
// 如 $emit 可直接通过 defineEmits 定义,无需访问实例
const emit = defineEmits(['change'])
const handleEmit = () => {
  emit('change', 'hello') // 更推荐的写法,无需依赖实例
}
</script>

补充说明:getCurrentInstance 返回的是组件内部实例(ComponentInternalInstance),而非选项式API 中的公开实例(ComponentPublicInstance),其部分属性(如 ctx)在生产环境打包后可能失效,因此仅在特殊场景使用,优先通过 Vue3 提供的显式 API(defineEmits、defineProps、useRoute 等)替代。

3. 易错场景:组合式API 中误用 this

组合式API 中,开发者容易习惯性使用 this,尤其是从选项式API 迁移过来的场景,TS 会直接报错,常见易错场景及正确写法如下:

<script setup lang="ts">
import { ref, reactive } from 'vue'

// 错误1:试图通过 this 访问响应式数据
const count = ref(0)
const wrong1 = () => {
  this.count.value++ // ❌ TS 报错:this is undefined
}

// 正确1:直接访问变量
const right1 = () => {
  count.value++ // ✅ 正确
}

// 错误2:在 reactive 对象中使用 this
const user = reactive({
  name: '张三',
  // 错误:reactive 对象中的方法,this 指向 user 本身,而非组件实例,TS 推导类型错误
  sayHello: function() {
    console.log(this.name) // 看似可用,但 this 指向 user,无法访问组件其他数据/方法
  }
})

// 正确2:使用箭头函数,避免 this 绑定,直接访问外部变量
const userRight = reactive({
  name: '张三',
  sayHello: () => {
    console.log(userRight.name) // ✅ 正确,直接访问 reactive 对象
  }
})

// 错误3:定时器回调中误用 this
setTimeout(function() {
  this.count.value++ // ❌ TS 报错:this is undefined
}, 1000)

// 正确3:直接访问变量,箭头函数无需考虑 this
setTimeout(() => {
  count.value++ // ✅ 正确
}, 1000)
</script>

四、Vue3+TS 中 this 指向总结(核心对比)

编写场景 this 指向 TS 类型推导 核心注意点
选项式API(defineComponent 包裹) 当前组件实例(ComponentPublicInstance) 自动推导,包含组件所有属性、方法、props 等 避免用箭头函数定义 methods,避免手动修改 this 绑定,否则会丢失实例指向
组合式API(setup/ undefined 明确推导为 undefined,禁止通过 this 访问任何属性 无需依赖 this,直接访问局部变量;需访问实例用 getCurrentInstance,优先显式 API
选项式API + 组合式API 混合使用 选项式API 中 this 指向实例;setup 中 this 为 undefined 各自独立推导,setup 中无法通过 this 访问选项式API 中的数据/方法 混合写法需注意 this 场景区分,避免交叉使用导致指向混乱

五、实战避坑要点(融入正文,不单独罗列)

  1. 始终开启 TS 严格模式(strict: true),强制校验 this 类型,避免 this 为 any 导致的运行时错误,这是 Vue3+TS 开发的基础配置。

  2. 选项式API 中,禁止用箭头函数定义 data、methods、watch、computed 等组件选项,因为箭头函数不绑定 this,会导致 this 指向外层作用域(undefined 或 window),TS 会直接报错。

  3. 组合式API 中,彻底摒弃 this 思维,所有响应式数据、方法均通过显式定义和访问,无需挂载到实例上,避免习惯性使用 this 导致的 TS 报错。

  4. 当需要访问组件实例内置属性时,优先使用 Vue3 提供的显式 API(如 defineEmits、defineProps、useRoute、useRouter 等),而非 getCurrentInstance,减少对内部实例的依赖,避免生产环境兼容问题。

  5. 回调函数(定时器、Promise、原生事件监听等)中,选项式API 需注意 this 绑定,优先使用箭头函数;组合式API 无需考虑 this,直接访问局部变量即可。

  6. 组件 props 定义后,选项式API 中可通过 this 直接访问,TS 会自动校验;组合式API 需通过 defineProps 定义并显式使用,无需通过 this 访问。

SSR页面上的按钮点不了?Nuxt 懒加载水合揭秘💧

写在开头

Hello吖,各位UU们好!👏

今是2026年03月14日,下午,幽静、无人打扰,刚刷了会手机,但有点看腻了。

然后,今天上午,小编将自己的上一台电脑叫了一个转转来上门回收,2021年款,联想小新R7,本来在APP上预估是能卖两千二左右的,结果线下验机后说只能卖1700了,就没卖,想着再找一个爱回收看看价格,🤔不知道能不能涨点。

还有个事,昨天听朋友说,他网恋成功了,说是在Soul上找的对象,已经线下面基过。唉,这年头...这也能成功?🥶 你们说小编要不要也去试试?🤔

好了,回到正题,今天要来分享的是小编上周工作中排查的一个问题,其实也是比较基础的概念问题,只是小编太久没用了,这次也写出来记录一下,请诸君按需食用哈~

需求背景 💡

最近小编正在做一个 SSR 项目,作为一名 Vue 老玩家,自然就选择 Nuxt 来搞,上次用 Nuxt 还是在上次,时间略久了!😗

整体项目开发进展还算顺利,也就是部署稍微麻烦一丢丢。然而,这天测试同学给我提了个问题:

"页面加载出来后,有时按钮点了没啥反应,总要多点几次或者要等一会才能点。"

小编一开始也按常规思路来:先看控制台有没有报错 —— 结果没有明显的红字错误(因为并不是水合错位报错,只是水合还没执行到那块,事件还没绑上)。于是怀疑是事件没绑好或者代码写错了,又查了一圈事件和逻辑,代码确实没问题!🤔

最后才反应过来:原来是 水合(Hydration) 还没完成,那部分组件还没绑上事件,所以有时候才能点。

什么是水合?

上面说了,按钮点不了是因为水合还没完成。那水合到底是什么?🤔

简单说:服务端先返回 HTML,客户端 JS 加载完后,把事件绑上去,让页面能点、能交互——这个过程就叫水合

下面简单用 CSR 和 SSR 对比一下,帮你建立直觉。

传统 CSR(客户端渲染)

普通的 Vue SPA 应用是这样的:

用户访问页面
  ↓
加载空白 HTML + JSJS 执行,渲染页面
  ↓
用户看到内容,可以交互 

缺点:首屏白屏时间长,SEO 也不友好。

SSR(服务端渲染)

SSR 是这样的:

用户访问页面
  ↓
服务端直接返回完整 HTML
  ↓
用户立刻看到内容(快!)
  ↓
加载 JS,执行"水合"
  ↓
页面变得可交互

优点:首屏快,SEO 友好。

很明显,CSR 和 SSR 是两种不同的取舍,没有谁一定更好,咱们得根据业务场景来选,不要一刀切。❌

问题来了

SSR 有个尴尬的地方:HTML 先出来了,但 JS 还没加载完,事件还没绑定上

用户看到页面了
  ↓
想点按钮 → 点不了 ❌(JS 还没准备好)
  ↓
等 1-2 秒...
  ↓
终于能点了

这就是测试同学遇到的问题!页面出来了,但还处于"僵尸"状态,看得见摸不着。😅

懒加载水合是什么?

既然问题是「要等一会儿才能点」,那有没有办法让首屏更快可交互?小编查了一下 Nuxt 的文档,发现有个功能叫 懒加载水合(Lazy Hydration),专门解决这类问题!

懒加载水合:它还是「水合」——还是把事件绑到服务端 HTML 上,只是不再一次性水合整页,而是按需、分优先级地水合。如,首屏先水合,下面的等需要时再水合。

所以呢,用词上要分清:水合 是整个过程,懒加载水合 是水合的一种策略(延迟一部分组件的水合时机)。

在 Vue 3.5 / Nuxt 里,这个策略常和 异步组件 一起用:异步组件负责延迟加载组件 JS(减包体),懒加载水合负责延迟该组件的水合时机(让首屏先可交互),两个搭配着用。

核心思想:不用一次性把所有组件都水合,按需水合!

比如:

  • 首屏可见的组件 → 立刻水合
  • 非首屏的组件 → 用户滚到那里再水合
  • 低优先级的组件 → 浏览器空闲时再水合

这样,首屏的 JS 体积就小了,水合速度就快了,用户点按钮就不会"卡壳"啦!🎯

Nuxt 中怎么用?

Nuxt 已经内置了懒加载水合的支持,用起来非常简单的!🏃

第1️⃣步:认识 Lazy 组件

在 Nuxt 中,所有放在 components/ 目录下的组件都会被自动导入。如果在组件名前加上 Lazy 前缀,就可以延迟加载:

<template>
  <!-- 普通组件 -->
  <MyComponent />

  <!-- 懒加载组件 -->
  <LazyMyComponent />
</template>

但这只是 懒加载,还不是 懒加载水合!区别在于:

  • 懒加载:延迟加载 JS 代码
  • 懒加载水合:延迟执行水合(JS 可能已经加载了,但不急着绑定事件)

第2️⃣步:添加水合策略

Nuxt 提供了多种水合策略,咱们来看几个常用的:

hydrate-on-visible(可见时水合)

组件进入视口时才水合,适合非首屏内容:

<template>
  <div>
    <h1>首屏内容</h1>

    <!-- 下面的组件要用户滚到这里才会水合 -->
    <LazyComments hydrate-on-visible />
  </div>
</template>

🍊 为什么这么做❓

非首屏的组件,用户不一定马上会看到,何必急着水合呢?等用户滚到那里再说,这样首屏更快。

hydrate-on-interaction(交互时水合)

用户点击/悬停组件时才水合:

<template>
  <!-- 用户点击这个区域时才水合 -->
  <LazyExpensiveComponent hydrate-on-interaction="click" />

  <!-- 或者鼠标悬停时水合 -->
  <LazyChart hydrate-on-interaction="mouseover" />
</template>

hydrate-after(延迟水合)

指定毫秒数后自动水合:

<template>
  <!-- 2 秒后水合 -->
  <LazySidebar :hydrate-after="2000" />
</template>

hydrate-on-media-query(媒体查询水合)

匹配特定媒体查询时水合:

<template>
  <!-- 只在移动端水合 -->
  <LazyMobileMenu hydrate-on-media-query="(max-width: 768px)" />
</template>

hydrate-when(条件水合)

根据条件决定是否水合:

<script setup>
const isReady = ref(false)

// 某个条件触发后
function triggerHydration() {
  isReady.value = true
}
</script>

<template>
  <LazyHeavyComponent :hydrate-when="isReady" />
</template>

第3️⃣步:监听水合完成事件

所有懒加载水合组件都会触发 @hydrated 事件:

<template>
  <LazyComments
    hydrate-on-visible
    @hydrated="onHydrated"
  />
</template>

<script setup>
function onHydrated() {
  console.log('组件水合完成!')
}
</script>

第4️⃣步:小编的实际应用

回到咱们的场景,测试反馈按钮点不了,小编的解决方案是这样的:

<template>
  <div>
    <!-- 首屏重要内容,正常水合 -->
    <Header />
    <MainContent />

    <!-- 非首屏的评论区,懒加载水合 -->
    <LazyComments hydrate-on-visible />

    <!-- 底部推荐,用户悬停时才水合 -->
    <LazyRecommendations hydrate-on-interaction="mouseover" />
  </div>
</template>

这样首屏的 JS 体积就小了,水合速度变快,按钮响应更及时!🎉

💡 小贴士

  • 首屏核心交互内容不要用懒加载水合,会影响用户体验。
  • 适合用在非首屏、低优先级的组件上。
  • 如果组件本身就用了 v-if="false",那就不需要懒加载水合了。

Vue 3.5 原生用法

如果你用的不是 Nuxt,而是纯 Vue 3.5 + 自己搭的 SSR,其实也可以用原生的懒加载水合。

底层原理其实是 Vue 3.5 提供的水合策略,Nuxt 只是在上面封装了一层更易用的 API。

官方文档:传送门

第1️⃣步:导入水合策略

import { defineAsyncComponent, hydrateOnVisible } from 'vue'

第2️⃣步:定义异步组件

const LazyComments = defineAsyncComponent({
  loader: () => import('./Comments.vue'),
  hydrate: hydrateOnVisible()
})

可用的水合策略

策略 说明
hydrateOnIdle() 浏览器空闲时水合
hydrateOnVisible() 进入视口时水合
hydrateOnInteraction('click') 点击时水合
hydrateOnMediaQuery('(max-width:768px)') 媒体查询匹配时水合

用法都差不多,小编就不一一列举了,大家看文档就好~😋





至此,本篇文章就写完啦,撒花撒花。

image.png

希望本文对你有所帮助,如有任何疑问,期待你的留言哦。
老样子,点赞+评论=你会了,收藏=你精通了。

Vue keep-alive 原理全解析(Vue2+Vue3适配)

Vue keep-alive 原理全解析(Vue2+Vue3适配)

Vue 中的 keep-alive 是一个内置抽象组件,核心作用是缓存组件实例,避免组件频繁创建和销毁,从而提升页面切换性能、保留组件状态(如表单输入、滚动位置)。它本身不会渲染 DOM,也不会出现在组件层级中,仅作为“容器”负责管理其包裹的组件的生命周期。

与 v-show(通过 CSS 控制显隐)、v-if(控制组件挂载/卸载)不同,keep-alive 是通过缓存组件实例实现状态保留,组件卸载时不会被销毁,而是被缓存到内存中,再次渲染时直接复用缓存的实例,无需重新执行 created、mounted 等生命周期钩子,这也是它提升性能的核心原因。

一、keep-alive 核心底层原理

keep-alive 的底层实现依赖 Vue 的组件生命周期钩子和缓存容器,核心逻辑分为“缓存存储”“缓存匹配”“实例复用”三个步骤,Vue2 和 Vue3 原理一致,仅底层 API 和缓存容器细节有细微差异。

1. 核心机制:缓存容器 + 生命周期拦截

keep-alive 内部维护了一个缓存对象(缓存容器) ,用于存储被包裹组件的实例,同时拦截组件的生命周期,修改其默认的挂载/卸载行为:

  • 当组件首次被渲染时,keep-alive 会将组件实例存入缓存容器,同时阻止组件的 destroy 钩子执行(避免实例被销毁);
  • 当组件被切换(路由跳转、v-if 切换)时,组件不会被卸载,而是被“缓存”起来,DOM 会被隐藏(并非删除);
  • 当组件再次被渲染时,keep-alive 会从缓存容器中取出之前缓存的实例,直接复用,无需重新创建,同时触发对应的缓存生命周期钩子。

2. 底层缓存容器实现(Vue2 vs Vue3)

keep-alive 的缓存容器本质是一个对象(或 Map),用于存储组件实例,key 通常是组件的 name(或内部生成的唯一标识),value 是组件实例本身,不同版本的实现略有差异:

// Vue2 底层缓存容器(简化版)
const cache = Object.create(null) // 用对象存储缓存,key为组件name,value为实例

// Vue3 底层缓存容器(简化版)
const cache = new Map() // 用Map存储缓存,key为组件name或唯一标识,value为实例

注意:keep-alive 缓存的是组件实例,而非 DOM 元素;DOM 元素会随着组件实例的缓存被保留,再次渲染时直接插入页面,避免重新渲染 DOM 的开销。

3. 生命周期拦截与重写

Vue 组件默认的生命周期是:创建(created)→ 挂载(mounted)→ 卸载(destroyed)。keep-alive 会拦截组件的 mounted 和 destroyed 钩子,并重写其行为:

  • 首次渲染:组件正常执行 created → mounted,执行完毕后,keep-alive 将实例存入缓存,同时标记组件为“已缓存”;
  • 缓存后再次渲染:不执行 created、mounted 钩子(避免重复初始化),直接复用缓存实例,触发 activated 钩子;
  • 组件被切换隐藏:不执行 destroyed 钩子(避免实例销毁),仅触发 deactivated 钩子,实例被保留在缓存中;
  • 缓存被清除:实例才会执行 destroyed 钩子,彻底销毁。

注意:只有被 keep-alive 包裹的组件,才会拥有 activated 和 deactivated 两个专属生命周期钩子,未被包裹的组件不会触发这两个钩子。

二、keep-alive 核心属性(控制缓存范围)

keep-alive 提供 3 个核心属性,用于控制缓存的组件范围,避免缓存过多组件导致内存占用过大,这是使用 keep-alive 时的关键配置,也是避免滥用缓存的核心:

1. include(白名单)

仅缓存名称匹配 include 的组件,支持字符串(逗号分隔)、数组、正则表达式。组件名称需与组件的 name 选项一致(不可省略),否则无法匹配。

<!-- 字符串:缓存 name 为 Home、About 的组件 -->
<keep-alive include="Home,About">
  <router-view />
</keep-alive>

<!-- 数组:缓存 Home、About 组件 -->
<keep-alive :include="['Home', 'About']">
  <router-view />
</keep-alive>

2. exclude(黑名单)

不缓存名称匹配 exclude 的组件,用法与 include 一致,优先级高于 include(若组件同时在两个名单中,以 exclude 为准,不缓存)。

<!-- 不缓存 name 为 Login 的组件 -->
<keep-alive exclude="Login">
  <router-view />
</keep-alive>

3. max(缓存数量限制)

限制缓存的组件实例数量,当缓存的实例数量超过 max 时,会按照“LRU(最近最少使用)”策略,删除最久未使用的缓存实例,避免内存泄漏。Vue2.5+ 新增该属性,Vue3 完全兼容。

<!-- 最多缓存 3 个组件实例,超过则删除最久未使用的 -->
<keep-alive :max="3">
  <router-view />
</keep-alive>

注意:LRU 策略是 keep-alive 内置的缓存淘汰机制,核心逻辑是“最近使用的组件优先保留,最久未使用的组件优先淘汰”,适用于需要缓存多个组件但担心内存占用的场景。

三、keep-alive 缓存逻辑细节

1. 缓存的匹配规则

keep-alive 匹配组件时,优先使用组件的 name 选项作为匹配依据,若组件未设置 name(或 name 为空),则无法被 include/exclude 匹配,也无法被缓存(Vue3 中未设置 name 的组件会被默认命名,但仍建议显式设置)。

注意:路由组件的 name 需与路由配置中的 name 保持一致,否则 include/exclude 无法匹配路由组件。

2. 组件状态的保留机制

keep-alive 缓存的是组件实例,因此组件内的 data 数据、表单输入、滚动位置等状态都会被保留:

  • 表单输入:缓存后再次进入组件,输入框中的内容不会丢失;
  • 滚动位置:缓存后再次进入组件,页面滚动条会停留在上一次离开时的位置;
  • 数据状态:组件内的 data 数据不会被重置,仍保持上一次的状态。

注意:若需要重置组件状态(如再次进入时清空表单),可在 activated 钩子中手动重置数据,因为 activated 钩子每次组件被激活时都会触发。

3. 动态组件与 keep-alive 的结合

keep-alive 常与动态组件(component 标签 + is 属性)结合使用,实现组件切换时的缓存:

<keep-alive include="ComponentA,ComponentB">
  <component :is="currentComponent" />
</keep-alive>

<script setup>
import { ref } from 'vue'
import ComponentA from './ComponentA.vue'
import ComponentB from './ComponentB.vue'

const currentComponent = ref('ComponentA') // 切换组件
</script>

此时,ComponentA 和 ComponentB 切换时,都会被缓存,避免频繁创建和销毁,提升切换流畅度。

4. Vue3 专属实战示例(可直接复制复用)

以下示例均适配 Vue3 组合式 API(

示例1:Vue3 路由缓存(最常用,配合 router-view)

<!-- App.vue 中使用,缓存指定路由组件 -->
<template>
  <div id="app">
    <router-link to="/home">首页</router-link>
    <router-link to="/list">列表页</router-link>
    <router-link to="/login">登录页</router-link>
    
    <!-- 缓存 Home、List 组件,排除 Login 组件 -->
    <keep-alive include="Home,List" exclude="Login" :max="2">
      <router-view />
    </keep-alive>
  </div>
</template>

<script setup>
// 无需额外引入,Vue3 内置 keep-alive
</script>

// 路由组件示例(List.vue,需显式设置name)
<template>
  <div>
    <h2>列表页</h2>
    <input v-model="keyword" placeholder="搜索关键词" />
    <ul>
      <li v-for="item in list" :key="item.id">{{ item.name }}</li>
    </ul>
  </div>
</template>

<script setup>
import { ref, onActivated, onDeactivated } from 'vue'

// 必须显式设置组件name,否则keep-alive无法匹配
defineOptions({
  name: 'List'
})

const keyword = ref('')
const list = ref([
  { id: 1, name: 'Vue3 keep-alive 实战' },
  { id: 2, name: 'Vue3 组合式 API 用法' }
])

// 组件被激活时触发(每次进入都执行)
onActivated(() => {
  console.log('列表页被激活,可执行刷新数据等操作')
})

// 组件被缓存隐藏时触发
onDeactivated(() => {
  console.log('列表页被缓存,可执行清理操作')
})
</script>

示例2:Vue3 动态组件缓存(配合 component 标签)

<template>
  <div>
    <button @click="currentComponent = 'UserInfo'">用户信息</button>
    <button @click="currentComponent = 'UserSetting'">用户设置</button>
    
    <!-- 缓存 UserInfo、UserSetting 两个动态组件,限制最多缓存2个 -->
    <keep-alive include="UserInfo,UserSetting" :max="2">
      <component :is="currentComponent" />
    </keep-alive>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import UserInfo from './UserInfo.vue'
import UserSetting from './UserSetting.vue'

const currentComponent = ref('UserInfo')
</script>

// UserInfo.vue(需显式设置name)
<script setup>
defineOptions({
  name: 'UserInfo'
})
// 组件内容省略...
</script>

// UserSetting.vue(需显式设置name)
<script setup>
defineOptions({
  name: 'UserSetting'
})
// 组件内容省略...
</script>

示例3:Vue3 缓存组件状态重置(activated 钩子用法)

<template>
  <keep-alive include="FormPage">
    <component :is="currentComponent" />
  </keep-alive>
</template>

<script setup>
import { ref } from 'vue'
import FormPage from './FormPage.vue'

const currentComponent = ref('FormPage')
</script>

// FormPage.vue(缓存后重置表单状态)
<template>
  <form>
    <input v-model="form.name" placeholder="姓名" />
    <input v-model="form.age" placeholder="年龄" />
  </form>
</template>

<script setup>
import { ref, onActivated } from 'vue'

defineOptions({
  name: 'FormPage'
})

const form = ref({
  name: '',
  age: ''
})

// 每次进入组件(被激活),重置表单状态
onActivated(() => {
  form.value = {
    name: '',
    age: ''
  }
})
</script>

示例4:Vue3 手动清除 keep-alive 缓存

<template>
  <div>
    <button @click="clearCache">清除列表页缓存</button>
    <keep-alive include="Home,List" ref="keepAliveRef">
      <router-view />
    </keep-alive>
  </div>
</template>

<script setup>
import { ref } from 'vue'

// 获取keep-alive实例
const keepAliveRef = ref(null)

// 手动清除指定组件的缓存(List组件)
const clearCache = () => {
  // cache 是keep-alive内部的缓存容器(Vue3为Map)
  const cache = keepAliveRef.value.cache
  // 遍历缓存,删除name为List的组件实例
  for (const [key, value] of cache.entries()) {
    if (value.type.name === 'List') {
      cache.delete(key)
      // 触发组件销毁(可选)
      value.component?.unmount()
    }
  }
}
</script>

说明:示例中所有组件均显式设置 name(Vue3 组合式 API 用 defineOptions 定义),确保 keep-alive 的 include/exclude 能正常匹配;所有代码可直接复制,替换组件名称和内容即可适配自身项目。

四、Vue3 keep-alive 核心缓存策略(重点)

Vue3 中 keep-alive 并非单一缓存逻辑,而是通过内置规则+属性配置+手动干预实现多层缓存控制,核心分为四大缓存策略,覆盖日常开发全场景,每类策略均对应底层逻辑和实战用法,避免缓存滥用和内存问题。

1. 全量默认缓存策略(基础无配置)

这是 keep-alive 最基础的缓存策略,不配置任何属性时默认生效,核心是缓存所有被包裹的组件实例,无筛选、无数量限制,适合仅需缓存单个组件的极简场景。

核心逻辑:组件首次挂载后存入内部 Map 缓存容器,切换时不销毁实例、仅隐藏 DOM,再次激活直接复用,全程仅执行一次 created、mounted 钩子。

<!-- 全量缓存示例:缓存 router-view 内所有路由组件 -->
<keep-alive>
  <router-view />
</keep-alive>

对应实战场景:适合单个路由组件缓存(如仅首页缓存),可将示例1中 include="Home,List" exclude="Login" :max="2" 简化为无任何属性配置,即 ,需注意避免多组件场景使用。

注意:该策略不适合多组件场景,会无限制占用内存,频繁切换多组件时严禁直接使用,必须搭配范围控制属性。

2. 范围筛选缓存策略(精准控制)

通过 include(白名单)exclude(黑名单) 两个属性实现精准筛选,是企业级开发最常用的策略,解决“只缓存需要保留状态的组件”核心需求,二者优先级:exclude > include。

(1)白名单缓存策略(include)

仅缓存组件 name 匹配的组件,未匹配组件完全不缓存,每次切换都会重新创建销毁,适合指定少数核心页面缓存。

<!-- 仅缓存 Home、List 两个路由组件 -->
<keep-alive :include="['Home','List']">
  <router-view />
</keep-alive>

对应实战示例:参考“示例1:Vue3 路由缓存”,其中 include="Home,List" 就是典型的白名单策略,仅缓存首页和列表页,排除登录页,贴合企业级路由缓存高频场景,与示例中配置完全匹配。

(2)黑名单缓存策略(exclude)

排除指定组件,其余被包裹组件全部缓存,适合大部分组件需要缓存、仅少数组件无需缓存的场景。

<!-- 缓存所有组件,排除 Login、Detail 组件 -->
<keep-alive exclude="Login,Detail">
  <router-view />
</keep-alive>

对应实战示例:可基于示例1修改,将 include="Home,List" 改为 exclude="Login",即可实现“缓存所有路由组件,仅排除登录页”,与示例1的路由缓存场景一致,适配大部分页面需缓存的业务需求。

关键要求:该策略依赖组件 name,Vue3 组合式API中必须用 defineOptions 显式声明 name,自动生成的默认name易匹配失败。

3. LRU 淘汰缓存策略(内存优化)

通过 max 属性配合内置 LRU(最近最少使用) 算法实现,是 Vue3 自带的内存保护策略,专门解决多组件缓存导致的内存溢出问题。

核心逻辑:设定最大缓存数量,当缓存实例数超过 max 值时,自动删除最久未被激活使用的组件缓存,保留近期高频使用的组件实例,平衡性能与内存占用。

<!-- 最多缓存3个组件,超出则触发LRU淘汰 -->
<keep-alive :include="['Home','List','User','Setting']" :max="3">
  <router-view />
</keep-alive>

对应实战示例:参考“示例2:Vue3 动态组件缓存”,其中 :max="2" 就是LRU淘汰策略的应用,限制缓存UserInfo和UserSetting两个组件,若新增组件切换(如新增UserCenter),会自动淘汰最久未使用的组件,与示例配置完全对应。

4. 手动干预缓存策略(灵活控制)

属于进阶策略,突破内置属性限制,通过 ref 获取 keep-alive 实例,直接操作内部 Map 缓存容器,实现手动清除指定/全部缓存,适合需要动态重置缓存的场景(如退出登录、表单提交后清空缓存)。

核心逻辑:Vue3 中 keep-alive 实例暴露 cache 属性(Map 类型),可通过遍历、删除键值对实现手动清缓存,还可配合组件 unmount 彻底销毁实例。

<template>
  <button @click="clearTargetCache">清空列表页缓存</button>
  <button @click="clearAllCache">清空全部缓存</button>
  <keep-alive ref="keepAliveRef" include="Home,List,User">
    <router-view />
  </keep-alive>
</template>

<script setup>
import { ref } from 'vue'
const keepAliveRef = ref(null)

// 手动清除指定组件(List)缓存
const clearTargetCache = () => {
  const cacheMap = keepAliveRef.value?.cache
  if (!cacheMap) return
  for (const [key, instance] of cacheMap.entries()) {
    if (instance.type.name === 'List') {
      cacheMap.delete(key)
      // 彻底销毁组件实例
      instance.component?.unmount()
    }
  }
}

// 手动清空所有缓存
const clearAllCache = () => {
  const cacheMap = keepAliveRef.value?.cache
  if (!cacheMap) return
  cacheMap.clear()
}
</script>

对应实战示例:完全匹配“示例4:Vue3 手动清除 keep-alive 缓存”,示例4中通过 ref 获取 keep-alive 实例、删除List组件缓存,与本策略“手动干预缓存”的核心逻辑、代码实现完全一致,可直接复制示例4代码适配自身项目。

缓存策略使用优先级(推荐)

日常开发优先按这个顺序选择,兼顾性能与易用性: 范围筛选策略(include/exclude)→ LRU淘汰策略(max)→ 手动干预策略 → 默认全量策略

策略与示例对应总结(无偏差):范围筛选策略(include/exclude)对应示例1(路由缓存)、示例2(动态组件缓存);LRU淘汰策略(max)对应示例2;手动干预策略对应示例4(手动清缓存);默认全量策略可基于示例1简化配置实现,各类策略与示例精准匹配,无偏差。

五、Vue2 与 Vue3 keep-alive 核心差异

keep-alive 的核心原理和用法在 Vue2 和 Vue3 中基本一致,主要差异集中在底层实现和部分细节,不影响日常使用:

对比维度 Vue2 Vue3
缓存容器 使用普通对象(Object)存储 使用 Map 存储,性能更优,支持更灵活的 key 类型
组件 name 要求 必须显式设置 name,否则无法匹配缓存 未显式设置 name 时,会自动生成默认名称(基于组件文件路径),但仍建议显式设置
生命周期钩子 activated/deactivated 钩子在组件内直接定义 选项式 API 用法与 Vue2 一致;组合式 API 中需使用 onActivated、onDeactivated 钩子
底层实现 基于 Vue 实例的 $destroy 方法拦截 基于组件的 unmount 生命周期拦截,与 Composition API 适配更友好
缓存策略拓展 仅基础筛选+LRU,无便捷手动清缓存方式 支持直接操作 Map 缓存,手动清缓存更便捷

注意:Vue3 中,keep-alive 不支持包裹多个根节点的组件,否则会抛出警告并失效,需确保被包裹的组件只有一个根节点。

六、常见使用场景与注意事项

1. 常见使用场景

  • 路由切换场景:如首页、列表页、详情页切换,缓存列表页状态(避免重新请求数据、重置滚动位置);
  • 动态组件切换场景:如标签页、步骤条,缓存每个标签/步骤的组件状态;
  • 表单场景:如长表单分页填写,缓存已填写的表单数据,避免切换分页时数据丢失。

2. 缓存策略专属注意事项

  • 范围策略必配name:使用include/exclude时,Vue3组合式API必须用defineOptions声明name,禁止依赖自动生成name;
  • LRU策略max值合理设置:max数值建议按业务高频页面数量设定,一般设3-5即可,不宜过大或过小;
  • 手动清缓存需彻底:删除缓存后建议调用unmount销毁实例,避免残留实例导致内存泄漏;
  • 禁止策略冲突:不同时配置冲突的include和exclude,避免缓存不生效;
  • 动态路由缓存适配:动态路由组件需保证name固定,否则范围策略匹配失效;
  • 缓存状态按需重置:即便用了缓存策略,仍需在onActivated钩子中处理状态重置,避免旧数据干扰。

3. 通用关键注意事项

  • 避免过度缓存:不要缓存所有组件,尤其是一次性使用、无需保留状态的组件(如登录页),否则会增加内存占用,反而影响性能;
  • 缓存组件的生命周期差异:被缓存的组件,created、mounted 仅执行一次,后续渲染仅触发 activated,卸载仅触发 deactivated;
  • 避免缓存带定时器/事件监听的组件:若组件内有定时器、事件监听,需在 deactivated 钩子中清除,在 activated 钩子中重新初始化,避免内存泄漏;
  • Vue3 多根组件限制:keep-alive 包裹的组件必须是单根节点,否则缓存失效并抛出警告。

🔔 如何实现一个优雅的通知中心?(Vue 3 + 消息队列实战)

前言

一个完善的通知系统可以显著提升用户体验,让用户及时了解:

  • 新评论回复
  • 文章被点赞
  • 系统公告
  • 签到奖励

今天分享如何实现一个优雅的通知中心!

功能设计

通知类型

// src/types/notification.ts
export type NotificationType = 
  | 'comment'      // 评论通知
  | 'reply'         // 回复通知
  | 'like'          // 点赞通知
  | 'follow'        // 关注通知
  | 'system'        // 系统通知
  | 'achievement'   // 成就通知

export interface Notification {
  id: string
  type: NotificationType
  title: string
  content: string
  avatar?: string
  link?: string
  read: boolean
  createTime: number
}

核心实现

1. 通知服务

// src/services/notification.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Notification, NotificationType } from '@/types/notification'

export const useNotificationStore = defineStore('notification', () => {
  const notifications = ref<Notification[]>([])
  
  // 加载通知
  function loadNotifications() {
    const data = localStorage.getItem('blog_notifications')
    if (data) {
      notifications.value = JSON.parse(data)
    }
  }
  
  // 保存通知
  function saveNotifications() {
    localStorage.setItem('blog_notifications', JSON.stringify(notifications.value))
  }
  
  // 添加通知
  function addNotification(notification: Omit<Notification, 'id' | 'read' | 'createTime'>) {
    const newNotification: Notification = {
      ...notification,
      id: `notif_${Date.now()}_${Math.random().toString(36).slice(2)}`,
      read: false,
      createTime: Date.now()
    }
    
    notifications.value.unshift(newNotification)
    saveNotifications()
    
    // 触发浏览器通知
    if (Notification.permission === 'granted') {
      new Notification(newNotification.title, {
        body: newNotification.content,
        icon: newNotification.avatar
      })
    }
    
    return newNotification
  }
  
  // 标记已读
  function markAsRead(id: string) {
    const notification = notifications.value.find(n => n.id === id)
    if (notification) {
      notification.read = true
      saveNotifications()
    }
  }
  
  // 全部已读
  function markAllAsRead() {
    notifications.value.forEach(n => {
      n.read = true
    })
    saveNotifications()
  }
  
  // 删除通知
  function deleteNotification(id: string) {
    const index = notifications.value.findIndex(n => n.id === id)
    if (index > -1) {
      notifications.value.splice(index, 1)
      saveNotifications()
    }
  }
  
  // 未读数量
  const unreadCount = computed(() => {
    return notifications.value.filter(n => !n.read).length
  })
  
  // 按类型分组
  const groupedNotifications = computed(() => {
    const groups: Record<NotificationType, Notification[]> = {
      comment: [],
      reply: [],
      like: [],
      follow: [],
      system: [],
      achievement: []
    }
    
    notifications.value.forEach(n => {
      groups[n.type].push(n)
    })
    
    return groups
  })
  
  // 请求通知权限
  async function requestPermission() {
    if ('Notification' in window) {
      const permission = await Notification.requestPermission()
      return permission === 'granted'
    }
    return false
  }
  
  loadNotifications()
  
  return {
    notifications,
    unreadCount,
    groupedNotifications,
    addNotification,
    markAsRead,
    markAllAsRead,
    deleteNotification,
    requestPermission
  }
})

2. 通知中心组件

<!-- src/components/notification/NotificationCenter.vue -->
<template>
  <el-popover
    v-model:visible="visible"
    placement="bottom-end"
    :width="360"
    trigger="click"
  >
    <template #reference>
      <div class="notification-trigger">
        <el-badge :value="unreadCount" :hidden="unreadCount === 0" :max="99">
          <el-button :icon="Bell" circle />
        </el-badge>
        <!-- 红点提醒 -->
        <span v-if="hasNewNotification" class="new-dot" />
      </div>
    </template>
    
    <template #default>
      <div class="notification-center">
        <!-- 头部 -->
        <div class="header">
          <h3>通知中心</h3>
          <el-button 
            v-if="unreadCount > 0" 
            text 
            size="small"
            @click="handleMarkAllRead"
          >
            全部已读
          </el-button>
        </div>
        
        <!-- 标签页 -->
        <el-tabs v-model="activeTab" class="notification-tabs">
          <el-tab-pane label="全部" name="all" />
          <el-tab-pane label="评论" name="comment" />
          <el-tab-pane label="点赞" name="like" />
          <el-tab-pane label="系统" name="system" />
        </el-tabs>
        
        <!-- 通知列表 -->
        <div class="notification-list">
          <div 
            v-for="notification in filteredNotifications"
            :key="notification.id"
            class="notification-item"
            :class="{ unread: !notification.read }"
            @click="handleClick(notification)"
          >
            <el-avatar 
              :src="notification.avatar || defaultAvatar" 
              :size="40"
            />
            
            <div class="content">
              <div class="title">{{ notification.title }}</div>
              <div class="message">{{ notification.content }}</div>
              <div class="time">{{ formatTime(notification.createTime) }}</div>
            </div>
            
            <div class="actions">
              <el-button 
                v-if="!notification.read"
                text 
                size="small"
                @click.stop="handleMarkRead(notification.id)"
              >
                标记已读
              </el-button>
              <el-button 
                text 
                size="small"
                @click.stop="handleDelete(notification.id)"
              >
                删除
              </el-button>
            </div>
          </div>
          
          <el-empty 
            v-if="filteredNotifications.length === 0"
            description="暂无通知"
          />
        </div>
      </div>
    </template>
  </el-popover>
</template>

<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { Bell } from '@element-plus/icons-vue'
import { useNotificationStore } from '@/services/notification'
import type { Notification } from '@/types/notification'
import { ElMessage } from 'element-plus'

const notificationStore = useNotificationStore()
const visible = ref(false)
const activeTab = ref('all')

const unreadCount = computed(() => notificationStore.unreadCount)
const hasNewNotification = computed(() => unreadCount.value > 0)

const defaultAvatar = '/default-avatar.png'

const filteredNotifications = computed(() => {
  if (activeTab.value === 'all') {
    return notificationStore.notifications
  }
  return notificationStore.notifications.filter(n => n.type === activeTab.value)
})

function formatTime(timestamp: number) {
  const date = new Date(timestamp)
  const now = new Date()
  const diff = now.getTime() - date.getTime()
  
  if (diff < 60000) return '刚刚'
  if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
  if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`
  if (diff < 604800000) return `${Math.floor(diff / 86400000)}天前`
  
  return date.toLocaleDateString()
}

function handleClick(notification: Notification) {
  notificationStore.markAsRead(notification.id)
  
  if (notification.link) {
    window.location.href = notification.link
  }
  
  visible.value = false
}

function handleMarkRead(id: string) {
  notificationStore.markAsRead(id)
}

function handleMarkAllRead() {
  notificationStore.markAllAsRead()
  ElMessage.success('已全部标记为已读')
}

function handleDelete(id: string) {
  notificationStore.deleteNotification(id)
}

// 监听新通知
watch(() => notificationStore.unreadCount, (newCount, oldCount) => {
  if (newCount > oldCount) {
    // 播放提示音
    const audio = new Audio('/notification.mp3')
    audio.play().catch(() => {})
  }
})

onMounted(() => {
  notificationStore.requestPermission()
})
</script>

<style scoped>
.notification-trigger {
  position: relative;
  display: inline-block;
}

.new-dot {
  position: absolute;
  top: 0;
  right: 0;
  width: 8px;
  height: 8px;
  background: #f56c6c;
  border-radius: 50%;
  animation: pulse 2s infinite;
}

@keyframes pulse {
  0%, 100% { transform: scale(1); opacity: 1; }
  50% { transform: scale(1.2); opacity: 0.8; }
}

.notification-center {
  margin: -12px;
}

.header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 12px 16px;
  border-bottom: 1px solid var(--el-border-color);
}

.header h3 {
  margin: 0;
  font-size: 16px;
}

.notification-tabs {
  padding: 0 8px;
}

.notification-list {
  max-height: 400px;
  overflow-y: auto;
  padding: 8px;
}

.notification-item {
  display: flex;
  gap: 12px;
  padding: 12px;
  border-radius: 8px;
  cursor: pointer;
  transition: background 0.2s;
}

.notification-item:hover {
  background: var(--el-fill-color-light);
}

.notification-item.unread {
  background: var(--el-color-primary-light-9);
}

.notification-item.unread::before {
  content: '';
  position: absolute;
  left: 4px;
  top: 50%;
  transform: translateY(-50%);
  width: 6px;
  height: 6px;
  background: var(--el-color-primary);
  border-radius: 50%;
}

.content {
  flex: 1;
  min-width: 0;
}

.title {
  font-weight: 600;
  margin-bottom: 4px;
}

.message {
  font-size: 13px;
  color: var(--el-text-color-secondary);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.time {
  font-size: 12px;
  color: var(--el-text-color-placeholder);
  margin-top: 4px;
}

.actions {
  display: flex;
  flex-direction: column;
  gap: 4px;
}
</style>

使用示例

<!-- 在 Header 中使用 -->
<template>
  <header>
    <div class="header-content">
      <!-- 其他内容 -->
      <NotificationCenter />
    </div>
  </header>
</template>

<script setup lang="ts">
import NotificationCenter from '@/components/notification/NotificationCenter.vue'
import { useNotificationStore } from '@/services/notification'

const notificationStore = useNotificationStore()

// 模拟收到新评论
function simulateNewComment() {
  notificationStore.addNotification({
    type: 'comment',
    title: '新评论',
    content: '用户"前端小白"评论了你的文章《Vue 3 入门指南》',
    avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=user1',
    link: '/article/vue3-guide'
  })
}
</script>

浏览器通知

// 在需要时请求权限并发送通知
async function sendBrowserNotification(title: string, options?: NotificationOptions) {
  if ('Notification' in window && Notification.permission === 'granted') {
    new Notification(title, {
      icon: '/logo.png',
      badge: '/badge.png',
      ...options
    })
  }
}

💡 进阶功能

  • 接入 WebSocket 实现实时推送
  • 添加通知免打扰模式
  • 支持通知折叠和展开

你的 Vue TransitionGroup 组件,VuReact 会编译成什么样的 React 代码?

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 中内置的 <TransitionGroup> 组件经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 3 中 <TransitionGroup> 组件的用法。

编译对照

TransitionGroup:列表过渡动画

<TransitionGroup> 是 Vue 中用于为列表项的插入、移除和重排提供过渡动画的内置组件,是 <Transition> 的列表版本。

基础列表过渡

  • Vue 代码:
<template>
  <TransitionGroup name="list" tag="ul">
    <li v-for="item in items" :key="item.id">
      {{ item.name }}
    </li>
  </TransitionGroup>
</template>
  • VuReact 编译后 React 代码:
import { TransitionGroup } from '@vureact/runtime-core';

<TransitionGroup name="list" tag="ul">
  {items.map((item) => (
    <li key={item.id}>{item.name}</li>
  ))}
</TransitionGroup>

从示例可以看到:Vue 的 <TransitionGroup> 组件被编译为 VuReact Runtime 提供的 TransitionGroup 适配组件,可理解为「React 版的 Vue TransitionGroup」。

这种编译方式的关键特点在于:

  1. 语义一致性:完全模拟 Vue <TransitionGroup> 的行为,实现列表过渡动画
  2. 列表支持:专门为列表项的进入、离开和移动提供动画支持
  3. 容器标签:通过 tag 属性指定列表容器元素
  4. key 要求:列表项必须提供稳定的 key 属性

对应的 CSS 样式

.list-enter-from,
.list-leave-to {
  opacity: 0;
  transform: translateX(30px);
}

.list-enter-active {
  opacity: 1;
  transform: translateX(0);
  transition: all 0.5s ease;
}

.list-leave-active {
  opacity: 0;
  transform: translateX(30px);
  transition: all 0.5s ease;
}

列表重排与移动动画

<TransitionGroup> 支持列表项重排时的平滑移动动画,通过 moveClass 属性实现。

  • Vue 代码:
<template>
  <TransitionGroup name="list" tag="ul" move-class="list-move">
    <li v-for="item in items" :key="item.id">
      {{ item.name }}
    </li>
  </TransitionGroup>
</template>
  • VuReact 编译后 React 代码:
<TransitionGroup name="list" tag="ul" moveClass="list-move">
  {items.map((item) => (
    <li key={item.id}>{item.name}</li>
  ))}
</TransitionGroup>

移动动画 CSS

/* 移动动画类 */
.list-move {
  transition: all 0.5s ease;
}

/* 离开动画需要绝对定位 */
.list-leave-active {
  position: absolute;
}

移动动画原理

  1. FLIP 技术:使用 First-Last-Invert-Play 技术实现平滑移动
  2. 位置计算:计算元素新旧位置差异,应用反向变换
  3. 平滑过渡:通过 CSS 过渡实现位置变化的动画效果
  4. 性能优化:使用 transform 属性实现高性能动画

自定义容器元素

通过 tag 属性可以指定列表的容器元素类型。

  • Vue 代码:
<template>
  <TransitionGroup name="fade" tag="div" class="item-list">
    <div v-for="item in items" :key="item.id" class="item">
      {{ item.name }}
    </div>
  </TransitionGroup>
</template>
  • VuReact 编译后 React 代码:
<TransitionGroup name="fade" tag="div" className="item-list">
  {items.map((item) => (
    <div key={item.id} className="item">
      {item.name}
    </div>
  ))}
</TransitionGroup>

tag 属性作用

  1. 容器类型:指定渲染的 HTML 元素类型(div、ul、ol 等)
  2. 语义化:使用合适的语义化标签
  3. 样式控制:方便应用容器样式
  4. 结构清晰:保持清晰的 DOM 结构

继承 Transition 功能

<TransitionGroup> 继承了 <Transition> 的所有功能,支持相同的属性和钩子。

  • Vue 代码:
<template>
  <TransitionGroup 
    name="slide" 
    tag="div"
    :duration="500"
    @enter="onEnter"
    @leave="onLeave"
  >
    <div v-for="item in items" :key="item.id">
      {{ item.name }}
    </div>
  </TransitionGroup>
</template>
  • VuReact 编译后 React 代码:
<TransitionGroup
  name="slide"
  tag="div"
  duration={500}
  onEnter={onEnter}
  onLeave={onLeave}
>
  {items.map((item) => (
    <div key={item.id}>{item.name}</div>
  ))}
</TransitionGroup>

继承的功能

  1. 自定义类名:支持 enter/leave 相关的自定义类名
  2. JavaScript 钩子:支持所有过渡生命周期钩子
  3. 持续时间:支持 duration 属性控制动画时长
  4. CSS 控制:支持 css 属性控制是否应用 CSS 过渡

编译策略总结

VuReact 的 TransitionGroup 编译策略展示了完整的列表过渡转换能力

  1. 组件直接映射:将 Vue <TransitionGroup> 直接映射为 VuReact 的 <TransitionGroup>
  2. 属性完全支持:支持 nametagmoveClass 等所有属性
  3. 列表渲染转换:将 v-for 转换为 map 函数调用
  4. 动画功能继承:继承所有 <Transition> 的动画功能

注意事项

  1. key 必须:列表项必须提供稳定的 key,否则动画可能异常
  2. CSS 要求:必须在 *-enter-active*-leave-active 中设置过渡外观
  3. 移动动画:离开动画需要设置 position: absolute

VuReact 的编译策略确保了从 Vue 到 React 的平滑迁移,开发者无需手动实现列表过渡动画逻辑。编译后的代码既保持了 Vue 的列表过渡语义和动画效果,又符合 React 的组件设计模式,让迁移后的应用保持完整的列表过渡能力。

🔗 相关资源


✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

VUE开发环境配置基础(构建工具→单文件组件SFC→css预处理器sass→eslint)及安装脚手架

VUE开发环境配置基础(构建工具→单文件组件SFC→css预处理器sass→eslint)

一、构建工具

作用:

打包压缩、转换(.vue文件转换成浏览器能识别的html、css、js)

内置了web服务器可进行热更新

  • webpack构建工具
  • vite构建工具

使用:

  1. npm init -y初始化项目,生成package.json文件
  2. npm i vite -D(npm install vite -D)安装vite构建工具(-S生产环境,-D局部安装/开发环境,-G全局安装)

安装之后可以使用vite命令启动内置web服务器

但开发中一般会在package.json文件中配置dev、build

"scripts": {
    "dev": "vite --host",
    "build": "vite build",
    "preview": "vite preview",
    "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
  },

 

  1. 执行npm run dev命令启动内置web服务器
  2. 执行npm run build打包项目,打包后会在根目录下生成一个dist文件夹,该文件夹下存放的就是打包压缩后的包

二、单文件组件SFC(.vue结尾的文件)包括<template><script><style>

  1. npm i @vitejs/plugin-vue -S 安装vite构建工具解析.vue文件的插件

根目录下创建vite.config.js文件,配置集成该插件

import vue from '@vitejs/plugin-vue' // vite构建工具解析 .vue文件的插件
import {defineConfig} from 'vite'//defineConfig方法,编写代码会有提示
export default defineConfig({
    plugins:[vue()] // 集成插件
})

2. npm i vue -S 安装vue框架

main.js

// import {createApp} from 'https://unpkg.com/vue@3/dist/vue.esm-browser.js'
import { createApp } from 'vue'

// import App from './App.js'
import App from './App.vue'

createApp(App).mount('#app')

App.vue

<!-- 模板 -->
<template>
    <div class="g-wrapper">
        <h2>单文件组件 SFC</h2>
        <p>{{message}}</p>
        <table>
            <tr>
                <th>序号</th>
                <th>名称</th>
                <th>价格</th>
            </tr>

            <!--  绑定key目的: 虚拟dom diff算法能够快速找到列表项,对列表项进行高效操作 -->
            <tr v-for="item,index in list" :key="item.id">
                 <td>{{item.id}}</td>
                 <td>{{item.name}}</td>
                 <td>{{item.price}}</td>
            </tr>
        </table>
        <ComA></ComA>
    </div>
</template>

<!-- js代码 -->
<script>
import ComA from './components/ComA.vue'
export default {
    components:{
        ComA
    },
    data() {
        return {
            message:'根组件App.vue',
            list:[
                {id:1001,name:'javascript编程',price:99.89},
                {id:1002,name:'css编程',price:89.89},
                {id:1003,name:'vue编程',price:178.88},
            ]
        }
    }
}
</script>

<!-- css样式 -->
<style scoped>
.g-wrapper{
    width: 1200px;
    margin: 100px auto;
}

.g-wrapper table{
    width: 100%;
    text-align: center;
}

.g-wrapper table tr td,th{
    border-bottom: 1px dotted gray;
    line-height: 40px;
}
</style>

ComA.vue

<template>
  <div class="g-wrapper">
    <h2>组件ComA</h2>
  </div>
</template>

<script>
export default {};
</script>

<!-- scoped :样式作用域只在当前组件生效 -->
<style scoped>
h2 {
  color: red;
}
</style>

三、css预处理器less,sass,stylus

sass(两个版本:sass、scss。scss是sass的升级版,完全兼容css)

官网:www.sass.hk/

npm i sass -D 安装sass(vite构建工具内置了sass库,安装后不需要配置)

1. 导入样式可以在main.js入口文件中导入,也可以在某个模块中导入

scss文件导入总结:

  • main.js入口文件:import  ‘./text.scss’
  • .vue文件:@import  url(./text.scss)
  • .scss同级文件:@import  ‘./text.scss’

main.js

import { createApp } from 'vue'
// 模块化导入样式
import "./assets/scss/text.scss";

import App from './App.vue'
createApp(App).mount('#app')

.vue文件

<style scoped>
// 导入样式
@import url(../assets/sass/test.scss);
</style>

2. scss语法

变量$btn、混合器@mixin可单独封装一个文件,便于维护

1>.定义变量:

$变量名:值

$c: blue; // scss定义变量
$h: 200px;
$btnH: 40px;
2>.嵌套语法:
//css写法
// .g-container {
//   background-color: pink;
//   height: $h;
// }

// .g-container h2 {
//     font-size: 18px;
//     color: $c;
// }

// 嵌套语法
.g-container {
  background-color: pink;
  height: $h;
  h2 {
    font-size: 18px;
    color: $c;
  }
}
3>.混合器:样式封装

定义混合器@mixin 混合器名{}(相当于函数function 函数名)

@mixin btn1{
    display: inline-block;
    //封装样式
    width: 100px;
    height: $btnH;
    text-align: center;
    line-height: $btnH;
    border: none;
    outline: none;
    background-color: skyblue;
    border-radius: 5px;
}

使用封装的混合器(@include 混合器名)

.m-a1 {
    color: blue;
    margin: 10px;
    @include btn1;
  }
4>.鼠标悬停(伪类&)
//css写法
// .m-a1:hover{
//   background-color: #3eb8e9;
// }

.m-a1 {
  color: blue;
  margin: 10px;
  @include btn1;
  &:hover {
    background-color: #3eb8e9;
  }
}
5>.控制指令@if

@if 表达式返回值不是false或者null时,条件成立,输出(}内的代码。

@if 声明后面可以跟多个@else if 声明,或者一个@else 声明。

$type: monster;

P{
@if $type == ocean {color: blue;}
@else if $type == matador {color: red;}
@else if $type == monster {color: green;}
@else {color: black;}
}

更多语法参官网

3.单文件组件中使用scss(lang=”scss”)

<style lang="scss" scoped>
// 模块化导入样式
/*@import url(../assets/sass/test.scss); */

.g-container{
  background-color: pink;
  h2{
    color:red;
  }
  div{
    width: 100px;
    height: 40px;
    background-color: skyblue;
  }
}
</style>

四、eslint一个语法规则和代码风格的检查工具,保证写出语法正确、风格统一的代码

官网:eslint.nodejs.cn/

手动集成:

  1. npm i eslint -D(yarn add eslint -D) 安装eslint
  2. npx eslint --init 初始化项目eslint,生成.eslintrc.js配置文件
/* eslint-disable no-undef */

module.exports = {
    "env": {
        "browser"true,
        "es2021"true
    },

    "extends": [
        "eslint:recommended",
        "plugin:vue/vue3-essential"
    ],

    "overrides": [
    ],

    "parserOptions": {
        "ecmaVersion""latest",
        "sourceType""module"
    },

    "plugins": [
        "vue"
    ],

    "rules": {//自定义规则
        // semi: ['error', 'never'],  // 使用分号结束报错
        // quotes: ['error', 'single'],  // 使用单引号报错
        // eqeqeq: ['error', 'always'],// 使用===,不能使用==
        // 'vue/no-unused-vars': 'error',
    }
}

3. npm i eslint-plugin-vue 安装检查单文件组件的插件 4. vscode搜索安装ESLint插件,自动检测,不符合规则会报错

  1. npm i pretter eslint-config-prettier -D(yarn add pretter eslint-config-prettier -D)安装eslint格式化插件,格式化时自动改正
  2. 根目录下创建配置.prettierrc.json格式化规则文件
{
    "tabWidth": 4,
    "useTabs": false,
    "semi": false,
    "singleQuote": true,
    "TrailingComma": "all",
    "bracketSpacing": true,
    "jsxBracketSameLine": false,
    "arrowParens": "avoid"
}

脚手架(create-vite、vue-cli、create-vue、quasar-cli)

1. create-vite

npm create vite@latest(yarn create vite)安装脚手架命令

2. vue-cli

npm i -g @vue-cli安装脚手架命令

vue create project1创建项目

3. create-vue(vue官方的项目脚手架工具,内置了vite构建工具)项目开发中使用的脚手架

npm init vue@latest安装脚手架命令,根据预设生成相应的配置文件

npm install(npm i) 安装依赖

image.png npm run dev运行

目录结构介绍

image.png

4. quasar-cli项目开发中使用的脚手架

关于quasar要求:

  • Node 12+用于Quasar CLI与Webpack,Node 14+用于Quasar CLI与Vite。
  • Yarn v1(强烈推荐),PNPM,或NPM。

npm i -g @quasar/cli 安装脚手架命令

npm init quasar 初始化quasar根据预设生成相应的配置文件 image.png 此时回车,会生成项目文件和目录 image.png 提示安装项目依赖,选择yes回车 image.png quasar dev(npm run dev)运行

vue2、vue3区别之混入mixins和过滤器filter

一、混入mixins

一个包含组件选项的对象数组(可复用),这些选项都将被混入到当前组件的实例中

属性相同时,原组件中的属性会覆盖混入的属性。

vue2多使用

作用:将组件公共的数据方法和生命周期函数提取出来,封装到一个独立对象中,被其它所有组件共享。

实现:

1.MyMixins.js定义混入对象(1.定义混入对象 2.在vue组件中通过mixins选项接收要混入的对象数组 3.使用)

export const mixins1 = {
  data() {
      return {
          message:'这是混入的message'
      }
  },

  methods: {
      plus(){
          console.log('这是混入的plus >>>>')
      }
  },
}

2.App.js引入接收使用

import { mixins1 } from "./mixins/MyMixins.js";

export default {
  mixins: [mixins1],

  data() {
    return {
      title"混入技术",
      vcolor"red",
      message:'这是组件app中message'
    };
  },

  methods: {
    bindUpdateColor() {
      this.vcolor = "blue";
    },
  },

  /*html*/

  template: `<div>
                <h2>{{title}}</h2>
                <p>{{ message }}</p>
                <button @click="plus">确定</button>
            </div>
            `,
};

二、过滤器filter

全局方法,本质是一个函数。

vue2中使用,vue3没有filter过滤器

注册:Vue.filter(过滤器名称,过滤器函数)

调用:  <p>{{  参数|过滤器名称 }}</p>

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>过滤器</title>
    <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
    <!-- <script src="./vue.js"></script> -->
</head>

<body>
    <div id="app"></div>
    <script>
        const root = {
            el:'#app',
            data: {
                title'过滤器'
            },

            /*html 调用*/
            template:`<div>
                    {{title}}
                    <p>{{ title|msgFilter }}</p>  
                 </div>`
        }

         //注册
         Vue.filter('msgFilter',(t)=>{
            const data =  new Date()
            return data.toLocaleTimeString()
        })

        // 创建vue实例
        new Vue(root)
    </script>
</body>
</html>

属性透传attribute、vue实例对象方法$nextTick()、虚拟dom与浏览器渲染机制

一、属性透传attribute

  • 指的是传递给一个组件,但没有通过props或emits接收,常见的如class、style、id
  • 透传的样式会自动被添加到子组件根元素上,如果子组件已经有一个样式,透传的样式会和已有样式合并。
  • 如果透传的是一个点击事件,也会自动被添加到子组件根元素上,如果子组件自身也有点击事件,点击时透传过来的事件和其本身的事件都会触发。
  • 透传的属性可以通过{{$attrs}}拿到
  • 属性透传只会透传到根元素上,如果有多个根节点,vue不知道要透传到哪个节点,需要通过v-bind=”$attrs”绑定到要透传到的那个节点上,否则会抛出警告。
  • 属性透传是可以禁用的,通过inheritAttrs: false可以阻止透传

例:

目录

image.png

1.新建assets样式文件夹,样式文件style.css

.large{
    color: red;
}
.small{
    background-color: pink;
}

2.在index.html中引入样式文件

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>首页</title>
    <link rel="stylesheet" href="./assets/css/style.css">
</head>

<body>
    <div id="app"></div>
    <script type="module" src="./src/main.js"></script>
</body>
</html>

父组件App.js

import Child from "./Child.js";
export default {
  components: {
    Child,
  },

  data() {
    return {
      title: "attribute属性透传",
      count:0
    };
  },

  /*html*/
  template: `<div style="width:400px;height:400px;background-color:skyblue;">
                <h2>{{title}}</h2>
                <p>{{count}}</p>
                 <Child class="large" @click="count++" ></Child>
            </div>`,
};

子组件Child.js

export default {
  data() {
    return {
      num10,
    };
  },

  inheritAttrs: true, // 阻止透传

  /*html*/
  template: `
              <button @click="num++" class="small">子按钮{{num}}</button>
              <main v-bind="$attrs">多个根节点</main>
          `,
};


二、vue实例对象方法$nextTick()

可以通过this.$nextTick(()=>{})访问,回调函数会在dom节点渲染完成后执行

如:正常操作dom节点是在mounted生命周期中操作,nextTick()方法可以实现在created生命周期中操作dom节点

export default {
  data() {
    return {
      title"vue实例对象的 $nextTick()",
      count:10
    };
  },

  created() {
      this.$nextTick(()=>{
        //回调函数,模板界面异步更新完成后执行
        const pEle = document.querySelector('#countP')
        console.log('bindPlus >> ',pEle.innerHTML);
      })
  },

  methods: {
    bindPlus(){
      this.count++
      // 验证, count数据变化,通知依赖更新界面是一个异步过程
      const pEle = document.querySelector('#countP')
      console.log('bindPlus >> ',pEle.innerHTML);//是更新之前的值,说明是异步更新的

      //模板界面异步更新完成后执行nextTick回调函数中代码
      this.$nextTick(()=>{
        const pEle = document.querySelector('#countP')
        console.log('bindPlus >> ',pEle.innerHTML); //是更新之后的值
      })
    }
  },

  /*html*/
  template: `<div>
                <h2>{{title}}</h2>
                <p id="countP">{{count}}</p>
                <button @click="bindPlus">加一 </button>
            </div>`,
};

三、虚拟dom、浏览器渲染机制

整个html文档是一个dom对象,整个html由dom节点对象构成。

浏览器渲染机制:

  1. 解析HTML生成dom树,同时解析CSS文档构建CSSOM树
  2. DOM树和CSSOM树关联起来生成渲染树(RenderTree)
  3. 浏览器按照渲染树进行重排重绘

重绘:CSS 样式改变(如:visibility,背景色的改变),使浏览器需要根据新的属性进行绘制

重排:对DOM的修改引发了DOM几何元素的变化(如:改变元素高度),渲染树需要重新计算,重新生成布局,重新排列元素。

重绘不一定导致重排,但重排一定会导致重绘

 

操作真实dom会引起重排重绘,vue框架操作的是虚拟dom(本质上就是一个普通的JS对象。是模拟真实dom得到的一个JS对象)

将真实dom多次操作在虚拟dom上完成,再将虚拟dom映射到真实dom,完成一次重排重绘,提高渲染效率。

我们在vue中写的template模板,vue编译过程中,会调用render()函数将其编译成虚拟dom树,后映射挂载成真实dom,然后重排重绘显示给用户

vue提供了一个h()方法,用于创建虚拟dom(vnode)

import { h } from 'vue'
render(){
  return[

        h('div')

        h('div', { id"foo" })

        h('div', { id"foo", class"bar", style: { color'red' }, onClick: () => { } },'标  题', [/*child*/])

  ]
}
❌