普通视图

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

使用 IntersectionObserver + 哨兵元素实现长列表懒加载

作者 王小新_926
2026年4月15日 15:48

一、背景与痛点

在一个设备监控数据看板项目中,设备列表可能包含 300+ 个设备卡片。如果一次性渲染全部 DOM 节点,会带来明显的性能问题:

  • 首屏白屏时间长:300+ 卡片组件同时挂载,主线程阻塞
  • 内存占用高:大量 DOM 节点常驻内存
  • 交互卡顿:滚动、点击等操作响应延迟

为此,我们采用 IntersectionObserver + 哨兵元素 方案实现懒加载。

二、核心思路

整体思路可以概括为  "分页截取 + 哨兵触发"

全量数据(300+)  →  分页截取显示(每页15条)  →  哨兵进入视口时追加下一页

关键设计:

  1. 数据全量存储,视图分页截取deviceList 保存完整数据,displayDeviceList 通过 computed 计算 slice(0, end) 返回当前应显示的子集
  2. 哨兵元素:在列表末尾放置一个不可见的 DOM 元素,当它进入视口时触发加载
  3. IntersectionObserver:原生浏览器 API,高效监听元素与视口的交叉状态,零滚动事件开销

三、架构图示

┌─────────────────────────────────────────────┐
│            Vue Component (data)              │
│  deviceList: [...]         // 全量300+设备   │
│  devicePageSize: 15        // 每页条数       │
│  deviceCurrentPage: 0      // 当前页码       │
│  observer: null            // Observer实例    │
├─────────────────────────────────────────────┤
│            Computed Properties               │
│  displayDeviceList → slice(0, pageSize*page) │
│  hasMoreDevices → displayed < total          │
├─────────────────────────────────────────────┤
│            Template 渲染逻辑                 │
│  v-for="device in displayDeviceList"         │
│  ┌─── Card ───┐  ┌─── Card ───┐  ...        │
│  └────────────┘  └────────────┘              │
│  ┌─── Sentinel (ref="sentinel") ───┐         │
│  │  v-if="hasMoreDevices"          │         │
│  │  <加载更多设备...>               │         │
│  └─────────────────────────────────┘         │
└─────────────────────────────────────────────┘
         │                    ▲
         │ observe(sentinel)  │ isIntersecting
         ▼                    │
┌─────────────────────────────────────────────┐
│        IntersectionObserver                  │
│  rootMargin: '200px'   // 提前200px触发      │
│  threshold: 0.1                             │
│  → 触发 loadMoreDevices()                    │
│  → deviceCurrentPage++                       │
│  → displayDeviceList 自动更新 → DOM 更新     │
│  → $nextTick → 重新绑定哨兵                   │
└─────────────────────────────────────────────┘

四、核心代码实现

4.1 数据定义

data() {
  return {
    deviceList: [],        // 全量设备数据
    devicePageSize: 15,    // 每页条数
    deviceCurrentPage: 0,  // 当前已加载页数
    observer: null,        // IntersectionObserver 实例
  }
}

4.2 计算属性(视图截取 + 状态判断)

computed: {
  /** 当前已加载的设备列表(懒加载切片) */
  displayDeviceList() {
    const end = this.devicePageSize * this.deviceCurrentPage
    return this.deviceList.slice(0, end)
  },
  /** 是否还有更多设备可加载 */
  hasMoreDevices() {
    return this.displayDeviceList.length < this.deviceList.length
  }
}

关键点:使用 computed 而非手动维护一个 displayed 数组,确保数据源变化时自动响应更新。

4.3 哨兵元素(模板)

<!-- 设备网格容器 -->
<div class="dm-device-grid">
  <!-- 仅渲染 displayDeviceList 而非 deviceList -->
  <div v-for="device in displayDeviceList" :key="device.id" class="dm-device-card">
    <!-- 设备卡片内容 -->
  </div>
  <!-- 哨兵元素:仅在还有未加载数据时显示 -->
  <div v-if="hasMoreDevices" ref="sentinel" class="dm-lazy-sentinel">
    <i class="el-icon-loading" />
    <span>加载更多设备...</span>
  </div>
</div>

关键点v-if="hasMoreDevices" 确保数据全部加载后哨兵消失,Observer 自动停止触发。

4.4 IntersectionObserver 初始化

initObserver() {
  // 先断开旧观察器,防止重复绑定
  this.disconnectObserver()
  this.$nextTick(() => {
    const sentinel = this.$refs.sentinel
    if (!sentinel) return  // 哨兵不存在(数据已全部加载)

    this.observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) {
          this.loadMoreDevices()
        }
      },
      {
        rootMargin: '200px',  // 提前200px触发,用户无感知
        threshold: 0.1
      }
    )
    this.observer.observe(sentinel)
  })
}

关键参数说明

  • rootMargin: '200px':哨兵距离视口还有 200px 时就触发回调,提前加载数据,实现 无感加载
  • threshold: 0.1:哨兵 10% 可见时即触发

4.5 加载更多 & 清理

/** 加载更多设备 */
loadMoreDevices() {
  if (!this.hasMoreDevices) return
  this.deviceCurrentPage++
  // 页码增加 → displayDeviceList 自动重新计算 → DOM 更新
  // Vue 响应式保证了这一链条无需手动操作
},

/** 断开观察器(组件销毁 / 切换组织时调用) */
disconnectObserver() {
  if (this.observer) {
    this.observer.disconnect()
    this.observer = null
  }
}

4.6 数据加载后重置

async loadDeviceList(organizationId) {
  this.deviceLoading = true
  try {
    const res = await fetchDeviceStatusList(params)
    this.deviceList = res.data.data || []
    // 重置懒加载分页
    this.deviceCurrentPage = 1  // 初始加载第一页
    this.$nextTick(() => {
      this.initObserver()  // 重新绑定观察器
    })
  } finally {
    this.deviceLoading = false
  }
}

4.7 生命周期钩子

mounted() {
  // ...其他初始化
  this.$nextTick(() => {
    this.initObserver()
  })
},
beforeDestroy() {
  // 清理 Observer,防止内存泄漏
  this.disconnectObserver()
}

五、数据流转全流程

用户滚动页面
    │
    ▼
IntersectionObserver 检测哨兵进入视口(提前200px)
    │
    ▼
回调触发 → loadMoreDevices()
    │
    ▼
deviceCurrentPage++ (1→2→3...)
    │
    ▼
displayDeviceList (computed) 自动重新计算
    slice(0, 15*2) → slice(0, 15*3) → ...
    │
    ▼
Vue 响应式更新 DOM(新增15个卡片)
    │
    ▼
哨兵元素被推到更下方
    │
    ▼
Observer 继续监听新位置的哨兵
    │
    ... 重复直到 hasMoreDevices === false
    │
    ▼
v-if="hasMoreDevices" = false → 哨兵从DOM移除
    │
    ▼
Observer 无目标 → 自动不再触发

六、方案优势总结

对比维度 传统 scroll 事件 本方案 (IntersectionObserver)
性能 滚动时高频触发,需 throttle/debounce 浏览器底层异步回调,零性能损耗
代码复杂度 需手动计算元素位置 getBoundingClientRect 声明式配置 rootMargin/threshold
兼容性 全兼容 IE 不支持,现代浏览器均支持
触发精度 节流后可能延迟或重复触发 精确触发一次,无重复

额外优点

  • 零依赖:纯浏览器原生 API,无需引入第三方库(如 vue-virtual-scroller)
  • 低侵入:仅需修改数据切片逻辑 + 添加哨兵元素,不改动现有卡片组件
  • 提前加载:通过 rootMargin 提前 200px 触发,用户几乎感知不到加载过程
  • 自动停止:数据全部加载后哨兵自动移除,Observer 不再触发

七、注意事项与踩坑

  1. $nextTick 必不可少initObserver 中获取 $refs.sentinel 必须在 DOM 更新后执行,所以需要 $nextTick 包裹
  2. 重置时机:切换组织 / 重新加载数据时,必须重置 deviceCurrentPage 并重新 initObserver
  3. 内存泄漏beforeDestroy 中务必调用 disconnectObserver() 清理
  4. Grid 布局兼容:哨兵元素需设置 grid-column: 1 / -1 确保占满整行,不会被挤到某一列
  5. v-if 而非 v-show:哨兵使用 v-if 控制而非 v-show,这样数据全部加载后哨兵完全从 DOM 移除,Observer 自然不再触发

「性能优化」虚拟列表极致优化实战:从原理到源码,打造丝滑滚动体验

2026年4月15日 14:35

前言

大家好,我是elk。

上篇文章我们聊了大文件的切片上传,这次再来看看另一个高频性能优化场景 —— 虚拟列表(Virtual List)

什么是虚拟列表?

虚拟列表「Virtual List」是一种前端性能优化技术,用于解决"长列表渲染"场景下,因DOM节点过多导致的页面卡顿,内存占用率高,首屏加载缓慢等问题。

核心思想是:只渲染当前视口可见的列表项,而非渲染全部列表数据。通过动态计算视口位置,复用DOM节点,实现"无限列表"的流畅渲染。

为什么需要虚拟列表?

在处理大数据量列表时,传统的渲染方式会面临两大瓶颈:

  1. DOM 节点过载:浏览器渲染 10,000 个复杂的 DOM 节点,内存消耗巨大。
  2. 布局与重绘:滚动时,大量的 DOM 节点重绘会导致帧率下降,产生明显的掉帧(Jank)。

适用业务场景

  • 大数据量列表渲染:后台管理系统的用户列表、日志列表、权限列表、数据报表等,数据量超1000条,全量渲染直接导致页面卡死、操作无响应。
  • 无限滚动场景:移动端信息流、商品列表、评论区、下拉选择器,用户持续下拉加载数据,DOM节点无限累加,最终引发页面崩溃。
  • 固定容器滚动列表:所有需要在固定高度容器内展示超长列表的业务场景。

核心原理

  • 视口计算:获取容器的可视高度,滚动距离,确定当前"可见区域"的范围
  • 数据截取:根据可见范围,计算需要渲染的列表项的起始索引和结束索引,从全部数据中截取范围内的数据,仅渲染截取后的可视数据
  • 偏移量计算:通过定位设置渲染区域的偏移量,让截取的数据精准的显现在视口内,模拟"滚动到指定位置的效果"
  • DOM复用:当滚动时,动态改变起始索引和结束索引,截取新的可视化数据,复用已渲染的DOM节点,减少DOM操作的开销

核心基础概念

  • 视口容器:用于展示列表的容器,用户的可见区域,通常设置为固定高度和overflow: auto
  • 列表项高度:单个列表项的高度,通常分为:"固定高度"和"动态高度"
  • 可见数量:可见区域中要展示的列表数量总个数,计算公式:Math.cell(视口高度 / 列表项高度)
  • 缓冲数量:在可见区域上下额外多渲染的数量,用于解决滚动时的"空白闪烁"问题。
  • 总高度:所有列表项的总高度,用于撑开容器,模拟长列表滚动(不设置,容器无法滚动)

核心知识点

主要是涉及到事件监听以及基础数据的计算和更新

基础知识点

滚动事件监听

通过监听容器的scoll事件,获取滚动距离(scrollTop),触发可见区域、起始索引、结束索引、可见列表、偏移量距离的计算

避免频繁触发滚动事件,需使用节流进行优化,避免过量计算损失性能

尺寸计算

  • 视口高度:可通过容器的「clientHeight」获得,一般定义固定高度
  • 滚动距离:通过容器滚动事件触发获得「scrollTop属性」
  • 固定高度:无需计算,自行设置的高度「itemHeight」
  • 动态高度:当容器滚动时,动态计算列表项的高度「clientHeight」,并列入缓存中

索引计算

起始索引「startIndex」

固定高度

index = Math.floor(scrollTop / ITEM_HEIGHT) 「滚动距离 / 固定单个项高度」

startIndex = Math.max(0, index - bufferCount) 「 减去缓冲个数获取真实起始索引 」

动态高度:需通过"累计高度"计算startIndex「遍历缓存的高度列表,通过二分法查找到大于等于scrollTop滚动距离的索引」

结束索引「endIndex」

index = startIndex + visibiliItemsCount + bufferCount 「起始索引 + 可见区域列表数量 + 缓冲量」

endIndex = Math.min( list.length, index )

偏移量计算

固定高度

    top = startIndex * ITEM_HEIGHT 「起始索引 * 单个项固定高度」

动态高度

top = prefixSumCache[startIndex] 「从高度缓存列表中获取当前起始索引的数据」

进阶知识点

在基础知识点上进行的优化措施,提升列表性能,优化用户体验

缓冲机制

当用户快速滚动时,如果是仅渲染可见区域内的数据,会出现"空白区域",数据未及时渲染

  • 缓存量设置1-5个,过多会增加DOM数量,削弱优化效果
  • 上方偏移量计算 startIndex + bufferCount , endIndex - bufferCount,就是确保上下都有缓冲

动态高度缓存与更新

在动态高度场景下,初始化时不知道每一项的真实高度,常见优化策略:

  • 先进行预估高度的渲染,渲染后通过nextTick获取真实高度
  • 将真实高度写入缓存,并重新计算前缀和
  • 后续滚动时,当实际高度和初始化缓存高度不匹配的时候才重新计算一次高度缓存

滚动事件节流

在滚动事件 handelScroll中使用了ticking锁和requestAnimationFrame

  • 滚动事件触发非常频繁,使用RAF可以确保浏览器在下一帧重绘前执行计算逻辑,避免掉帧,使滚动更平滑

二分查找优化索引定位

在动态高度场景下,需要根据 scrollTop 找到起始索引。如果每次都线性查找,时间复杂度 O(n)。利用 前缀和数组的单调递增特性,使用二分查找可将复杂度降至 O(log n)。

整体代码 —— 组件封装(Vue 3 + TypeScript)

以下是一个支持 动态高度缓冲区高度缓存二分查找 的完整虚拟列表组件。

<template>
  <div
    @scroll="handleScroll"
    ref="containerRef"
    :style="{ height: `${height}px` }"
    class="w-full position-relative top-0 left-0 overflow-auto"
  >
    <!-- 空状态 -->
    <div v-if="data.length === 0" class="w-full h-full flex items-center justify-center">
      <slot name="empty" />
    </div>
    <!-- 占位撑高容器 -->
    <template v-else>
      <div
        :style="{ height: `${containerHeight}px` }"
        class="w-full position-absolute top-0 left-0"
      ></div>
      <!-- 可视化容器 -->
      <div
        :style="{ transform: `translateY(${offset}px)` }"
        class="w-full position-absolute top-0 left-0"
      >
        <div
          v-for="(item, index) in visibleList"
          :key="item.id || index"
          ref="itemRef"
          :style="{ height: `${itemHeight}px` }"
          class="w-full flex items-center justify-center"
        >
          <slot name="default" :item="item" :index="index + startIndex" />
        </div>
      </div>
    </template>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, computed, nextTick, watchEffect } from 'vue'
import type { PropType } from 'vue'

interface ListItem {
  id: number | string
  name: string
}

interface PropsParams {
  // 列表数据
  data: ListItem[]
  // 容器高度
  height: number
  // 项高度-预估高度
  itemHeight: number
  // 缓冲区数量
  bufferCount: number
}
const props: PropsParams = defineProps({
  data: {
    type: Array as PropType<ListItem[]>,
    default: () => [],
    required: true,
  },
  height: {
    type: Number,
    default: 250,
  },
  itemHeight: {
    type: Number,
    default: 50,
  },
  bufferCount: {
    type: Number,
    default: 5,
  },
})

// 容器ref
const containerRef = ref<HTMLDivElement>()
// 项ref
const itemRef = ref<HTMLDivElement[]>([])
// 滚动距离
const scrollTop = ref(0)

// 项高度-缓存集合
const itemHeightCache = ref<number[]>([])
// 前缀和-缓存集合
const prefixSumCache = ref<number[]>([])

// 可视化容器-开始索引
const startIndex = computed(() => {
  const index = getStartIndex(scrollTop.value)
  return Math.max(0, index - props.bufferCount)
})

// 可视化容器-结束索引
const endIndex = computed(() => {
  const index = startIndex.value + visibleCount.value + props.bufferCount * 2
  return Math.min(props.data.length, index)
})

// 撑开容器-高度
const containerHeight = computed(() => {
  return prefixSumCache.value[prefixSumCache.value.length - 1]
})
// 可视化容器-列表数量
const visibleCount = computed(() => {
  return Math.ceil(props.height / props.itemHeight)
})

// 可视化容器-渲染列表
const visibleList = computed(() => {
  return props.data.slice(startIndex.value, endIndex.value)
})

// 偏移量-计算
const offset = computed(() => {
  return prefixSumCache.value[startIndex.value]
})

/**
 * @description: 二分法-计算初始索引
 * @return {*}
 */
const getStartIndex = (scrollTop: number) => {
  let left = 0
  let right = prefixSumCache.value.length - 1
  while (left <= right) {
    const mid = Math.floor((left + right) / 2)
    if (prefixSumCache.value[mid] === scrollTop) return mid
    if (prefixSumCache.value[mid] > scrollTop) {
      right = mid - 1
    } else {
      left = mid + 1
    }
  }
  return left
}

/**
 * @description: 初始化高度
 * @return {*}
 */
const initHeight = () => {
  try {
    // 初始化项高度缓存集合
    itemHeightCache.value = props.data.map(() => props.itemHeight)
    // 初始化前缀和缓存集合
    initPrefixSum()
  } catch (error) {
    console.error('初始化高度失败:', error)
  }
}

/**
 * @description: 初始化|修改 前缀和缓存集合
 * @return {*}
 */
const initPrefixSum = (index: number = 0) => {
  try {
    prefixSumCache.value = []
    let sum = 0
    // 计算前缀和缓存集合,从索引开始计算,直到列表结束
    itemHeightCache.value.forEach((item, i) => {
      if (i >= index) {
        prefixSumCache.value.push(sum)
        sum += item
      }
    })
  } catch (error) {
    console.error('初始化前缀和缓存集合失败:', error)
  }
}

/**
 * @description: 修改项的真实高度-当高度发生变化时才更新
 * @return {*}
 */
const updateItemHeight = async () => {
  try {
    await nextTick()
    const visibleItems = itemRef.value
    if (visibleItems.length === 0) return
    let hasHeightChanged = false
    visibleItems.forEach((el, index) => {
      if (el) {
        const itemIndex = index + startIndex.value
        const itemHeight = el.clientHeight
        // const itemHeight = el.getBoundingClientRect().height
        // 只有高度变化的时候才更新缓存
        if (itemHeight !== itemHeightCache.value[itemIndex]) {
          itemHeightCache.value[itemIndex] = itemHeight
          hasHeightChanged = true
        }
        if (hasHeightChanged) {
          initPrefixSum(itemIndex)
        }
      }
    })
  } catch (error) {
    console.error('更新项目高度失败:', error)
  }
}

/**
 * @description: 处理滚动事件
 * @return {*}
 */
let ticking = false
const handleScroll = () => {
  console.log('🚀 ~ handleScroll ~ containerRef: 触发了滚动事件')
  if (!ticking) {
    requestAnimationFrame(() => {
      if (containerRef.value) {
        scrollTop.value = containerRef.value?.scrollTop || 0
        updateItemHeight()
      }
      ticking = false
    })
    ticking = true
  }
}

// 监听数据变化-更新项高度
watchEffect(() => {
  if (props.data.length > 0) {
    initHeight()
    updateItemHeight()
  }
})

// 初始化-更新项高度
onMounted(() => {
  initHeight()
  updateItemHeight()
})
</script>

<style lang="css" scoped></style>

常见问题 & 最佳实践

Q1:为什么我的虚拟列表在快速滚动时还是会白屏?

  • 缓冲区太小:适当增加 bufferCount(比如从 2 提升到 5)。
  • 动态高度更新不及时:确保在 nextTick 后获取真实高度,并重新计算前缀和。
  • 未使用 requestAnimationFrame:滚动回调中的 DOM 操作可能被延迟,导致渲染跟不上。

Q2:动态高度组件中,prefixSum 的维护很容易出错,有什么建议?

推荐使用 长度 = n+1 的前缀和数组,其中 prefixSum[0] = 0prefixSum[i] 表示前 i 项的总高度。这样:

  • 第 i 项的偏移量 = prefixSum[i]
  • 总高度 = prefixSum[n]
  • 查找 scrollTop 对应索引时,二分查找第一个大于 scrollTop 的 prefixSum[i],然后 i-1 即为起始索引。

Q3:如何支持列表项内容动态变化(比如展开/收起)?

  • 监听内容变化,调用 updateRealHeights 重新测量受影响的项。
  • 如果是通过用户交互(如点击展开),可以手动触发更新并重新构建前缀和。

Q4:除了 transform 偏移,还有别的方案吗?

也可以使用 padding-top 偏移,但 transform 性能更好(不触发重排)。推荐使用 translateY

总结

虚拟列表是前端性能优化中 性价比极高 的一类技术 —— 实现成本可控,却能将万级列表的渲染性能从秒级降到毫秒级。本文从原理到代码,覆盖了固定高度、动态高度、缓冲区、二分查找、滚动节流等关键点。

优化永无止境,如果你还想更进一步,可以探索:

  • 使用 ResizeObserver 监听每一项的尺寸变化,自动更新高度缓存。
  • 结合 IntersectionObserver 实现可视区外图片懒加载。
  • 将虚拟列表与 分页 / 懒加载数据 结合,实现真正意义上的“无限滚动”。

希望这篇文章能帮你彻底掌握虚拟列表,写出更流畅的 Web 应用。如果觉得有帮助,欢迎点赞、评论、转发~

别再用 JSON.parse 深拷贝了,聊聊 StructuredClone

作者 ErpanOmer
2026年4月15日 09:58

临近下班,我们业务线出了一个极度无语的线上 Bug。

产品侧反馈,在一个非常核心的财务表单里,用户明明选择了 2026-04-14 作为结算日期,但点击提交后,整个页面直接白屏崩溃。

我打开错误监控看了一眼日志,立刻就把组里那个刚入职不久的小伙子叫了过来。 原因极其经典:他在把表单的原始状态同步给历史快照时,为了图省事,顺手写了一段几乎所有前端都写过的代码:

// 模拟用户表单数据
const formData = {
  amount: 1000,
  date: new Date("2026-04-14"), // 用户选的结算日期(Date对象)
};

// 深拷贝
const snapshot = JSON.parse(JSON.stringify(formData));

console.log("原始:", formData.date, typeof formData.date); 
// Date object

console.log("快照:", snapshot.date, typeof snapshot.date); 
// "2026-04-14T00:00:00.000Z" string

// 后续业务代码
function calcSettlementTime(data) {
  // 这里默认 date 是 Date 对象
  return data.date.getTime();
}

// 页面直接崩溃😢
try {
  const time = calcSettlementTime(snapshot);
  console.log("时间戳:", time);
} catch (err) {
  console.error("页面崩溃:", err);
}

他满脸委屈:老大,大家平时深拷贝不都是这么写的吗?🤷‍♂️

我让他自己把这段代码在控制台跑一遍。 当他看到表单里原本好好的 Date 对象,经过这一进一出,硬生生变成了一串 ISO 格式的字符串,导致后面调用 snapshot.date.getTime() 直接抛出 TypeError 时,他自己也沉默了。

作为前端老油条,这种因为 JSON.parse(JSON.stringify()) 引发的血案,我见过太多了。 它不仅会把 Date 变成字符串,还会把 MapSet 变成空对象 {},会把 undefinedSymbol 以及函数直接活生生抹除,更别提遇到循环引用时,它会当场抛出异常让你的主线程直接崩溃。

以前,我们为了解决这个破事,不得不在每个项目里老老实实 npm install lodash,然后引入那个笨重的 cloneDeep

但现在是 2026 年了。浏览器早就原生内置了完美的终极解药——structuredClone

今天咱们不聊虚的架构,就花三分钟,把这个原生 API 的底层逻辑讲清楚。


它是怎么解决历史遗留问题的?

structuredClone 不是什么语法糖,它是浏览器底层暴露出来的 结构化克隆算法(Structured Clone Algorithm)。这就意味着,它在 C++ 引擎层面的处理逻辑,远比 JS 业务层面的递归拷贝要深得多。

看一下原生 API 的用法:

const original = {
  date: new Date(),
  set: new Set([1, 2, 3]),
  map: new Map([['key', 'value']]),
  regex: /hello/i,
  buffer: new Uint8Array([1, 2, 3]).buffer,
};

// 制造一个循环引用
original.self = original;

// 一行代码,原生搞定
const cloned = structuredClone(original);

console.log(cloned.date instanceof Date); // true
console.log(cloned.set instanceof Set);   // true
console.log(cloned.self === cloned);      // true 完美处理循环引用!

发现没有?它不仅完美保留了所有的内置对象类型,连 JSON.parse 绝对搞不定的循环引用,它都处理得游刃有余。由于是在引擎底层运行,不需要像 Lodash 那样在 JS 运行时里疯狂压栈递归,它的执行效率在大部分复杂场景下都具有压倒性优势👍👍👍。


零拷贝转移 (Transferable Objects)

如果你以为 structuredClone 只是为了少引入一个 Lodash,那你就太小看浏览器的底层野心了。

它藏着一个 90% 的前端都不知道的极其硬核的功能:内存转移(Transfer)

在前端处理音视频、WebGL、或者读取几十 MB 的大文件时,我们经常会生成巨大的 ArrayBuffer。如果你用传统的深拷贝,内存瞬间翻倍,几十兆的内存分配极容易引起页面的掉帧卡顿。

structuredClone 提供了一个极其变态的第二个参数配置:{ transfer }

// 假设这是一个极大的 50MB 数据内存块
const u8Array = new Uint8Array(1024 * 1024 * 50);
const hugeBuffer = u8Array.buffer;

// 传统的深拷贝:内存翻倍,耗时极长
// const badCopy = lodash.cloneDeep(hugeBuffer); 

// 直接内存转移
const fastClone = structuredClone(hugeBuffer, { transfer: [hugeBuffer] });

console.log(fastClone.byteLength); // 52428800 (50MB 完美转移)
console.log(hugeBuffer.byteLength); // 0 (原对象的内存地址被转移)

这段代码的核心在于:它压根没有复制数据。 它直接在内存层面,把这块 50MB 数据的所有权,从 hugeBuffer 强行转移给了 fastClone。原对象被彻底掏空(变成了 detached 状态)。

这种零拷贝机制,在结合 Web Worker 处理复杂后台计算时,是打破性能瓶颈的绝对神器。这是任何第三方 JS 库都做不到的底层API。


一些坑要讲清楚🤔

既然这么牛,是不是以后项目里所有的拷贝闭着眼睛用它就行了? 作为一个踩过无数坑的老兵,我必须点出它的几个致命死角。如果你在真实的业务架构里滥用,下场比用 JSON.parse 还要惨。

对于函数和 DOM 节点的处理

JSON.parse 遇到函数,它会默默地忽略掉,至少不报错。 但 structuredClone 很直接。只要你的对象树里藏着一个方法,或者藏着一个 DOM 节点的引用,它会直接给你抛出 DataCloneError

const objWithFunc = {
  data: 123,
  onClick: () => console.log('click')
};

// 只要带有函数,直接抛同步错误
// DOMException: () => console.log('click') could not be cloned.
const copy = structuredClone(objWithFunc); 

这就意味着,如果你要拷贝的是一个 Vue/React 的响应式组件实例,或者是带有业务方法的数据模型,绝对不能用它👋。

原型链的断裂

不管你原本是一个通过 class 实例化的多么高级的业务对象,经过 structuredClone 的洗礼后,它都会变成一个普通的纯对象(Plain Object)。

class User {
  constructor(name) { this.name = name; }
  sayHi() { console.log('hi'); }
}

const user = new User('前端');
const cloneUser = structuredClone(user);

console.log(cloneUser instanceof User); // false 
cloneUser.sayHi(); // TypeError: cloneUser.sayHi is not a function

原型链上的所有方法全部丢失。它只关心纯粹的数据,不关心你的面向对象架构。‘


需要时收藏起来⭐⭐⭐

这几年,前端的工具链卷得飞起,大家的 package.json 越来越臃肿。遇到数组去重找库,遇到时间格式化找库,遇到深拷贝也要找库。

如果你只是单纯地处理一些后端传过来的嵌套数据,或者表单的复杂配置结构,完全可以直接把 structuredClone 敲在你的代码里。不用担心兼容性,目前主流浏览器(包括 Node.js)的支持率早就达到了工业级使用的标准了。

image.png

下次 Code Review 时,别再让我看到满屏的 JSON.parse 了 (玩笑😁😁😁)。

分享完毕,谢谢大家🙌

Suggestion.gif

你的 Vue 3 生命周期,VuReact 会编译成什么样的 React?

作者 Ruihong
2026年4月15日 09:22

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 中常见的生命周期钩子经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

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

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 3 中生命周期钩子例如 onMounted、onBeforeMount、onUpdated、onBeforeUpdate、onBeforeUnmount、onUnmounted 的 API 用法与核心行为。

编译对照

Vue onMounted() → React useMounted()

onMounted 是 Vue 3 中用于组件首次挂载后执行逻辑的生命周期钩子,适合放初始化请求、订阅启动、DOM 相关准备等操作。VuReact 会将它编译为 useMounted,让 React 端也能在组件挂载后执行一次性副作用。

  • Vue 代码:
<script setup>
  import { onMounted } from 'vue';

  onMounted(() => {
    console.log('组件已挂载');
  });
</script>
  • VuReact 编译后 React 代码:
import { useMounted } from '@vureact/runtime-core';

useMounted(() => {
  console.log('组件已挂载');
});

从示例可以看到:Vue 的 onMounted() 被翻译为 useMounted。VuReact 提供的 useMountedonMounted 的适配 API完全模拟 Vue onMounted 的首次挂载后执行时机

Vue onBeforeMount() → React useBeforeMount()

onBeforeMount 是 Vue 3 中用于组件挂载前执行逻辑的钩子,适合放需要在布局阶段之前准备的内容。VuReact 会将它编译为 useBeforeMount,基于 React 的布局效果在挂载前执行。

  • Vue 代码:
<script setup>
  import { onBeforeMount } from 'vue';

  onBeforeMount(() => {
    console.log('组件即将挂载');
  });
</script>
  • VuReact 编译后 React 代码:
import { useBeforeMount } from '@vureact/runtime-core';

useBeforeMount(() => {
  console.log('组件即将挂载');
});

VuReact 提供的 useBeforeMountonBeforeMount 的适配 API完全模拟 Vue onBeforeMount 的首次挂载前时机

Vue onBeforeUpdate() → React useBeforeUpdate()

onBeforeUpdate 是 Vue 3 中用于跳过首次挂载,仅在组件更新前执行的钩子,适合放变更前校验、记录旧值、提前准备等逻辑。VuReact 会将它编译为 useBeforeUpdate,并支持依赖数组以控制触发时机。

  • Vue 代码:
<script setup>
  import { reactive, onBeforeUpdate } from 'vue';

  const state = reactive({ count: 0 });

  onBeforeUpdate(() => {
    console.log('更新前,当前 count:', state.count);
  });
</script>
  • VuReact 编译后 React 代码:
import { useReactive, useBeforeUpdate } from '@vureact/runtime-core';

const state = useReactive({ count: 0 });

useBeforeUpdate(
  () => {
    console.log('更新前,当前 count:', state.count);
  },
  [state.count],
);

从示例可以看到:Vue 的 onBeforeUpdate() 被翻译为 useBeforeUpdate。VuReact 提供的 useBeforeUpdateonBeforeUpdate 的适配 API完全模拟 Vue onBeforeUpdate 的更新前触发时机。当 React 对应 API 需要依赖数组时,deps 数组可用于只在指定值变化时触发,VuReact 会在编译阶段自动分析依赖并映射到对应依赖数组,避免开发者手动管理依赖

Vue onUpdated() → React useUpdated()

onUpdated 是 Vue 3 中用于组件更新后执行逻辑的钩子,适合放读取最新渲染结果、执行后续同步等操作。VuReact 会将它编译为 useUpdated,并支持可选依赖数组来精确控制触发条件。

  • Vue 代码:
<script setup>
  import { reactive, onUpdated } from 'vue';

  const state = reactive({ count: 0 });

  onUpdated(() => {
    console.log('组件更新后,count:', state.count);
  });
</script>
  • VuReact 编译后 React 代码:
import { useReactive, useUpdated } from '@vureact/runtime-core';

const state = useReactive({ count: 0 });

useUpdated(
  () => {
    console.log('组件更新后,count:', state.count);
  },
  [state.count],
);

VuReact 提供的 useUpdatedonUpdated 的适配 API完全模拟 Vue onUpdated 的更新后执行时机。如果 React API 使用 deps 数组,VuReact 会自动分析依赖并生成对应的数组,无需开发者手动维护依赖

Vue onBeforeUnmount() → React useBeforeUnMount()

onBeforeUnmount 是 Vue 3 中用于组件卸载前执行的钩子,适合放动画停止、资源解绑、日志上报等清理前逻辑。VuReact 会将它编译为 useBeforeUnMount,在卸载前执行。

  • Vue 代码:
<script setup>
  import { onBeforeUnmount } from 'vue';

  onBeforeUnmount(() => {
    console.log('组件即将卸载');
  });
</script>
  • VuReact 编译后 React 代码:
import { useBeforeUnMount } from '@vureact/runtime-core';

useBeforeUnMount(() => {
  console.log('组件即将卸载');
});

VuReact 提供的 useBeforeUnMountonBeforeUnmount 的适配 API完全模拟 Vue onBeforeUnmount 的卸载前时机

Vue onUnmounted() → React useUnmounted()

onUnmounted 是 Vue 3 中用于组件卸载时执行逻辑的钩子,适合放最终资源释放、异步取消、上报日志等收尾逻辑。VuReact 会将它编译为 useUnmounted,在组件卸载时执行。

  • Vue 代码:
<script setup>
  import { onUnmounted } from 'vue';

  onUnmounted(() => {
    console.log('组件已卸载');
  });
</script>
  • VuReact 编译后 React 代码:
import { useUnmounted } from '@vureact/runtime-core';

useUnmounted(() => {
  console.log('组件已卸载');
});

VuReact 提供的 useUnmountedonUnmounted 的适配 API完全模拟 Vue onUnmounted 的卸载时机

🔗 相关资源

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

当 Vue 3 遇上桥接模式:手把手教你优雅剥离虚拟滚动的业务大泥球

作者 guojb824
2026年4月14日 22:20

当 Vue 3 遇上桥接模式:手把手教你优雅剥离虚拟滚动的业务大泥球

摘要:本文结合 Vue3 $attrs 特性与桥接模式,详细解析如何优雅解耦虚拟滚动容器与复杂业务组件。通过拆分抽象与实现,实现属性事件无缝透传,告别臃肿代码。

在企业级前端开发中,长列表渲染是一个永远绕不开的性能瓶颈。在 Vue 技术栈中,我们经常会使用 vue3-virtual-scroll-list 这样的第三方库来实现虚拟滚动,从而保证页面在面对万条甚至十万条数据时依然如丝般顺滑。

但是,随着业务复杂度的提升,一个棘手的设计问题往往会浮出水面:如何在使用第三方虚拟滚动库时,优雅地实现基础组件与业务组件的解耦与隔离?

今天,我们就来详细拆解这个场景,并探讨在 Vue 3 下利用 $attrs 透传机制实现完美隔离的设计思路。

一、 场景痛点与需求分析

想象一下这样一个典型的开发场景:

你正在负责一个大型后台管理系统。项目中有多处需要用到虚拟滚动列表:有的是简单的文本日志列表,有的是复杂的商品卡片列表,还有的是带有各种交互按钮(点赞、删除、编辑)的用户评论列表。

为了复用代码,你决定封装一个基础虚拟滚动组件(VirtualScrollerBasic),它负责引入第三方库,设定预估高度。同时,你还需要一个基础列表项组件(ItemBasic),它负责最基本的数据渲染和样式布局。

但是,业务部门的需求是千变万化的:

  • 场景 A 的商品列表需要传入一个特殊的业务参数 customText 来显示促销信息。
  • 场景 B 的评论列表需要在点击时触发一个专属的业务事件 @customEvent
  • 场景 C 的日志列表需要在每一项的底部插入一段自定义的 DOM 结构(使用插槽)。

如果直接在基础组件里把这些业务参数和事件全部写死,基础组件就会变得无比臃肿,甚至最终沦为一个不可维护的“大泥球”。

我们的核心诉求是:基础列表和基础 Item 只关心自己该关心的事情(比如基础的布局、基础的数据 source),而业务列表和业务 Item 可以自由地增加属性、监听事件、甚至传递插槽,且这一切对基础组件来说必须是“无感”的。

二、 方案设计思路:桥接模式与职责分离

为了解决上述痛点,我们需要引入**桥接模式(Bridge Pattern)**的思想。

桥接模式的核心是“将抽象部分与实现部分分离,使它们都可以独立地变化”。在虚拟滚动的场景中:

  • 抽象部分(Abstraction):是列表的容器(如 VirtualScrollerBasic),负责虚拟滚动的核心机制、数据调度和预估高度计算。
  • 实现部分(Implementor):是具体的列表项渲染器接口,负责单条数据的 UI 展示和交互。

这两部分通过一个“桥梁”(即动态传入的 listComponent 属性)连接起来。在此基础上,业务组件只需要处理自己的业务逻辑,剩下的不属于自己范围的基础属性和事件,通过 Vue 3 的 $attrs(在组合式 API 中通过 useAttrs() 获取)完美透传给基础组件。

Vue 3 的 $attrs 有一个非常棒的特性:它不仅包含了外部传入的非 Props 属性,还包含了绑定的事件(自动转化为 onXxx 形式)。这为我们实现属性和事件的跨层透传提供了天然的便利。

设计架构图

classDiagram
    class VirtualScrollerBasic {
        +items: Array
        +listComponent: Component
        +basicText: String
        +render()
    }

    class VirtualScrollerList {
        +customText: String
        +handleCustomEvent()
        +render()
    }

    class ItemBasic {
        +index: Number
        +basicText: String
        +render()
    }

    class Item {
        +customText: String
        +handleEvent()
        +render()
    }

    VirtualScrollerBasic o-- ItemBasic : Bridge (通过 listComponent 桥接)
    VirtualScrollerBasic <|-- VirtualScrollerList : 扩展 (组合包裹)
    ItemBasic <|-- Item : 扩展 (组合包裹)

三、 Vue 3 下的代码实现

让我们来看看这套设计模式在 Vue 3 中是如何落地的。

1. 基础列表组件 (VirtualScrollerBasic.vue)

基础列表组件的职责是封装第三方库 vue3-virtual-scroll-list,并且负责将外部传入的 $attrs 整合后向下透传。

<template>
  <div class="virtual-scroller-container">
    <virtual-list
      class="virtual-list"
      :data-key="'id'"
      :data-sources="items"
      :data-component="listComponent"
      :estimate-size="50"
      :extra-props="{
        // 关键点1:合并透传所有外部传入的业务属性和业务事件(onXxx)
        ...attrs,
        // 关键点2:基础层私有的参数和事件,互不干扰
        basicText: '这是基础层参数',
        onBasicEvent: handleBasicEvent,
      }"
    >
      <template #header>
        <slot name="header"></slot>
      </template>
    </virtual-list>
  </div>
</template>

<script setup>
import { ref, useAttrs } from "vue";
import VirtualList from "vue3-virtual-scroll-list";
import ItemBasic from "./ItemBasic.vue";

const attrs = useAttrs();

const props = defineProps({
  listComponent: {
    type: Object,
    default: () => ItemBasic,
  },
});

// 关键点3:阻止属性直接绑定到根节点 div 上,防止 DOM 污染和事件重复触发
defineOptions({
  inheritAttrs: false,
});

const items = ref([
  /* 模拟数据 */
]);
const handleBasicEvent = (source) => {
  console.log("基础事件触发");
};
</script>

2. 基础 Item 组件 (ItemBasic.vue)

基础 Item 组件负责渲染列表项的最基本信息。它只关心基础的 UI 和数据结构,不知道任何关于业务层的特殊参数。

<template>
  <div class="basic-item">
    <div class="basic-content">
      <span>#{{ index }} - ID: {{ source.id }}</span>
      <span v-if="basicText" class="basic-text">({{ basicText }})</span>
      <button @click="handleClick">触发基础事件</button>
    </div>
    <!-- 留出插槽供业务层扩展 -->
    <slot name="footer"></slot>
  </div>
</template>

<script setup>
const props = defineProps({
  source: {
    type: Object,
    required: true,
  },
  index: {
    type: Number,
    default: 0,
  },
  basicText: {
    type: String,
    default: "",
  },
});

const emit = defineEmits(["basicEvent"]);

const handleClick = () => {
  emit("basicEvent", props.source);
};
</script>

<style scoped>
.basic-item {
  padding: 10px;
  border-bottom: 1px solid #eee;
}
.basic-text {
  color: #888;
  margin-left: 10px;
}
</style>

3. 业务 Item 组件 (Item.vue)

业务 Item 组件的职责是拦截并消费属于业务层的属性(customText)和事件(customEvent),并将剩下的属性通过 v-bind 透传给基础 Item 组件。

<template>
  <div class="custom-item">
    <!-- 关键点1:v-bind="attrs" 将没被当前组件消费的属性和事件透传给基础组件 -->
    <item-basic v-bind="attrs" :source="source">
      <template #footer>
        <div class="custom-footer">
          自定义footer <span v-if="customText"> - {{ customText }}</span>
          <el-button type="primary" @click="handleEvent(source)">
            触发业务事件 customEvent
          </el-button>
        </div>
      </template>
    </item-basic>
  </div>
</template>

<script setup>
import { useAttrs } from "vue";
import ItemBasic from "./ItemBasic.vue";

const attrs = useAttrs();

// 关键点2:只声明业务层自己需要消费的属性。
// 如果业务层确实需要用到基础层的参数,就需要手动传给基础组件
const props = defineProps({
  source: { type: Object, required: true }, // 点击事件需使用,所以保留
  customText: { type: String, default: "" }, // 业务专属属性
});

const emit = defineEmits(["customEvent"]);

// 关键点3:同样需要阻止属性绑定到根节点
defineOptions({
  inheritAttrs: false,
});

const handleEvent = (source) => {
  // 触发业务层专属事件
  emit("customEvent", source);
};
</script>

4. 业务列表组件 (VirtualScrollerList.vue)

在最外层的业务列表中,我们就可以像使用普通组件一样,随心所欲地传递业务参数和监听业务事件了,底层的一切复杂透传对它来说都是透明的。

<template>
  <div class="virtual-scroller-list-wrapper">
    <virtual-scroller-basic
      :list-component="Item"
      :customText="'这是通过透传传入的业务参数 customText'"
      @customEvent="handleCustomEvent"
    >
      <template #header>
        <div class="custom-header">自定义业务 Header 内容</div>
      </template>
    </virtual-scroller-basic>
  </div>
</template>

<script setup>
import VirtualScrollerBasic from "./VirtualScrollerBasic.vue";
import Item from "./Item.vue";

const handleCustomEvent = (source) => {
  alert(`业务层成功拦截 customEvent,Item ID: ${source.id}`);
};
</script>

四、 Vue 3 与 Vue 2 的实现区别解析

如果你还在使用 Vue 2,或者刚从 Vue 2 迁移过来,可能会对上面的实现感到一些疑惑。这里有必要重点强调一下 Vue 3 和 Vue 2 在透传机制上的巨大差异。

Vue 2:属性与事件是分离的

在 Vue 2 中,组件的“属性”和“事件”是严格区分开的:

  • 传递的数据和非 Props 属性会被收集到 $attrs 中。
  • 通过 @v-on 绑定的事件会被收集到 $listeners 中。

所以在 Vue 2 中,如果你想把业务组件绑定的 @customEvent 透传给底层的 vue-virtual-scroll-listextra-props,你必须手动去遍历 $listeners,把它们转换成 onXxx 格式的函数,然后再和 $attrs 合并:

// Vue 2 下的 Hack 写法
computed: {
  mergedExtraProps() {
    const listenersAsProps = {};
    for (const eventName in this.$listeners) {
      const propName = 'on' + eventName.charAt(0).toUpperCase() + eventName.slice(1);
      listenersAsProps[propName] = this.$listeners[eventName];
    }
    return { ...this.$attrs, ...listenersAsProps };
  }
}

不仅如此,在 Vue 2 中,由于底层组件接收到的 extra-props 只能以 Props 的形式被子组件接收,为了让子组件能像普通组件一样响应 @事件,我们往往还需要引入一个中间包装组件(Wrapper),利用函数式组件将 onXxx 的 Props 重新还原为真正的 $listeners 并绑定到实际渲染的组件上。

例如,我们需要定义一个 VirtualScrollerItemWrapper.vue

<script>
// Vue 2 函数式组件 Wrapper
export default {
  name: "VirtualScrollerItemWrapper",
  functional: true,
  render(h, context) {
    const { props, data } = context;
    const originalComponent = props.originalComponent; // 真实的业务组件
    const attrs = {};
    const on = {};

    // 遍历 props,将 onXxx 还原为事件监听器
    for (const key in props) {
      if (key === "originalComponent") continue;

      if (key.startsWith("on") && typeof props[key] === "function") {
        const eventName = key.charAt(2).toLowerCase() + key.slice(3);
        on[eventName] = props[key];
      } else {
        attrs[key] = props[key];
      }
    }

    return h(originalComponent, {
      attrs,
      on, // 重新绑定事件
      scopedSlots: data.scopedSlots,
    });
  },
};
</script>

然后在基础滚动组件中,我们不能直接渲染业务组件,而是必须把这个 Wrapper 传给 vue-virtual-scroll-listdata-component 属性,并将实际的业务组件通过 extra-props 传进去:

<template>
  <virtual-list
    :data-key="'id'"
    :data-sources="items"
    :data-component="VirtualScrollerItemWrapper" <!-- 使用包装组件 -->
    :estimate-size="50"
    :extra-props="{
      ...mergedExtraProps,
      originalComponent: listComponent // 将真实的渲染组件传给包装器
    }"
  />
</template>

可以看到,在 Vue 2 中为了实现这一套隔离与透传机制,代码非常冗长且绕脑。

Vue 3:大一统的 $attrs

Vue 3 进行了一次非常优雅的底层重构。它移除了 $listeners 对象,将所有通过 @event 绑定的事件,在编译时自动转换成了以 onXxx 开头的属性名(例如 @custom-event 变成了 onCustomEvent),并且统一收集到了 $attrs

正因为 Vue 3 的这个特性,我们在 VirtualScrollerBasic 中只需要写一句 ...attrs,就同时完成了属性和事件的透传!这与 vue3-virtual-scroll-list 要求的 extra-props 接收对象的 API 设计简直是天作之合。

五、 运行效果与总结

当代码运行起来后,你会看到:

  1. 列表顶部正确渲染了“自定义业务 Header 内容”。
  2. 每一项都正确渲染了基础数据(如 #0)和基础参数(如 基础参数)。
  3. 每一项的 Footer 都正确渲染了业务参数 这是通过透传传入的业务参数 customText

image.png

  1. 点击“触发业务事件 customEvent”按钮,外层的业务列表组件成功弹出了 Alert 提示框,拦截到了事件。

image-1.png

image-2.png

总结:用到的设计模式

通过这次重构,我们实际上是在前端工程中落地了以下几种经典的设计模式:

  1. 桥接模式 (Bridge Pattern):这是本文的核心架构。将“虚拟滚动容器(抽象层)”与“列表项渲染(实现层)”彻底解耦。通过 listComponent 这一桥梁连接,使得业务列表可以随意更换基础滚动机制,业务项也可以在不同的列表中复用,两者独立变化。
  2. 装饰器模式 (Decorator Pattern) / 高阶组件模式 (HOC):业务 Item 没有去修改基础 ItemBasic 的内部代码,而是通过包裹对其进行了增强(增加了业务插槽和事件),并通过 $attrs 将基础属性完美透传。
  3. 模板方法模式 (Template Method Pattern)VirtualScrollerBasic 定义了虚拟滚动的算法骨架(如何引入库、设定高度等),具体的 UI 表现通过插槽和动态组件延迟到了业务层去实现。

优雅的架构设计,往往不需要多么高深的语法,而是对框架特性(如 $attrs)的深刻理解,以及对“单一职责”原则的坚守。希望这篇文章能为你在处理复杂 Vue 组件封装时带来一些启发!

Vue条件渲染详解:v-if、v-show用法与实战指南

作者 PeterMap
2026年4月14日 19:52

在Vue开发中,页面交互往往需要根据不同的状态展示不同的内容——比如用户登录后显示个人中心,未登录时显示登录按钮;表单验证失败时显示错误提示,成功时显示提交成功信息。这种“按需显示”的需求,Vue提供了一套简洁高效的条件渲染方案,核心就是v-if、v-else、v-else-if和v-show这几个指令。今天就从基础用法到进阶技巧,全方位拆解Vue条件渲染,帮你精准掌握不同场景下的最优使用方式,写出更灵活、可维护的前端代码。

核心指令:v-if 系列——“按需渲染”的核心

v-if 是Vue中最基础、最常用的条件渲染指令,它的核心作用是:根据绑定表达式的真假,决定是否渲染当前元素及它的子元素。当表达式为真值(Truthy)时,元素会被渲染到DOM中;当表达式为假值(Falsy)时,元素会被从DOM中移除,而非简单隐藏。

1. v-if 基础用法:单个条件判断

v-if 可以直接绑定一个响应式状态,实现简单的“显示/隐藏”切换。需要注意的是,v-if 是一个指令,必须依附于某个具体的DOM元素,不能单独使用。

实战示例:根据用户登录状态显示不同内容

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

// 模拟用户登录状态:true为已登录,false为未登录
const isLogin = ref(false);

// 模拟登录操作
function login() {
  isLogin.value = true;
}

// 模拟退出登录操作
function logout() {
  isLogin.value = false;
}
</script>

<template>
  <div class="user-section">
    <!-- 已登录时显示个人中心入口 -->
    <button v-if="isLogin" @click="logout">退出登录</button>
    <span v-if="isLogin" class="user-info">欢迎回来,用户123</span>
    
    <!-- 未登录时显示登录按钮 -->
    <button v-if="!isLogin" @click="login">立即登录</button>
  </div>
</template>

<style>
.user-section {
  margin: 20px 0;
  padding: 15px;
  border: 1px solid #eee;
}
.user-info {
  margin: 0 10px;
  color: #42b983;
}
button {
  padding: 6px 12px;
  cursor: pointer;
  border: none;
  border-radius: 4px;
  background: #42b983;
  color: white;
}
button:hover {
  opacity: 0.9;
}
</style>

image.png

image.png

2. v-else:补充v-if的“否则”场景

当v-if的条件不成立时,我们可以用v-else指令添加一个“否则”的渲染区块。需要特别注意的是,v-else 必须紧跟在v-if 或 v-else-if 元素之后,不能单独存在,也不能插入其他元素,否则Vue会无法识别。

实战示例:优化登录状态显示(用v-else简化代码)

<script setup>
import { ref } from 'vue';
const isLogin = ref(false);

function toggleLogin() {
  isLogin.value = !isLogin.value;
}
</script>

<template>
  <div class="user-section">
    <button @click="toggleLogin">{{ isLogin ? '退出登录' : '立即登录' }}</button>
    <!-- v-if 和 v-else 紧跟,实现互斥显示 -->
    <div v-if="isLogin" class="user-info">
      欢迎回来,用户123<br/>
      <a href="#">进入个人中心</a>
    </div>
    <div v-else class="login-tip">
      请登录后查看更多内容 😊
    </div>
  </div>
</template>

<style>
/* 样式沿用上面的基础样式,新增提示样式 */
.login-tip {
  margin-top: 10px;
  color: #666;
  font-size: 14px;
}
</style>

3. v-else-if:多条件分支判断

当需要判断多个条件分支时,v-else-if 可以实现“if-else if-else”的逻辑,它可以连续使用多次,最终用v-else收尾(可选)。同样,v-else-if 必须紧跟在v-if 或前一个v-else-if 元素之后。

实战示例:根据用户等级显示不同权限提示

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

// 模拟用户等级:0-普通用户,1-会员,2-管理员
const userLevel = ref(1);

// 切换用户等级
function changeLevel(level) {
  userLevel.value = level;
}
</script>

<template>
  <div class="level-section">
    <button @click="changeLevel(0)">普通用户</button>
    <button @click="changeLevel(1)">会员用户</button>
    <button @click="changeLevel(2)">管理员</button>
    
    <div class="level-tip" v-if="userLevel === 0">
      普通用户:可查看基础内容,解锁更多功能请升级会员
    </div>
    <div class="level-tip" v-else-if="userLevel === 1">
      会员用户:可查看专属内容,享受优先客服服务
    </div>
    <div class="level-tip" v-else-if="userLevel === 2">
      管理员:拥有全部操作权限,可管理所有用户数据
    </div>
    <div class="level-tip" v-else>
      未知用户等级,请联系管理员
    </div>
  </div>
</template>

<style>
.level-section {
  margin: 20px 0;
  padding: 15px;
  border: 1px solid #eee;
}
.level-tip {
  margin-top: 15px;
  padding: 10px;
  border-radius: 4px;
}
.level-tip:nth-child(2) { background: #f5fafe; color: #4299e1; }
.level-tip:nth-child(3) { background: #fdf2f8; color: #9f7aea; }
.level-tip:nth-child(4) { background: #eaf6fa; color: #38b2ac; }
.level-tip:nth-child(5) { background: #faf0f5; color: #e53e3e; }
</style>

4. template 上的v-if:批量切换多个元素

v-if 必须依附于单个DOM元素,但如果我们需要同时切换多个元素的显示/隐藏,又不想额外添加一个包裹容器(比如div),就可以在template标签上使用v-if。template只是一个不可见的包装器,最终渲染的结果不会包含这个标签,完美解决“多元素切换”的需求。

实战示例:批量切换表单提示信息

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

// 模拟表单提交状态:true为提交成功,false为未提交
const submitSuccess = ref(false);

function submitForm() {
  // 模拟表单提交逻辑
  setTimeout(() => {
    submitSuccess.value = true;
  }, 1000);
}
</script>

<template>
  <form class="form" @submit.prevent="submitForm">
    <input type="text" placeholder="请输入内容" required />
    <button type="submit">提交</button>
    
    <!-- 用template包裹多个元素,批量切换显示/隐藏 -->
    <template v-if="submitSuccess">
      <div class="success-icon"></div>
      <p class="success-tip">表单提交成功!感谢您的反馈</p>
      <button type="button" @click="submitSuccess = false">重新提交</button>
    </template>
  </form>
</template>

<style>
.form {
  margin: 20px 0;
  padding: 15px;
  border: 1px solid #eee;
  display: flex;
  flex-direction: column;
  gap: 10px;
}
input {
  padding: 8px;
}
.success-icon {
  font-size: 24px;
  color: #48bb78;
}
.success-tip {
  color: #48bb78;
  margin: 5px 0;
}
</style>

image.png

image.png

另一种选择:v-show 指令——“简单隐藏”的高效方案

除了v-if,Vue还提供了v-show指令用于条件显示元素,它的用法和v-if非常相似,都是通过绑定表达式的真假来控制元素的显示状态,但二者的底层实现和使用场景有很大区别。

v-show 的核心特点:无论条件是否成立,元素都会被渲染到DOM中,它只是通过切换元素的CSS display属性来控制显示/隐藏(display: none 或 display: 初始值),元素本身始终存在于DOM中。

v-show 基础用法

v-show 直接绑定响应式状态,语法和v-if一致,适合简单的显示/隐藏切换场景。

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

// 模拟开关状态
const isShow = ref(true);

function toggleShow() {
  isShow.value = !isShow.value;
}
</script>

<template>
  <div class="show-section">
    <button @click="toggleShow">{{ isShow ? '隐藏' : '显示' }}内容</button>
    <div v-show="isShow" class="show-content">
      这是v-show控制的内容,隐藏时只是display: none,不会从DOM中移除
    </div>
  </div>
</template>

<style>
.show-section {
  margin: 20px 0;
  padding: 15px;
  border: 1px solid #eee;
}
.show-content {
  margin-top: 10px;
  padding: 10px;
  background: #f5f5f5;
}
</style>

注意:v-show 不支持在template标签上使用,也不能和v-else搭配使用,只能单独作用于单个DOM元素。

关键对比:v-if vs v-show 怎么选?

很多初学者会混淆v-if和v-show的用法,其实二者的核心区别在于“是否从DOM中移除元素”,这也决定了它们的适用场景。下面通过表格清晰对比,帮你快速判断:

对比维度 v-if v-show
DOM存在性 条件为假时,元素从DOM中移除 无论条件真假,元素始终在DOM中
实现方式 动态创建/销毁DOM元素 切换CSS display属性
初始渲染开销 惰性渲染:条件为假时不渲染,初始开销小 无论条件如何,都会渲染,初始开销大
切换开销 创建/销毁DOM,切换开销大 仅切换CSS,切换开销小
适用场景 条件很少切换(如登录/未登录状态) 条件需要频繁切换(如弹窗、tab切换)
支持搭配 支持v-else、v-else-if,支持template 不支持v-else,不支持template

实战建议:如果需要频繁切换显示状态(比如导航菜单、弹窗),优先用v-show;如果条件切换频率低(比如用户身份判断、页面权限控制),优先用v-if,这样能减少DOM节点数量,提升页面性能。

进阶注意:v-if 与 v-for 的使用禁忌

在实际开发中,我们可能会遇到“需要过滤列表后渲染”的场景,这时容易习惯性地将v-if和v-for写在同一个元素上,但这种用法是Vue不推荐的,因为二者的优先级不明确,会导致渲染异常和性能问题。

Vue中,当v-if和v-for同时存在于一个元素上时,v-if会先执行,也就是说,Vue会先判断每个列表项是否满足v-if的条件,再进行循环渲染,这会导致v-for的循环次数增加,影响性能。

错误示例(不推荐)

<!-- 错误:v-if和v-for写在同一个元素上 -->
<ul>
  <li v-for="item in list" v-if="item.status === 1" :key="item.id">
    {{ item.name }}
  </li>
</ul>

正确做法(推荐)

方案1:先通过计算属性过滤列表,再用v-for渲染,避免v-if和v-for同元素

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

const list = ref([
  { id: 1, name: 'Vue基础', status: 1 },
  { id: 2, name: 'React基础', status: 0 },
  { id: 3, name: 'Vue条件渲染', status: 1 },
  { id: 4, name: 'JavaScript进阶', status: 0 }
]);

// 计算属性过滤状态为1的列表项
const activeList = computed(() => {
  return list.value.filter(item => item.status === 1);
});
</script>

<template>
  <ul>
    <!-- 只渲染过滤后的列表,无需v-if -->
    <li v-for="item in activeList" :key="item.id">
      {{ item.name }}
    </li>
  </ul>
</template>

方案2:用template包裹v-for,将v-if写在template上(适用于整体过滤列表)

<template>
  <!-- 先判断列表是否有数据,再循环渲染 -->
  <template v-if="list.length > 0">
    <ul>
      <li v-for="item in list" :key="item.id">
        {{ item.name }}
      </li>
    </ul>
  </template>
  <div v-else>
    暂无数据
  </div>
</template>

常见误区与避坑指南

  1. v-else、v-else-if 位置错误:必须紧跟在v-if或v-else-if元素之后,不能插入其他元素,否则会被Vue识别为无效指令;
  2. v-show 用于复杂组件:v-show 会始终渲染元素,即使条件为假,复杂组件的初始渲染会增加页面加载时间,建议改用v-if;
  3. v-if 用于频繁切换场景:频繁切换v-if会导致DOM频繁创建/销毁,产生性能开销,建议改用v-show;
  4. v-if 和 v-for 同元素使用:优先级混乱,导致渲染异常和性能问题,优先用计算属性过滤列表;
  5. 在template上使用v-show:v-show不支持template标签,会导致指令失效,需改为作用于具体DOM元素。

总结:条件渲染的最佳实践

Vue的条件渲染指令(v-if、v-else、v-else-if、v-show)为我们提供了灵活的页面交互方案,核心是根据场景选择合适的指令,兼顾性能和可读性。结合实战场景,提炼以下最佳实践:

  1. 简单显示/隐藏、频繁切换:用v-show,减少DOM切换开销;
  2. 条件切换少、需要销毁DOM:用v-if,减少初始渲染和DOM节点数量;
  3. 多条件分支:用v-if + v-else-if + v-else,确保指令顺序正确;
  4. 批量切换多个元素:用template包裹v-if,避免额外添加容器;
  5. 过滤列表渲染:用计算属性过滤列表后,再用v-for渲染,避免v-if和v-for同元素;
  6. 避免无效指令:v-show不搭配v-else、不用于template,v-else不单独使用。

掌握条件渲染的核心用法和场景差异,能让你在Vue开发中更灵活地控制页面展示,写出更高效、可维护的代码。条件渲染是Vue页面交互的基础,后续结合列表渲染、组件封装等知识点,还能实现更复杂的页面逻辑,解锁更多前端开发技巧。

昨天以前首页

Vue 项目结构与命名规范

作者 28256_
2026年4月14日 17:43

Vue 项目结构与命名规范

统一命名规则

  1. 普通文件夹:全小写(单单词 / 小驼峰双单词),统一、易读、兼容 URL
  2. 页面/视图文件夹:大驼峰(PascalCase),明确标识路由页面
  3. .vue 组件文件:大驼峰(PascalCase),官方推荐,与组件名保持一致
  4. JS / 工具 / 样式文件:小驼峰(camelCase),遵循 JavaScript 通用规范

官方依据


vue3-project/
├── .vscode/
├── node_modules/
├── public/
│   └── favicon.ico
├── src/
│   ├── assets/
│   │   ├── image/
│   │   │   ├── logo.png
│   │   │   └── userAvatar.png
│   │   └── styleGlobal/
│   │       ├── base.css
│   │       └── commonStyle.css
│   ├── components/
│   │   ├── common/
│   │   │   ├── Button.vue
│   │   │   └── UserInfo.vue
│   │   └── userCommon/
│   │       ├── Card.vue
│   │       └── OrderList.vue
│   ├── views/
│   │   ├── Home/
│   │   │   ├── index.vue
│   │   │   ├── HomeBanner.vue
│   │   │   ├── banner/
│   │   │   │   ├── Item.vue
│   │   │   │   └── BannerItem.vue
│   │   │   └── homeSection/
│   │   │       ├── Block.vue
│   │   │       └── SectionBlock.vue
│   │   └── UserCenter/
│   │       ├── index.vue
│   │       └── UserOrder.vue
│   ├── router/
│   │   ├── index.js
│   │   └── routeGuard.js
│   ├── store/
│   │   ├── modules/
│   │   │   ├── user.js
│   │   │   └── userInfo.js
│   │   └── index.js
│   ├── api/
│   │   ├── request.js
│   │   └── orderList.js
│   ├── utils/
│   │   ├── time.js
│   │   └── formatDate.js
│   ├── composables/
│   │   ├── index.js
│   │   └── useUser.js
│   ├── App.vue
│   └── main.js
├── .env.development
├── .env.production
├── .gitignore
├── index.html
├── package.json
├── vite.config.js
└── README.md

Vue3项目中给组件命名的方式

2026年4月14日 16:18

1.不是用插件给组件设置名称的方式

<template>
    <div><div>
</template>
<script lang="ts">
export default {
    name: "xxxx"
}
</script>

<script lang="ts" setup>

</script>
<style scoped></style>

2.通过vite-plugin-vue-setup-extend插件(推荐)

<template>
    <div><div>
</template>
<script lang="ts" setup name="xxxx">

</script>
<style scoped></style>

3.vite-plugin-vue-setup-extend安装与配置

(1)第一步

npm i vite-plugin-vue-setup-extend -D

(2)第二步配置vite.config.ts

import VueSetupExtend from "vite-plugin-vue-setup-extend";

export default defineConfig({
    plugins: [
        ...
        VueSetupExtend()
    ],
    ...
})

VueUse 全面指南|Vue3组合式工具集实战

2026年4月14日 15:57

VueUse 是基于 Vue3 Composition API 开发的实用函数集合库,由 Vue 核心团队成员主导维护,收录了200+开箱即用的工具函数,覆盖 DOM 操作、浏览器 API、响应式状态管理、性能优化等几乎所有前端开发场景。其核心理念是“拒绝重复造轮子”,将开发中常用但繁琐的逻辑(如本地存储、鼠标监听、防抖节流)封装成可复用的组合式函数,让开发者专注于业务逻辑,大幅提升开发效率。

VueUse 完美适配 Vue3,原生支持 TypeScript,支持摇树优化(Tree Shaking),按需引入不冗余,同时兼容 Vue2(需使用对应版本)和 SSR 场景,是 Vue3 项目开发的必备工具库之一。

一、VueUse 核心特点

  • Composition API 原生适配:所有函数均基于 Vue3 setup 语法和 ref/reactive 构建,API 风格与 Vue3 原生语法高度一致,上手无压力,无需额外学习成本。
  • 类型友好:全程使用 TypeScript 编写,自带完整类型定义,开发时可获得精准代码提示,减少类型错误,适配 TS 项目开发需求。
  • 模块化设计:采用按需引入机制,仅打包用到的函数,避免引入全部模块造成的体积膨胀,优化项目打包性能。
  • 场景覆盖广泛:涵盖响应式状态、浏览器能力、DOM 操作、表单控制、网络请求、性能优化等200+场景,满足日常开发99%的需求。
  • 灵活通用:支持 CDN 引入(无需打包器),适配 Vite、Webpack、Nuxt 等多种构建工具,同时支持 SSR 友好,可搭配 Vue Router、Firebase 等插件使用。
  • 中文文档完善:官方提供中文文档,每个函数均有交互式演示,查询便捷,新手可快速上手。

二、环境安装(Vue3 实战首选)

VueUse 核心包为 @vueuse/core,包含绝大多数常用工具函数;若需特定场景(如音频、地图),可安装对应子包。以下是主流安装方式,推荐使用 npm 或 pnpm:

2.1 核心包安装(必装)

// npm 安装(推荐,适配绝大多数项目)
npm install @vueuse/core -S

// yarn 安装
yarn add @vueuse/core

// pnpm 安装(高效包管理,推荐)
pnpm add @vueuse/core

2.2 特定场景子包安装(按需选择)

若需使用音频、地图、Firebase 等特定功能,可单独安装对应子包:

// 音频相关工具(如播放、录音)
npm install @vueuse/sound -S

// 地图相关工具(如高德、百度地图集成)
npm install @vueuse/map -S

// Firebase 集成工具
npm install @vueuse/firebase -S

2.3 CDN 引入(无需打包器,快速测试)

适合快速演示或无需打包的简单项目,引入后可通过 window.VueUse 访问所有函数:

<script src="https://unpkg.com/@vueuse/shared"></script>
<script src="https://unpkg.com/@vueuse/core"></script>

2.4 Nuxt 项目适配

Nuxt 3 已内置 VueUse 支持,无需单独安装,仅需在配置文件中注册模块即可实现自动引入:

// nuxt.config.ts(Nuxt 3)
export default defineNuxtConfig({
  modules: ['@vueuse/nuxt']
})

三、核心用法(按场景分类,实战必备)

VueUse 的使用逻辑简单统一:按需引入所需函数,在 setup 语法中调用,即可获得响应式结果或封装好的逻辑,无需手动处理事件绑定、销毁等冗余操作。以下按高频场景分类讲解,代码可直接复制套用。

3.1 响应式状态与本地存储(最常用)

用于处理响应式状态切换、计数器、本地存储(localStorage/sessionStorage)等场景,自动处理 JSON 序列化和响应式同步,刷新页面数据不丢失。

3.1.1 useLocalStorage(本地持久化存储)

替代原生 localStorage,返回响应式 ref 对象,修改后自动同步到本地存储,适合存储用户偏好、登录态等需要持久化的数据:

<template>
  <div>
    <p>当前主题:{{ theme }}</p>
    <button @click="theme = theme === 'light' ? 'dark' : 'light'">切换主题</button>
  </div>
</template>

<script setup lang="ts">
// 按需引入
import { useLocalStorage } from '@vueuse/core'

// 第一个参数:localStorage 键名;第二个参数:默认值
const theme = useLocalStorage('app_theme', 'light')
// 修改值时,自动同步到 localStorage
// theme.value = 'dark'
</script>

3.1.2 useSessionStorage(会话级存储)

用法与 useLocalStorage 完全一致,区别在于数据存储在 sessionStorage 中,关闭页面后自动丢失,适合存储临时数据(如表单草稿):

import { useSessionStorage } from '@vueuse/core'

// 存储临时表单数据
const tempForm = useSessionStorage('temp_form', { username: '', password: '' })

3.1.3 useToggle(布尔值切换)

快速实现布尔值切换逻辑,适合弹窗显示/隐藏、开关状态等场景:

<template>
  <button @click="toggle">{{ isShow ? '隐藏' : '显示' }}弹窗</button>
  <div v-if="isShow" class="modal">弹窗内容</div>
</template>

<script setup lang="ts">
import { useToggle } from '@vueuse/core'

// 接收默认值,返回 [状态值, 切换函数]
const [isShow, toggle] = useToggle(false)
// 也可自定义切换值(如切换主题字符串)
// const [theme, toggleTheme] = useToggle('light', ['light', 'dark'])
</script>

3.1.4 useCounter(计数器工具)

封装计数器逻辑,支持增减、重置、设置值等操作,适合数量选择、分页页码等场景:

<template>
  <div>
    <button @click="dec()">-</button>
    <span>{{ count }}</span>
    <button @click="inc()">+</button>
    <button @click="reset()">重置</button>
    <button @click="set(10)">设为10</button>
  </div>
</template>

<script setup lang="ts">
import { useCounter } from '@vueuse/core'

// 默认值为0,可指定初始值和范围(如 min:0, max:10)
const { count, inc, dec, reset, set } = useCounter(0, { min: 0, max: 10 })
</script>

3.2 浏览器能力封装(简化原生 API)

将浏览器原生 API(如鼠标监听、网络状态、窗口尺寸)封装为响应式函数,自动处理事件绑定与销毁,避免内存泄漏。

3.2.1 useMouse(鼠标位置监听)

实时获取鼠标坐标,支持限制监听范围(如某元素内),适合鼠标跟随、悬浮交互等场景:

<template>
  <div>
    <p>鼠标位置:({{ x.toFixed(0) }}, {{ y.toFixed(0) }})</p>
    <div 
      class="follow" 
      :style="{ left: `${x + 10}px`, top: `${y + 10}px` }"
    ></div>
  </div>
</template>

<script setup lang="ts">
import { useMouse } from '@vueuse/core'

// 获取鼠标x、y坐标(响应式)
const { x, y } = useMouse()
// 限制监听范围(仅在id为container的元素内监听)
// const { x, y } = useMouse({ target: document.getElementById('container') })
</script>

<style scoped>
.follow {
  position: fixed;
  width: 10px;
  height: 10px;
  background: red;
  border-radius: 50%;
}
</style>

3.2.2 useNetwork(网络状态监听)

监听用户网络连接状态(在线/离线),适合提示用户网络异常、离线缓存等场景:

<template>
  <div v-if="!isOnline" class="offline-tip">
    ❌ 网络已断开,请检查网络连接
  </div>
</template>

<script setup lang="ts">
import { useNetwork } from '@vueuse/core'

const { isOnline, downlink } = useNetwork()
// isOnline:是否在线(布尔值)
// downlink:网络速度(Mbps)
</script>

3.2.3 useDark(深色模式切换)

快速实现深色/浅色模式切换,自动同步系统主题偏好,支持自定义主题类名:

<template>
  <div>
    <h1>当前模式:{{ isDark ? '🌙 深色' : '☀️ 浅色' }}</h1>
    <button @click="toggleDark()">切换主题</button>
  </div>
</template>

<script setup lang="ts">
import { useDark, useToggle } from '@vueuse/core'

// 监听系统深色模式,同步到 html 标签的 class(默认添加 dark 类)
const isDark = useDark({
  selector: 'html',
  valueDark: 'dark',
  valueLight: ''
})
// 结合 useToggle 实现切换
const toggleDark = useToggle(isDark)
</script>

<style>
html.dark {
  background-color: #121212;
  color: #fff;
}
</style>

3.2.4 useWindowSize(窗口尺寸监听)

实时获取窗口宽高,响应式更新,适合响应式布局、适配移动端/桌面端场景:

import { useWindowSize } from '@vueuse/core'
import { computed } from 'vue'

const { width, height } = useWindowSize()
// 判断是否为移动端(屏幕宽度 < 768px)
const isMobile = computed(() => width.value < 768)

3.3 表单与输入控制(优化交互体验)

封装防抖、节流、剪贴板等常用表单交互逻辑,简化输入框搜索、复制粘贴等功能开发。

3.3.1 useDebounce(防抖输入)

对输入值进行防抖处理,延迟执行逻辑,适合搜索框、输入验证等场景,避免频繁触发请求:

<template>
  <input 
    v-model="searchInput" 
    placeholder="请输入搜索关键词"
    style="width: 300px; padding: 8px;"
  />
</template>

<script setup lang="ts">
import { ref, watch } from 'vue'
import { useDebounce } from '@vueuse/core'

const searchInput = ref('')
// 防抖处理:延迟500ms,返回防抖后的响应式值
const debouncedInput = useDebounce(searchInput, 500)

// 监听防抖后的值,触发搜索逻辑
watch(debouncedInput, (val) => {
  if (val) {
    console.log('搜索关键词:', val)
    // 调用搜索接口...
  }
})
</script>

3.3.2 useThrottle(节流控制)

限制函数执行频率,适合滚动事件、resize 事件等频繁触发的场景,优化性能:

import { useThrottle } from '@vueuse/core'

// 对窗口滚动事件进行节流,200ms内仅执行一次
const scrollY = useThrottle(window.scrollY, 200)

3.3.3 useCopyToClipboard(剪贴板操作)

简化复制文本到剪贴板的逻辑,自带复制状态反馈,无需编写原生 API 代码:

<template>
  <div>
    <input v-model="copyText" placeholder="请输入要复制的内容" />
    <button @click="copy()">{{ copied ? '已复制✅' : '点击复制' }}</button>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useCopyToClipboard } from '@vueuse/core'

const copyText = ref('https://vueuse.org')
// 接收复制源,返回 [复制函数, 复制状态]
const { copy, copied } = useCopyToClipboard({ source: copyText })
</script>

3.4 DOM 操作与交互(简化 DOM 操作)

封装常用 DOM 操作逻辑,自动处理元素监听、尺寸获取、拖拽等功能,避免手动操作 DOM 带来的冗余代码。

3.4.1 useScroll(滚动位置监听)

监听元素或窗口的滚动位置,适合滚动加载、回到顶部、滚动导航等场景:

<template>
  <div ref="container" class="scroll-container"&gt;
    <!-- 滚动内容 -->
  </div>
  <button @click="scrollToTop()" v-if="y > 100">回到顶部</button>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useScroll } from '@vueuse/core'

const container = ref(null)
// 监听指定元素的滚动位置(默认监听窗口)
const { y, scrollTo } = useScroll(container)

// 回到顶部
const scrollToTop = () => {
  scrollTo({ top: 0, behavior: 'smooth' })
}
</script>

3.4.2 useElementSize(元素尺寸监听)

实时获取元素的宽高,响应式更新,适合自适应布局、元素尺寸变化监听等场景:

<template>
  <div ref="box" class="box">自适应盒子</div>
  <p>盒子尺寸:{{ width }}px × {{ height }}px</p>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useElementSize } from '@vueuse/core'

const box = ref(null)
// 获取元素宽高(响应式)
const { width, height } = useElementSize(box)
</script>

3.4.3 onClickOutside(点击外部关闭)

监听元素外部的点击事件,适合弹窗、下拉菜单等场景,点击外部自动关闭:

<template>
  <button @click="isOpen = true">打开下拉菜单</button>
  <div ref="menu" v-if="isOpen" class="menu">
    下拉菜单内容
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { onClickOutside } from '@vueuse/core'

const isOpen = ref(false)
const menu = ref(null)

// 点击 menu 外部,关闭下拉菜单
onClickOutside(menu, () => {
  isOpen.value = false
})
</script>

3.5 网络请求(简化请求逻辑)

封装 fetch API,自带加载状态、错误处理,返回响应式数据,适合简单网络请求场景,可替代 axios 基础用法。

3.5.1 useFetch(通用网络请求)

<template>
  <div>
    <div v-if="isLoading">加载中...</div>
    <div v-if="error" class="error">请求失败:{{ error.message }}</div>
    <div v-if="data">{{ data.content }}</div>
  </div>
</template>

<script setup lang="ts">
import { useFetch } from '@vueuse/core'

// 发起 GET 请求,返回响应式数据、加载状态、错误信息
const { data, isLoading, error, execute } = useFetch('https://api.example.com/data', {
  method: 'GET',
  // 可选配置:请求头、参数等
  headers: { 'Content-Type': 'application/json' }
})

// 手动触发请求(如点击按钮发起请求)
// const handleFetch = () => execute()
</script>

四、VueUse 进阶技巧(实战提升)

4.1 函数组合使用

VueUse 的函数可自由组合,实现复杂功能,例如结合 useDark、useLocalStorage、useToggle 实现主题切换并持久化:

import { useDark, useToggle, useLocalStorage } from '@vueuse/core'

// 结合本地存储,持久化主题状态
const theme = useLocalStorage('app_theme', 'light')
const isDark = useDark({ valueDark: 'dark', valueLight: 'light' })
// 同步主题状态与本地存储
theme.value = isDark.value ? 'dark' : 'light'
// 切换主题时同步更新本地存储
const toggleTheme = useToggle(isDark, [false, true])
toggleTheme(() => {
  theme.value = isDark.value ? 'dark' : 'light'
})

4.2 自定义配置参数

大多数函数支持自定义配置,例如限制计数器范围、自定义本地存储键名、指定监听目标等,灵活适配业务需求:

// 1. 限制计数器范围(0-100)
const { count, inc } = useCounter(0, { min: 0, max: 100 })

// 2. 自定义本地存储键名和存储方式
const user = useLocalStorage('user_info', {}, {
  storage: sessionStorage, // 改用 sessionStorage 存储
  mergeDefaults: true // 合并默认值和存储值
})

// 3. 指定鼠标监听目标(仅在指定元素内监听)
const { x, y } = useMouse({ target: document.getElementById('container') })

4.3 避免常见误区

  • 不要全局引入所有函数:VueUse 支持摇树优化,按需引入即可,全局引入(如 import * as VueUse from '@vueuse/core')会导致打包体积膨胀。
  • 注意浏览器兼容性:部分函数(如 useBattery、useGeolocation)依赖浏览器原生 API,需做降级处理,避免在低版本浏览器中报错。
  • Vue2 适配:VueUse v12.0 及以上版本不再支持 Vue2,若使用 Vue2 项目,需安装 v11.x 版本:npm install @vueuse/core@11 -S

五、常用函数速查表(快速查询)

函数分类 常用函数 核心功能
响应式状态 useToggle、useCounter、useStorage 布尔值切换、计数器、响应式存储
浏览器能力 useMouse、useNetwork、useDark、useWindowSize 鼠标监听、网络状态、深色模式、窗口尺寸
表单控制 useDebounce、useThrottle、useCopyToClipboard 防抖、节流、剪贴板操作
DOM 操作 useScroll、useElementSize、onClickOutside 滚动监听、元素尺寸、点击外部关闭
网络请求 useFetch、useWebSocket 通用请求、WebSocket 连接

六、官方资源与学习渠道

总结:VueUse 是 Vue3 开发的“效率神器”,通过封装常用逻辑,大幅减少冗余代码,提升开发效率和代码可维护性。新手可从本文讲解的高频函数入手,结合官方文档,快速上手并应用到实际项目中,逐步掌握所有核心用法。

Vue3+Pinia实战完整版|从入门到精通,替代Vuex的状态管理首选

2026年4月14日 15:51

本文专为Vue3开发者打造,从Pinia基础认知入手,逐步讲解环境搭建、核心API用法,结合Vue3+TypeScript实战案例,覆盖日常开发99%场景,新手可直接套用代码,快速掌握Pinia全局状态管理,替代传统Vuex,提升开发效率。

核心定位:Pinia是Vue官方推荐的全局状态管理工具,2019年推出,旨在替代Vuex,采用组合式API风格,轻量、简洁且原生支持TS,适配Vue3和Vue2(本文重点聚焦Vue3+TS实战)。

一、Pinia基础认知(入门必看)

1.1 什么是Pinia

Pinia是一个用于跨组件、跨页面进行状态共享的全局状态管理库,功能与Vuex、Redux一致,但API更简洁,使用体验更贴近Vue3组合式API,本质上是Vuex5的最终实现形态——Vue官方团队在探索Vuex下一次迭代时,发现Pinia已满足大部分需求,最终决定用Pinia替代Vuex。

1.2 Pinia核心特点

  • 完整TS支持:无需手动编写复杂类型声明,原生支持类型推断,TS开发体验拉满,补全更流畅。
  • 极致轻量:压缩后体积仅1KB左右,无多余依赖,不增加项目负担。
  • 简化语法:移除Vuex中繁琐的mutations,仅保留state、getters、actions,降低学习和使用成本。
  • actions多支持:既支持同步操作,也支持异步操作(如接口请求),无需区分同步/异步逻辑。
  • 扁平化结构:无模块嵌套,只有store概念,每个store独立存在,可自由调用,无需管理复杂的命名空间。
  • 自动注册:store一旦创建,无需手动添加到全局,自动挂载,开箱即用。
  • 跨版本兼容:同时支持Vue3和Vue2,除初始化安装和SSR配置外,两者API完全一致。

1.3 Pinia与Vuex的核心区别

Pinia最初是为探索Vuex下一次迭代而设计,整合了Vuex核心团队的诸多想法,最终成为Vuex的替代方案,两者核心区别如下:

对比维度 Vuex Pinia
核心结构 State、Getters、Mutations(同步)、Actions(异步) State、Getters、Actions(同步+异步),无Mutations
版本适配 Vuex4适配Vue3,Vuex3适配Vue2,无法跨版本使用 最新版2.x,同时适配Vue3和Vue2
TS支持 需创建自定义复杂包装器,类型推断不友好 原生支持TS,类型推断完善,无需额外配置
模块结构 支持模块嵌套,需配置命名空间,逻辑繁琐 扁平化结构,无嵌套,store独立,可自由调用
注册方式 需手动注册store到全局 自动注册,创建后即可使用
API复杂度 API繁琐,需记住mutations提交、命名空间等规则 API简洁,贴近组合式API,上手成本低

1.4 适用场景

任何需要跨组件、跨页面共享状态的Vue3项目,无论是中小型项目(如个人博客、管理后台),还是大型项目(如电商平台),Pinia都能胜任,尤其适合TS开发的项目,能大幅提升开发效率和代码可维护性。

二、Vue3+Pinia环境搭建(实战第一步)

本章节以Vue3+TypeScript项目为例,讲解Pinia的安装、全局注册,步骤简洁,可直接复制命令和代码执行。

2.1 前提条件

已创建Vue3+TS项目(若未创建,执行命令:npm create vue@latest,选择TS、Pinia(可选,此处可跳过,后续手动安装))。

2.2 安装Pinia

打开终端,进入项目根目录,执行以下命令(三选一,推荐npm或yarn):

// npm 安装(推荐)
npm install pinia -S

// yarn 安装
yarn add pinia

// cnpm 安装
cnpm install pinia -S

2.3 全局注册Pinia(Vue3)

修改项目入口文件main.ts,引入并挂载Pinia实例,全局仅需配置一次:

// main.ts
import { createApp } from 'vue'
import App from './App.vue'
// 引入Pinia的createPinia方法
import { createPinia } from 'pinia'

// 创建Pinia实例
const pinia = createPinia()
// 创建Vue应用并挂载Pinia
const app = createApp(App)
app.use(pinia) // 挂载Pinia到Vue应用
app.mount('#app')

补充:Vue2中注册方式略有不同(需引入PiniaVuePlugin),本文聚焦Vue3,Vue2用法可参考文末补充说明。

三、Pinia核心用法(Vue3+TS实战)

Pinia的核心是Store(仓库),每个Store对应一个独立的状态模块,通过defineStore方法创建,包含state(状态)、getters(计算属性)、actions(业务逻辑)三部分,以下逐一讲解。

3.1 初始化Store(核心步骤)

推荐在项目根目录下创建src/store文件夹,用于存放所有Store文件,按业务模块划分(如用户模块、购物车模块),规范命名(如userStore.tscartStore.ts)。

步骤:先定义Store名称枚举(避免重复),再创建Store实例。

第一步:定义Store名称枚举(可选,推荐)

创建src/store/store-name.ts,用于统一管理Store名称,避免重复(尤其多Store场景):

// src/store/store-name.ts
// 用枚举定义Store名称,唯一且直观
export const enum Names {
  Test = 'TEST', // 测试Store名称
  User = 'USER', // 用户Store名称
  Cart = 'CART'  // 购物车Store名称
}

第二步:创建Store实例

创建src/store/index.ts(或按模块拆分,如userStore.ts),使用defineStore方法创建Store,核心包含state、getters、actions:

// src/store/index.ts
import { defineStore } from 'pinia';
import { Names } from './store-name'; // 引入Store名称枚举

// defineStore接收两个参数:
// 1. 唯一标识(必须与枚举值一致,全局唯一,不可重复)
// 2. 配置对象(包含state、getters、actions)
export const useTestStore = defineStore(Names.Test, {
  // 1. state:存储全局状态,必须是箭头函数(避免SSR数据污染,优化TS类型推导)
  state: () => {
    return {
      current: 1, // 数字类型状态
      name: '小马', // 字符串类型状态
      list: [1, 2, 3] // 数组类型状态
    };
  },

  // 2. getters:类似组件的computed,用于修饰状态,有缓存功能
  getters: {
    // 方式一:接收state作为参数(推荐,类型推断更友好)
    myGetCount(state) {
      // 缓存特性:页面多次使用,仅执行一次计算
      console.log('getters被调用');
      return state.current + 1;
    },

    // 方式二:不传递参数,使用this访问state(需指定返回值类型,否则TS推导失败)
    myGetName(): string {
      return `姓名:${this.name}`;
    },

    // 进阶:getters依赖其他getters
    myGetCombined(): string {
      return `${this.myGetName()},计数+1:${this.myGetCount}`;
    }
  },

  // 3. actions:类似组件的methods,用于修改state,支持同步和异步
  actions: {
    // 同步action:修改state(不能用箭头函数,否则this指向异常)
    setCurrentParam(num: number) {
      this.current += num; // 直接通过this访问state并修改
    },

    // 同步action:批量修改多个状态
    updateState(newCurrent: number, newName: string) {
      this.current = newCurrent;
      this.name = newName;
    },

    // 异步action:结合async/await(如接口请求)
    async fetchData() {
      // 模拟接口请求(实际开发中替换为真实接口)
      const res = await new Promise((resolve) => {
        setTimeout(() => {
          resolve({ current: 10, name: '异步更新后' });
        }, 1000);
      });
      // 异步请求成功后,修改state
      const data = res as { current: number; name: string };
      this.current = data.current;
      this.name = data.name;
    }
  },
});

3.2 组件中使用Store(核心实战)

在Vue3组件(<script setup lang="ts">)中,引入Store实例,即可访问、修改状态,调用actions,以下是完整示例。

3.2.1 基础使用(访问state、getters)

<template>
  <div class="pinia-demo">
    <h3>基础使用</h3>
    <!-- 直接访问state -->
    <p>当前计数:{{ testStore.current }}</p>
    <p>姓名:{{ testStore.name }}</p>
    <!-- 访问getters(直接当作属性使用,无需调用) -->
    <p>计数+1:{{ testStore.myGetCount }}</p>
    <p>组合getters:{{ testStore.myGetCombined }}</p>
  </div>
</template>

<script setup lang="ts">
// 1. 引入Store实例
import { useTestStore } from '@/store';

// 2. 创建Store实例(Pinia自动管理单例,多次调用返回同一个实例)
const testStore = useTestStore();
</script>

3.2.2 修改state(5种方式,实战常用)

Pinia提供多种修改state的方式,按需选择,推荐使用$patch(批量修改)和actions(业务逻辑封装)。

<template>
  <div class="pinia-demo">
    <h3>修改state</h3>
    <p>当前计数:{{ testStore.current }}</p>
    <button @click="handleDirectModify">1.直接修改</button>
    <button @click="handlePatchObj">2.$patch对象批量修改</button>
    <button @click="handlePatchFn">3.$patch函数自定义修改</button>
    <button @click="handleReplaceState">4.$state替换整个状态</button>
    <button @click="handleActionsModify">5.通过actions修改</button>
  </div>
</template>

<script setup lang="ts">
import { useTestStore } from '@/store';
const testStore = useTestStore();

// 方式1:直接修改(简单场景可用,不推荐复杂场景)
const handleDirectModify = () => {
  testStore.current++; // 直接修改单个状态
  // testStore.name = '新姓名'; // 直接修改单个状态
};

// 方式2:$patch对象形式(批量修改多个状态,推荐简单批量场景)
const handlePatchObj = () => {
  testStore.$patch({
    current: 10,
    name: '批量修改后',
    list: [4, 5, 6]
  });
};

// 方式3:$patch函数形式(自定义修改逻辑,推荐复杂场景)
const handlePatchFn = () => {
  testStore.$patch((state) => {
    state.current += 5; // 复杂计算修改
    state.list.push(7); // 数组操作
    if (state.current > 20) {
      state.name = '计数超标';
    }
  });
};

// 方式4:$state替换整个状态(需修改所有属性,不推荐常规场景)
const handleReplaceState = () => {
  testStore.$state = {
    current: 0,
    name: '替换整个状态',
    list: []
  };
};

// 方式5:通过actions修改(推荐,封装业务逻辑,便于维护和复用)
const handleActionsModify = () => {
  testStore.setCurrentParam(3); // 调用同步action
  // testStore.updateState(15, 'actions修改'); // 调用同步action
  // testStore.fetchData(); // 调用异步action
};
</script>

3.2.3 响应式解构state(关键技巧)

直接解构state会丢失响应性(Pinia的state默认用reactive处理,与Vue3 reactive解构规则一致),需使用Pinia提供的storeToRefs方法,实现响应式解构。

<template>
  <div class="pinia-demo">
    <h3>响应式解构</h3>
    <p>解构后计数:{{ current }}</p>
    <p>解构后姓名:{{ name }}</p>
    <button @click="handleChange">修改解构后的值</button>
  </div>
</template>

<script setup lang="ts">
import { useTestStore } from '@/store';
import { storeToRefs } from 'pinia'; // 引入storeToRefs

const testStore = useTestStore();

// 错误写法:直接解构,失去响应性
// const { current, name } = testStore;

// 正确写法:用storeToRefs解构,保持响应性
const { current, name } = storeToRefs(testStore);

// 修改解构后的值(需用.value,因为storeToRefs会将状态转为ref)
const handleChange = () => {
  current.value++;
  name.value = '解构后修改';
};
</script>

3.2.4 调用异步actions(实战常用)

actions支持async/await,可直接在组件中调用异步action,处理接口请求等异步逻辑,示例如下:

<template>
  <div class="pinia-demo">
    <h3>异步actions</h3>
    <p>当前计数:{{ testStore.current }}</p>
    <p>姓名:{{ testStore.name }}</p>
    <button @click="handleFetchData" :disabled="loading">
      {{ loading ? '加载中...' : '异步请求更新' }}
    </button>
  </div>
</template>

<script setup lang="ts">
import { useTestStore } from '@/store';
import { ref } from 'vue';

const testStore = useTestStore();
const loading = ref(false);

// 调用异步action
const handleFetchData = async () => {
  loading.value = true;
  try {
    await testStore.fetchData(); // 等待异步action执行完成
  } catch (err) {
    console.error('异步请求失败:', err);
  } finally {
    loading.value = false;
  }
};
</script>

3.3 多Store使用(实战场景)

Pinia无模块嵌套,多个Store独立存在,可在组件中同时引入多个Store,也可在一个Store中引入另一个Store(实现Store间通信)。

3.3.1 组件中引入多个Store

// src/store/userStore.ts(新增用户Store)
import { defineStore } from 'pinia';
import { Names } from './store-name';

export const useUserStore = defineStore(Names.User, {
  state: () => ({
    token: '',
    userInfo: { name: '游客', age: 18 }
  }),
  actions: {
    login(token: string, userInfo: any) {
      this.token = token;
      this.userInfo = userInfo;
    },
    logout() {
      this.token = '';
      this.userInfo = { name: '游客', age: 18 };
    }
  }
});

// 组件中使用多个Store
<script setup lang="ts">
import { useTestStore } from '@/store';
import { useUserStore } from '@/store/userStore';

const testStore = useTestStore();
const userStore = useUserStore();

// 调用不同Store的方法
const handleLogin = () => {
  userStore.login('abc123', { name: '小明', age: 20 });
};
</script>

3.3.2 Store间通信(一个Store调用另一个Store)

在一个Store的actions中,引入另一个Store实例,即可实现Store间的数据交互:

// src/store/cartStore.ts(购物车Store)
import { defineStore } from 'pinia';
import { Names } from './store-name';
import { useUserStore } from './userStore'; // 引入用户Store

export const useCartStore = defineStore(Names.Cart, {
  state: () => ({
    cartList: [] as { id: number; name: string; price: number }[]
  }),
  actions: {
    // 添加商品到购物车(需判断用户是否登录)
    addToCart(goods: { id: number; name: string; price: number }) {
      const userStore = useUserStore(); // 实例化用户Store
      if (!userStore.token) {
        alert('请先登录');
        return;
      }
      this.cartList.push(goods);
    }
  }
});

四、Vue3+Pinia实战案例(模拟电商场景)

结合前面的核心用法,实现一个简单的电商场景实战案例,包含「用户登录/退出」「购物车添加/删除」「全局状态共享」,整合多Store、异步actions、响应式解构等核心知识点,可直接复制到项目中使用。

4.1 实战准备(创建3个Store)

创建store-name.tsuserStore.ts(用户)、cartStore.ts(购物车)、goodsStore.ts(商品),代码如下:

// 1. store-name.ts(Store名称枚举)
export const enum Names {
  User = 'USER',
  Cart = 'CART',
  Goods = 'GOODS'
}

// 2. userStore.ts(用户Store)
import { defineStore } from 'pinia';
import { Names } from './store-name';

// 定义用户信息类型(TS类型约束)
interface UserInfo {
  name: string;
  age: number;
  avatar: string;
}

export const useUserStore = defineStore(Names.User, {
  state: () => ({
    token: localStorage.getItem('token') || '', // 持久化存储token
    userInfo: {} as UserInfo
  }),
  actions: {
    // 登录(异步,模拟接口请求)
    async login(account: string, password: string) {
      // 模拟接口请求
      const res = await new Promise((resolve) => {
        setTimeout(() => {
          resolve({
            token: 'pinia_demo_token_123',
            userInfo: { name: '小明', age: 22, avatar: 'https://picsum.photos/200/200' }
          });
        }, 1000);
      });
      const data = res as { token: string; userInfo: UserInfo };
      this.token = data.token;
      this.userInfo = data.userInfo;
      // 本地持久化token(避免页面刷新丢失)
      localStorage.setItem('token', data.token);
    },
    // 退出登录
    logout() {
      this.token = '';
      this.userInfo = {} as UserInfo;
      localStorage.removeItem('token');
    }
  }
});

// 3. goodsStore.ts(商品Store)
import { defineStore } from 'pinia';
import { Names } from './store-name';

// 商品类型约束
interface Goods {
  id: number;
  name: string;
  price: number;
  img: string;
  stock: number;
}

export const useGoodsStore = defineStore(Names.Goods, {
  state: () => ({
    goodsList: [] as Goods[] // 商品列表
  }),
  actions: {
    // 异步获取商品列表(模拟接口)
    async fetchGoodsList() {
      const res = await new Promise((resolve) => {
        setTimeout(() => {
          resolve([
            { id: 1, name: 'Vue3实战教程', price: 99, img: 'https://picsum.photos/200/200', stock: 100 },
            { id: 2, name: 'Pinia入门手册', price: 59, img: 'https://picsum.photos/200/200', stock: 50 },
            { id: 3, name: 'TS入门到精通', price: 79, img: 'https://picsum.photos/200/200', stock: 80 }
          ]);
        }, 800);
      });
      this.goodsList = res as Goods[];
    }
  }
});

// 4. cartStore.ts(购物车Store)
import { defineStore } from 'pinia';
import { Names } from './store-name';
import { useUserStore } from './userStore';
import { Goods } from './goodsStore'; // 复用商品类型

export const useCartStore = defineStore(Names.Cart, {
  state: () => ({
    cartList: [] as { goods: Goods; count: number }[] // 购物车列表(商品+数量)
  }),
  getters: {
    // 计算购物车总价格
    cartTotalPrice(state) {
      return state.cartList.reduce((total, item) => {
        return total + item.goods.price * item.count;
      }, 0);
    },
    // 计算购物车商品总数
    cartTotalCount(state) {
      return state.cartList.reduce((total, item) => total + item.count, 0);
    }
  },
  actions: {
    // 添加商品到购物车
    addToCart(goods: Goods, count: number = 1) {
      const userStore = useUserStore();
      if (!userStore.token) {
        alert('请先登录');
        return;
      }
      // 判断商品是否已在购物车中
      const existingItem = this.cartList.find(item => item.goods.id === goods.id);
      if (existingItem) {
        existingItem.count += count;
      } else {
        this.cartList.push({ goods, count });
      }
    },
    // 从购物车删除商品
    removeFromCart(goodsId: number) {
      this.cartList = this.cartList.filter(item => item.goods.id !== goodsId);
    },
    // 修改购物车商品数量
    updateCartCount(goodsId: number, count: number) {
      const item = this.cartList.find(item => item.goods.id === goodsId);
      if (item) {
        item.count = count;
      }
    },
    // 清空购物车
    clearCart() {
      this.cartList = [];
    }
  }
});

4.2 实战组件开发(3个核心组件)

4.2.1 登录组件(Login.vue)

<template>
  <div class="login-container">
    <h2>用户登录</h2>
    <div class="form-item">
      <label>账号:</label>
      <input v-model="account" type="text" placeholder="请输入账号" />
    </div>
    <div class="form-item">
      <label>密码:</label>
      <input v-model="password" type="password" placeholder="请输入密码" />
    </div>
    <button @click="handleLogin" :disabled="loading">
      {{ loading ? '登录中...' : '登录' }}
    </button>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { useUserStore } from '@/store/userStore';
import { useRouter } from 'vue-router'; // 路由跳转(需配置路由)

const userStore = useUserStore();
const router = useRouter();
const account = ref('');
const password = ref('');
const loading = ref(false);

const handleLogin = async () => {
  if (!account.value || !password.value) {
    alert('请输入账号和密码');
    return;
  }
  loading.value = true;
  try {
    await userStore.login(account.value, password.value);
    alert('登录成功');
    router.push('/home'); // 登录成功跳转首页
  } catch (err) {
    alert('登录失败,请重试');
  } finally {
    loading.value = false;
  }
};
</script>

<style scoped>
.login-container {
  width: 300px;
  margin: 100px auto;
  text-align: center;
}
.form-item {
  margin: 15px 0;
  text-align: left;
}
input {
  width: 100%;
  padding: 8px;
  margin-top: 5px;
}
button {
  width: 100%;
  padding: 10px;
  background: #42b983;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
button:disabled {
  background: #ccc;
  cursor: not-allowed;
}
</style>

4.2.2 首页组件(Home.vue)

<template>
  <div class="home-container">
    <header class="home-header">
      <h1>Pinia电商实战</h1>
      <div class="user-info">
        <img v-if="userInfo.avatar" :src="userInfo.avatar" alt="用户头像" class="avatar" />
        <span v-if="userInfo.name">欢迎您,{{ userInfo.name }}</span>
        <button @click="handleLogout" v-if="token">退出登录</button>
        <button @click="toLogin" v-else>去登录</button>
        <div class="cart-icon" @click="toCart">
          购物车({{ cartTotalCount }})
        </div>
      </header>

      <section class="goods-list">
        <h2>商品列表</h2>
        <div class="goods-item" v-for="goods in goodsList" :key="goods.id">
          <img :src="goods.img" alt="商品图片" class="goods-img" />
          <div class="goods-info">
            <h3>{{ goods.name }}</h3>
            <p class="price">¥{{ goods.price }}</p>
            <p class="stock">库存:{{ goods.stock }}</p>
            <button @click="addToCart(goods)">加入购物车</button>
          </div>
        </div>
      </section>
    </div>
</template>

<script setup lang="ts">
import { onMounted } from 'vue';
import { useUserStore } from '@/store/userStore';
import { useGoodsStore } from '@/store/goodsStore';
import { useCartStore } from '@/store/cartStore';
import { storeToRefs } from 'pinia';
import { useRouter } from 'vue-router';

// 实例化Store
const userStore = useUserStore();
const goodsStore = useGoodsStore();
const cartStore = useCartStore();
const router = useRouter();

// 响应式解构状态(避免失去响应性)
const { token, userInfo } = storeToRefs(userStore);
const { goodsList } = storeToRefs(goodsStore);
const { cartTotalCount, addToCart } = cartStore;

// 组件挂载时,获取商品列表
onMounted(() => {
  goodsStore.fetchGoodsList();
});

// 退出登录
const handleLogout = () => {
  userStore.logout();
  alert('退出成功');
  router.push('/login');
};

// 跳转登录页
const toLogin = () => {
  router.push('/login');
};

// 跳转购物车页
const toCart = () => {
  if (!token.value) {
    alert('请先登录');
    router.push('/login');
    return;
  }
  router.push('/cart');
};
</script>

<style scoped>
.home-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 10px 20px;
  border-bottom: 1px solid #eee;
}
.avatar {
  width: 40px;
  height: 40px;
  border-radius: 50%;
  margin-right: 10px;
}
.user-info {
  display: flex;
  align-items: center;
}
.user-info button {
  margin-left: 20px;
  padding: 5px 10px;
  cursor: pointer;
}
.cart-icon {
  margin-left: 20px;
  cursor: pointer;
  font-weight: bold;
}
.goods-list {
  padding: 20px;
}
.goods-item {
  display: flex;
  margin: 20px 0;
  border-bottom: 1px solid #eee;
  padding-bottom: 20px;
}
.goods-img {
  width: 100px;
  height: 100px;
  margin-right: 20px;
}
.goods-info {
  flex: 1;
}
.price {
  color: #ff4400;
  font-size: 18px;
  font-weight: bold;
}
.stock {
  color: #666;
  margin: 10px 0;
}
.goods-info button {
  padding: 8px 15px;
  background: #ff4400;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
</style>

4.2.3 购物车组件(Cart.vue)

<template>
  <div class="cart-container">
    <h2>我的购物车</h2>
    <div class="cart-empty" v-if="cartList.length === 0">
      购物车为空,快去添加商品吧!
    </div>
    <div class="cart-list" v-else>
      <div class="cart-item" v-for="item in cartList" :key="item.goods.id">
        <img :src="item.goods.img" alt="商品图片" class="cart-img" />
        <div class="cart-info">
          <h3>{{ item.goods.name }}</h3>
          <p class="price">¥{{ item.goods.price }}</p>
          <div class="count-control">
            <button @click="updateCount(item.goods.id, item.count - 1)" :disabled="item.count <= 1">-</button>
            <span>{{ item.count }}</span>
            <button @click="updateCount(item.goods.id, item.count + 1)" :disabled="item.count >= item.goods.stock">+</button>
          </div>
        </div>
        <button class="delete-btn" @click="removeFromCart(item.goods.id)">删除</button>
      </div>
      <div class="cart-footer">
        <button class="clear-btn" @click="clearCart">清空购物车</button>
        <div class="total-info">
          合计:<span class="total-price">¥{{ cartTotalPrice.toFixed(2) }}</span>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { useCartStore } from '@/store/cartStore';
import { useUserStore } from '@/store/userStore';
import { storeToRefs } from 'pinia';
import { onMounted } from 'vue';
import { useRouter } from 'vue-router';

const cartStore = useCartStore();
const userStore = useUserStore();
const router = useRouter();

// 响应式解构
const { cartList, cartTotalPrice } = storeToRefs(cartStore);
const { token } = storeToRefs(userStore);
const { updateCartCount, removeFromCart, clearCart } = cartStore;

// 组件挂载时,判断是否登录
onMounted(() => {
  if (!token.value) {
    alert('请先登录');
    router.push('/login');
  }
});

// 修改商品数量
const updateCount = (goodsId: number, count: number) => {
  updateCartCount(goodsId, count);
};
</script>

<style scoped>
.cart-container {
  padding: 20px;
}
.cart-empty {
  text-align: center;
  padding: 50px;
  color: #666;
  font-size: 18px;
}
.cart-item {
  display: flex;
  align-items: center;
  margin: 20px 0;
  border-bottom: 1px solid #eee;
  padding-bottom: 20px;
}
.cart-img {
  width: 80px;
  height: 80px;
  margin-right: 20px;
}
.cart-info {
  flex: 1;
}
.price {
  color: #ff4400;
  font-weight: bold;
  margin: 10px 0;
}
.count-control {
  display: flex;
  align-items: center;
}
.count-control button {
  width: 30px;
  height: 30px;
  border: 1px solid #eee;
  background: #fff;
  cursor: pointer;
}
.count-control button:disabled {
  background: #eee;
  cursor: not-allowed;
}
.count-control span {
  width: 60px;
  text-align: center;
}
.delete-btn {
  padding: 8px 15px;
  background: #ff0000;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
.cart-footer {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-top: 30px;
}
.clear-btn {
  padding: 8px 15px;
  background: #666;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
.total-info {
  font-size: 18px;
  font-weight: bold;
}
.total-price {
  color: #ff4400;
  margin-left: 10px;
}
</style>

4.3 路由配置(router/index.ts)

配置路由,实现组件跳转,需先安装vue-router:npm install vue-router@4 -S,然后配置路由:

// src/router/index.ts
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
import Login from '@/views/Login.vue';
import Home from '@/views/Home.vue';
import Cart from '@/views/Cart.vue';

const routes: RouteRecordRaw[] = [
  { path: '/', redirect: '/home' },
  { path: '/login', component: Login },
  { path: '/home', component: Home },
  { path: '/cart', component: Cart }
];

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

export default router;

4.4 入口文件配置(main.ts)

// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { createPinia } from 'pinia'
import router from './router' // 引入路由

const app = createApp(App)
app.use(createPinia()) // 挂载Pinia
app.use(router) // 挂载路由
app.mount('#app')

4.5 实战效果说明

  1. 未登录状态下,点击「加入购物车」「购物车图标」,会提示登录并跳转登录页;

  2. 登录成功后,跳转首页,显示用户信息,可查看商品列表、添加商品到购物车;

  3. 购物车页面可修改商品数量、删除商品、清空购物车,实时显示合计价格和商品总数;

  4. 退出登录后,清空用户状态和token,购物车状态保留(可结合持久化插件优化,见下文)。

五、Pinia进阶技巧(实战必备)

5.1 数据持久化(避免页面刷新丢失)

Pinia默认不持久化数据,页面刷新后state会重置,可使用pinia-plugin-persistedstate插件实现本地存储(localStorage/sessionStorage)。

// 安装插件
npm install pinia-plugin-persistedstate -S
// main.ts 配置插件
import { createApp } from 'vue'
import App from './App.vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' // 引入插件
import router from './router'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate) // 挂载插件

const app = createApp(App)
app.use(pinia)
app.use(router)
app.mount('#app')

在Store中配置持久化(以购物车Store为例):

export const useCartStore = defineStore(Names.Cart, {
  state: () => ({ /* ... */ }),
  getters: { /* ... */ },
  actions: { /* ... */ },
  // 配置持久化
  persist: {
    key: 'cartStore', // 存储的key(localStorage中的key)
    storage: localStorage, // 存储方式(localStorage/sessionStorage)
    paths: ['cartList'] // 需要持久化的state字段(默认全部持久化)
  }
});

5.2 调试工具使用(Vue DevTools)

Pinia支持Vue DevTools,可实时查看Store状态、跟踪actions执行,便于调试:

  • Vue3中,安装Vue DevTools扩展,打开开发者工具,切换到「Pinia」面板,即可查看所有Store的state、getters;
  • 支持跟踪actions执行记录,可查看每次actions调用的参数和状态变化;
  • Vue3中暂不支持time-travel功能(时间回溯),Vue2中支持(需配合Vuex接口)。

5.3 模块热更新(HMR)

Pinia支持模块热更新,修改Store代码后,无需重新加载页面,即可生效,且会保留现有状态,提升开发效率,无需额外配置,Vue3项目默认支持。

六、常见问题与解决方案(实战避坑)

  • 问题1:组件中解构state后,修改值不生效? 解决方案:使用storeToRefs解构,修改时需加.value(如current.value++),直接解构会丢失响应性。
  • 问题2:actions中使用this指向异常? 解决方案:actions中的方法不能用箭头函数,需用普通函数,否则this无法指向Store实例。
  • 问题3:页面刷新后,Pinia状态丢失? 解决方案:使用pinia-plugin-persistedstate插件,配置持久化存储。
  • 问题4:多个Store之间无法通信? 解决方案:在需要通信的Store中,引入目标Store实例,即可访问其state和actions。
  • 问题5:TS类型推断失败,提示“this类型为any”? 解决方案:getters中使用this时,需指定返回值类型;actions中修改state时,确保state字段类型与赋值类型一致。
  • 问题6:Vue2中使用Pinia报错? 解决方案:Vue2中需额外引入PiniaVuePlugin,注册方式参考上传文档中的Vue2配置。

七、总结

Pinia是Vue3官方推荐的状态管理工具,相比Vuex,它更简洁、轻量、易上手,原生支持TS,完美适配组合式API,是Vue3项目的首选状态管理方案。

本文从基础认知、环境搭建、核心用法,到完整实战案例,覆盖了Pinia开发的全流程,重点讲解了Vue3+TS下的实战技巧,新手可按步骤搭建环境、编写代码,快速上手;老手可通过实战案例查漏补缺,优化项目中的状态管理逻辑。

核心要点:Store是Pinia的核心,每个Store包含state、getters、actions;修改state推荐使用$patch和actions;解构state需用storeToRefs保持响应性;结合插件可实现数据持久化,提升用户体验。

一文看懂:Vue3 watch 用 VuReact 转成 React 长啥样

作者 Ruihong
2026年4月14日 11:12

大家好,我是专注前端框架迁移、编译工具实践的掘金博主~在 Vue3 转 React 的过程中,watch 作为最常用的响应式监听 API,手动改写很容易丢失逻辑、写错依赖。

今天继续用 VuReact 工具,给大家带来 Vue3 watch → React 编译对照,全程一比一还原、保留所有行为与内链,看完直接上手迁移。


前言

先明确核心: VuReact 是能将 Vue 3 代码编译为标准、可维护 React 代码的工具 它最大亮点:编译阶段自动分析依赖、自动生成依赖追踪,完美对齐 Vue 响应式监听行为,不用手动处理 React Hooks 依赖。

本文只聚焦一个高频 API: 👉 Vue3 watch → React 等价代码 全程对照,不冗余、直接看核心。

前置约定(避免理解偏差)

为了示例清爽,先统一两点:

  1. 只保留核心逻辑,省略组件包裹、无关配置
  2. 默认你已熟悉 Vue3 watch 用法与核心行为

一、基础版:watch → useWatch

Vue 标准 watch 监听,支持 immediate、清理函数 onCleanup,VuReact 直接编译为 useWatch

Vue 源码

<script setup>
import { ref, watch } from 'vue';
const userId = ref(1);

watch(
  userId,
  async (newId, oldId, onCleanup) => {
    let cancelled = false;
    onCleanup(() => {
      cancelled = true;
    });
    const data = await fetchUser(newId);
    if (!cancelled) {
      userData.value = data;
    }
  },
  { immediate: true },
);
</script>

VuReact 编译后 React 代码

import { useVRef, useWatch } from '@vureact/runtime-core';
const userId = useVRef(1);

useWatch(
  userId,
  async (newId, oldId, onCleanup) => {
    let cancelled = false;
    onCleanup(() => {
      cancelled = true;
    });
    const data = await fetchUser(newId);
    if (!cancelled) {
      setUserData(data);
    }
  },
  { immediate: true },
);

核心要点

  • Vue watch() 直接编译为 useWatch
  • 完全保留:回调参数、immediateonCleanup 清理机制
  • 编译阶段自动分析依赖、深度追踪,无需手动管理依赖数组

二、深度监听 & 多源监听:对象/数组来源兼容

watch 监听对象内部属性、多源数组时,VuReact 同样支持 deep 与多源写法,行为完全对齐 Vue。

Vue 源码(深度监听 + 多源监听)

<script setup>
import { reactive, watch } from 'vue';
const state = reactive({
  info: { name: 'Vureact', version: '1.0' },
  count: 0,
});

// 深度监听对象内部
watch(
  () => state.info,
  (newInfo) => {
    console.log('对象内部变化:', newInfo.name);
  },
  { deep: true },
);

// 多源监听
watch([state.count, () => state.info.name], ([newCount, newName]) => {
  console.log('计数:', newCount, '名称:', newName);
});
</script>

VuReact 编译后 React 代码

import { useReactive, useWatch } from '@vureact/runtime-core';
const state = useReactive({
  info: { name: 'Vureact', version: '1.0' },
  count: 0,
});

useWatch(
  () => state.info,
  (newInfo) => {
    console.log('对象内部变化:', newInfo.name);
  },
  { deep: true },
);

useWatch([state.count, () => state.info.name], ([newCount, newName]) => {
  console.log('计数:', newCount, '名称:', newName);
});

对应关系

  • 监听函数写法、deep: true 深度监听完全保留
  • 多源数组监听直接兼容
  • 编译器自动做依赖分析,不用手动写 deps

三、一句话总结

用 VuReact 做 Vue3 → React 迁移,watch 相关规则:

  1. watchuseWatch
  2. 支持 immediate / deep / onCleanup 全部选项
  3. 支持单源、函数返回值、多源数组监听
  4. 依赖自动追踪,无需手动管理依赖数组
  5. 行为 1:1 对齐 Vue,迁移零逻辑损耗

相关资源

❤️ 觉得有用就 点赞 + 收藏 + 关注,持续更新前端迁移/编译工具实战!

一文看懂:Vue3 watchEffect 用 VuReact 转成 React 长啥样

作者 Ruihong
2026年4月14日 11:06

大家好,我是专注前端框架迁移、编译工具实践的掘金博主~最近很多同学在做 Vue3 → React 技术栈迁移,被响应式 API 对齐、依赖手动管理搞得头大,尤其是 watchEffect 这种自动依赖收集的核心 API,在 React 里很容易漏写依赖。

今天就用 VuReact 这个编译工具,直接把 Vue3 watchEffect 的各种用法一比一翻译成标准可维护的 React 代码,全程对照、看完即用。


前言

先明确核心: VuReact 是能将 Vue 3 代码编译为标准、可维护 React 代码的工具 它最大亮点:编译阶段自动分析依赖、自动生成依赖数组,完美对齐 Vue 响应式行为,不用手动维护 React Hooks 依赖。

本文只聚焦一个高频 API: 👉 Vue3 watchEffect → React 等价代码 全程对照,不冗余、直接看核心。

前置约定(避免理解偏差)

为了示例清爽,先统一两点:

  1. 只保留核心逻辑,省略组件包裹、无关配置
  2. 默认你已熟悉 Vue3 watchEffect 用法与行为

一、基础版:watchEffect → useWatchEffect

Vue 最常用的基础 watchEffect,自动收集依赖、自动触发副作用。

Vue 源码

<script setup>
import { ref, watchEffect } from 'vue';
const count = ref(0);

watchEffect(() => {
  console.log(`当前计数是: ${count.value}`);
});
</script>

VuReact 编译后 React 代码

import { useVRef, useWatchEffect } from '@vureact/runtime-core';
const count = useVRef(0);

useWatchEffect(() => {
  console.log(`当前计数是: ${count.value}`);
}, [count.value]);

核心要点

  • Vue watchEffect() 直接编译为 useWatchEffect
  • 编译阶段自动分析依赖并生成精准依赖数组,无需手动管理
  • 完全模拟 Vue watchEffect 的自动依赖收集、清理机制、停止控制

二、带 flush 选项:post / sync 对齐渲染时机

Vue 中通过 flush: 'post' / flush: 'sync' 控制执行时机,VuReact 直接映射为专用 Hook,保持渲染时机一致。

Vue 源码(post + sync)

<script setup>
import { ref, watchEffect } from 'vue';
const width = ref(0);
const elRef = ref(null);

// DOM 更新后执行
watchEffect(
  () => {
    if (elRef.value) {
      width.value = elRef.value.offsetWidth;
    }
  },
  { flush: 'post' },
);

// 同步立即执行
watchEffect(
  () => {
    console.log(elRef.value);
  },
  { flush: 'sync' },
);
</script>

VuReact 编译后 React 代码

import { useVRef } from '@vureact/runtime-core';
import { useWatchPostEffect, useWatchSyncEffect } from '@vureact/runtime-core';

const width = useVRef(0);
const elRef = useVRef(null);

useWatchPostEffect(
  () => {
    if (elRef.value) {
      width.value = elRef.value.offsetWidth;
    }
  },
  [elRef.value, width.value, elRef.value.offsetWidth]
);

useWatchSyncEffect(
  () => {
    console.log(elRef.value);
  },
  [elRef.value]
);

对应关系

  • flush: 'post'useWatchPostEffect
  • flush: 'sync'useWatchSyncEffect
  • 执行时机、依赖追踪、副作用行为完全对齐 Vue
  • 依赖数组依旧自动生成,无需手动编写

三、一句话总结

用 VuReact 做 Vue3 → React 迁移,watchEffect 相关规则:

  1. watchEffectuseWatchEffect
  2. flush: 'post'useWatchPostEffect
  3. flush: 'sync'useWatchSyncEffect
  4. 依赖自动收集、deps 自动生成,不用手动维护
  5. 行为 1:1 对齐 Vue,迁移成本极低

相关资源

互动一下

你在 Vue 转 React 时,最头疼哪个 API? watch / computed / defineProps / defineEmits? 评论区留言,下期直接出对照编译手册

❤️ 觉得有用就 点赞 + 收藏 + 关注,持续更新前端迁移/编译工具实战!

antdv-next/x:面向 Vue 的 AI 组件体系

作者 carl_chen
2026年4月14日 09:35

写在前面

antdv-next/x 的核心价值,是让 Ant Design X 的源设计体系在 Vue 中可复用、可扩展、可落地。

如果你正在做 AI 产品,这意味着你不用从零搭一套“聊天+生成+引用+反馈”的界面体系,也不用在一致性和开发效率之间反复取舍。

antdv-next/x 把这些高频能力沉淀成可复用的 Vue 组件,让团队可以更快上线、更稳迭代。

为什么现在需要它?

传统组件库解决的是通用页面问题,但 AI 产品面临的是另一套体验挑战:

  • 回答要流式呈现,状态要可感知
  • 输入不只是文本,还包括 Prompt 组织与附件
  • 多轮会话需要上下文切换与管理
  • 输出结果需要复制、重试、反馈、引用溯源

这些能力如果每个项目都重写一遍,代价会非常高:

  • 开发周期被拉长
  • 交互风格难统一
  • 后续维护与扩展成本持续上升

antdv-next/x 带来的价值

1. 更快落地 AI 界面

开箱即用的 Vue 3 AI 组件,减少重复造轮子,把时间投入到业务差异化能力上。

2. 更统一的产品体验

提炼自 Ant Design X 的交互语言与视觉风格,并与 antdv-next 体验协同,降低“页面像拼起来的”割裂感。

3. 更灵活的场景扩展

不仅能做 Chat,还能覆盖 Agent 任务流、知识问答、附件处理、推理过程展示等复合场景。

4. 更稳的工程基础

内建 Markdown 增强渲染能力,支持流式渲染、公式、代码高亮、Mermaid;同时提供 TypeScript 类型支持,并兼容 SSR 与 Electron。

设计取向:融合,而非照搬

antdv-next/x 不是 React 方案的机械迁移,而是面向 Vue 生态的原生化实现:

  • 保留 Vue 的表达习惯(Slots、Composition API)
  • 强化可定制渲染,适应 AI 场景快速变化
  • 对齐 antdv-next 的主题与开发体验

适合谁用?

  • 已经基于 antdv-next 开发业务系统的团队
  • 正在搭建 AI 助手、Copilot、问答、Agent 类产品的团队
  • 需要“快速上线 + 长期可维护”并重的团队

写在最后

如果你希望在 Vue 生态里,以更低成本交付高质量 AI 界面,antdv-next/x 是一条非常务实的路径。

npm install @antdv-next/x

欢迎试用,也欢迎在 GitHub 提 Issue 一起共建。

全面升级!看看人家的后台管理系统,确实清新优雅!

作者 MacroZheng
2026年4月14日 09:23

关注过我的mall项目的小伙伴应该有所了解,mall项目的后台管理系统一直都是Vue2版本的,主要原因是项目从Vue2升级到Vue3基本等于要重写了。 最近我花了一个月的时间,将mall项目的后台管理系统升级到了Vue3版本,今天和大家聊聊做了哪些升级!

项目介绍

mall-admin-web是mall电商项目后台管理系统的前端项目,基于Vue3+Element-Plus实现。主要包括商品管理、订单管理、会员管理、促销管理、运营管理、内容管理、统计报表、财务管理、权限管理、设置等功能。

下面是mall-admin-web项目运行的效果图,界面还是很清新优雅的!如果你想体验完整功能的话,可以访问这个在线演示地址:www.macrozheng.com/admin/

技术栈

mall-admin-web技术栈已经全面升级,基于目前主流的前端技术栈,版本也是比较新的,具体技术栈如下。

技术 说明 版本
Vue 前端框架 3.5.25
Element Plus 前端UI框架 2.12.0
Vue Router 路由框架 4.6.3
Pinia 全局状态管理框架 3.0.4
Pinia Plugin Persistedstate Pinia持久化插件 4.7.1
Axios 前端HTTP框架 1.13.2
Vue-charts 基于Echarts的图表框架 8.0.1
TinyMCE Vue 富文本编辑器 5.1.1
Js-cookie cookie管理工具 3.0.5

升级内容

这里和大家聊聊mall-admin-web做了哪些升级!

Vue2升级Vue3

项目的Vue版本从之前的2.7.2升级到了3.5.25,改动还是挺大的,之前使用的选项式API都已经改成了Vue3的组合式API。

我在升级项目的同时,给代码添加了更加详尽的注释,方便大家来学习。

之前经常有小伙伴问接口文档在哪里,其实把后端项目运行起来,就有接口文档了,我这里给前端调用的接口方法添加了详细的注释,大家也可以直接从代码中查看接口调用。

JavaScript升级TypeScript

TypeScript我们可以把它看作是带有类型的JavaScript,JavaScript里的支持的语法,它基本都支持。

项目中对于使用到的对象添加了类型支持,用起来有点Java中对象的感觉。

这样我们在编写代码时就可以有属性提示了,使用TypeScript我们在编译时就可以发现错误,以便及时修正。

这里有两者使用的优势对比,大家可以参考下!

Element UI升级Element Plus

由于Element UI已经停止更新,这里升级到了支持Vue3的Element Plus组件库,两者使用过程中的特性与优缺点对比如下。

Vuex升级Pinia

Pinia是Vue官方开发的状态管理库,使用它API更简洁,而且完美支持Vue3和TypeScript。

项目中的用户信息存储就使用了它,配合pinia-plugin-persistedstate插件,还可以实现数据的持久化。

两者使用过程中的特性与优缺点对比如下。

v-charts升级vue-charts

之前项目中使用的图表库v-charts已经停止维护,这里升级到了vue-charts,使用该库生成的图表功能也更加强大了!

两者使用过程中的特性与优缺点对比如下。

总结

今天给大家分享了mall后台管理系统前端的升级内容,主要是项目升级到了Vue3,一些过时的库也迁移到了新的库,升级之后项目更加适合学习了,感兴趣的小伙伴可以学习下!

项目地址

一个轻量级 Vue3 轮播组件:支持多视图、滑动距离决定切换数量,核心原理与 Swiper 对比

作者 禅思院
2026年4月14日 09:19

一个轻量级 Vue3 轮播组件:支持多视图、滑动距离决定切换数量,核心原理与 Swiper 对比

在这里插入图片描述

支持 slidesPerViewspaceBetween、滑动距离决定滑动数量,代码仅 400 行,核心原理全解析。

引言

在业务开发中,轮播图是几乎每个前端都会遇到的场景。Swiper 无疑是功能最全面的库,但它体积较大(核心库 ~30kB,加上模块更重),且在某些轻量化项目中显得有些“杀鸡用牛刀”。因此,我决定用 Vue 3 + TypeScript 手写一个轮播组件,只保留最常用的 NavigationPagination,同时支持多视图(slidesPerView)和间距(spaceBetween),并实现“根据滑动距离决定切换数量”的自然交互。

本文会详细讲解实现原理、核心难点,并与 Swiper 进行对比,希望能给正在造轮子或想深入理解轮播机制的你一些启发。

组件特性

  • 多视图模式:通过 slidesPerView 控制每屏显示几张幻灯片
  • 可配置间距spaceBetween 设置幻灯片之间的间隔
  • 循环播放:无缝无限滚动,复制首尾元素实现
  • 自动播放:支持悬停暂停
  • 拖拽滑动:鼠标/触摸拖拽,根据滑动距离(四舍五入)决定一次滑动的 slide 数量,而非固定 1 张
  • 导航与分页:分页器在非循环模式下显示可滑动步数(总条数 - 每屏个数 + 1
  • 点击事件:区分拖拽与点击,避免误触发
  • TypeScript:完整类型定义,便于接入大型项目

实现原理

1. 多视图与间距的布局计算

核心思路:使用 flex 布局,每个 slide 的宽度动态计算,右外边距实现间距。

const slideWidth = (containerWidth - (slidesPerView - 1) * spaceBetween) / slidesPerView;
const slideStep = slideWidth + spaceBetween; // 每次滚动的总步长

滚动时通过 transform: translate3d(-currentOffset * slideStep, 0, 0) 移动整个轨道。

2. 循环模式(Loop)的实现

真正的无限循环不是把数据无限复制,而是在原始数组前后各复制 slidesPerView 个 slide,形成“假首尾”。初始时偏移量设为复制品的起始位置。当用户滑动到复制品区域时,过渡结束后立即无动画跳转到对应的真实 slide,视觉上无感知。

关键步骤

  • displaySlides = [...clonesFront, ...originals, ...clonesBack]
  • displayOffset = cloneCount + activeIndex(循环模式)或 activeIndex(非循环)
  • 过渡结束后检测 displayOffset 是否小于 cloneCount 或大于 cloneCount + originals.length - 1,若是则修正 activeIndex 并重置位置。

注意:分页器在循环模式下仍显示原始数据条数,change 事件始终返回原始索引。

3. 根据滑动距离决定滑动数量

很多简单轮播只支持一次滑动一张,体验呆板。我们希望像 Swiper 那样:拖拽超过半个 slide 宽度就切换,且滑动距离越大,一次切换的张数越多

实现方法:

  • 拖拽结束时计算 deltaSlides = Math.round(dragDistance / slideStep)
  • 目标索引 = currentIndex - deltaSlides(向右滑动为正,索引减少)
  • 调用 goTo(newIndex),内部自动处理边界和循环取模。

4. 分页器点数计算

这是许多开发者容易出错的地方。假设有 20 张图,每屏显示 3 张,那么分页器应该有几个点?

  • 非循环模式:用户可以滑动到的不同起始索引有 20 - 3 + 1 = 18 个位置,因此分页器应为 18 个点,每个点代表一组可见 slide。
  • 循环模式:由于可以无限滚动,分页器仍然显示 20 个点,对应原始数据的索引。

组件中通过 maxStartIndex = slides.length - slidesPerView 计算最大起始索引,paginationCount = loop ? slides.length : maxStartIndex + 1

5. 拖拽与点击的区分

直接给 slide 绑 @click 会导致拖拽结束后也触发点击。解决方案:在 touchstart/mousedown 时设置 dragOccurred = false,在 touchmove 中检测移动距离超过 5px 时置为 truetouchend 时重置(延迟一帧)。click 事件检查该标志,若为 true 则忽略。

6. 自动播放与性能优化

  • 自动播放使用 setInterval,在用户交互(拖拽、点击导航)时重置定时器。
  • 窗口 resize 时重新计算宽度并修正位置。
  • 使用 will-change: transform 开启 GPU 加速。

与 Swiper 的对比

维度 本组件 Swiper
体积 ~400 行源码,无依赖 核心 ~30KB,完整功能 ~70KB+
功能覆盖 Navigation, Pagination, 多视图, 循环, 自动播放, 拖拽滑动数量 所有你能想到的轮播功能(缩略图、3D 流、懒加载、RTL 等)
学习成本 极低,Props 直观 配置项丰富,需要查阅文档
扩展性 简单,可自由修改源码 通过模块和 API 扩展,但定制复杂功能仍需理解内部机制
TypeScript 原生 TS 编写,类型完整 有 @types/swiper,但配置项类型复杂
移动端适配 支持触摸,已处理被动事件 专业级,手势非常顺滑
维护性 个人项目,需自行维护 社区维护,更新及时
适用场景 轻量级项目、特定场景、学习目的 企业级、复杂交互、追求稳定全面

总结:如果你的项目只需要基础轮播且对体积敏感,或者你想完全掌控交互细节,这个组件是很好的选择;如果需要支持 IE、复杂手势或特殊效果,Swiper 仍是首选。

组件使用示例

<template>
  <Carousel
    :slides="banners"
    :slidesPerView="3"
    :spaceBetween="20"
    :loop="true"
    :autoplay="true"
    @slide-click="onClick"
  >
    <template #slide="{ item }">
      <div class="card">
        <img :src="item.url" />
        <p>{{ item.title }}</p>
      </div>
    </template>
  </Carousel>
</template>

核心代码片段

拖拽滑动数量计算

const endDrag = () => {
  const deltaSlides = Math.round(dragDelta.value / slideStep.value);
  if (deltaSlides !== 0) {
    goTo(activeIndex.value - deltaSlides);
  } else {
    // 回弹
    wrapperRef.value.style.transform = `translate3d(${translateDistance.value}px, 0, 0)`;
  }
};

循环修正

const performLoopCorrection = () => {
  const offset = displayOffset.value;
  const min = cloneCount.value;
  const max = cloneCount.value + slidesLength.value - 1;
  if (offset < min) {
    activeIndex.value += slidesLength.value;
    jumpToOffset(cloneCount.value + activeIndex.value, true);
    emit('loop-correct', activeIndex.value);
  } else if (offset > max) {
    activeIndex.value -= slidesLength.value;
    jumpToOffset(cloneCount.value + activeIndex.value, true);
    emit('loop-correct', activeIndex.value);
  }
};

总结

造轮子不是为了重复发明,而是为了深入理解。通过实现这个轮播组件,我掌握了多视图布局、循环复制的技巧、拖拽距离映射滑动数量、分页器正确计数等核心知识。相比直接使用 Swiper,这个组件让我的 Vue 能力提升了一个台阶。

如果您的项目需要轻量级、可定制的轮播,不妨试试这个组件;如果您需要更全面的功能,Swiper 依然是标杆。希望这篇文章能给您带来启发!


组件代码仓库:可在评论区留言获取完整源码。

你的 Vue 3 reactive(),VuReact 会编译成什么样的 React?

作者 Ruihong
2026年4月13日 17:17

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 中高频使用的 reactive()shallowReactive(),经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

  1. 示例只保留核心逻辑,省略完整组件包裹
  2. 你已熟悉 Vue 3 reactive / shallowReactive 用法

一、Vue reactive() → React useReactive()

reactive 是 Vue 3 最核心的对象响应式 API,在 VuReact 中会被精准映射。

基础编译对照

Vue 输入

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

  const state = reactive({
    count: 0,
    title: 'VuReact',
  });
</script>

VuReact 输出(React)

import { useReactive } from '@vureact/runtime-core';

const state = useReactive({
  count: 0,
  title: 'VuReact',
});

reactive 直接编译为 useReactive Hook:

  • 完全保留 Vue 响应式语义
  • 直接修改属性自动触发视图更新
  • 深层对象、数组、Map/Set 全部支持
  • 和 React 生命周期完美协同

TypeScript 场景:类型完整保留

Vue 输入(TS)

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

  interface User {
    id: number;
    name: string;
  }

  const state = reactive<{
    loading: boolean;
    users: User[];
    config: Record<string, any>;
  }>({
    loading: false,
    users: [],
    config: { theme: 'dark' },
  });
</script>

VuReact 输出(TS)

import { useReactive } from '@vureact/runtime-core';

interface User {
  id: number;
  name: string;
}

const state = useReactive<{
  loading: boolean;
  users: User[];
  config: Record<string, any>;
}>({
  loading: false,
  users: [],
  config: { theme: 'dark' },
});

接口、泛型、类型约束完全迁移
React 侧智能提示、类型检查全部正常
不用改一行类型逻辑


二、Vue shallowReactive() → React useShallowReactive()

浅层响应式用于性能优化,只监听顶层属性变化,VuReact 同样完美对齐。

基础编译对照

Vue 输入

<script setup>
  import { shallowReactive } from 'vue';

  const state = shallowReactive({
    nested: { count: 0 },
  });
</script>

VuReact 输出(React)

import { useShallowReactive } from '@vureact/runtime-core';

const state = useShallowReactive({
  nested: { count: 0 },
});

useShallowReactive 行为完全对齐 Vue:

  • 修改顶层属性 → 触发更新
  • 修改深层嵌套属性 → 不触发更新
  • 替换整个对象 → 触发更新
  • 适合大型列表、复杂状态、第三方数据等性能场景

总结一句话

  • Vue reactive → React useReactive
  • Vue shallowReactive → React useShallowReactive
  • 响应式行为一致
  • TypeScript 类型一致
  • 开发心智完全一致

用 VuReact,你可以:

  • 继续用 Vue 3 舒服的写法
  • 直接产出可维护的 React 代码
  • 无痛渐进迁移,不用一次性重构

🔗 相关资源

你的 Vue 3 ref(),VuReact 会编译成什么样的 React?

作者 Ruihong
2026年4月13日 17:14

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的编译工具,并非运行时混合框架。今天我们直接看核心:Vue 高频使用的 ref() / shallowRef(),经过 VuReact 编译后会对应 React 的哪些代码?

前置约定

  1. 文中 Vue/React 代码均为核心逻辑简写,省略完整组件与冗余结构
  2. 你已熟悉 Vue 3 refshallowRef 的用法与行为

一、Vue ref() → React useVRef()

ref 是 Vue 3 最基础的响应式 API,在 VuReact 中会被直接编译为 React Hook。

基础编译对照

Vue 输入

<script setup>
  import { ref } from 'vue';
  const count = ref(0);
</script>

VuReact 输出(React)

import { useVRef } from '@vureact/runtime-core';
const count = useVRef(0);

ref 会被编译成 useVRef,它是 Vue ref 在 React 里的语义完全对齐的适配 API,保留 .value 访问与响应式更新行为。

带 TypeScript 类型场景

Vue 输入(TS)

<script lang="ts" setup>
  const title = ref<string>('');
  const isLoading = ref<boolean>(false);
  const userList = ref<Array<{ id: number; name: string }>>([]);
  const config = ref<Record<string, any>>({ theme: 'dark' });
</script>

VuReact 输出(TS)

const title = useVRef<string>('');
const isLoading = useVRef<boolean>(false);
const userList = useVRef<Array<{ id: number; name: string }>>([]);
const config = useVRef<Record<string, any>>({ theme: 'dark' });

TS 泛型、类型注解完整保留,React 侧类型提示完全可用。


二、Vue shallowRef() → React useShallowVRef()

shallowRef 是浅层响应式 API,只监听顶层引用变化,适合大对象性能优化。

基础编译对照

Vue 输入

<script setup>
  import { shallowRef } from 'vue';
  const count = shallowRef({ a: { b: 1, c: { d: 2 } } });
</script>

VuReact 输出(React)

import { useShallowVRef } from '@vureact/runtime-core';
const count = useShallowVRef({ a: { b: 1, c: { d: 2 } } });

useShallowVRef 完全对齐 shallowRef 行为:

  • 修改嵌套属性 → 不触发更新
  • 直接替换 .value触发更新

🔗 相关资源

Vue3 转 React:组件透传 Attributes 与 useAttrs 使用详解|VuReact 实战

作者 Ruihong
2026年4月13日 13:08

在 Vue3 迁移 React、跨框架组件封装的场景里,透传 Attributes 是几乎必用、但极易踩坑的能力。Vue 的 $attrs / useAttrs 和 React 的 props 体系设计差异很大,而 VuReact 作为稳定的 Vue3 → React 编译工具,已经把这套逻辑做了完整对齐。

本文带你一次性搞懂:透传属性是什么、为什么必须用 useAttrs、TS 怎么写、转换后长什么样,直接复制就能用。


一、先搞懂:透传 Attributes 到底是什么?

1. Vue 官方定义

透传 attribute:传给组件,但没有被声明为 props / emits 的属性或事件监听器。 最常见:classstyleid、自定义属性、v-on 监听等。

Vue 默认会把它们自动继承到组件根节点,也可以用 $attrsuseAttrs() 手动控制。

2. React 里的等价逻辑

React 没有“透传”这个名词,但行为一致: 所有没在 Props 里定义的属性,都属于“透传属性”,全部挂在 props 上。

区别是:

  • Vue:运行时自动处理
  • React + TS:必须显式写类型,否则报错

3. VuReact 的核心适配规则

VuReact 把透传属性统一理解为: 无类型约束的运行时对象 + 已声明 Props 合并 = 最终组件属性

  • 组件无 Props → 自动生成:props: Record<string, unknown>
  • 组件有 Props → 自动交叉类型:Props & Record<string, unknown>

二、关键:必须从 $attrs 转向 useAttrs()

Vue 里有两种写法:

  • $attrs:运行时隐式变量 → VuReact 无法静态分析
  • useAttrs():显式 API → VuReact 完美支持、推荐唯一写法

1. Vue 中标准 useAttrs 写法(必背)

<script setup>
import { useAttrs } from 'vue'
const attrs = useAttrs()
</script>

好处:

  • 编译器可静态识别
  • 支持 TS 类型注解
  • 符合 React“显式优于隐式”的习惯

2. VuReact 转换规则(一张表看懂)

Vue useAttrs 写法 React 转换结果
无类型 const attrs = props as Record<string, unknown>
类型断言 as Attrs const attrs = props as Attrs
变量带类型 attrs: Attrs const attrs = props as Attrs
搭配 defineProps Props & Record<string, unknown>

三、实战示例:从 Vue 到 React 完整对照

示例 1:基础用法(无 TS)

Vue 输入

<template>
  <div :class="attrs.class" :style="attrs.style">
    {{ attrs.title }}
  </div>
</template>

<script setup>
import { useAttrs } from 'vue'
const attrs = useAttrs()
</script>

React 输出

import { memo } from 'react'

const Comp = memo((props: Record<string, unknown>) => {
  const attrs = props as Record<string, unknown>

  return (
    <div className={attrs.class} style={attrs.style}>
      {attrs.title}
    </div>
  )
})

export default Comp

示例 2:TS 类型增强(企业级推荐)

Vue 输入

<template>
  <div :class="attrs.class" :style="attrs.style">
    {{ attrs.customTitle }}
  </div>
</template>

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

interface CustomAttrs {
  class?: string
  style?: React.CSSProperties
  customTitle?: string
  [key: string]: unknown
}

const props = defineProps<{
  id: string
}>()

const attrs = useAttrs() as CustomAttrs
</script>

React 输出

import { memo } from 'react'

interface CustomAttrs {
  class?: string
  style?: React.CSSProperties
  customTitle?: string
  [key: string]: unknown
}

type ICompProps = { id: string }

const Comp = memo((props: ICompProps & Record<string, unknown>) => {
  const attrs = props as CustomAttrs

  return (
    <div className={attrs.class} style={attrs.style}>
      {attrs.customTitle}
    </div>
  )
})

export default Comp

示例 3:动态属性 / 可选链(真实业务常用)

Vue 输入

<template>
  <div
    :class="[
      'base',
      attrs.class,
      attrs.xx?.class,
      attrs['custom-class']
    ]"
  >
    {{ attrs?.xxx?.content }}
  </div>
</template>

React 输出

import { memo } from 'react'
import { dir } from '@vureact/runtime-core'

const Comp = memo((props: Record<string, unknown>) => {
  const attrs = props

  return (
    <div
      className={dir.cls([
        'base',
        attrs.class,
        attrs.xx?.class,
        attrs['custom-class']
      ])}
    >
      {attrs?.xxx?.content}
    </div>
  )
})

四、避坑指南(VuReact 必看)

  1. 必须用 useAttrs(),禁止用 $attrs 编译器无法分析运行时变量,会丢属性。

  2. TS 尽量写接口 有利于提示、重构、避免空值报错。

  3. class/style 自动适配 classclassName style → 自动适配 React.CSSProperties

  4. defineProps + useAttrs 会自动合并类型 不用手动改。

  5. JS 项目直接用 会被编译成 const attrs = props,完全兼容。


五、总结

VuReact 处理透传 Attributes 的核心思想只有一句话: 把 Vue 隐式的 $attrs 变成显式的 useAttrs,再映射到 React 的 props 体系。

  • 你只管按 Vue 官方写法写
  • 编译器自动转成标准 React TSX
  • 类型安全、生产可用、迁移成本极低

正在做 Vue3 → React 迁移的同学,这套透传方案可以直接进团队规范。


🔗 相关资源


推荐阅读

#Vue3 #React #Vue转React #VuReact #前端迁移 #useAttrs #组件封装 #TypeScript

vue3中静态提升和patchflag实现

2026年4月13日 11:48

1. 更快的 Virtual DOM (VDOM) - 具体体现

Vue 3 在虚拟 DOM 方面的改进是多方面的,旨在提高渲染效率和减少不必要的计算。

A. 编译时优化 (Compile-time Optimizations)

这是 Vue 3 与 Vue 2 最大的区别之一。Vue 2 的 VDOM diff 过程是在运行时进行的,它需要逐个比较节点和属性。而 Vue 3 的编译器在构建阶段就能分析模板,生成包含“优化提示”的渲染函数。

  • 静态提升 (Hoisting/Diff Skipping): 编译器会识别出模板中的静态节点(即内容不会改变的节点),并将它们提取到渲染函数之外。在后续更新时,Vue 完全跳过对这些节点的比较,因为它们永远不会变。
<!-- 模板 -->
<div>
  <h1>This is static</h1> <!-- 静态节点 -->
  <p>{{ dynamicValue }}</p> <!-- 动态节点 -->
  <span>Another static content</span> <!-- 静态节点 -->
</div>

在 Vue 2 中,每次更新 dynamicValue 时,都会对整个 <div> 的所有子节点进行 diff。在 Vue 3 中,<h1><span> 会被提升,只对 <p> 进行比较,大大减少了工作量。

  • Block Tree (块树): Vue 3 会将动态节点组织成一棵“块树”。更新时,只需要遍历这棵更小的动态节点树,而不是整个 VDOM 树。
  • Patch Flags (补丁标志): 编译器会给动态节点打上标记(flag),标明该节点哪些部分可能会变化(如文本、class、props、事件监听器等)。在 diff 阶段,Vue 可以根据这些标志跳过不必要的比较,直接执行特定的更新操作。
<!-- 模板 -->
<p :class="className">{{ message }}</p>

编译器会知道这个 <p> 元素可能变化的部分是 class 和文本内容,并打上相应的 flag。更新时就不会去检查它的 id 或其他不变的属性。

B. 更高效的 Diff 算法

虽然核心思想仍是双端 Diff,但 Vue 3 的实现更加优化,尤其是在处理列表更新时。

  • 快速路径 (Fast Paths) for List Updates: 对于一些常见的列表更新模式(如在末尾添加元素、替换整个列表等),Vue 3 提供了专门的快速路径算法,避免了复杂的最长递增子序列计算。
  • 更精确的移动策略: 在处理列表项顺序改变时,Vue 3 的算法能更精确地判断哪些元素需要移动,哪些可以就地复用,从而减少 DOM 操作次数。

总结 VDOM 性能提升体现:

  • 更快的初始渲染: 静态节点提升和块树优化减少了首次渲染的计算量。
  • 更快的状态更新: Patch flags 和优化的 Diff 算法减少了状态变更时的比较和更新开销。
  • 更少的内存占用: Block tree 结构和静态提升减少了运行时需要跟踪的节点数量。

2.静态提升和pathflag例子

<template>
  <div id="app">
    <h1 class="title">Welcome to My App</h1>
    <p>{{ greeting }}</p>
    <ul>
      <li>Static Item 1</li>
      <li>Static Item 2</li>
      <li>{{ dynamicItem }}</li> <!-- 这一项是动态的 -->
    </ul>
    <button @click="changeGreeting">Change Greeting</button>
  </div>
</template>

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

const greeting = ref('Hello Vue 3!');
const dynamicItem = ref('Dynamic Item 3');

const changeGreeting = () => {
  greeting.value = 'Greetings from Vue 3!';
};
</script>

编译器分析和优化过程:

  1. 识别静态节点:
    • <h1>Welcome to My App</h1>:标签名、内容、class 属性都不变,是静态节点
    • <li>Static Item 1</li><li>Static Item 2</li>:标签名和内容都不变,是静态节点
    • <button>:标签名、内容和事件处理器(@click)都不变,是静态节点
  1. 识别动态节点:
    • <p>{{ greeting }}</p>:内容 {{ greeting }} 是动态的。
    • <li>{{ dynamicItem }}</li>:内容 {{ dynamicItem }} 是动态的。
    • <div id="app">:虽然 id 是静态的,但它包含了动态子节点,因此自身是动态的。
  1. 执行静态提升:
    • 编译器会将上面识别出的静态节点的 VNode 对象创建代码提取出来,放在渲染函数外面,通常赋值给一个变量(比如 _hoisted_1, _hoisted_2 等)。这样它们只会被创建一次。
  1. 添加 Patch Flags:
    • <p> 节点:它的内容是动态的,编译器会为其 VNode 添加 patchFlag: Text (数值通常是 1)。这告诉运行时,只需要比较和更新它的文本内容。
    • <li>{{ dynamicItem }}</li> 节点:它的内容是动态的,同样会添加 patchFlag: Text (数值通常是 1)。
    • <ul> 节点:它的子节点列表是动态的(因为包含动态的 <li>),编译器会为其添加 patchFlag: Children (数值通常是 8 或更复杂的组合)。这告诉运行时,需要对其子节点进行 diff。

编译后生成的渲染函数(简化示意):

import { createElementVNode as _createElementVNode, createTextVNode as _createTextVNode, Fragment as _Fragment, openBlock as _openBlock, createBlock as _createBlock, toDisplayString as _toDisplayString } from 'vue'

// --- 静态提升的 VNodes ---
// 这些 VNodes 只在模块加载时创建一次,后续渲染直接复用
const _hoisted_1 = /*#__PURE__*/_createElementVNode("h1", { class: "title" }, "Welcome to My App", -1 /* HOISTED */)
const _hoisted_2 = /*#__PURE__*/_createTextVNode("Static Item 1")
const _hoisted_3 = /*#__PURE__*/_createTextVNode("Static Item 2")
const _hoisted_4 = /*#__PURE__*/_createElementVNode("button", { onClick: "changeGreeting" }, "Change Greeting", -1 /* HOISTED */)
// -----------------------------------

function render(_ctx, _cache, $props, $setup, $data, $options) {
  // `_ctx` 通常包含 `greeting` 和 `dynamicItem` 等响应式数据
  return (_openBlock(), _createBlock("div", { id: "app" },
    [
      _hoisted_1, // 直接复用,无需 diff
      _createElementVNode("p", null, _toDisplayString($setup.greeting), 1 /* TEXT */), // patchFlag: 1
      _createElementVNode("ul", null, [
        _hoisted_2, // 直接复用,无需 diff
        _hoisted_3, // 直接复用,无需 diff
        _createElementVNode("li", null, _toDisplayString($setup.dynamicItem), 1 /* TEXT */) // patchFlag: 1
      ], 16 /* FULL_PROPS */), // patchFlag: 16 (这里可能表示子节点是动态的,需要 diff)
      _hoisted_4  // 直接复用,无需 diff
    ]
  ))
}

关键点解读:

  • _hoisted_1, _hoisted_2, _hoisted_3, _hoisted_4:这些都是在编译时创建好的静态 VNode 对象。/* HOISTED */ 注释表明它们被提升了。在运行时,渲染函数直接使用这些对象,而不必每次都重新创建。
  • _createElementVNode("p", ...)_createElementVNode("li", ...):这些是动态节点,每次渲染时都需要重新创建 VNode。
  • 1 /* TEXT */:这就是 patchFlag。它告诉运行时,这个 VNode 只需要关心文本内容的变化。当 $setup.greeting$setup.dynamicItem 改变时,运行时只需比较新旧文本字符串,然后更新真实 DOM 的 textContent,而不需要比较 classid 等其他属性。
  • 16 /* FULL_PROPS */ (或类似的数值):<ul>patchFlag 表明其子节点是动态的,需要进行子节点的 diff。

通过这种方式,Vue 3 在编译时就对模板进行了深度优化,使得运行时的渲染和更新过程更加高效

❌
❌