普通视图

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

事件监听器销毁完全指南:如何避免内存泄漏?

作者 wuhen_n
2026年3月17日 09:24

前言

我们在实际开发中可能遇到过这样的情况:打开一个网页,一开始很流畅,但后面越用越卡;尤其是切换页面后,感觉浏览器变慢了;长时间不刷新,页面最终崩溃了。

这很可能就是 内存泄漏 在作祟。

想象一下:我们有个垃圾桶,每天都在往里面扔垃圾,但从来不倒。一开始没什么问题,但一个月后,垃圾堆满了屋子,我们连站的地方都没有了。

事件监听器导致的内存泄漏,就是这样——垃圾不倒,导致越积越多。

本文将从最基础的概念讲起,用最通俗的语言,配合完整的代码示例,深入探讨事件监听器导致内存泄漏的成因、检测方法、预防措施,以及 TypeScript 如何帮助我们构建类型安全的清理策略。

为什么事件监听器会成为内存杀手?

从一个简单的例子开始

App.vue

<template>
  <div>
    <button @click="show = !show">切换组件</button>
    <ChildComponent v-if="show" />
  </div>
</template>

<script setup>
import { ref } from 'vue'
const show = ref(true)
</script>

ChildComponent.vue

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

onMounted(() => {
  // 每次组件挂载时,都添加一个滚动监听
  window.addEventListener('scroll', () => {
    console.log('滚动位置:', window.scrollY)
  })
})
</script>

这看起来没什么问题,但实际上发生了什么呢? 每次切换组件,都会增加一个新的监听器! 当成百上千次切换后,就有上千个监听器在工作...

为什么没有自动清理?

很多人都以为只要组件销毁了,它里面的东西会自动清理。但事实是:

  • Vue 可以自动清理:组件的数据、事件、计算属性等
  • Vue 不能自动清理:window/document 上的事件、定时器、WebSocket 等

内存泄漏的危害有多大?

指标 正常状态 泄漏状态 影响
内存占用 50MB 500MB+ 页面卡顿,甚至崩溃
事件响应 即时 延迟1-2秒 用户体验差
CPU使用率 10% 60%+ 电脑发烫,风扇狂转
电池消耗 正常 快3倍 移动端灾难

三种事件注册方式及其清理

三种注册方式对比

注册方式 优点 缺点 清理方法
内联事件 简单直接 无法移除多个,污染HTML 赋值为null
属性赋值 可移除 只能绑定一个 赋值为null
addEventListener 可绑定多个,灵活 需要对应 remove removeEventListener

内联事件的清理

// 移除内联事件
const button = document.querySelector('button')
button.onclick = null

// 或者移除整个元素
button.remove()

// 更彻底:清空父元素内容
parent.innerHTML = ''  // 会移除所有子元素的事件

注:实际 Vue 开发中,不推荐直接使用内联事件,推荐使用 Vue 的事件绑定 @click 等。

属性赋值的清理

// 注册
window.onresize = handleResize
document.onkeydown = handleKeyDown
button.onclick = handleClick

// 清理
window.onresize = null
document.onkeydown = null
button.onclick = null

注:属性赋值只能有一个监听器 window.onresize = fn1 window.onresize = fn2
此时 fn2 会覆盖 fn1

addEventListener 的正确清理

function handleResize() {
  console.log('resize')
}
window.addEventListener('resize', handleResize)
window.removeEventListener('resize', handleResize)

为什么 removeEventListener 有时候不工作?

场景一:匿名函数无法移除

window.addEventListener('click', () => {})
window.removeEventListener('click', () => {})  // ❌ 错误:匿名函数无法移除

因为匿名函数每次创建时都是新的,会重复创建,因此无法移除。

场景二:capture 参数不同,无法移除

window.addEventListener('click', handleClick, true)
window.removeEventListener('click', handleClick, false)  //   ❌ 错误::capture 不同,无法移除

场景三:options 对象不同,无法移除

const options1 = { passive: true }
const options2 = { passive: true }
element.addEventListener('click', handleClick, options1)
element.removeEventListener('click', handleClick, options2)  //  ❌ 错误:不同对象,无法移除

一句话总结:removeEventListener 的参数必须和 addEventListener 完全一致才能移除。

Vue 组件中的事件清理

最基本的清理模式

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

const scrollTop = ref(0)

// 1. 使用具名函数
function handleScroll() {
  scrollTop.value = window.scrollY
}

onMounted(() => {
  // 2. 注册事件
  window.addEventListener('scroll', handleScroll)
})

onUnmounted(() => {
  // 3. 组件卸载时移除事件
  window.removeEventListener('scroll', handleScroll)
})
</script>

<template>
  <div>滚动位置: {{ scrollTop }}</div>
</template>

封装可复用的组合式函数

// composables/useEventListener.js
import { onMounted, onUnmounted } from 'vue'

export function useEventListener(target, event, handler) {
  // 确保 target 存在
  if (!target?.addEventListener) return
  
  // 注册
  onMounted(() => {
    target.addEventListener(event, handler)
  })
  
  // 自动清理
  onUnmounted(() => {
    target.removeEventListener(event, handler)
  })
}

使用示例:

useEventListener(window, 'resize', () => {
  console.log('窗口大小变化', window.innerWidth)
})

useEventListener(document, 'visibilitychange', () => {
  console.log('页面可见性变化')
})

useEventListener(document, 'keydown', (e) => {
  if (e.key === 'Escape') {
    console.log('按下 ESC 键')
  }
})

支持多个事件的组合式函数

// composables/useWindowEvents.js
import { onMounted, onUnmounted } from 'vue'

export function useWindowEvents(handlers) {
  const entries = Object.entries(handlers)
  
  onMounted(() => {
    entries.forEach(([event, handler]) => {
      window.addEventListener(event, handler)
    })
  })
  
  onUnmounted(() => {
    entries.forEach(([event, handler]) => {
      window.removeEventListener(event, handler)
    })
  })
}

使用示例:

useWindowEvents({
  resize: () => console.log('resize'),
  scroll: () => console.log('scroll'),
  click: (e) => console.log('click at', e.clientX, e.clientY)
})

返回清理函数的 Hook 模式

// composables/useResizeObserver.js
import { ref, onUnmounted } from 'vue'

export function useResizeObserver(target) {
  const width = ref(0)
  const height = ref(0)
  
  // 创建观察者
  const observer = new ResizeObserver((entries) => {
    const entry = entries[0]
    if (entry) {
      width.value = entry.contentRect.width
      height.value = entry.contentRect.height
    }
  })
  
  // 开始观察
  const el = unref(target)
  if (el) {
    observer.observe(el)
  }
  
  // 返回清理函数
  const cleanup = () => {
    observer.disconnect()
  }
  
  // 组件卸载时自动清理
  onUnmounted(cleanup)
  
  return {
    width,
    height,
    cleanup  // 也可以手动调用
  }
}

使用示例:

const container = ref()
const { width, height } = useResizeObserver(container)

内存泄漏的检测与诊断

Chrome DevTools 内存面板使用

// 步骤1:录制内存分配时间线
// Performance 面板 → Memory 勾选 → 开始录制
// 执行可能导致泄漏的操作 → 停止录制
// 查看内存曲线:正常应该波动后回落,泄漏会持续增长

// 步骤2:拍摄堆快照
// Memory 面板 → Take heap snapshot

// 步骤3:对比快照
// 操作前后各拍一次 → 选择 Comparison 视图
// 重点查看:
// - Detached 元素(已从 DOM 移除但未被回收)
// - 增加的 EventListener 数量
// - 新增的闭包引用

// 步骤4:使用 Allocation instrumentation on timeline
// 实时记录内存分配,定位泄漏的具体代码

Performance Monitor 实时监控

// 在 DevTools 中打开 Performance Monitor(Ctrl+Shift+P 搜索)
// 关注指标:
// - JS Heap size:堆内存大小,正常应该稳定在某个范围
// - DOM Nodes:DOM 节点数量,动态内容应有增有减
// - Event Listeners:事件监听器数量,不应无限增长
// - Documents:文档数量,通常为1

// 正常情况:操作前后指标应该基本持平
// 泄漏情况:指标持续增长,不会下降

手动检测代码

// 在开发环境添加监控工具
if (import.meta.env.DEV) {
  // 每5秒输出一次内存状态
  setInterval(() => {
    console.table({
      '时间': new Date().toLocaleTimeString(),
      'JS Heap': formatBytes((performance as any).memory?.usedJSHeapSize),
      'DOM Nodes': document.querySelectorAll('*').length,
      'Event Listeners': countEventListeners(),
      'Detached Nodes': countDetachedNodes()
    })
  }, 5000)
}

function countEventListeners(): number {
  // 遍历所有 DOM 元素,统计监听器(仅限 Chrome)
  let count = 0
  const allElements = document.querySelectorAll('*')
  
  allElements.forEach(el => {
    const listeners = (el as any).getEventListeners?.()
    if (listeners) {
      count += Object.values(listeners).flat().length
    }
  })
  
  return count
}

function countDetachedNodes(): number {
  // 统计已从 DOM 移除但未被回收的元素
  const heapSnapshot = (window as any).heapSnapshot
  if (!heapSnapshot) return 0
  
  let count = 0
  // 遍历堆快照统计 detached 元素
  // 具体实现依赖 DevTools 协议
  return count
}

function formatBytes(bytes: number): string {
  if (bytes < 1024) return bytes + ' B'
  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB'
  return (bytes / (1024 * 1024)).toFixed(2) + ' MB'
}

常见陷阱与解决方案

陷阱一:在循环中注册事件

// ❌ 错误:每秒增加一个监听器
setInterval(() => {
  window.addEventListener('resize', () => {
    console.log('resize')
  })
}, 1000)

// ✅ 正确:只注册一次
window.addEventListener('resize', () => {
  console.log('resize')
})

setInterval(() => {
  // 做其他事
}, 1000)

陷阱二:watch 中注册事件

// ❌ 错误:每次 ID 变化都增加监听器
watch(() => route.params.id, () => {
  window.addEventListener('scroll', handleScroll)
})

// ✅ 正确:只注册一次
onMounted(() => {
  window.addEventListener('scroll', handleScroll)
})

function handleScroll() {
  // 根据当前 ID 做不同处理
  if (route.params.id) {
    console.log('当前ID:', route.params.id)
  }
}

onUnmounted(() => {
  window.removeEventListener('scroll', handleScroll)
})

陷阱三:箭头函数的 this 问题

class Component {
  data = 'test'
  
  // ❌ 错误:每次调用都创建新函数
  render() {
    button.addEventListener('click', () => {
      console.log(this.data)  // 无法移除
    })
  }
  
  // ✅ 正确:使用类属性方法
  handleClick = () => {
    console.log(this.data)
  }
  
  render() {
    button.addEventListener('click', this.handleClick)
    // 可以移除
    button.removeEventListener('click', this.handleClick)
  }
}

陷阱四:第三方库不销毁

import Swiper from 'swiper'
import * as echarts from 'echarts'

let swiper = null
let chart = null

onMounted(() => {
  // ❌ 只创建不销毁
  swiper = new Swiper('.swiper', {})
  chart = echarts.init(document.getElementById('chart'))
})

onUnmounted(() => {
  // ✅ 必须调用销毁方法
  if (swiper) {
    swiper.destroy(true, true)
    swiper = null
  }
  
  if (chart) {
    chart.dispose()
    chart = null
  }
})

最佳实践清单

开发时 Checklist

  • 每个 addEventListener 都有对应的 removeEventListener
  • 清理函数是否在 onUnmounted 中调用?
  • 匿名函数是否改成了具名函数或变量引用?
  • 节流/防抖的定时器是否清理了?
  • IntersectionObserver/ResizeObserver 是否调用了 disconnect
  • 第三方库实例是否调用了 destroydispose 方法?
  • 动态添加的元素,事件是否在移除元素时清理?

代码审查 Checklist

  • 是否有在循环或高频操作中注册事件?
  • 事件回调中是否持有大量数据的引用?(可能导致内存泄漏)
  • 多个组件共享的全局事件,是否考虑了竞态条件?
  • 组件销毁时,是否清理了所有自定义事件?
  • 使用 once 选项的事件是否确实只需要执行一次?

性能监控 Checklist

  • 是否定期检查 DevTools 的 Event Listeners 数量?
  • 是否有内存泄漏的自动化测试?
  • 生产环境是否有内存监控告警?
  • 是否建立了性能基准,跟踪内存趋势?
  • 是否在关键操作前后进行了内存快照对比?

注册清理对应表

注册 清理
addEventListener removeEventListener
setInterval clearInterval
setTimeout clearTimeout
new Observer observer.disconnect()
new WebSocket websocket.close()
new Swiper swiper.destroy()
echarts.init chart.dispose()

结语

好的代码不仅要能运行,还要能优雅地停止。学会正确地清理事件监听器,是每个前端开发者从入门到进阶的必修课。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

昨天以前首页

v-once和v-memo完全指南:告别不必要的渲染,让应用飞起来

作者 wuhen_n
2026年3月16日 09:10

前言

在日常开发中,我们可能遇到过这样的情况:写了一个 Vue 应用,数据量稍微大一点,页面就开始卡顿;用户只是点击了一个按钮,整个页面都要重新渲染;明明大部分内容都没变,却感觉应用像“老了十岁”一样慢。这是为什么呢?

Vue 的响应式系统很智能,但它也有“过度反应”的时候。就像我们只是拍了拍桌子,整个办公室的人都站起来看看发生了什么——这显然是一种浪费。

v-oncev-memo 就是来解决这个问题的。它们像两个聪明的“保安”,告诉 Vue:“这部分内容不用每次都检查,它没变” 和 “这部分内容只有在特定条件变化时才需要检查”。

本文将从最基础的概念讲起,用最通俗的语言,配合完整的代码示例,帮助我们彻底掌握这两个性能优化神器。

为什么要关注不必要的渲染

从一个简单的例子开始

我们先来看一个简单的例子:

<template>
  <div>
    <!-- 动态内容:会变化 -->
    <h2>当前计数:{{ count }}</h2>
    <button @click="count++">点我增加</button>
    
    <!-- 静态内容:永远不会变 -->
    <footer>
      <p>© 2026 我的公司. 版权所有</p>
      <p>联系方式:contact@example.com</p>
      <p>地址:xxx</p>
    </footer>
  </div>
</template>

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

这段代码看起来没什么,但实际上会发生了什么呢?

每次点击按钮是,count 都会变化,整个组件都会重新渲染。包括那个 永远不会变 的页脚。

虽然 Vue 的虚拟 DOM 会最终发现页脚没变,不会更新真实的 DOM,但这个过程仍然需要:

  • 执行渲染函数
  • 创建新的虚拟 DOM
  • 和旧的虚拟 DOM 进行对比
  • 确认没有变化,跳过更新

这就像我们每天早上去公司,尽管保安每天都会看到我们,但他们仍然每天都要重新核对我们的身份信息,这是一种不必要的浪费。

Vue 的默认更新机制

响应式数据变化
    ↓
组件重新渲染函数执行
    ↓
生成新的虚拟 DOM 树
    ↓
与旧虚拟 DOM 进行 diff 比较
    ↓
计算出需要更新的真实 DOM
    ↓
执行 DOM 更新

不必要的渲染有多"贵"?

我们先看一段数据:

组件规模 一次不必要的渲染耗时 每天10万次操作 额外开销
小型组件(50个节点) 0.5ms 50,000ms 50秒
中型组件(200个节点) 2ms 200,000ms 3.3分钟
大型组件(1000个节点) 10ms 1,000,000ms 16.7分钟

想象一下,用户每天要多等十几分钟,就因为应用在“瞎忙活”。

什么是不必要的渲染?

简单来说就是:渲染的结果和上一次 完全一样,但过程却重复执行了。

// 这是一个"不必要的渲染"的典型案例
const App = {
  template: `
    <div>
      <!-- 这部分每次都会重新计算,但结果永远一样 -->
      <div>{{ getStaticData() }}</div>
      
      <!-- 这部分确实需要更新 -->
      <div>{{ dynamicData }}</div>
    </div>
  `,
  
  methods: {
    getStaticData() {
      console.log('我被调用了!') // 其实只需要调用一次
      return '永远不变的内容'
    }
  }
}

问题:即使大部分内容没变,渲染函数仍会执行,虚拟 DOM 树仍会创建,diff 算法仍需遍历。

v-once:一次渲染,终身躺平

v-once 是什么?

v-once 是 Vue 提供的一个指令,它的作用就像它的名字一样:只渲染一次。之后无论数据怎么变化,这部分内容都不会再更新。

用生活化的比喻理解v-once

想象一下,我们正在装修房子:

  • 普通渲染:每天都要重新粉刷一遍墙壁,尽管颜色没变
  • v-once 渲染:装修一次,以后再也不动它

v-once 的基本用法

<template>
  <div>
    <!-- 普通内容:每次count变化都会更新 -->
    <p>当前计数:{{ count }}</p>
    
    <!-- v-once内容:只渲染一次,之后永远不变 -->
    <p v-once>初始计数:{{ count }}</p>
    
    <button @click="count++">增加计数</button>
  </div>
</template>

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

运行效果

  • 首次加载:两个都显示“0”
  • 点击按钮:上面变成“1”,下面还是“0”
  • 继续点击:上面一直变,下面永远是“0”

v-once的工作原理

让我们用流程图来理解:

首次渲染
    ↓
遇到 v-once 指令
    ↓
正常渲染内容
    ↓
将生成的虚拟DOM缓存起来
    ↓
打上"静态标记"
    ↓
─────────────────
    ↓
后续更新时
    ↓
遇到 v-once 标记
    ↓
直接返回缓存的虚拟DOM
    ↓
跳过所有更新逻辑

v-once 的实现机制

// 简化版的 v-once 实现原理
function processOnceNode(vnode) {
  if (vnode.shapeFlag & ShapeFlags.COMPONENT_ONCE) {
    // 如果是组件,标记为静态组件
    vnode.isStatic = true
    return vnode
  }
  
  // 如果是元素,创建静态节点
  const staticNode = createStaticVNode(
    vnode.children,
    vnode.props
  )
  
  // 后续更新直接返回缓存的静态节点
  return staticNode
}

v-once 的适用场景

场景一:页脚版权信息等纯静态内容

<!-- 页脚版权信息,永远不变 -->
<footer v-once>
  <p>© 2026 我的公司. All rights reserved.</p>
  <p>ICP备案号:xxxxx</p>
  <div class="contact">
    <p>邮箱:contact@example.com</p>
    <p>电话:400-123-4567</p>
  </div>
</footer>

场景二:一次性初始数据

<template>
  <div class="user-profile">
    <!-- 用户 ID 只在创建时显示,后续不变 -->
    <div v-once class="user-meta">
      <span>用户ID:{{ userId }}</span>
      <span>注册时间:{{ registerDate }}</span>
      <span>会员等级:{{ initialLevel }}</span>
    </div>
    
    <!-- 动态更新的内容 -->
    <div class="user-points">
      当前积分:{{ points }}
      <button @click="points++">签到</button>
    </div>
  </div>
</template>

场景三:复杂的静态组件

<template>
  <div class="dashboard">
    <!-- 左侧:帮助文档组件,完全静态,只需加载一次 -->
    <HelpDocumentation v-once class="sidebar" />
    
    <!-- 右侧:动态更新的内容 -->
    <div class="main-content">
      <DashboardCharts :data="liveData" />
      <RealTimeLogs :logs="systemLogs" />
    </div>
  </div>
</template>

场景四:与 v-for 配合优化列表

<template>
  <div class="data-table">
    <!-- 表格头部完全静态 -->
    <div v-once class="table-header">
      <div class="col">姓名</div>
      <div class="col">年龄</div>
      <div class="col">部门</div>
      <div class="col">操作</div>
    </div>
    
    <!-- 动态列表项 -->
    <div v-for="item in list" :key="item.id" class="table-row">
      <div class="col">{{ item.name }}</div>
      <div class="col">{{ item.age }}</div>
      <div class="col">{{ item.department }}</div>
      <div class="col">
        <button @click="edit(item.id)">编辑</button>
      </div>
    </div>
  </div>
</template>

v-once 的使用注意事项

注意事项 说明 示例
失去响应性 v-once 内的所有数据绑定都变成静态,不再响应更新 <div v-once>{{ count }}</div> 永远不会更新
子树全静态 v-once 作用于元素时,其所有子元素也变为静态 整个组件树都会静态化
避免滥用 只在真正不需要更新的地方使用,否则会导致数据和视图不一致 动态内容不能用 v-once
组件中使用 组件上加 v-once,整个组件只会渲染一次 <ComplexChart v-once />

v-once 性能收益实测

测试环境

  • 页面包含 200 个静态节点
  • 每秒触发 10 次更新
  • 运行 60 秒
指标 未优化 使用 v-once 提升
渲染函数调用次数 60,000 次 600 次 99%
虚拟 DOM 创建 60,000 次 600 次 99%
内存分配 850MB 85MB 90%
CPU 使用率 65% 8% 88%
平均帧率 45fps 60fps 33%

v-memo:有条件地记忆渲染

为什么要 v-memo?

v-once 虽然好,但它的缺点也很明显:要么永远更新,要么永远不更新。现实开发中,我们经常遇到这样的情况:

  • 列表项的大部分内容稳定,但少数字段会变
  • 组件的大部分数据不变,但需要响应某些特定变化

这时候就需要 v-memo 了。

v-memo 是什么?

v-memo 是 Vue 3.2+ 引入的新指令,它可以接受一个依赖数组,只有当数组中的值变化时,才会重新渲染。

用生活化的比喻理解 v-memo

想象一下,我们在公司里:

  • 普通员工:领导一喊,所有人都站起来(不管是不是叫自己)
  • v-memo 员工:只有听到自己名字才站起来

v-memo的基本用法

<template>
  <div 
    v-for="item in items" 
    :key="item.id"
    v-memo="[item.id, item.price, item.stock]"
  >
    <!-- 只有当 item.id、item.price 或 item.stock 变化时才重新渲染 -->
    <h3>{{ item.name }}</h3>
    <p>价格:{{ item.price }}</p>
    <p>库存:{{ item.stock }}</p>
    <button @click="toggleFavorite(item.id)">
      {{ item.isFavorite ? '取消收藏' : '收藏' }}
    </button>
  </div>
</template>

v-memo的工作原理

让我们用流程图来理解:

首次渲染
    ↓
计算依赖数组的值
    ↓
缓存这些值和生成的虚拟DOM
    ↓
─────────────────
    ↓
后续更新触发
    ↓
重新计算依赖数组的新值
    ↓
和缓存的值比较
    ↓
有变化?→ 是 → 重新渲染,更新缓存
    ↓       
    否
    ↓
直接返回缓存的虚拟DOM
    ↓
跳过所有更新逻辑

v-memo 工作机制的三阶段

1. 依赖收集阶段

  • 编译时解析依赖数组
  • 建立响应式依赖图谱
  • 为每个节点创建 memo 缓存

2. 缓存对比阶段

  • 重新渲染前计算依赖数组的新值
  • 与缓存的上次值进行浅比较
  • 若未变化 → 直接复用缓存的 VNode 树
  • 若已变化 → 重新生成 VNode 并更新缓存

3. 虚拟 DOM 跳过

  • 完全跳过该节点的 diff 计算
  • 不触发子树的渲染函数
  • 直接复用真实 DOM

v-memo的实战场景

场景一:超大规模商品列表

想象一个电商网站的商品列表,有1万件商品:

<template>
  <div class="product-list">
    <div 
      v-for="product in products" 
      :key="product.id"
      v-memo="[
        product.id, 
        product.price, 
        product.stock, 
        product.isFavorite
      ]"
      class="product-item"
    >
      <img :src="product.image" :alt="product.name" />
      <h3>{{ product.name }}</h3>
      <p class="price">¥{{ product.price }}</p>
      <p class="stock">库存: {{ product.stock }}件</p>
      <p class="sales">销量: {{ product.sales }}件</p>
      <p class="rating">评分: {{ product.rating }}分</p>
      <button 
        @click="toggleFavorite(product.id)"
        :class="{ active: product.isFavorite }"
      >
        {{ product.isFavorite ? '已收藏' : '收藏' }}
      </button>
    </div>
  </div>
</template>

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

// 生成1万件商品
const products = ref(
  Array.from({ length: 10000 }, (_, i) => ({
    id: i,
    name: `商品 ${i}`,
    price: Math.floor(Math.random() * 1000),
    stock: Math.floor(Math.random() * 100),
    sales: Math.floor(Math.random() * 1000),
    rating: (Math.random() * 5).toFixed(1),
    image: `https://picsum.photos/200/150?random=${i}`,
    isFavorite: false
  }))
)

function toggleFavorite(id) {
  const product = products.value.find(p => p.id === id)
  product.isFavorite = !product.isFavorite
  // ✅ 只有被点击的那一项会重新渲染
}
</script>

优化效果:

  • 用户点击收藏时,只有被点击的商品重新渲染
  • 后台更新价格时,只有价格变化的商品重新渲染
  • 其他 9999 件商品完全不动

场景二:复杂计算缓存

<template>
  <div class="dashboard">
    <!-- 只有当原始数据或用户设置变化时才重新计算 -->
    <div 
      class="dashboard-content"
      v-memo="[rawData.version, userSettings.theme]"
    >
      <DashboardHeader />
      
      <!-- 这里的数据需要复杂计算 -->
      <DataVisualization :data="processedData" />
      <StatsCards :stats="computedStats" />
      <ActivityChart :chart-data="chartData" />
    </div>
  </div>
</template>

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

const rawData = ref(fetchData()) // 10MB的原始数据
const userSettings = ref({ theme: 'light', language: 'zh' })

// 复杂计算:处理10MB数据
const processedData = computed(() => {
  console.log('正在处理数据...') // 我们希望这个不要频繁执行
  return rawData.value.map(item => ({
    ...item,
    processed: heavyComputation(item)
  }))
})

// 当用户切换主题时,不应该重新计算processedData
// 但上面的v-memo确保了这一点:只有rawData.version或userSettings.theme变化时才重新渲染
</script>

场景三:聊天消息列表

<template>
  <div class="chat-messages">
    <div 
      v-for="msg in messages" 
      :key="msg.id"
      v-memo="[msg.id, msg.content, msg.timestamp, msg.isRead]"
      class="message"
      :class="{ 'message-self': msg.senderId === currentUserId }"
    >
      <img :src="msg.avatar" class="avatar" />
      <div class="content">
        <div class="sender">{{ msg.senderName }}</div>
        <div class="text">{{ msg.content }}</div>
        <div class="time">{{ formatTime(msg.timestamp) }}</div>
      </div>
      <div class="status">
        <span v-if="msg.isRead">已读</span>
        <span v-else-if="msg.isSending">发送中...</span>
        <span v-else-if="msg.isFailed">发送失败</span>
      </div>
    </div>
  </div>
</template>

<script setup>
const messages = ref([])

// 新消息到来时,只有新消息会渲染
// 已读状态变化时,只有那条消息会更新
// 其他消息完全不动
</script>

场景四:选中状态高亮

<template>
  <div class="image-gallery">
    <div 
      v-for="image in images" 
      :key="image.id"
      v-memo="[selectedId === image.id]"
      class="image-item"
      :class="{ selected: selectedId === image.id }"
      @click="selectedId = image.id"
    >
      <img :src="image.thumbnail" :alt="image.title" />
      <div class="overlay">
        <h4>{{ image.title }}</h4>
        <button @click.stop="download(image.id)">下载</button>
      </div>
    </div>
  </div>
</template>

<script setup>
const selectedId = ref(null)

// 点击时,只有之前选中的和当前选中的两个图片会重新渲染
// 其他9998张图片完全不动
</script>

v-memo 依赖项选择的黄金法则

  • 精准包含:只放那些真正会影响渲染的字段
  • 避免冗余:不要把整个对象放进去
  • 稳定依赖:不要用 Date.now() 这种每次都变的值
  • 版本控制:复杂对象可以用版本号

选择决策树

graph TD
    Start[遇到一个组件/元素] --> Question1{内容永远不变吗?}
    Question1 -->|是| A[用 v-once]
    Question1 -->|否| Question2{是长列表?<br>(>500项)}
    
    Question2 -->|否| B[暂时不需要优化]
    Question2 -->|是| Question3{更新频率高吗?}
    
    Question3 -->|低| C[保持现状]
    Question3 -->|高| Question4{能否精确控制更新?}
    
    Question4 -->|否| D[考虑虚拟滚动]
    Question4 -->|是| E[用 v-memo 精确优化]

v-once vs v-memo,如何选择?

特性对比表

对比维度 v-once v-memo
适用版本 Vue 2+ Vue 3.2+
更新策略 永不更新 条件更新
依赖声明 显式数组
学习难度 ⭐⭐⭐
适用场景 纯静态内容 大部分稳定的动态内容
代码侵入性

组合使用示例

<template>
  <div class="app">
    <!-- 1. 完全静态的头部 -->
    <header v-once>
      <AppLogo />
      <AppTitle />
      <NavigationMenu />
    </header>
    
    <!-- 2. 动态列表,但有条件更新 -->
    <div class="content">
      <div 
        v-for="item in items" 
        :key="item.id"
        v-memo="[item.id, item.updatedAt]"
      >
        <!-- 2.1 每个列表项内部的静态部分 -->
        <div v-once class="item-static">
          <img :src="item.avatar" />
          <span>ID: {{ item.id }}</span>
        </div>
        
        <!-- 2.2 每个列表项内部的动态部分 -->
        <div class="item-dynamic">
          <h3>{{ item.title }}</h3>
          <p>{{ item.content }}</p>
          <span>点赞: {{ item.likes }}</span>
        </div>
      </div>
    </div>
    
    <!-- 3. 完全静态的页脚 -->
    <footer v-once>
      <Copyright />
      <ContactInfo />
    </footer>
  </div>
</template>

性能收益对比

场景 优化前 v-once v-memo
静态页脚 每次更新都渲染 0次更新 不适用
收藏按钮点击 整个列表重绘 不适用 只更新单个项
价格批量更新 整个列表重绘 不适用 只更新价格变化项
列表项1000条 120ms 不适用 35ms

常见陷阱与解决方案

v-memo 依赖遗漏

<!-- ❌ 错误:遗漏了关键依赖 -->
<div 
  v-for="item in items"
  v-memo="[item.id]"
>
  {{ item.name }}  <!-- 当name变化时,这里不会更新! -->
  <span :class="{ active: item.isActive }">
    {{ item.status }}
  </span>
</div>

<!-- ✅ 正确:包含所有依赖 -->
<div 
  v-for="item in items"
  v-memo="[item.id, item.name, item.isActive, item.status]"
>
  {{ item.name }}
  <span :class="{ active: item.isActive }">
    {{ item.status }}
  </span>
</div>

在错误的位置使用 v-memo

<!-- ❌ 错误:在父容器上使用v-memo -->
<ul v-memo="[items.length]">
  <li v-for="item in items" :key="item.id">
    {{ item.name }}
  </li>
</ul>
<!-- 结果:items.length不变时,整个列表都不更新 -->
<!-- 但item.name变化时也不会更新! -->

<!-- ✅ 正确:在v-for的项上使用 -->
<ul>
  <li 
    v-for="item in items" 
    :key="item.id"
    v-memo="[item.id, item.name]"
  >
    {{ item.name }}
  </li>
</ul>

滥用v-once导致bug

<!-- ❌ 错误:动态内容用了v-once -->
<div v-once>
  <h3>当前用户:{{ username }}</h3>  <!-- 永远不会更新! -->
  <button @click="logout">退出登录</button>
</div>

<!-- ✅ 正确:只静态化真正静态的部分 -->
<div>
  <h3>当前用户:{{ username }}</h3>  <!-- 动态 -->
  <div v-once>操作面板</div>  <!-- 静态 -->
  <button @click="logout">退出登录</button>  <!-- 动态 -->
</div>

最佳实践清单

什么时候用 v-once?

  • 版权信息、页脚
  • 表格表头
  • 静态导航菜单
  • 一次性初始数据
  • 复杂的静态组件(帮助文档、使用说明)

什么时候用 v-memo?

  • 超长列表(>500项)
  • 高频更新的区域隔离
  • 选中状态切换
  • 复杂计算的缓存
  • 聊天消息列表

优化检查清单

  • v-memo 的依赖数组包含了所有影响渲染的字段
  • 避免在 v-memo 中使用 Date.now()Math.random()
  • v-memo 正确放在 v-for 的项上,而不是父容器
  • v-once 只用于真正静态的内容
  • 组合使用时逻辑清晰
  • 用性能工具验证了优化效果

性能优化的哲学

  1. 优化不是炫技:用数据和用户体感说话
  2. 适度原则:不是所有地方都需要优化
  3. 持续演进:性能优化是过程,不是终点
  4. 量化的力量:没有数据的优化是盲目的

结语

v-oncev-memo 是 Vue 提供的两个强大的优化工具,但它们不是银弹。真正的性能优化,是在理解业务场景的基础上,选择合适的技术,验证优化效果,持续改进的过程。让该更新的更新,该躺平的躺平,这才是 Vue 性能优化的真谛!

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

Pinia 高效指南:状态管理的最佳实践与性能陷阱

作者 wuhen_n
2026年3月13日 09:00

前言

在 Vue3 生态中,Pinia 已经成为官方推荐的状态管理库。它以其极简的 API、完美的 TypeScript 支持和与 Composition API 的无缝集成,彻底改变了我们管理全局状态的方式。然而,再好的工具如果使用不当,也会带来性能问题和维护噩梦。

本文将深入探讨 Pinia 的核心设计哲学,从基础的类型安全定义到高级性能优化,从常见陷阱到测试策略,帮助你在实际项目中真正驾驭这个强大的工具。

为什么我们需要Pinia?

从一个真实场景开始

想象我们正在开发一个电商网站,有这样一个需求:

<!-- 头部组件:显示用户名和购物车数量 -->
<template>
  <header>
    <div>欢迎您,{{ username }}</div>
    <div>购物车({{ cartCount }})</div>
  </header>
</template>

<!-- 商品列表组件:用户点击加入购物车 -->
<template>
  <div v-for="product in products">
    <h3>{{ product.name }}</h3>
    <button @click="addToCart(product)">加入购物车</button>
  </div>
</template>

<!-- 购物车组件:显示已选商品 -->
<template>
  <div v-for="item in cartItems">
    {{ item.name }} x {{ item.quantity }}
  </div>
</template>

这时候问题来了:当用户在商品列表页点击"加入购物车"时:

  • 头部组件需要更新购物车数量
  • 购物车组件需要显示新加的商品
  • 用户信息可能在多个地方使用

如果没有状态管理,我们可能会使用 事件总线props 层层传递,这样组件之间通信会变得极其复杂。

Pinia是什么?

简单来说,Pinia就是一个 中央数据仓库

┌─────────────────┐
│   Pinia Store   │
│  (数据仓库)      │
├─────────────────┤
│  用户信息        │
│  购物车数据      │
│  主题设置        │
└─────────────────┘
      ▲    ▲    ▲
      │    │    │
┌─────┴────┴────┴─────┐
│    所有组件直接访问  │
└─────────────────────┘

Pinia vs Vuex:为什么选Pinia?

在 Vue2 中,类似的功能我们通常使用 Vuex4 进行管理,为什么不继续使用 Vuex4 ,而要改用 Pinia 呢?让我们做个简单对比:

Vuex4 写法 - 繁琐的模板代码

const store = createStore({
  state: { count: 0 },
  mutations: {          // 为什么要多一层?
    increment(state) {
      state.count++
    }
  },
  actions: {            // 又要一层?
    increment({ commit }) {
      commit('increment')
    }
  }
})

Pinia 写法 - 简单直观

const useStore = defineStore('main', {
  state: () => ({ count: 0 }),
  actions: {
    increment() {
      this.count++      // 直接修改state,不需要mutations
    }
  }
})

Pinia的核心优势:

  • 更少的代码:比 Vuex4 少了 30% - 40% 的模板代码
  • 更好的 TypeScrip t支持:不用额外写类型定义
  • 更简单的API:只有stategettersactions
  • 模块化:每个 store 都是独立的,不需要额外的 module

快速上手 - 第一个Pinia Store

安装和配置

首先,我们需要在 Vue3 项目中安装 Pinia

npm install pinia
# 或者
yarn add pinia

然后在 main.js 中注册:

// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const app = createApp(App)
const pinia = createPinia()  // 创建Pinia实例

app.use(pinia)  // 使用Pinia
app.mount('#app')

创建第一个 Store

src/stores 目录下创建一个 counter.js 文件:

// stores/counter.js
import { defineStore } from 'pinia'

// 定义并使用一个store
export const useCounterStore = defineStore('counter', {
  // state:存储数据的地方
  state: () => ({
    count: 0,
    name: '计数器'
  }),
  
  // getters:计算属性,相当于computed
  getters: {
    // 自动推导返回类型
    doubleCount: (state) => state.count * 2,
    
    // 带参数的getter(返回一个函数)
    multiply: (state) => (times) => state.count * times
  },
  
  // actions:修改state的方法
  actions: {
    // 普通修改
    increment() {
      this.count++
    },
    
    // 带参数修改
    add(amount) {
      this.count += amount
    },
    
    // 异步操作
    async fetchAndSet() {
      // 模拟API调用
      const res = await fetch('/api/count')
      const data = await res.json()
      this.count = data.count
    }
  }
})

在组件中使用Store

现在,在任何组件中都可以使用这个计数器了:

<!-- Counter.vue -->
<template>
  <div class="counter">
    <h2>{{ store.name }}</h2>
    <p>当前值: {{ store.count }}</p>
    <p>双倍值: {{ store.doubleCount }}</p>
    <p>乘以3: {{ store.multiply(3) }}</p>
    
    <button @click="store.increment()">+1</button>
    <button @click="store.add(5)">+5</button>
    <button @click="handleAsync">异步获取</button>
  </div>
</template>

<script setup>
import { useCounterStore } from '@/stores/counter'

// 获取store实例
const store = useCounterStore()

// 异步操作
async function handleAsync() {
  await store.fetchAndSet()
}
</script>

深入理解 - Store的三个核心部分

State:数据存储

创建 state

State 就是存储数据的地方,类似于组件的 data 选项:

// stores/user.js
export const useUserStore = defineStore('user', {
  state: () => ({
    // 基础信息
    id: null,
    name: '',
    email: '',
    
    // 复杂数据
    preferences: {
      theme: 'light',
      language: 'zh-CN',
      notifications: true
    },
    
    // 集合类型
    permissions: [],
    
    // 状态标志
    isLoading: false,
    lastLogin: null
  })
})

访问和修改 state

// 获取store
const userStore = useUserStore()

// ✅ 读取state
console.log(userStore.name)
console.log(userStore.preferences.theme)

// ✅ 直接修改state(最简单的方式)
userStore.name = '张三'
userStore.preferences.theme = 'dark'

// ✅ 批量修改(推荐,只触发一次更新)
userStore.$patch({
  name: '李四',
  email: 'lisi@example.com'
})

// ✅ 更灵活的批量修改
userStore.$patch((state) => {
  state.name = '王五'
  state.preferences.theme = 'dark'
  state.permissions.push('admin')
})

// ✅ 重置state到初始值
userStore.$reset()

Getter:计算属性

创建 Getter

Getter 类似于组件的 computed 属性,用于派生出新的数据:

// stores/user.js
export const useUserStore = defineStore('user', {
  state: () => ({
    firstName: '张',
    lastName: '三',
    todos: [
      { text: '学习Pinia', done: true },
      { text: '写代码', done: false }
    ]
  }),
  
  getters: {
    // 基础getter
    fullName: (state) => `${state.firstName}${state.lastName}`,
    
    // 使用其他getter
    introduction: (state) => {
      return `我是${state.firstName}${state.lastName}`
    },
    
    // 带参数的getter(返回函数)
    getTodoByStatus: (state) => (done) => {
      return state.todos.filter(todo => todo.done === done)
    },
    
    // 统计完成数量
    completedCount: (state) => {
      return state.todos.filter(todo => todo.done).length
    },
    
    // 进度百分比
    progress: (state) => {
      const completed = state.todos.filter(todo => todo.done).length
      const total = state.todos.length
      return total === 0 ? 0 : Math.round((completed / total) * 100)
    }
  }
})

在组件中使用 getters

<template>
  <div>
    <h3>{{ userStore.fullName }}</h3>
    <p>进度: {{ userStore.progress }}%</p>
    
    <!-- 使用带参数的getter -->
    <div v-for="todo in userStore.getTodoByStatus(false)">
      {{ todo.text }} (未完成)
    </div>
  </div>
</template>

Action:业务逻辑

创建 action

Action 是修改 state 的地方,可以包含异步操作:

// stores/user.js
export const useUserStore = defineStore('user', {
  state: () => ({
    user: null,
    loading: false,
    error: null
  }),
  
  actions: {
    // 同步action
    setUser(user) {
      this.user = user
    },
    
    // 带参数的同步action
    updateUserInfo({ name, email }) {
      if (this.user) {
        this.user.name = name
        this.user.email = email
      }
    },
    
    // 异步action
    async login(credentials) {
      this.loading = true
      this.error = null
      
      try {
        // 调用登录API
        const response = await fetch('/api/login', {
          method: 'POST',
          body: JSON.stringify(credentials)
        })
        
        if (!response.ok) {
          throw new Error('登录失败')
        }
        
        const data = await response.json()
        this.user = data.user
        
        // 可以返回数据给组件
        return data.user
      } catch (error) {
        this.error = error.message
        throw error // 抛出错误,让组件处理
      } finally {
        this.loading = false
      }
    },
    
    // 组合多个action
    async logout() {
      try {
        await fetch('/api/logout')
      } finally {
        // 重置所有状态
        this.$reset()
      }
    }
  }
})

在组件中使用 action

import { useUserStore } from '@/stores/user'
import { ref } from 'vue'

const userStore = useUserStore()
const email = ref('')
const password = ref('')
const errorMsg = ref('')

async function handleLogin() {
  try {
    await userStore.login({
      email: email.value,
      password: password.value
    })
    // 登录成功,跳转到首页
    router.push('/dashboard')
  } catch (error) {
    errorMsg.value = error.message
  }
}

组合式风格 - 更现代的写法

从 Vue3 开始,组合式 API 成为主流。Pinia 也支持用组合式风格定义 store

基础组合式 Store

// stores/user.js (组合式风格)
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useUserStore = defineStore('user', () => {
  // ========== State:用ref定义 ==========
  const user = ref(null)
  const token = ref(localStorage.getItem('token'))
  const loading = ref(false)
  const error = ref(null)
  
  // ========== Getters:用computed定义 ==========
  const isLoggedIn = computed(() => !!token.value && !!user.value)
  
  const fullName = computed(() => {
    if (!user.value) return ''
    return `${user.value.lastName}${user.value.firstName}`
  })
  
  const isAdmin = computed(() => user.value?.role === 'admin')
  
  // 返回函数的getter
  const hasPermission = (permission) => {
    return computed(() => user.value?.permissions?.includes(permission))
  }
  
  // ========== Actions:普通函数 ==========
  function setUser(userData) {
    user.value = userData
  }
  
  async function login(credentials) {
    loading.value = true
    error.value = null
    
    try {
      const response = await fetch('/api/login', {
        method: 'POST',
        body: JSON.stringify(credentials)
      })
      
      const data = await response.json()
      user.value = data.user
      token.value = data.token
      
      // 保存到localStorage
      localStorage.setItem('token', data.token)
      
      return data.user
    } catch (err) {
      error.value = err.message
      throw err
    } finally {
      loading.value = false
    }
  }
  
  function logout() {
    user.value = null
    token.value = null
    localStorage.removeItem('token')
  }
  
  // 返回所有内容
  return {
    // state
    user,
    token,
    loading,
    error,
    
    // getters
    isLoggedIn,
    fullName,
    isAdmin,
    hasPermission,
    
    // actions
    setUser,
    login,
    logout
  }
})

为什么推荐组合式风格?

选项式风格:数据和逻辑分离

defineStore('counter', {
  state: () => ({ count: 0 }),
  getters: { double: (state) => state.count * 2 },
  actions: { increment() { this.count++ } }
})

组合式风格:相关代码在一起,更易维护

defineStore('counter', () => {
  // 所有的相关代码都在这里
  const count = ref(0)
  const double = computed(() => count.value * 2)
  
  function increment() {
    count.value++
  }
  
  return { count, double, increment }
})

组合式风格的优势:

  • 更好的代码组织:相关的逻辑放在一起
  • 更容易复用:可以提取公共逻辑到组合式函数
  • 更灵活的TypeScript支持

实用技巧 - 解决常见问题

解构陷阱:为什么不能用解构?

这是新手很容易犯的错误:

import { useUserStore } from '@/stores/user'

// ❌ 错误:解构会失去响应式
const { name, email } = useUserStore()

// 当store中的name变化时,这里的name不会更新!

原理示意图

Store (响应式对象)
  ├── name (响应式属性)
  ├── email (响应式属性)
  └── login (普通函数)

直接解构:
const { name } = store
name --> 变成了普通变量,失去响应式

正确解构:storeToRefs

import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'

const userStore = useUserStore()

// ✅ 正确:使用storeToRefs保持响应式
const { name, email, isAdmin } = storeToRefs(userStore)

// ✅ actions可以直接解构(它们不是响应式的)
const { login, logout } = userStore

// 现在name是ref,修改会自动更新
console.log(name.value)  // 注意要加.value

storeToRefs 做了什么

// 简单理解它的原理
function storeToRefs(store) {
  const refs = {}
  
  for (const key in store) {
    const value = store[key]
    
    // 如果是响应式数据,转换为ref
    if (isRef(value) || isReactive(value)) {
      refs[key] = toRef(store, key)
    }
    // actions被忽略,保持原样
  }
  
  return refs
}

批量更新:避免多次渲染

// ❌ 错误:多次修改导致多次渲染
function addItems(items) {
  for (const item of items) {
    this.items.push(item)  // 触发一次渲染
    this.total += item.price  // 又触发一次
    this.count++  // 又一次触发
  }
}

// ✅ 正确:使用$patch批量更新
function addItems(items) {
  this.$patch((state) => {
    // 在$patch内部的所有修改只触发一次更新
    for (const item of items) {
      state.items.push(item)
      state.total += item.price
      state.count++
    }
  })
}

// ✅ 或者:先计算再赋值
function addItems(items) {
  const newItems = [...this.items, ...items]
  const total = newItems.reduce((sum, i) => sum + i.price, 0)
  
  // 一次性更新
  this.items = newItems
  this.total = total
  this.count = newItems.length
}

大型数据性能优化

当需要存储大量数据时:

// stores/data.js
import { defineStore } from 'pinia'
import { shallowRef } from 'vue'

export const useDataStore = defineStore('data', () => {
  // ❌ 如果数据很大,ref会让所有属性都变成响应式
  const bigData = ref(fetchHugeDataset())
  
  // ✅ 使用shallowRef,只跟踪引用变化,内部属性不跟踪
  const bigDataOptimized = shallowRef(fetchHugeDataset())
  
  // 更新时整体替换
  function updateData(newData) {
    bigDataOptimized.value = newData  // 触发更新
    // 修改内部属性不会触发更新
    // bigDataOptimized.value[0].name = 'test' ❌ 不会触发渲染
  }
  
  return { bigDataOptimized, updateData }
})

避免在循环中使用store

<!-- ❌ 错误:每次循环都创建一个store实例 -->
<template>
  <div v-for="user in users" :key="user.id">
    <UserCard :store="useUserStore(user.id)" />
  </div>
</template>

解决方案:使用store工厂或传递ID

// stores/user.js
export const useUserStore = defineStore('user', () => {
  const users = ref(new Map()) // 用Map存储多个用户
  
  function getUser(id) {
    if (!users.value.has(id)) {
      users.value.set(id, null)
    }
    return computed({
      get: () => users.value.get(id),
      set: (value) => users.value.set(id, value)
    })
  }
  
  async function fetchUser(id) {
    const user = await api.getUser(id)
    users.value.set(id, user)
  }
  
  return { getUser, fetchUser }
})

// 在组件中使用
const userStore = useUserStore()
const user = userStore.getUser(props.userId)

watchEffect(() => {
  if (!user.value) {
    userStore.fetchUser(props.userId)
  }
})

循环依赖

// ❌ 错误:两个store相互引用
// storeA.js
export const useAStore = defineStore('a', () => {
  const bStore = useBStore()  // 依赖B
  const data = ref(bStore.someData)
  return { data }
})

// storeB.js
export const useBStore = defineStore('b', () => {
  const aStore = useAStore()  // 依赖A
  const data = ref(aStore.someData)
  return { data }
})

解决方案:提取共享逻辑

// 创建共享store:storeShared.js
export const useSharedStore = defineStore('shared', () => {
  const sharedData = ref({})
  return { sharedData }
})

// storeA.js
export const useAStore = defineStore('a', () => {
  const shared = useSharedStore()
  const data = computed(() => shared.sharedData.a)
  return { data }
})

// storeB.js
export const useBStore = defineStore('b', () => {
  const shared = useSharedStore()
  const data = computed(() => shared.sharedData.b)
  return { data }
})

Store 组合:1+1 > 2

一个 Store 中使用另一个 Store

// stores/cart.js
import { defineStore } from 'pinia'
import { useUserStore } from './user'
import { useProductStore } from './product'

export const useCartStore = defineStore('cart', () => {
  // 引入其他store
  const userStore = useUserStore()
  const productStore = useProductStore()
  
  // state
  const items = ref([])
  const coupon = ref(null)
  
  // getters - 组合多个store的数据
  const cartItems = computed(() => {
    return items.value.map(item => {
      // 从商品store获取详细信息
      const product = productStore.getProductById(item.productId)
      return {
        ...item,
        product,
        subtotal: product.price * item.quantity
      }
    })
  })
  
  const total = computed(() => {
    return cartItems.value.reduce((sum, item) => sum + item.subtotal, 0)
  })
  
  const canCheckout = computed(() => {
    // 同时依赖多个store
    return userStore.isLoggedIn && items.value.length > 0
  })
  
  // actions
  function addItem(productId, quantity = 1) {
    const existing = items.value.find(i => i.productId === productId)
    
    if (existing) {
      existing.quantity += quantity
    } else {
      items.value.push({ productId, quantity })
    }
    
    // 调用其他store的action
    productStore.reduceStock(productId, quantity)
  }
  
  async function checkout() {
    if (!canCheckout.value) {
      throw new Error('不能结算')
    }
    
    // 使用用户信息和购物车数据创建订单
    const order = {
      userId: userStore.user.id,
      items: items.value,
      total: total.value,
      coupon: coupon.value
    }
    
    // 调用订单API
    const result = await api.createOrder(order)
    
    // 清空购物车
    items.value = []
    
    return result
  }
  
  return {
    items,
    coupon,
    cartItems,
    total,
    canCheckout,
    addItem,
    checkout
  }
})

共享逻辑复用:工厂模式

// stores/factories/createListStore.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

/**
 * 创建一个通用的列表管理store
 * @param {string} id store的唯一标识
 * @param {Object} options 配置选项
 */
export function createListStore(id, options) {
  return defineStore(id, () => {
    // state
    const items = ref([])
    const loading = ref(false)
    const error = ref(null)
    const filters = ref({})
    
    // getters
    const total = computed(() => items.value.length)
    
    const filteredItems = computed(() => {
      let result = items.value
      
      // 应用自定义过滤逻辑
      if (options.filter) {
        result = result.filter(item => options.filter(item, filters.value))
      }
      
      return result
    })
    
    // actions
    async function fetchItems(params) {
      loading.value = true
      error.value = null
      filters.value = params || {}
      
      try {
        const data = await options.fetch(params)
        items.value = data
      } catch (err) {
        error.value = err.message
        throw err
      } finally {
        loading.value = false
      }
    }
    
    async function addItem(data) {
      if (!options.create) {
        throw new Error('create method not implemented')
      }
      
      const newItem = await options.create(data)
      items.value.push(newItem)
      return newItem
    }
    
    async function updateItem(id, data) {
      if (!options.update) {
        throw new Error('update method not implemented')
      }
      
      const updated = await options.update(id, data)
      const index = items.value.findIndex(i => i.id === id)
      if (index !== -1) {
        items.value[index] = updated
      }
      return updated
    }
    
    async function deleteItem(id) {
      if (!options.delete) {
        throw new Error('delete method not implemented')
      }
      
      await options.delete(id)
      items.value = items.value.filter(i => i.id !== id)
    }
    
    return {
      items,
      loading,
      error,
      filters,
      total,
      filteredItems,
      fetchItems,
      addItem,
      updateItem,
      deleteItem
    }
  })
}

// 使用工厂创建具体的store
// stores/users.js
import { createListStore } from './factories/createListStore'
import { userApi } from '@/api/user'

export const useUserStore = createListStore('users', {
  fetch: userApi.getUsers,
  create: userApi.createUser,
  update: userApi.updateUser,
  delete: userApi.deleteUser,
  filter: (user, filters) => {
    if (filters.keyword && !user.name.includes(filters.keyword)) {
      return false
    }
    if (filters.role && user.role !== filters.role) {
      return false
    }
    return true
  }
})

// 在组件中使用
const userStore = useUserStore()
await userStore.fetchItems({ keyword: '张' })

黄金法则与最佳实践

Store设计原则

原则 说明 示例
按业务划分 每个store管理一个业务领域 user、product、cart
扁平化 避免嵌套,保持简单 不要用modules
单一职责 一个store只做一件事 购物车不处理订单
可组合 store之间可以互相使用 购物车使用商品和用户

性能优化原则

原则 说明 示例
使用 storeToRefs 只解构需要的响应式数据 const { name } = storeToRefs(store)
actions 直接解构 actions 不是响应式的 const { login } = store
批量更新 $patch 批量更新,减少触发更新次数 store.$patch({ ... })
大型数据用 shallowRef 避免深度响应式 const data = shallowRef([])
避免循环依赖 store 之间不要相互引用 使用共享 store 解耦
按需加载 路由级别拆分 store 只在需要时 import

代码组织原则

推荐的 store 文件结构

stores/
├── index.js              # 统一导出
├── user.js               # 用户相关
├── product.js            # 商品相关
├── cart.js               # 购物车相关
└── factories/            # 工厂函数
    └── createListStore.js

推荐的 store 内部结构

export const useStore = defineStore('id', () => {
  // 1. state (ref)
  const data = ref(null)
  
  // 2. getters (computed)
  const computedData = computed(() => data.value)
  
  // 3. actions (functions)
  function action() {}
  
  // 4. return
  return { data, computedData, action }
})

常见错误检查清单

  • 是不是直接解构了 store
  • 是不是忘了用 storeToRefs
  • 是不是在循环中创建 store 实例?
  • 是不是有循环依赖?
  • 是不是用了太多响应式?
  • 是不是在 getter 中做了异步操作?

最终建议

Pinia 的成功在于它的简单类型安全。但简单不等于随意,类型安全不等于复杂。在实际项目中:

  1. 从简单的 store 开始,不要一开始就追求完美设计
  2. 遵循组合式风格,它更适合 Vue 3 的生态
  3. 注意性能陷阱,特别是 storeToRefs 的使用
  4. 充分利用 TypeScript,让类型系统帮你发现错误
  5. 测试核心逻辑,特别是涉及异步操作的 actions

结语

Pinia 只是工具,不是目标,不要为了用而用,而是要在真正需要共享状态的地方使用它。好的状态管理应该让业务代码更清晰,而不是增加复杂度。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

VUE3 中的 Axios 二次封装与请求策略

作者 wuhen_n
2026年3月13日 08:59

前言

在现代前端应用中,网络请求是不可或缺的一部分。Axios 作为最流行的 HTTP 客户端,以其简洁的 API 和强大的功能赢得了开发者的青睐。然而,直接在每个组件中使用 Axios 会导致大量的代码冗余、错误处理混乱、难以维护等问题。

因此,我们需要对 Axios 进行二次封装,其核心价值在于:统一处理、集中配置、复用逻辑,把复杂的事情变得简单,把重复的事情变得自动化。

本文将从零开始,深入探讨如何构建一个健壮、易用、类型安全的请求层,涵盖拦截器、请求取消、重试机制、缓存策略等高级特性。

为什么要封装 Axios?

没有封装的代码长什么样?

在本文开篇之前,我们先来看一个没有封装的 Axios 请求是什么样的:

// 用户页面
async function getUser() {
  try {
    const res = await axios.get('http://localhost:3000/api/users', {
      headers: { token: localStorage.getItem('token') },
      timeout: 10000
    })
    user.value = res.data
  } catch (err) {
    if (err.response?.status === 401) {
      router.push('/login')
    }
    console.error('获取用户失败', err)
  }
}

// 商品页面
async function getProduct() {
  try {
    const res = await axios.get('http://localhost:3000/api/products', {
      headers: { token: localStorage.getItem('token') },
      timeout: 10000
    })
    product.value = res.data
  } catch (err) {
    if (err.response?.status === 401) {
      router.push('/login')
    }
    console.error('获取商品失败', err)
  }
}

这段代码有哪些问题呢?

  • 每个请求都需要重复配置 headerstimeout 等重复配置项
  • 每个请求都要重复获取和处理 tokenlocalStorage.getItem('token')
  • 每个请求都要写 try/catch 等错误处理
  • 当需要修改请求配置时,与之相关的所有文件都要修改

封装之后的代码长什么样?

二次封装后,所有的重复配置都只需要写一次:

// request.js
const request = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 10000
})

// 使用
import request from './request'
request.get('/users')
request.get('/products')

封装的核心价值

  • 统一配置:一次配置,到处使用
  • 统一处理:token 自动添加、错误统一处理
  • 复用逻辑:loading状态、重试机制等都可复用
  • 易于维护 :修改一处,生效全局

从零开始构建我们的请求层

第一层:基础配置

创建一个 request.js 文件,这是所有请求的基础:

// request.js
import axios from 'axios'

// 1. 创建axios实例
const request = axios.create({
  // 基础URL - 通过环境变量区分开发/生产
  baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api',
  
  // 超时时间 - 10秒后自动断开
  timeout: 10000,
  
  // 请求头 - 默认配置
  headers: {
    'Content-Type': 'application/json'
  }
})

export default request

第二层:拦截器

拦截器就像机场的安检通道,每个请求和其响应都要经过检查:请求拦截器/响应拦截器:

// request.js
import { useUserStore } from '@/stores/user'

// 请求拦截器 - 请求发出前的处理
request.interceptors.request.use(
  (config) => {
    // 1. 获取用户token
    const userStore = useUserStore()
    
    // 2. 如果用户已登录,自动添加token
    if (userStore.token) {
      config.headers.Authorization = `Bearer ${userStore.token}`
    }
    
    // 3. GET请求添加时间戳,防止浏览器缓存
    if (config.method === 'get') {
      config.params = {
        ...config.params,
        _t: Date.now()
      }
    }
    
    return config
  },
  (error) => {
    // 请求配置出错时的处理
    return Promise.reject(error)
  }
)

// 响应拦截器 - 收到响应后的处理
request.interceptors.response.use(
  (response) => {
    // 直接返回数据部分,简化使用
    return response.data
  },
  (error) => {
    // 统一的错误处理
    if (error.response) {
      // 服务器返回了错误状态码
      switch (error.response.status) {
        case 401: // 未授权
          const userStore = useUserStore()
          userStore.logout() // 清除用户信息
          router.push('/login') // 跳转到登录页
          break
        case 403: // 禁止访问
          ElMessage.error('没有权限执行此操作')
          break
        case 404: // 资源不存在
          ElMessage.error('请求的资源不存在')
          break
        case 500: // 服务器错误
          ElMessage.error('服务器开小差了,请稍后再试')
          break
        default:
          ElMessage.error(error.response.data?.message || '请求失败')
      }
    } else if (error.request) {
      // 请求发出去了,但没有收到响应
      ElMessage.error('网络连接失败,请检查网络设置')
    } else {
      // 请求配置出错
      ElMessage.error('请求配置错误')
    }
    
    return Promise.reject(error)
  }
)

第三层:Loading 状态自动化

当我们在发送请求时,手动控制 loading 状态会很麻烦,可以让拦截器帮我们自动处理:

// stores/loading.js
import { ref } from 'vue'
import { defineStore } from 'pinia'

export const useLoadingStore = defineStore('loading', () => {
  const count = ref(0) // 当前正在进行的请求数
  const isLoading = computed(() => count.value > 0) // 是否显示loading
  
  function add() {
    count.value++
  }
  
  function remove() {
    if (count.value > 0) {
      count.value--
    }
  }
  
  return { isLoading, add, remove }
})

在 request.js中,我们就可以使用上述 lodaing :

// request.js - 修改拦截器
import { useLoadingStore } from '@/stores/loading'

request.interceptors.request.use((config) => {
  // 如果不是手动禁用了loading
  if (!config.headers?.disableLoading) {
    const loadingStore = useLoadingStore()
    loadingStore.add()
  }
  return config
})

request.interceptors.response.use(
  (response) => {
    if (!response.config.headers?.disableLoading) {
      const loadingStore = useLoadingStore()
      loadingStore.remove()
    }
    return response
  },
  (error) => {
    if (!error.config?.headers?.disableLoading) {
      const loadingStore = useLoadingStore()
      loadingStore.remove()
    }
    return Promise.reject(error)
  }
)

在组件中使用:

<template>
  <div>
    <!-- 自动显示/隐藏loading -->
    <div v-if="loadingStore.isLoading" class="loading">加载中...</div>
    <div v-else>
      <!-- 页面内容 -->
    </div>
  </div>
</template>

<script setup>
import { useLoadingStore } from '@/stores/loading'

const loadingStore = useLoadingStore()

// 发起请求会自动显示loading
async function fetchData() {
  await request.get('/users')
}
</script>

实战技巧 - 解决常见痛点

场景1:请求取消,告别重复请求

当用户在使用搜索功能时,首先在搜索框输入"手机"发送搜索请求,此时请求还没返回;又将输入变成了"手机号",重新发送一次请求。此时应该取消第一个请求,只保留最新的一次请求:

// utils/CancelRequest.js
class CancelRequest {
  constructor() {
    // 存储所有pending状态的请求
    this.pendingMap = new Map()
  }
  
  // 生成请求的唯一标识
  getRequestKey(config) {
    const { method, url, params, data } = config
    return [method, url, JSON.stringify(params), JSON.stringify(data)].join('&')
  }
  
  // 添加请求到pending列表
  addPending(config) {
    const key = this.getRequestKey(config)
    
    // 如果已有相同的请求,取消它
    if (this.pendingMap.has(key)) {
      const abort = this.pendingMap.get(key)
      abort() // 取消请求
      this.pendingMap.delete(key)
    }
    
    // 创建新的AbortController
    const controller = new AbortController()
    config.signal = controller.signal
    
    // 保存取消函数
    this.pendingMap.set(key, () => controller.abort())
  }
  
  // 请求完成后,从pending列表移除
  removePending(config) {
    const key = this.getRequestKey(config)
    if (this.pendingMap.has(key)) {
      this.pendingMap.delete(key)
    }
  }
  
  // 取消所有pending请求(这在页面切换时很有用)
  cancelAll() {
    this.pendingMap.forEach(cancel => cancel())
    this.pendingMap.clear()
  }
}

export const cancelRequest = new CancelRequest()

在拦截器中使用:

// request.js
import { cancelRequest } from './utils/CancelRequest'

request.interceptors.request.use((config) => {
  // 如果没有禁用取消功能
  if (!config.headers?.disableCancel) {
    cancelRequest.addPending(config)
  }
  return config
})

request.interceptors.response.use(
  (response) => {
    cancelRequest.removePending(response.config)
    return response
  },
  (error) => {
    // 如果是手动取消的请求,不抛出错误
    if (axios.isCancel(error)) {
      console.log('请求已取消')
      return new Promise(() => {}) // 返回pending的Promise
    }
    
    if (error.config) {
      cancelRequest.removePending(error.config)
    }
    return Promise.reject(error)
  }
)

// 路由切换时,取消所有请求
router.beforeEach((to, from, next) => {
  cancelRequest.cancelAll()
  next()
})

场景2:自动重试,提升用户体验

当网络不稳定时,我们需要自动重试功能,让用户无感知地完成操作,而不是简单地返回一句“网络异常,请稍后重试”:

// utils/retry.js
/**
 * 带重试功能的请求
 * @param {Function} requestFn 请求函数
 * @param {Object} options 配置选项
 */
export async function retryRequest(requestFn, options = {}) {
  const {
    retries = 3,           // 最大重试次数
    delay = 1000,          // 初始延迟(毫秒)
    factor = 2,            // 延迟增长倍数
    maxDelay = 30000,      // 最大延迟
    retryCondition = (error) => {
      // 默认重试条件:网络错误 或 5xx服务器错误
      return !error.response || error.response.status >= 500
    }
  } = options
  
  let attempt = 0
  
  while (attempt <= retries) {
    try {
      return await requestFn()
    } catch (error) {
      attempt++
      
      // 最后一次尝试失败,抛出错误
      if (attempt > retries) {
        throw error
      }
      
      // 检查是否应该重试
      if (!retryCondition(error)) {
        throw error
      }
      
      // 计算等待时间(指数退避)
      const waitTime = Math.min(delay * Math.pow(factor, attempt - 1), maxDelay)
      
      console.log(`请求失败,${waitTime}ms后第${attempt}次重试...`)
      
      // 等待后继续循环
      await new Promise(resolve => setTimeout(resolve, waitTime))
    }
  }
}

// 使用示例
async function fetchImportantData() {
  return retryRequest(
    () => request.get('/important-data'),
    {
      retries: 5,
      delay: 2000,
      onRetry: (attempt, error) => {
        // 可以在这里记录日志或通知用户
        console.log(`第${attempt}次重试`, error)
      }
    }
  )
}

场景3:数据缓存,减少不必要的请求

当用户频繁查看某个商品详情时,每次都要发送一次请求,这样既浪费资源,又慢,因此我们可以将数据缓存起来:

// utils/cache.js
class RequestCache {
  constructor() {
    this.cache = new Map()
  }
  
  /**
   * 设置缓存
   * @param {string} key 缓存键
   * @param {any} data 缓存数据
   * @param {number} ttl 过期时间(毫秒)
   */
  set(key, data, ttl = 60000) {
    this.cache.set(key, {
      data,
      expire: Date.now() + ttl
    })
  }
  
  /**
   * 获取缓存
   * @param {string} key 缓存键
   */
  get(key) {
    const item = this.cache.get(key)
    
    // 没有缓存
    if (!item) return null
    
    // 检查是否过期
    if (Date.now() > item.expire) {
      this.cache.delete(key)
      return null
    }
    
    return item.data
  }
  
  // 清除特定缓存
  delete(key) {
    this.cache.delete(key)
  }
  
  // 清除所有缓存
  clear() {
    this.cache.clear()
  }
}

export const requestCache = new RequestCache()

// 封装带缓存的请求
async function requestWithCache(url, options = {}) {
  const { cacheTTL = 60000, ...restOptions } = options
  
  // 只有GET请求才使用缓存
  if (restOptions.method && restOptions.method !== 'GET') {
    return request(url, restOptions)
  }
  
  // 生成缓存键
  const cacheKey = `${url}:${JSON.stringify(restOptions.params)}`
  
  // 检查缓存
  const cached = requestCache.get(cacheKey)
  if (cached) {
    console.log('使用缓存数据:', cacheKey)
    return cached
  }
  
  // 发起真实请求
  const data = await request(url, restOptions)
  
  // 存入缓存
  requestCache.set(cacheKey, data, cacheTTL)
  
  return data
}

TypeScript 加持 - 让代码更可靠

自定义类型系统

// types/api.d.ts
// 通用响应格式
export interface ApiResponse<T = any> {
  code: number        // 业务状态码
  message: string     // 提示信息
  data: T            // 实际数据
  timestamp?: number  // 时间戳
}

// 分页参数
export interface PaginationParams {
  page: number        // 当前页码
  pageSize: number    // 每页条数
  sort?: string       // 排序字段
  order?: 'asc' | 'desc' // 排序方式
}

// 分页结果
export interface PaginatedResult<T> {
  list: T[]           // 数据列表
  total: number       // 总条数
  page: number        // 当前页码
  pageSize: number    // 每页条数
  totalPages: number  // 总页数
}

// 扩展的请求配置
export interface RequestConfig {
  url: string
  method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
  data?: any
  params?: any
  headers?: Record<string, string>
  
  // 自定义选项
  disableLoading?: boolean  // 是否禁用loading
  disableCancel?: boolean   // 是否禁用自动取消
  cacheTTL?: number        // 缓存时间(毫秒)
  retries?: number         // 重试次数
}

创建类型安全的API模块

// api/user.ts
import request from '@/request'
import type { PaginationParams, PaginatedResult } from '@/types/api'

// 用户类型定义
export interface User {
  id: number
  name: string
  email: string
  avatar: string
  role: 'admin' | 'user'
  status: 'active' | 'inactive'
  createdAt: string
  updatedAt: string
}

export interface CreateUserDto {
  name: string
  email: string
  password: string
  role?: 'admin' | 'user'
}

export interface UpdateUserDto extends Partial<CreateUserDto> {
  status?: 'active' | 'inactive'
}

export interface UserListParams extends PaginationParams {
  keyword?: string
  role?: string
  status?: string
}

// 用户API模块
export const userApi = {
  // 获取用户列表
  getList: (params: UserListParams) => 
    request.get<PaginatedResult<User>>('/users', { params }),
  
  // 获取单个用户
  getDetail: (id: number) => 
    request.get<User>(`/users/${id}`),
  
  // 创建用户
  create: (data: CreateUserDto) => 
    request.post<User>('/users', data),
  
  // 更新用户
  update: (id: number, data: UpdateUserDto) => 
    request.put<User>(`/users/${id}`, data),
  
  // 删除用户
  delete: (id: number) => 
    request.delete(`/users/${id}`),
  
  // 修改状态
  updateStatus: (id: number, status: User['status']) => 
    request.patch(`/users/${id}/status`, { status })
}

在组件中使用

<script setup lang="ts">
import { ref } from 'vue'
import { userApi } from '@/api/user'
import type { User, UserListParams } from '@/api/user'

const users = ref<User[]>([])
const loading = ref(false)

const params = ref<UserListParams>({
  page: 1,
  pageSize: 10,
  keyword: ''
})

async function loadUsers() {
  loading.value = true
  try {
    const result = await userApi.getList(params.value)
    users.value = result.list
  } finally {
    loading.value = false
  }
}

// 完全的类型提示和自动补全!
async function handleCreate() {
  const newUser = await userApi.create({
    name: '张三',
    email: 'zhangsan@example.com',
    password: '123456',
    role: 'user'
  })
  users.value.push(newUser)
}
</script>

封装的度 - 如何把握封装分寸?

封装层次图

graph TB
    subgraph "业务层"
        A[业务组件]
    end
    
    subgraph "API层"
        B[API模块]
    end
    
    subgraph "基础层"
        C[请求实例]
        D[拦截器]
        E[工具函数]
    end
    
    A --> B
    B --> C
    C --> D
    D --> E

封装原则

原则一:够用即可

不要过度设计,根据项目规模选择合适的封装程度:

// ✅ 小型项目:简单封装就够了
const request = axios.create({ baseURL: '/api' })

// ✅ 中型项目:添加拦截器、类型定义
request.interceptors.response.use(/* 错误处理 */)

// ✅ 大型项目:完整的缓存、重试、监控机制

原则二:可配置性

提供出口,让特殊场景可以绕过封装:

// 通过配置项控制
await request.get('/important-data', {
  headers: {
    disableLoading: true,  // 不显示loading
    disableCancel: true,   // 不自动取消
    disableRetry: true     // 不重试
  }
})

原则三:渐进增强

从简单开始,逐步完善:

// 第一阶段:基础封装
export const api = {
  getUser: () => request.get('/user')
}

// 第二阶段:添加类型
export const api = {
  getUser: (): Promise<User> => request.get('/user')
}

// 第三阶段:添加高级特性
export const api = {
  getUser: () => retryRequest(
    () => requestWithCache('/user'),
    { retries: 3 }
  )
}

封装的检查清单

检查项 是否必需 说明
基础配置 baseURL、超时、请求头
错误处理 统一错误提示、状态码处理
Token管理 自动附加、过期处理
Loading状态 推荐 提升用户体验
TypeScript 推荐 类型安全、开发体验
请求取消 看场景 搜索、标签切换等
数据缓存 看场景 频繁访问的静态数据
自动重试 看场景 网络不稳定时

完整目录结构

src/
├── api/
│   ├── index.ts           # API统一出口
│   ├── user.ts            # 用户模块
│   ├── product.ts         # 商品模块
│   └── order.ts           # 订单模块
├── utils/
│   ├── request.ts         # 请求核心
│   ├── cache.ts           # 缓存工具
│   ├── retry.ts           # 重试工具
│   └── cancel.ts          # 取消工具
├── types/
│   └── api.d.ts           # 类型定义
└── stores/
    └── loading.ts         # loading状态

最终建议

Axios 封装没有标准答案,关键在于根据项目规模和团队习惯找到平衡点

  • 小型项目:简单的拦截器 + 类型定义就够了
  • 中型项目:需要请求取消、错误统一处理
  • 大型项目:完整的缓存、重试、监控机制

结语

封装不是为了炫技,而是为了让代码更简单,让开发更高效。一个好的封装应该让 90% 的场景变得简单,同时给 10% 的特殊场景留出出口。希望这篇文章能帮助我们构建适合自己的请求层。记住,最好的封装是让使用它的人感受不到封装的存在。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

虚拟列表完全指南:从原理到实战,轻松渲染10万条数据

作者 wuhen_n
2026年3月14日 07:23

前言

想象一下这个场景:我们正在开发一个聊天应用,需要展示最近一年的聊天记录,总共有10万条消息。如果用传统方式渲染,页面会直接卡死,用户直接口吐芬芳了。

再想象另一个场景:我们在做一个数据后台,需要在表格中展示5万条日志。如果一次性渲染所有数据,内存占用轻松超过 500MB,用户的电脑风扇会疯狂嘶吼。

这就是虚拟列表要解决的问题:让海量数据的渲染变得像渲染几十条数据一样流畅。

本文将从最基础的概念讲起,用最通俗的语言,配合完整的代码示例,带领我们一步步掌握虚拟列表的核心技术。

为什么需要虚拟列表?

大量 DOM 元素导致的渲染性能问题

我们先来看一段最普通的代码:

<template>
  <div class="list">
    <div v-for="item in 100000" :key="item" class="list-item">
      第 {{ item }} 条数据
    </div>
  </div>
</template>

猜猜看,这段代码会有什么后果? 实际测试结果

  • 渲染时间:Chrome 需要 3-5 秒才能完成渲染
  • 内存占用:100,000 个 DOM 节点占用约 300MB 内存
  • 滚动卡顿:每秒需要处理大量重绘和回流,像幻灯片一样卡
  • 交互延迟:点击、选中等操作有明显延迟

为什么会出现这种情况?让我们用一个生活中的小示例来解释。

用生活化的比喻理解问题

假如我们在一个巨大的图书馆工作,每天需要整理10万本书:

  • 传统渲染方式:把10万本书全部搬到桌子上,想用哪本拿哪本

    • 桌子被堆得满满的
    • 找一本书要翻半天
    • 挪动一下都费劲
  • 虚拟列表方式:只把当前需要用到的几本书放在桌上:

    • 桌子永远只有几本书
    • 想看其他书时,把新书拿上来,旧书放回去
    • 永远轻松自如

DOM元素为什么这么"重"?

每个DOM元素都不是简单的“标签”,而是一个庞大的 JavaScript 对象:

// 一个简单的 div 元素包含的属性(简化版)
{
  tagName: 'DIV',
  id: '',
  className: '',
  style: { ... },      // 几十个样式属性
  attributes: { ... },  // 属性集合
  children: [],         // 子节点
  parentNode: ...,      // 父节点引用
  offsetHeight: 0,      // 位置信息
  offsetWidth: 0,
  offsetTop: 0,
  offsetLeft: 0,
  // ... 还有几百个其他属性
}

内存占用计算

一个div ≈ 4-8KB
10万个div ≈ 400-800MB
Vue组件实例 ≈ 每个额外占用2-3KB
总计 ≈ 700MB-1.1GB

这就是为什么传统渲染方式会卡死的根本原因。

虚拟列表的核心原理

核心思想:只渲染看得见的元素

虚拟列表的核心思想其实特别简单:用户能看到多少,就渲染多少

可视区域高度: 400px
每个列表项高度: 50px
可视区域能容纳: 8个列表项

数据总量: 100,000条
实际渲染: 8条 + 少量缓冲 = 12条
节省了: 99.988%的DOM节点

图解虚拟列表原理

┌─────────────────────────┐
│   滚动容器 (height:400px)│
│  ┌─────────────────────┐│
│  │   不可见区域(顶部)  ││ ← 用padding-top撑开
│  │   (1000px 空白)     ││
│  ├─────────────────────┤│
│  │   ┌─────────────┐  ││
│  │   │  可视区域    │  ││ ← 只渲染这8条
│  │   │  Item 100   │  ││
│  │   │  Item 101   │  ││
│  │   │  Item 102   │  ││
│  │   │  Item 103   │  ││
│  │   │  Item 104   │  ││
│  │   │  Item 105   │  ││
│  │   │  Item 106   │  ││
│  │   │  Item 107   │  ││
│  │   └─────────────┘  ││
│  ├─────────────────────┤│
│  │   不可见区域(底部) ││ ← 用padding-bottom撑开
│  │   (9000px 空白)     ││
│  └─────────────────────┘│
└─────────────────────────┘

三个关键技术点

1. 计算可视区域

// 已知条件
容器高度 = 400px
列表项高度 = 50px

// 计算可视区域能显示多少个
可视数量 = 容器高度 / 列表项高度 = 8// 根据滚动位置计算应该显示哪些
开始索引 = 滚动高度 / 列表项高度
结束索引 = 开始索引 + 可视数量

2. 撑起滚动条

为了让滚动条显示正确的总高度,我们通常需要创建一个占位元素

<div class="container">
  <!-- 占位元素:只有高度,没有内容,用于撑开滚动条 -->
  <div :style="{ height: totalHeight + 'px' }"></div>
  
  <!-- 实际内容:通过绝对定位或transform移动位置 -->
  <div :style="{ transform: `translateY(${offsetY}px)` }">
    <div v-for="item in visibleItems">...</div>
  </div>
</div>

3. 滚动时更新内容

function onScroll(event) {
  // 获取滚动位置
  const scrollTop = event.target.scrollTop
  
  // 计算新的开始索引
  const startIndex = Math.floor(scrollTop / itemHeight)
  
  // 更新可视区域的数据
  visibleItems.value = data.slice(startIndex, startIndex + visibleCount)
  
  // 计算偏移量,让内容移动到正确位置
  offsetY.value = startIndex * itemHeight
}

从零实现固定高度虚拟列表

最简单的实现

让我们从一个最基础的版本开始,帮助我们理解虚拟列表的核心逻辑:

<template>
  <!-- 滚动容器 -->
  <div 
    class="virtual-list" 
    @scroll="onScroll"
    :style="{ height: containerHeight + 'px' }"
    ref="containerRef"
  >
    <!-- 占位元素:撑开滚动条 -->
    <div 
      class="placeholder" 
      :style="{ height: totalHeight + 'px' }"
    ></div>
    
    <!-- 实际内容区域 -->
    <div 
      class="content" 
      :style="{ transform: `translateY(${offsetY}px)` }"
    >
      <div 
        v-for="item in visibleData" 
        :key="item.id"
        class="list-item"
      >
        {{ item.name }}
      </div>
    </div>
  </div>
</template>

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

// 接收父组件传过来的数据
const props = defineProps({
  data: {
    type: Array,
    required: true
  },
  itemHeight: {
    type: Number,
    default: 50
  },
  containerHeight: {
    type: Number,
    default: 400
  }
})

// 当前滚动位置
const scrollTop = ref(0)

// 计算可视区域能显示多少个
const visibleCount = computed(() => 
  Math.ceil(props.containerHeight / props.itemHeight)
)

// 计算开始索引
const startIndex = computed(() => 
  Math.floor(scrollTop.value / props.itemHeight)
)

// 计算结束索引
const endIndex = computed(() => 
  Math.min(startIndex.value + visibleCount.value, props.data.length)
)

// 可视区域的数据
const visibleData = computed(() => 
  props.data.slice(startIndex.value, endIndex.value)
)

// 内容总高度
const totalHeight = computed(() => 
  props.data.length * props.itemHeight
)

// 内容偏移量
const offsetY = computed(() => 
  startIndex.value * props.itemHeight
)

// 滚动处理函数
function onScroll(event) {
  scrollTop.value = event.target.scrollTop
}
</script>

<style scoped>
.virtual-list {
  position: relative;
  overflow-y: auto;
  border: 1px solid #e8e8e8;
}

.placeholder {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  z-index: -1;
}

.content {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
}

.list-item {
  height: v-bind(itemHeight + 'px');
  line-height: v-bind(itemHeight + 'px');
  padding: 0 16px;
  border-bottom: 1px solid #f0f0f0;
  box-sizing: border-box;
}
</style>

组件使用示例

<template>
  <VirtualList 
    :data="largeData" 
    :item-height="50" 
    :container-height="400"
  />
</template>

<script setup>
import VirtualList from './components/VirtualList.vue'

// 生成10万条测试数据
const largeData = ref(
  Array.from({ length: 100000 }, (_, i) => ({
    id: i,
    name: `用户 ${i}`,
    email: `user${i}@example.com`
  }))
)
</script>

存在的问题和改进

上面的基础版本虽然能用,但有几个问题:

  • 快速滚动时会出现白屏
  • 没有缓冲区域,滚动体验不好
  • 性能还可以进一步优化

解决方案:添加缓冲区

// 添加 overscan 参数,在可视区域上下额外渲染几个
const props = defineProps({
  // ... 其他参数
  overscan: {
    type: Number,
    default: 3 // 上下各多渲染3个
  }
})

const startIndex = computed(() => {
  let index = Math.floor(scrollTop.value / props.itemHeight)
  // 减去上缓冲
  index = Math.max(0, index - props.overscan)
  return index
})

const endIndex = computed(() => {
  let index = startIndex.value + visibleCount.value + props.overscan * 2
  index = Math.min(index, props.data.length)
  return index
})

封装成可复用的组合式函数

为了更好的复用性,我们可以把逻辑提取到组合式函数中:

// composables/useVirtualList.js
import { ref, computed } from 'vue'

export function useVirtualList(data, options) {
  const {
    itemHeight,
    containerHeight,
    overscan = 3
  } = options

  const scrollTop = ref(0)
  
  // 可视区域能显示的最大项目数
  const visibleCount = computed(() => 
    Math.ceil(containerHeight / itemHeight)
  )
  
  // 起始索引
  const startIndex = computed(() => {
    let index = Math.floor(scrollTop.value / itemHeight)
    index = Math.max(0, index - overscan)
    return index
  })
  
  // 结束索引
  const endIndex = computed(() => {
    let index = startIndex.value + visibleCount.value + overscan * 2
    index = Math.min(index, data.length)
    return index
  })
  
  // 可视区域的数据
  const visibleData = computed(() => 
    data.slice(startIndex.value, endIndex.value)
  )
  
  // 内容总高度
  const totalHeight = computed(() => data.length * itemHeight)
  
  // 内容偏移量
  const offsetY = computed(() => startIndex.value * itemHeight)
  
  // 滚动处理函数
  const onScroll = (event) => {
    scrollTop.value = event.target.scrollTop
  }
  
  // 滚动到指定索引
  const scrollTo = (index) => {
    const targetScroll = index * itemHeight
    scrollTop.value = targetScroll
    return targetScroll
  }
  
  return {
    visibleData,
    totalHeight,
    offsetY,
    onScroll,
    scrollTo,
    startIndex,
    endIndex
  }
}

进阶:动态高度的虚拟列表

为什么要处理动态高度?

在实际应用中,列表项的高度往往是动态的,我们无法提前得知它到底会占用多少高度:

<!-- 每条消息的高度都不一样 -->
<div class="message">
  <div class="header">张三 14:30</div>
  <div class="content">
    这是一条很短的消息
  </div>
</div>

<div class="message">
  <div class="header">李四 14:31</div>
  <div class="content">
    这是一条很长的消息,可能会换行,可能会换很多行,
    所以这个元素的高度会比上一条高很多...
  </div>
</div>

核心挑战

动态高度的主要挑战是:在渲染之前,我们不知道每个元素的具体高度,这就带来了两个问题:

  • 无法准确计算滚动条的总高度
  • 无法精确定位滚动到某个元素

解决方案:预估 + 测量 + 缓存

1. 预估一个默认高度

// 先给每个元素一个预估高度
const itemSizes = ref(
  data.map(() => ({
    height: 40,        // 预估高度
    measured: false    // 是否已测量
  }))
)

2. 渲染后测量真实高度

// 在组件渲染后测量实际高度
function measureItem(index, element) {
  if (element && !itemSizes.value[index].measured) {
    const height = element.offsetHeight
    itemSizes.value[index].height = height
    itemSizes.value[index].measured = true
  }
}

3. 缓存测量结果,并更新总高度

// 计算累积高度(用于快速定位)
const cumulativeHeights = computed(() => {
  const heights = [0]
  let total = 0
  
  for (let i = 0; i < itemSizes.value.length; i++) {
    total += itemSizes.value[i].height
    heights.push(total)
  }
  
  return heights
})

// 总高度
const totalHeight = computed(() => 
  cumulativeHeights.value[data.length] || 0
)

完整实现

<!-- DynamicVirtualList.vue -->
<template>
  <div 
    class="virtual-list"
    :style="{ height: containerHeight + 'px' }"
    @scroll="onScroll"
    ref="containerRef"
  >
    <!-- 占位元素:撑起滚动条 -->
    <div 
      class="phantom"
      :style="{ height: totalHeight + 'px' }"
    ></div>
    
    <!-- 可见内容 -->
    <div 
      class="content"
      :style="{ transform: `translateY(${offsetY}px)` }"
    >
      <div
        v-for="(item, idx) in visibleData"
        :key="item.id"
        :data-index="startIndex + idx"
        ref="itemRefs"
        class="list-item"
      >
        <slot 
          name="item" 
          :item="item" 
          :index="startIndex + idx"
        >
          <div class="default-item">
            {{ item.name || item }}
          </div>
        </slot>
      </div>
    </div>
  </div>
</template>

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

const props = defineProps({
  data: {
    type: Array,
    required: true
  },
  estimatedItemHeight: {
    type: Number,
    default: 40
  },
  containerHeight: {
    type: Number,
    required: true
  },
  overscan: {
    type: Number,
    default: 3
  }
})

// 存储每个项的高度
const itemSizes = ref(
  props.data.map(() => ({
    height: props.estimatedItemHeight,
    measured: false
  }))
)

// 当前滚动位置
const scrollTop = ref(0)

// 容器引用
const containerRef = ref()
const itemRefs = ref([])

// 计算累积高度(用于快速定位)
const cumulativeHeights = computed(() => {
  const heights = [0]
  let total = 0
  
  for (let i = 0; i < itemSizes.value.length; i++) {
    total += itemSizes.value[i].height
    heights.push(total)
  }
  
  return heights
})

// 总高度
const totalHeight = computed(() => 
  cumulativeHeights.value[props.data.length] || 0
)

// 二分查找:根据滚动位置找起始索引
function findStartIndex(scrollTop) {
  const heights = cumulativeHeights.value
  let left = 0
  let right = heights.length - 1
  
  while (left <= right) {
    const mid = Math.floor((left + right) / 2)
    const midValue = heights[mid]
    
    if (midValue === scrollTop) {
      return mid
    } else if (midValue < scrollTop) {
      left = mid + 1
    } else {
      right = mid - 1
    }
  }
  
  return Math.max(0, right)
}

// 计算可见区域的起止索引
const startIndex = computed(() => {
  return Math.max(0, findStartIndex(scrollTop.value) - props.overscan)
})

const endIndex = computed(() => {
  let end = startIndex.value
  let currentHeight = cumulativeHeights.value[startIndex.value]
  const targetHeight = scrollTop.value + props.containerHeight
  
  while (
    end < props.data.length && 
    currentHeight < targetHeight + props.estimatedItemHeight * props.overscan
  ) {
    end++
    currentHeight = cumulativeHeights.value[end]
  }
  
  return Math.min(end + props.overscan, props.data.length)
})

// 可见区域的数据
const visibleData = computed(() => 
  props.data.slice(startIndex.value, endIndex.value)
)

// 内容偏移量
const offsetY = computed(() => 
  cumulativeHeights.value[startIndex.value] || 0
)

// 测量元素高度
function measureItems() {
  nextTick(() => {
    itemRefs.value.forEach((el, idx) => {
      if (!el) return
      
      const globalIndex = startIndex.value + idx
      const height = el.offsetHeight
      
      // 如果高度变化了,更新缓存
      if (height > 0 && itemSizes.value[globalIndex].height !== height) {
        itemSizes.value[globalIndex].height = height
        itemSizes.value[globalIndex].measured = true
      }
    })
  })
}

// 滚动处理
function onScroll(event) {
  scrollTop.value = event.target.scrollTop
}

// 当可见数据变化时,重新测量
watch(visibleData, measureItems, { immediate: true })

// 滚动到指定项
function scrollTo(index) {
  if (index < 0 || index >= props.data.length) return
  
  const targetScroll = cumulativeHeights.value[index]
  if (containerRef.value) {
    containerRef.value.scrollTop = targetScroll
    scrollTop.value = targetScroll
  }
}

defineExpose({
  scrollTo
})
</script>

<style scoped>
.virtual-list {
  position: relative;
  overflow-y: auto;
}

.phantom {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  z-index: -1;
}

.content {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
}

.list-item {
  box-sizing: border-box;
}
</style>

组件使用示例

<template>
  <div class="demo">
    <h3>动态高度虚拟列表</h3>
    <p>可见区域: {{ startIndex }} - {{ endIndex }}</p>
    
    <DynamicVirtualList
      :data="messages"
      :container-height="500"
      :estimated-item-height="60"
      ref="listRef"
    >
      <template #item="{ item, index }">
        <div class="message" :class="{ mine: item.isMine }">
          <div class="header">
            <span class="name">{{ item.name }}</span>
            <span class="time">{{ item.time }}</span>
          </div>
          <div class="content">{{ item.content }}</div>
          <div v-if="item.image" class="image">
            <img :src="item.image" @load="listRef?.measureItems()" />
          </div>
        </div>
      </template>
    </DynamicVirtualList>
    
    <button @click="scrollTo(500)">滚动到第500条</button>
  </div>
</template>

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

// 生成模拟聊天数据
const messages = ref(
  Array.from({ length: 5000 }, (_, i) => {
    const hasImage = i % 10 === 0
    const isLong = i % 5 === 0
    
    return {
      id: i,
      name: i % 2 === 0 ? '张三' : '李四',
      time: new Date(Date.now() - i * 60000).toLocaleTimeString(),
      content: isLong 
        ? '这是一条很长的消息,用来测试动态高度效果。'.repeat(5 + Math.floor(Math.random() * 10))
        : '这是一条普通消息',
      isMine: i % 3 === 0,
      image: hasImage ? `https://picsum.photos/200/150?random=${i}` : null
    }
  })
)

const listRef = ref()
const startIndex = ref(0)
const endIndex = ref(0)

function scrollTo(index) {
  listRef.value?.scrollTo(index)
}
</script>

<style>
.message {
  padding: 12px 16px;
  border-bottom: 1px solid #f0f0f0;
}

.message.mine {
  background-color: #e6f7ff;
}

.header {
  margin-bottom: 8px;
}

.name {
  font-weight: 600;
  margin-right: 12px;
}

.time {
  color: #999;
  font-size: 12px;
}

.content {
  line-height: 1.6;
  color: #333;
}

.image {
  margin-top: 8px;
}

.image img {
  max-width: 200px;
  border-radius: 4px;
}
</style>

性能优化技巧

使用 requestAnimationFrame 优化滚动

滚动事件会触发频繁计算更新,可以使用 requestAnimationFrame 节流:

let ticking = false

function onScroll(event) {
  if (!ticking) {
    requestAnimationFrame(() => {
      scrollTop.value = event.target.scrollTop
      ticking = false
    })
    ticking = true
  }
}

使用 v-memo 缓存列表项

对于高度复杂的列表项,可以使用 v-memo 缓存渲染结果,避免不必要的更新:

<template>
  <div
    v-for="item in visibleData"
    :key="item.id"
    v-memo="[item.id, item.version, item.likes]"
    class="list-item"
  >
    <ComplexItem :data="item" />
  </div>
</template>

<!-- v-memo 的作用:只有当依赖的值变化时才重新渲染 -->
<!-- 避免因为父组件更新导致的无关渲染 -->

使用 shallowRef 处理大型数据

对于大型数据,如果直接使用 ref 定义,每个属性都变成响应式,开销大。这时我们可以使用 shallowRef 避免深层响应式:

import { shallowRef } from 'vue'

// shallowRef:只有数组引用变化时才会触发更新
const data = shallowRef(generateLargeArray())

// 更新时替换整个数组
function updateData(newArray: any[]) {
  data.value = newArray
}

// 修改单个项不会触发响应式
function updateItem(index: number, newValue: any) {
  // 更新时,需要创建新数组
  const newData = [...data.value]
  newData[index] = newValue
  data.value = newData
}

使用 Intersection Observer 优化图片加载

// 使用 Intersection Observer 实现图片懒加载
const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        const img = entry.target
        const src = img.dataset.src
        if (src) {
          img.src = src
          img.removeAttribute('data-src')
          observer.unobserve(img)
        }
      }
    })
  },
  {
    rootMargin: '100px' // 提前100px加载
  }
)

// 在列表项渲染后观察图片
watch(visibleData, () => {
  nextTick(() => {
    document.querySelectorAll('img[data-src]').forEach(img => {
      observer.observe(img)
    })
  })
})

性能优化清单

优化点 方法 效果
滚动事件 requestAnimationFrame 减少计算次数
列表项更新 v-memo 避免无关渲染
大型数据 shallowRef 减少响应式开销
图片加载 Intersection Observer 按需加载
高度测量 ResizeObserver 监听高度变化
缓存策略 LRU缓存 限制缓存大小

第三方库推荐

vue-virtual-scroller

安装

npm install vue-virtual-scroller@next
# 或者:
yarn install vue-virtual-scroller@next

使用

<template>
  <RecycleScroller
    class="scroller"
    :items="list"
    :item-size="50"
    key-field="id"
    v-slot="{ item }"
  >
    <div class="item">
      {{ item.name }}
    </div>
  </RecycleScroller>
</template>

<script setup>
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'

const list = ref(Array.from({ length: 10000 }, (_, i) => ({
  id: i,
  name: `Item ${i}`
})))
</script>

优点

  • 功能完善,支持动态高度
  • 性能优秀,经过大量项目验证
  • 提供网格布局支持
  • 有活跃的社区维护

缺点

  • 需要额外引入CSS
  • 包体积较大(约20KB)
  • 定制复杂样式可能受限

与手写对比

场景 推荐方案 原因
学习目的 手写 深入理解原理
简单固定高度 手写 实现简单,无依赖
生产环境复杂需求 第三方库 稳定可靠,功能完善
特殊定制需求 手写 完全可控
团队协作项目 第三方库 减少维护成本

常见问题与解决方案

问题1:快速滚动出现白屏

当页面滚动太快时,新的内容来不及渲染,出现白屏。

解决方案:缓冲区 + 骨架屏占位

<script setup>
// 增加缓冲
const props = defineProps({
  overscan: {
    type: Number,
    default: 5 // 增加到5个
  }
})
</script>

<!-- 显示骨架屏占位 -->
<template>
  <div v-if="loading" class="skeleton">
    <div v-for="n in 5" class="skeleton-item"></div>
  </div>
</template>

问题2:高度测量不准确

由于不同规格的图片加载、字体渲染等原因,导致高度发生变化,高度测量不准。

解决方案:使用 ResizeObserver 监听高度变化

import { useResizeObserver } from '@vueuse/core'

useResizeObserver(itemRefs, (entries) => {
  entries.forEach(entry => {
    const index = entry.target.dataset.index
    if (index) {
      measureItem(Number(index), entry.contentRect.height)
    }
  })
})

问题3:滚动位置跳动

当上方元素的高度发生变化时,滚动位置会跳动。

解决方案:使用 scroll-save 保持滚动位置

// 保存当前视口顶部的元素
function saveScrollPosition() {
  const container = containerRef.value
  if (!container) return
  
  const firstVisibleIndex = findStartIndex(container.scrollTop)
  const firstVisibleElement = document.querySelector(`[data-index="${firstVisibleIndex}"]`)
  
  if (firstVisibleElement) {
    const offset = firstVisibleElement.getBoundingClientRect().top
    savedPosition.value = { index: firstVisibleIndex, offset }
  }
}

问题4:内存泄漏

当组件销毁时没有及时清理观察者和定时器,导致内存泄漏。

解决方案:及时清理

import { onUnmounted } from 'vue'

// 保存所有需要清理的资源
const observers = []
const timers = []

// 组件销毁时清理
onUnmounted(() => {
  observers.forEach(observer => observer.disconnect())
  timers.forEach(timer => clearTimeout(timer))
})

虚拟列表的适用场景

何时应该使用虚拟列表?

场景 数据量 是否使用 原因
聊天记录 1000+ 无限滚动,DOM 爆炸
商品列表 1000+ 首屏加载慢
后台表格 10000+ 性能卡顿
下拉菜单 <100 简单列表,没必要
评论列表 <500 ⚠️ 酌情使用,看复杂度
卡片列表 <200 正常渲染即可

性能对比

方案 DOM 节点数 内存占用 滚动帧率 实现复杂度
传统渲染 100,000 500-800MB 5-10fps
固定高度虚拟列表 20-30 5-10MB 60fps
动态高度虚拟列表 20-30 5-10MB 55-60fps
第三方库 20-30 5-10MB 60fps

最佳实践清单

  • 预估高度:动态高度列表需要合理的预估高度
  • 缓冲区域:上下各保留 2-5 个缓冲项
  • 测量机制:动态高度需要精确测量
  • 滚动优化:使用 ref 节流
  • 键值管理:使用稳定的唯一键
  • 内存释放:及时清理观察者和定时器

性能优化清单

  • 使用 requestAnimationFrame 优化滚动事件
  • 添加 overscan 缓冲区域
  • 使用 v-memo 缓存复杂列表项
  • 大型数据用 shallowRef 存储
  • 图片使用懒加载
  • 监听高度变化并及时更新
  • 组件销毁时清理资源

用户体验清单

  • 快速滚动时显示骨架屏
  • 滚动到底部自动加载更多
  • 有新消息,自动滚动到底部
  • 支持点击滚动到指定项
  • 支持滚动位置(返回时恢复)

最终的代码模板

// 一个完整的虚拟列表组合式函数模板
export function useVirtualList<T>(
  data: Ref<T[]>,
  options: {
    itemHeight: number
    containerHeight: number
    dynamicHeight?: boolean
    overscan?: number
  }
) {
  // 状态管理
  const scrollTop = ref(0)
  const startIndex = ref(0)
  
  // 计算可见数据
  const visibleData = computed(() => {
    // 计算逻辑
  })
  
  // 滚动处理(节流)
  const onScroll = useThrottle((e: Event) => {
    // 更新 scrollTop
  }, 16)
  
  // 动态高度测量
  const measureItem = (index: number, height: number) => {
    // 更新缓存
  }
  
  // 滚动到指定项
  const scrollTo = (index: number) => {
    // 计算目标位置并滚动
  }
  
  return {
    visibleData,
    totalHeight: computed(() => data.value.length * options.itemHeight),
    offsetY: computed(() => startIndex.value * options.itemHeight),
    onScroll,
    measureItem,
    scrollTo
  }
}

结语

虚拟列表的核心思想很简单:用计算换渲染,用内存换时间。通过只渲染可见区域,我们可以在处理海量数据时保持流畅的体验。无论是固定高度还是动态高度,掌握其原理后,我们就能根据实际需求选择最合适的方案。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

Vue Router 进阶:路由懒加载、导航守卫与元信息的高效运用

作者 wuhen_n
2026年3月14日 07:21

前言

如果你问我:在一个Vue应用中,最重要的部分是什么?我的答案是:路由系统。路由就像是应用的骨架,它决定了:

  • 用户如何从一个页面导航到另一个页面
  • 哪些用户可以访问哪些页面
  • 页面的加载速度有多快
  • 用户体验是否流畅

但很多开发者对 Vue Router 的理解,仅仅停留在配置路径和组件的层面。这就像知道如何使用电灯开关,却不懂电路设计一样。本文将从最基础的概念讲起,一步步深入Vue Router 4的核心功能,最终帮你构建一个健壮、高效、易维护的企业级路由系统。无论你是刚接触Vue3的新手,还是经验丰富的老手,都能在这里找到有价值的内容。

为什么需要深入理解路由?

从一个真实场景开始

假设我们在开发一个后台管理系统,需要实现以下功能:

  • 未登录用户只能访问登录页
  • 不同角色的用户看到不同的菜单
  • 页面切换时显示进度条
  • 离开页面时如果有未保存数据要提示
  • 某些页面需要预加载数据
  • 页面标题要动态更新

如果只是简单地配置路由,代码很快就会变得混乱不堪:

  • 每个组件都要自己检查权限
  • 每个组件都要自己更新标题
  • 每个组件都要自己处理数据预加载

这就是为什么我们需要深入理解路由,路由系统可以统一处理这些横切关注点,让代码更加清晰、可维护。

Vue Router 4 的核心设计哲学

从 Vue Router 3 到 4 的演进

Vue Router 4 专为 Vue3 设计,带来了几个重要的变化:

特性 Vue Router 3 Vue Router 4 优势
API风格 Options API Composition API优先 更好的逻辑复用
TypeScript 有限支持 原生支持 类型安全
动态路由 addRoutes addRoute(更灵活) 精细控制

路由懒加载:让首屏飞起来

为什么要懒加载?

我们先看一个反例,如果没有路由懒加载,那么所有路由组件都会直接打包成一个 JS 文件,导致首屏加载慢,白屏时间长:

// ❌ 错误写法:所有组件一起打包
import Home from '@/views/Home.vue'
import About from '@/views/About.vue'
import User from '@/views/User.vue'
import Dashboard from '@/views/Dashboard.vue'
// ... 假设有50个页面

const routes = [
  { path: '/', component: Home },
  { path: '/about', component: About },
  // ...
]

上述代码,问题出在哪?

  • 所有页面代码都打包成一个巨大的JS文件
  • 用户访问首页,却要下载所有页面的代码
  • 首屏加载时间随着项目变大而线性增长

正确的懒加载方式

// ✅ 正确写法:使用动态导入
const routes = [
  {
    path: '/',
    name: 'Home',
    component: () => import('@/views/Home.vue')
  },
  {
    path: '/about',
    name: 'About',
    component: () => import('@/views/About.vue')
  },
  {
    path: '/user/:id',
    name: 'User',
    component: () => import('@/views/User.vue')
  }
]

动态导入的原理

  1. () => import('@/views/Home.vue') 看起来像函数调用,但本质是一个操作符(类似 typeof),它返回一个 Promise
  2. 构建时的处理(Webpack/Vite):
    1. 解析这个动态导入语句
    2. 为这个模块创建一个独立的 chunk(代码块)
    3. 生成对应的 chunk 文件名(如:Home.[hash].js
    4. 记录这个映射关系
  3. 在路由匹配时动态加载这个 chunk 文件
  4. 加载完成后渲染组件

路由懒加载的最佳实践

策略一:按路由层级拆分

// 每个路由单独打包,适合页面之间差异大的场景
const routes = [
  {
    path: '/dashboard',
    component: () => import('@/layouts/DashboardLayout.vue'),
    children: [
      {
        path: 'overview',
        component: () => import('@/views/dashboard/Overview.vue')
      },
      {
        path: 'analytics',
        component: () => import('@/views/dashboard/Analytics.vue')
      }
    ]
  }
]
打包结果
dashboard.js      - 包含布局组件
overview.js       - 包含概览页面
analytics.js      - 包含分析页面

策略二:按功能模块拆分

// 同一个模块的路由打包在一起,适合关联性强的页面
const UserModule = () => import(/* webpackChunkName: "user" */ '@/views/user')

const routes = [
  {
    path: '/user',
    component: UserModule,
    children: [
      { 
        path: 'profile', 
        component: () => import('@/views/user/Profile.vue') 
      },
      { 
        path: 'settings', 
        component: () => import('@/views/user/Settings.vue') 
      }
    ]
  }
]
打包结果
user.js  - 包含用户模块的所有页面(适合模块内页面关联性强的场景)

策略三:路由预加载(Preloading)

// 用户鼠标悬停在链接上时预加载
const handleMouseEnter = () => {
  // 预加载用户页面
  import('@/views/User.vue')
}

// 或者在路由元信息中配置预加载
const routes = [
  {
    path: '/dashboard',
    component: () => import('@/views/Dashboard.vue'),
    meta: { 
      preload: true  // 表示需要预加载
    }
  }
]

// 全局预加载策略
router.beforeEach(async (to, from) => {
  // 预加载可能访问的下一个页面
  const likelyNextRoutes = ['/products', '/about']
  likelyNextRoutes.forEach(routePath => {
    // 找到对应的路由配置并预加载
    const route = router.resolve(routePath)
    if (route.matched.length) {
      // 触发组件加载
      route.matched.forEach(record => {
        if (record.components?.default) {
          // 预加载组件
          const component = record.components.default
          if (typeof component === 'function') {
            component()  // 调用加载函数
          }
        }
      })
    }
  })
})

懒加载的性能收益分析

指标 优化前 优化后 提升
首屏 JS 体积 2.3 MB 450 KB 80%
FCP 2.8 s 1.2 s 57%
LCP 3.5 s 1.6 s 54%
TTI 4.2 s 2.1 s 50%

导航守卫:路由的守门人

什么是导航守卫?

想象一下,当我们需要进入一个安检的大楼:

  • 门口保安:检查身份证(全局前置守卫)
  • 楼层管理员:检查是否有权限进入该楼层(路由独享守卫)
  • 办公室门禁:检查是否是该办公室的员工(组件内守卫)

导航守卫就是路由系统的安检系统

导航守卫的执行流程全景图

用户点击链接
    ↓
触发导航
    ↓
【离开当前页面的组件守卫】← 如果有未保存数据,可以阻止离开
    ↓
【全局前置守卫】← 检查登录状态、权限等
    ↓
【路由独享守卫】← 特定路由的额外检查
    ↓
【组件内守卫(进入前)】← 可以在这里预加载数据
    ↓
解析异步组件(如果还没加载)
    ↓
【全局解析守卫】← 所有守卫都通过后,导航确认前
    ↓
导航被确认
    ↓
更新DOM
    ↓
【全局后置钩子】← 可以记录日志、更新标题等

三种守卫的详细用法

1. 全局守卫 - 适合处理通用逻辑

全局前置守卫 - 导航触发时调用
router.beforeEach(async (to, from) => {
  console.log('→ 开始导航:', to.path)
  
  // 场景1:检查登录状态
  const userStore = useUserStore()
  const isAuthenticated = userStore.isLoggedIn
  
  // 如果页面需要登录但用户未登录
  if (to.meta.requiresAuth && !isAuthenticated) {
    // 重定向到登录页,并记录要访问的页面
    return {
      path: '/login',
      query: { redirect: to.fullPath }
    }
  }
  
  // 场景2:如果已登录用户访问登录页,跳转到首页
  if (to.path === '/login' && isAuthenticated) {
    return '/'
  }
})
全局解析守卫:所有守卫完成后,导航确认前
router.beforeResolve(async (to, from) => {
  // 适合做数据预加载
  if (to.meta.preload) {
    await to.meta.preload(to)
  }
})
全局后置守卫:导航完成后调用
router.afterEach((to, from, failure) => {
  console.log('← 导航完成:', to.path)
  
  // 场景1:更新页面标题
  if (to.meta.title) {
    document.title = `${to.meta.title} - 我的应用`
  }
  
  // 场景2:页面访问统计
  if (!failure) {
    sendAnalytics({
      page: to.path,
      title: to.meta.title,
      referrer: from.path
    })
  }
  
  // 场景3:滚动到顶部
  window.scrollTo(0, 0)
})

2. 路由独享守卫 - 只对特定路由生效

const routes = [
  {
    path: '/admin',
    component: () => import('@/views/Admin.vue'),
    beforeEnter: (to, from) => {
      // 只在这个路由进入时触发
      // 参数变化时不会触发
      
      // 检查权限
      const userStore = useUserStore()
      if (!userStore.isAdmin) {
        return { path: '/403' }
      }
    }
  },
  {
    path: '/user/:id',
    component: () => import('@/views/User.vue'),
    beforeEnter: [
      // 可以传入数组,按顺序执行
      checkUserExists,
      checkUserStatus,
      logUserAccess
    ]
  }
]

// 独立的守卫函数
async function checkUserExists(to, from) {
  const userStore = useUserStore()
  const exists = await userStore.checkExists(to.params.id)
  if (!exists) {
    return { path: '/404' }
  }
}

3. 组件内守卫 - 处理组件相关的逻辑

1. 离开当前组件时调用
onBeforeRouteLeave((to, from) => {
  if (hasUnsavedChanges.value) {
    const answer = window.confirm('有未保存的更改,确定离开吗?')
    if (!answer) return false
  }
})

2. 路由参数变化但组件复用时调用

onBeforeRouteUpdate(async (to, from) => {
  console.log('路由参数变化', to.params, from.params)
  
  // 当路由参数变化时,重新获取数据
  if (to.params.id !== from.params.id) {
    const userId = to.params.id as string
    await fetchUserData(userId)
  }
})

3. 选项式 API 中的 beforeRouteEnter

export default {
  beforeRouteEnter(to, from, next) {
    // ⚠️ 注意:此时不能访问组件实例this
    // 因为组件还没创建
    
    // 可以通过next回调访问实例
    next(vm => {
      // vm就是组件实例
      vm.loadData()
    })
  },
  
  beforeRouteUpdate(to, from) {
    // 可以访问this
    this.fetchData(to.params.id)
  },
  
  beforeRouteLeave(to, from) {
    // 可以访问this
    if (this.hasUnsavedChanges) {
      return confirm('确定离开吗?')
    }
  }
}

导航守卫的实战模式

模式一:权限检查统一处理

// router/guards/permission.ts
import { useUserStore } from '@/stores/user'

export async function permissionGuard(to, from) {
  const userStore = useUserStore()
  
  // 不需要登录的页面
  const publicPages = ['/login', '/register', '/forgot-password']
  if (publicPages.includes(to.path)) {
    return true
  }
  
  // 检查是否登录
  if (!userStore.isLoggedIn) {
    return {
      path: '/login',
      query: { redirect: to.fullPath }
    }
  }
  
  // 检查角色权限
  const requiredRoles = to.meta.roles as string[]
  if (requiredRoles) {
    const hasRole = requiredRoles.some(role => 
      userStore.roles.includes(role)
    )
    if (!hasRole) {
      return { path: '/403' }
    }
  }
  
  // 检查权限点
  const requiredPermissions = to.meta.permissions as string[]
  if (requiredPermissions) {
    const hasPermission = requiredPermissions.every(perm => 
      userStore.permissions.includes(perm)
    )
    if (!hasPermission) {
      return { path: '/403' }
    }
  }
}

模式二:页面数据预加载

// router/guards/prefetch.ts
import { useLoadingStore } from '@/stores/loading'

export async function prefetchGuard(to, from) {
  // 如果路由配置了需要预加载的数据
  if (to.meta.prefetch) {
    const loadingStore = useLoadingStore()
    
    try {
      loadingStore.start()
      
      // 执行预加载函数
      if (typeof to.meta.prefetch === 'function') {
        await to.meta.prefetch(to)
      }
    } finally {
      loadingStore.stop()
    }
  }
}

// 在路由配置中使用
const routes = [
  {
    path: '/dashboard',
    component: () => import('@/views/Dashboard.vue'),
    meta: {
      prefetch: async (to) => {
        const userStore = useUserStore()
        const dashboardStore = useDashboardStore()
        
        // 并行预加载多个数据
        await Promise.all([
          userStore.fetchProfile(),
          dashboardStore.fetchStats(),
          dashboardStore.fetchCharts()
        ])
      }
    }
  }
]

模式三:页面切换进度条

// router/guards/progress.ts
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'

NProgress.configure({
  minimum: 0.1,
  easing: 'ease',
  speed: 500,
  showSpinner: false
})

export function setupProgressGuard(router) {
  let timer: NodeJS.Timeout
  
  router.beforeEach(() => {
    clearTimeout(timer)
    timer = setTimeout(() => {
      NProgress.start()
    }, 200) // 延迟200ms显示,避免快速切换时闪烁
  })
  
  router.afterEach(() => {
    clearTimeout(timer)
    NProgress.done()
  })
  
  router.onError(() => {
    clearTimeout(timer)
    NProgress.done()
  })
}

模式四:页面访问日志

// router/guards/logger.ts
export function setupLoggerGuard(router) {
  router.beforeEach((to, from) => {
    if (import.meta.env.DEV) {
      console.group('🚀 路由导航')
      console.log('从:', from.fullPath || '首次访问')
      console.log('到:', to.fullPath)
      console.log('时间:', new Date().toLocaleString())
      console.log('元信息:', to.meta)
      console.groupEnd()
    }
  })
  
  router.afterEach((to, from, failure) => {
    if (import.meta.env.DEV) {
      if (failure) {
        console.error('❌ 导航失败:', failure)
      } else {
        console.log('✅ 导航成功')
      }
    }
  })
}

路由元信息:路由的隐形背包

什么是路由元信息?

**路由元信息(meta)**是附加在路由配置上的自定义数据,想象每个路由都有一个“背包”,我们可以往里面放任何我们需要的东西,可以包含任何业务需要的字段:

const routes = [
  {
    path: '/admin',
    component: AdminLayout,
    meta: {
      requiresAuth: true,      // 需要登录
      roles: ['admin'],        // 允许的角色
      title: '管理后台',        // 页面标题
      icon: 'admin-icon',      // 菜单图标
      keepAlive: true,         // 需要缓存
      transition: 'fade',       // 切换动画
      breadcrumb: [            // 面包屑
        { name: '首页', path: '/' },
        { name: '管理' }
      ],
      permissions: [           // 权限点
        'user:view',
        'user:edit'
      ]
    },
    children: [
      {
        path: 'users',
        component: UserList,
        meta: {
          title: '用户管理',
          icon: 'user-icon'
        }
      }
    ]
  }
]

元信息的合并策略

// 嵌套路由中的元信息会合并(对象合并,不是覆盖)
const routes = [
  {
    path: '/dashboard',
    meta: { 
      requiresAuth: true, 
      title: '仪表盘',
      breadcrumb: ['首页']
    },
    children: [
      {
        path: 'analytics',
        meta: { 
          title: '数据分析',     // 覆盖父级 title
          breadcrumb: ['首页', '分析']  // 追加到父级 breadcrumb
        },
        component: Analytics
      }
    ]
  }
]

// 最终 Analytics 的 meta:
// {
//   requiresAuth: true,
//   title: '数据分析',
//   breadcrumb: ['首页', '分析']
// }

元信息的高效运用

场景一:动态页面标题

// router/index.ts
router.afterEach((to) => {
  // 获取路由的标题元信息
  const title = to.meta.title as string
  const appName = import.meta.env.VITE_APP_NAME
  
  if (title) {
    document.title = `${title} - ${appName}`
  } else {
    document.title = appName
  }
})

场景二:控制页面缓存

<!-- App.vue -->
<template>
  <router-view v-slot="{ Component, route }">
    <!-- 使用keep-alive缓存需要缓存的页面 -->
    <keep-alive :include="cachedViews">
      <component 
        :is="Component" 
        v-if="route.meta.keepAlive"
        :key="route.fullPath"
      />
    </keep-alive>
    
    <!-- 不需要缓存的页面 -->
    <component 
      :is="Component" 
      v-else
      :key="route.fullPath"
    />
  </router-view>
</template>

<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'

const route = useRoute()

// 获取所有需要缓存的视图名称
const cachedViews = computed(() => {
  return route.matched
    .filter(r => r.meta.keepAlive)
    .map(r => r.components?.default.name)
    .filter(Boolean)
})
</script>

场景三:动态菜单生成

// utils/menu.ts
export function generateMenu(routes, parentPath = '') {
  return routes
    .filter(route => !route.meta?.hidden)  // 过滤隐藏菜单
    .filter(route => route.meta?.title)    // 必须有标题
    .map(route => {
      const fullPath = parentPath + route.path
      
      const menuItem = {
        key: fullPath,
        title: route.meta.title,
        icon: route.meta.icon,
        children: [],
        permissions: route.meta.permissions || []
      }
      
      if (route.children) {
        menuItem.children = generateMenu(route.children, fullPath + '/')
      }
      
      return menuItem
    })
}

// 在组件中使用
const menuList = computed(() => {
  const userStore = useUserStore()
  const routes = router.getRoutes()
  
  return generateMenu(routes).filter(menu => {
    // 过滤没有权限的菜单
    if (menu.permissions.length) {
      return menu.permissions.every(p => userStore.hasPermission(p))
    }
    return true
  })
})

场景四:动态面包屑

// composables/useBreadcrumb.ts
import { computed } from 'vue'
import { useRoute } from 'vue-router'

export function useBreadcrumb() {
  const route = useRoute()
  
  const breadcrumbs = computed(() => {
    const matched = route.matched.filter(item => item.meta?.breadcrumb)
    
    // 收集所有的面包屑
    const items: Array<{ title: string; path?: string }> = []
    
    matched.forEach((item, index) => {
      const bc = item.meta.breadcrumb
      
      if (Array.isArray(bc)) {
        // 如果是数组,直接添加
        bc.forEach((crumb, i) => {
          // 最后一个面包屑不需要路径
          if (index === matched.length - 1 && i === bc.length - 1) {
            items.push({ title: crumb.title })
          } else {
            items.push(crumb)
          }
        })
      } else if (typeof bc === 'string') {
        // 如果是字符串,转换为对象
        if (index === matched.length - 1) {
          items.push({ title: bc })
        } else {
          items.push({ title: bc, path: item.path })
        }
      }
    })
    
    return items
  })
  
  return { breadcrumbs }
}

场景五:路由切换动画

<!-- App.vue -->
<template>
  <router-view v-slot="{ Component, route }">
    <transition :name="route.meta.transition || 'fade'" mode="out-in">
      <component :is="Component" :key="route.fullPath" />
    </transition>
  </router-view>
</template>

<style>
/* 基础动画 */
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.3s ease;
}

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}

/* 滑动动画 */
.slide-enter-active,
.slide-leave-active {
  transition: transform 0.3s ease;
}

.slide-enter-from {
  transform: translateX(100%);
}

.slide-leave-to {
  transform: translateX(-100%);
}

/* 缩放动画 */
.scale-enter-active,
.scale-leave-active {
  transition: transform 0.2s ease, opacity 0.2s ease;
}

.scale-enter-from {
  transform: scale(0.9);
  opacity: 0;
}

.scale-leave-to {
  transform: scale(1.1);
  opacity: 0;
}
</style>

路由性能优化策略

组件缓存策略

<template>
  <router-view v-slot="{ Component, route }">
    <keep-alive :include="cachedViews" :max="10">
      <component 
        :is="Component" 
        :key="route.fullPath"
        v-if="route.meta.keepAlive"
      />
    </keep-alive>
    
    <component 
      :is="Component" 
      :key="route.fullPath"
      v-else
    />
  </router-view>
</template>

<script setup>
import { computed, watch } from 'vue'
import { useRoute } from 'vue-router'

const route = useRoute()
const cachedViews = ref([])

// 动态管理缓存
watch(() => route.meta.keepAlive, (keepAlive) => {
  if (keepAlive && route.name) {
    if (!cachedViews.value.includes(route.name)) {
      cachedViews.value.push(route.name)
    }
  }
}, { immediate: true })

// 监听路由离开,清理不需要的缓存
watch(() => route.fullPath, (newPath, oldPath) => {
  // 如果离开的页面不需要缓存,从缓存中移除
  if (route.matched.some(r => r.meta.keepAlive === false)) {
    const componentName = route.matched[route.matched.length - 1]?.components?.default?.name
    if (componentName) {
      cachedViews.value = cachedViews.value.filter(name => name !== componentName)
    }
  }
})
</script>

数据预加载策略

策略一:路由守卫中预加载

router.beforeResolve(async (to) => {
  if (to.meta.prefetch) {
    const start = performance.now()
    
    // 显示加载状态
    const loading = ElLoading.service({
      fullscreen: true,
      text: '加载中...'
    })
    
    try {
      await to.meta.prefetch(to)
    } finally {
      loading.close()
      
      const end = performance.now()
      console.log(`预加载耗时: ${(end - start).toFixed(2)}ms`)
    }
  }
})

策略二:组件内预加载

import { onBeforeRouteUpdate } from 'vue-router'

// 路由参数变化时重新获取数据
onBeforeRouteUpdate(async (to, from) => {
  if (to.params.id !== from.params.id) {
    // 显示骨架屏
    showSkeleton.value = true
    
    try {
      await fetchData(to.params.id)
    } finally {
      showSkeleton.value = false
    }
  }
})

// 初始加载
await fetchData(route.params.id)

策略三:路由元信息配置预加载函数

const routes = [
  {
    path: '/dashboard',
    component: () => import('@/views/Dashboard.vue'),
    meta: {
      prefetch: async () => {
        // 并行预加载多个数据
        await Promise.all([
          useDashboardStore().fetchStats(),
          useDashboardStore().fetchCharts(),
          useUserStore().fetchProfile()
        ])
      }
    }
  }
]

滚动行为优化

const router = createRouter({
  scrollBehavior(to, from, savedPosition) {
    // 返回上一页时恢复滚动位置
    if (savedPosition) {
      // 延迟执行,等待页面渲染完成
      return new Promise((resolve) => {
        setTimeout(() => {
          resolve(savedPosition)
        }, 100)
      })
    }
    
    // 有hash时滚动到对应元素
    if (to.hash) {
      return {
        el: to.hash,
        behavior: 'smooth',
        top: 80 // 减去固定头部的高度
      }
    }
    
    // 不同路由使用不同的滚动行为
    if (to.meta.scrollToTop === false) {
      return {} // 保持当前位置
    }
    
    // 默认滚动到顶部
    return { 
      top: 0, 
      left: 0,
      behavior: 'smooth'
    }
  }
})

路由解析性能监控

// 开发环境下监控路由性能
if (import.meta.env.DEV) {
  router.beforeEach((to) => {
    to.meta.startTime = performance.now()
  })
  
  router.afterEach((to) => {
    const end = performance.now()
    const start = to.meta.startTime
    const duration = (end - start).toFixed(2)
    
    console.log(`✅ 路由 ${to.path} 加载完成: ${duration}ms`)
    
    // 如果超过阈值,记录警告
    if (duration > 500) {
      console.warn(`⚠️ 路由加载较慢: ${duration}ms`)
      
      // 分析哪个部分耗时
      const matched = to.matched
      matched.forEach(record => {
        if (record.components?.default) {
          const comp = record.components.default
          if (typeof comp === 'function') {
            console.log(`  组件 ${record.path} 是懒加载的`)
          }
        }
      })
    }
  })
  
  // 监控组件加载时间
  router.beforeResolve((to) => {
    const components = to.matched.map(record => 
      record.components?.default?.name || record.path
    )
    console.log('即将加载组件:', components)
  })
}

路由性能优化策略

路由组件缓存策略

<!-- App.vue -->
<template>
  <router-view v-slot="{ Component, route }">
    <!-- 使用 include 精确控制缓存 -->
    <keep-alive :include="cachedViews" :max="10">
      <component 
        :is="Component" 
        :key="route.fullPath"
      />
    </keep-alive>
  </router-view>
</template>

<script setup>
import { useCacheStore } from '@/stores/cache'

const cacheStore = useCacheStore()

// 动态控制需要缓存的视图
const cachedViews = computed(() => {
  return cacheStore.cachedViews
})

// 手动清除缓存
function clearCache(routeName) {
  cacheStore.removeCachedView(routeName)
}

// 监听路由变化,动态添加/移除缓存
watch(route, (to, from) => {
  // 如果离开的页面需要缓存
  if (from.meta?.keepAlive) {
    cacheStore.addCachedView(from.name)
  }
  
  // 如果进入的页面不需要缓存,且之前缓存了
  if (!to.meta?.keepAlive && cacheStore.hasCachedView(to.name)) {
    cacheStore.removeCachedView(to.name)
  }
})
</script>

路由切换时的数据预加载

方案一:路由守卫中预加载

router.beforeEach(async (to, from) => {
  if (to.meta.preload) {
    const start = performance.now()
    await to.meta.preload(to)
    const end = performance.now()
    console.log(`预加载耗时: ${(end - start).toFixed(2)}ms`)
  }
})

方案二:组件内预加载

// 路由参数变化时重新获取数据
onBeforeRouteUpdate(async (to, from) => {
  if (to.params.page !== from.params.page) {
    await fetchData(to.params.page)
  }
})

// 使用 Suspense 预加载
await fetchData()

方案三:路由元信息配置预加载

const routes = [
  {
    path: '/dashboard',
    component: () => import('@/views/Dashboard.vue'),
    meta: {
      preload: async () => {
        // 并行预加载多个数据
        await Promise.all([
          useDashboardStore().fetchStats(),
          useDashboardStore().fetchCharts(),
          useUserStore().fetchProfile()
        ])
      }
    }
  }
]

路由过渡动画的性能优化

/* 使用 transform 代替 left/top 触发硬件加速 */
.slide-enter-active,
.slide-leave-active {
  transition: transform 0.3s ease;
  transform: translate3d(0, 0, 0); /* 开启硬件加速 */
  will-change: transform; /* 提示浏览器优化 */
}

/* 避免同时动画太多元素 */
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.2s ease;
  /* 只动画 opacity,性能更好 */
  will-change: opacity;
}

/* 使用 CSS 动画代替 JS 动画 */
@keyframes fadeIn {
  from { opacity: 0; }
  to { opacity: 1; }
}

.fade-enter-active {
  animation: fadeIn 0.3s ease;
}

滚动行为的优化

const router = createRouter({
  history: createWebHistory(),
  routes,
  scrollBehavior(to, from, savedPosition) {
    // 返回上一页时恢复滚动位置
    if (savedPosition) {
      return new Promise((resolve) => {
        setTimeout(() => {
          resolve(savedPosition)
        }, 100) // 延迟100ms,等待页面渲染完成
      })
    }
    
    // 有 hash 时滚动到对应元素
    if (to.hash) {
      return {
        el: to.hash,
        behavior: 'smooth',  // 平滑滚动
        top: 80 // 考虑固定头部的高度
      }
    }
    
    // 默认滚动到顶部
    return { 
      top: 0, 
      left: 0,
      behavior: 'smooth' 
    }
  }
})

路由解析的性能监控

// 开发环境下监控路由解析时间
if (import.meta.env.DEV) {
  router.beforeEach((to, from) => {
    const start = performance.now()
    to.meta.startTime = start
    
    // 记录导航开始
    console.log(`开始导航到: ${to.path}`)
  })
  
  router.afterEach((to, from) => {
    const end = performance.now()
    const start = to.meta.startTime
    const duration = (end - start).toFixed(2)
    
    console.log(`✅ 导航完成: ${to.path} (${duration}ms)`)
    
    // 如果超过阈值,记录警告
    if (duration > 300) {
      console.warn(`⚠️ 路由 ${to.path} 加载较慢: ${duration}ms`)
    }
  })
  
  // 监控组件加载性能
  router.beforeResolve((to) => {
    const components = to.matched.map(record => 
      record.components?.default.name
    ).filter(Boolean)
    
    console.log('即将加载组件:', components)
  })
}

常见问题与解决方案

问题一:重复添加路由导致警告

// ❌ 错误:多次添加相同路由
function addRoutes() {
  asyncRoutes.forEach(route => {
    router.addRoute(route)  // 第二次调用时会警告
  })
}

解决方法1:检查是否已添加

// ✅ 正确:检查是否已添加
function addRoutes() {
  asyncRoutes.forEach(route => {
    // 使用 router.hasRoute 检查
    if (!router.hasRoute(route.name)) {
      router.addRoute(route)
    }
  })
}

解决方法2:先移除再添加

// ✅ 正确:先移除再添加
function updateRoute(route) {
  if (router.hasRoute(route.name)) {
    router.removeRoute(route.name)
  }
  router.addRoute(route)
}

解决方案3:批量添加时使用 addRoute 的 parent 参数

function addChildRoutes(parentName, routes) {
  routes.forEach(route => {
    if (!router.hasRoute(route.name)) {
      router.addRoute(parentName, route)
    }
  })
}

问题2:路由参数变化但组件不更新

<template>
  <div>
    <h2>{{ user?.name }}</h2>
    <p>{{ user?.email }}</p>
  </div>
</template>

<script setup>
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'

const route = useRoute()
const user = ref(null)

// ❌ 错误:只在组件创建时获取一次数据
user.value = await fetchUser(route.params.id)
</script>

解决方法1:监听 route.params 的变化

watch(() => route.params.id, async (newId, oldId) => {
  console.log(`ID从 ${oldId} 变为 ${newId}`)
  await fetchData(newId)
}, { immediate: true })

解决方法2:使用 onBeforeRouteUpdate

import { onBeforeRouteUpdate } from 'vue-router'
onBeforeRouteUpdate(async (to, from) => {
  if (to.params.id !== from.params.id) {
    await fetchData(to.params.id)
  }
})

解决方法3:使用 key 强制重新渲染

<router-view :key="route.fullPath" />

问题3:路由守卫中的异步操作导致导航卡顿

// ❌ 错误:守卫中做太多同步操作
router.beforeEach((to) => {
  const start = Date.now()
  while (Date.now() - start < 1000) {
    // 模拟耗时操作 - 会阻塞导航
  }
})

解决方法1:使用异步操作(但要注意异步操作不影响导航)

router.beforeEach(async (to) => {
  // 显示 loading
  const loading = ElLoading.service({
    fullscreen: true,
    text: '加载中...'
  })
  
  try {
    // 执行异步操作
    await loadData()
  } finally {
    // 导航完成后隐藏 loading
    router.afterEach(() => {
      loading.close()
    })
  }
})

解决方法2:使用 nextTick 延迟执行

router.beforeEach((to) => {
  // 先放行导航
  nextTick(() => {
    // 导航完成后执行耗时操作
    doHeavyWork()
  })
})

解决方案3:使用Web Worker处理复杂计算

router.beforeEach((to) => {
  if (to.meta.heavyComputation) {
    const worker = new Worker('/worker.js')
    worker.postMessage(to.meta.data)
    worker.onmessage = (e) => {
      // 处理计算结果
    }
  }
})

问题4:路由懒加载导致的白屏闪烁

解决方法:加载占位动画

<!-- App.vue -->
<template>
  <router-view v-slot="{ Component }">
    <Suspense>
      <template #default>
        <component :is="Component" />
      </template>
      <template #fallback>
        <!-- 加载占位动画 -->
        <div class="page-loading">
          <LoadingSpinner />
          <p>页面加载中...</p>
        </div>
      </template>
    </Suspense>
  </router-view>
</template>

<style>
.page-loading {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  min-height: 400px;
  color: #909399;
}

@keyframes spin {
  from { transform: rotate(0deg); }
  to { transform: rotate(360deg); }
}

.loading-spinner {
  animation: spin 1s linear infinite;
  font-size: 32px;
}
</style>

问题5:浏览器前进后退时滚动位置丢失

解决方法1:使用 scrollBehavior

const router = createRouter({
  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) {
      return savedPosition  // 返回时恢复位置
    }
    return { top: 0 }       // 新页面滚动到顶部
  }
})

解决方案2:手动保存滚动位置

import { onActivated, onDeactivated } from 'vue'

let scrollTop = 0

onDeactivated(() => {
  // 离开时保存滚动位置
  const container = document.querySelector('.scroll-container')
  scrollTop = container?.scrollTop || 0
})

onActivated(() => {
  // 回来时恢复滚动位置
  nextTick(() => {
    const container = document.querySelector('.scroll-container')
    if (container) {
      container.scrollTop = scrollTop
    }
  })
})

路由使用的最佳实践清单

路由设计原则

原则 说明 示例
按功能拆分 将路由按业务模块拆分成独立文件 router/modules/*.ts
懒加载 所有路由组件使用动态导入 () => import('@/views/xxx.vue')
命名路由 使用 name 而不是 path 跳转 router.push({ name: 'User' })
参数验证 在导航守卫中验证路由参数 if (!to.params.id) return '/404'
元信息丰富 把配置都放在 meta 中 meta: { title, requiresAuth }
404放在最后 通配符路由放最后 { path: '/:pathMatch(.*)*', redirect: '/404' }

导航守卫使用原则

原则 说明 示例
职责单一 每个守卫只做一件事 authGuard, permissionGuard
全局守卫通用 认证、日志、进度条 router.beforeEach(authGuard)
路由独享特定 特定路由的权限检查 beforeEnter: checkPermission
组件内守细腻 数据加载、离开确认 onBeforeRouteLeave
避免耗时操作 守卫中不要做同步耗时操作 使用异步或推迟执行
顺序很重要 按依赖关系排列守卫:认证 -> 权限 -> 预加载 router.beforeEach(auth)
返回值明确 返回false/路径/undefined return '/login'

性能优化清单

  • 路由懒加载:所有路由组件使用动态导入
  • 预加载关键路由:使用 meta.preload 配置,预加载用户可能访问的下一个页面
  • 合理使用缓存:合理使用 keep-alive 缓存频繁访问的页面
  • 体验优化:使用 transform 代替位置属性,使用 Suspense 和骨架屏提升用户体验
  • 监控性能:监控路由解析时间,优化慢的路由
  • 滚动优化:优化滚动行为,保存/恢复滚动位置,实现平滑滚动和位置恢复

用户体验清单

  • 进度条:切换页面时显示进度反馈
  • 加载占位:使用 Suspense 处理异步组件
  • 错误处理:统一处理路由错误页面
  • 标题更新:根据 meta.title 更新 document.title
  • 面包屑:根据路由元信息生成面包屑
  • 过渡动画:添加合适的页面切换动画
  • 保存提示:离开页面时提示未保存更改

结语

Vue Router 不仅仅是 URL 和组件的映射,更是整个应用的骨架神经系统,把路由设计好了,整个应用就成功了一半。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

5年前端,我为什么要all in AI Agent?

作者 wuhen_n
2026年3月13日 17:27

一个普通 Vue/Electron 工程师的转型自白


前言

我是一个普通的前端工程师。5年经验,公司开发框架也就是 Vue2/3 + TS。自己倒腾过Electron,uni-app,能写一些简单的功能模块。没进过大厂,没写过框架,不是什么技术大神。

每天的生活就是:上班写代码,下班刷掘金/知乎/CSDN,周末偶尔看看新技术。工资一般般,饿不死,也富不了。原本以为自己会这样一直干到退休。

直到去年,年奖金只有以往的一半了!


某一天的顿悟

和往常一样,看看手机,刷刷帖子,直到刷到:《35岁程序员裸辞两月,找不到工作,感慨程序员是碗青春饭》。

那一刻,我突然意识到:我也30多了,离35也不远了。

那天晚上,我失眠了。翻来覆去想几个问题:

  • 我的核心竞争力是什么?
  • 如果明天被裁,我能做什么?
  • 5年后,我还在写代码吗?

没有答案。只有焦虑。


一次偶然的机遇

随着公司业务发展,各部门都在鼓励使用 AI,自然而然的,我也分配了相关的任务:处理后端 AI 大模型的流式返回数据。这本来也是很简单的需求:

  • fetchEventSource 发送请求
  • onMessage() 接收并处理数据
  • onError() 处理异常/错误情况

此时,问题来了:后端返回的并不是的 text/event-stream 格式,而是 application/json;在网上查了一圈,知道可以在 onOpen() 里重新发一条 GET 请求来解决这个问题。

然后,又出现新的问题了:后端异常没有正常返回,全部要前端处理!烦躁之下,我打开了DeepSeek(之前只用它写过文档),10 秒钟,它给出了完整的解决方案。

我当时震惊的不是它写出来了,而是它写出来的代码,完全符合我的需求,而且比我预设需求的还要完整!

那一刻,我突然意识到:这东西真方便。

后来的开发中,我开始疯狂用它:

  • 让它生成Vue3组件,它懂得用<script setup>,知道我喜欢用ref而不是reactive
  • 让它写TS类型,它知道我的命名规范(IPropsTResponse
  • 让它解释一段看不懂的配置,它讲得比文档还清楚

我开始想:如果 AI 这么懂前端,那我是不是可以用它做更多事?


AI突围战”

从那天起,我给自己定了一个目标:用4个月时间,成为一个会“玩AI”的前端。

我不学 PyTorch,不学 Transformer,不调模型参数。我的路线很简单:

阶段 目标 时间
第一阶段 学会让AI按我的要求生成代码(Prompt工程) 3周
第二阶段 打通Electron + AI API,做桌面工具 4周
第三阶段 让AI能调用我写的函数(Function Calling) 6周
第四阶段 做一个真正能“干活”的Agent 8周

这4个月,我经历了什么?

第一周:信心满满 → 被 Prompt 折磨(AI 就是不按格式输出)
第二周:第一个 Electron + AI 应用跑通 → 激动得发朋友圈
第四周:Function Calling 总是失败 → 怀疑人生
第六周:第一个能用的 Agent 诞生(帮我处理Git) → 比发工资还开心
第十周:做的一个桌面助手被吐槽“鸡肋” → 反思产品思维
第十六周:现在,我能用Cursor + AI 在30分钟内开发一个小工具

这4个月,我学会了什么?

  • 不是:模型原理、Attention机制、微调技术
  • 而是:怎么让AI听我的话、怎么把AI集成进Electron、怎么让AI调用我的函数、怎么用AI帮我写代码

最重要的是:我不焦虑了,因为我知道,AI时代,前端不仅没有被淘汰,反而有了新的机会。


为什么说前端是AI时代的“天选之子”?

这4个月让我想明白一件事:

AI 是发动机,前端是驾驶舱。发动机很重要,但用户接触的是驾驶舱。

我们的优势是什么?

优势 说明
UI/UX思维 我们知道怎么让AI的“答案”变成好用的“产品”
TypeScript 严格的类型定义,让 AI 生成的代码更可控
Electron经验 桌面端是 AI 的下一站(隐私、离线、本地资源)
工程化能力 组件化、模块化,这些思维在 AI 应用开发中同样重要

我不是在安慰自己,而是在这4个月我见过太多 AI 应用翻车的案例:

  • 技术很强,但 UI 一塌糊涂 → 没人用
  • 模型很准,但交互反人类 → 用户流失
  • 功能很多,但不会产品化 → 自嗨

这些都是我们前端擅长的地方。


这个专栏要写什么

所以,我开了这个专栏。

它不是:

  • ❌ 大模型原理讲解(我不懂)
  • ❌ Python/PyTorch教程(我不会)
  • ❌ 教你成为AI科学家(做不到)

它是:

  • 一个普通前端的真实转型记录(不装逼,只记录自己踩过的坑)
  • 前端视角的AI应用开发实战(Vue3/TS/Electron)
  • Agent和Vibe Coding的落地经验(能跑起来的代码)

结语

前端已死”,这句话从10年前就开始兴起了,“死”了这么多年还没死透,我认为它就是有价值的。现如今,在 AI 的加持,对前端的要求会越来越高,但这条路我会继续走下去,也会把每一步都记录下来。

如果你也在这条路上,欢迎同行。

❌
❌