前端性能优化:虚拟列表、懒加载、keep-alive

1. 性能问题分类

上一篇讲了构建优化(包体积),这一篇讲运行时性能。两者结合才能真正让页面快起来。

运行时卡顿一般来自四类原因:

1. 一次性渲染太多 DOM      → 虚拟列表 / 分页
2. 资源加载阻塞首屏         → 懒加载(图片/组件/路由)
3. 切页面状态丢失重新加载    → keep-alive
4. 不必要的重复计算/渲染     → computed / shallowRef / v-memo / 防抖节流

逐个击破。

Java 对照:类似后端性能调优——不是"一招鲜",而是按瓶颈对症下药。先 profile(用 Chrome DevTools 的 Performance 标签),再改。


2. 虚拟列表:大数据量表格/列表必备

1000 条数据渲染 1000 个 <tr>,浏览器吃不消。视口内其实只看得到 20 行——虚拟列表的核心就是"只渲染看得见的部分"。

2.1 原生 Element Plus 方案

Element Plus 的虚拟 Tableel-table-v2)支持大数据量:

<template>
  <el-auto-resizer>
    <template #default="{ height, width }">
      <el-table-v2
        :columns="columns"
        :data="data"
        :width="width"
        :height="height"
        :estimated-row-height="50"
      />
    </template>
  </el-auto-resizer>
</template>

<script setup lang="ts">
const data = Array.from({ length: 10000 }, (_, i) => ({
  id: i,
  title: `笔记 ${i}`,
  date: '2026-05-12',
}))

const columns = [
  { key: 'id', dataKey: 'id', title: 'ID', width: 100 },
  { key: 'title', dataKey: 'title', title: '标题', width: 300 },
  { key: 'date', dataKey: 'date', title: '日期', width: 150 },
]
</script>

差异提醒

什么时候换:数据量超过 200 条且经常操作时。否则老 el-table 够用。

2.2 通用虚拟列表:@tanstack/vue-virtual

非表格场景(笔记卡片列表、聊天消息列表)用通用虚拟滚动库:

pnpm add @tanstack/vue-virtual
<script setup lang="ts">
import { useVirtualizer } from '@tanstack/vue-virtual'
import { ref } from 'vue'

const notes = Array.from({ length: 5000 }, (_, i) => ({
  id: i,
  title: `笔记 ${i}`,
  summary: '...',
}))

const parentRef = ref<HTMLElement>()

const rowVirtualizer = useVirtualizer({
  count: notes.length,
  getScrollElement: () => parentRef.value,
  estimateSize: () => 120, // 每项高度(px)
  overscan: 5,             // 可视区外多渲几个,滚动更平滑
})
</script>

<template>
  <div ref="parentRef" class="h-[600px] overflow-auto">
    <div
      :style="{
        height: `${rowVirtualizer.getTotalSize()}px`,
        position: 'relative',
      }"
    >
      <div
        v-for="v in rowVirtualizer.getVirtualItems()"
        :key="v.key"
        :style="{
          position: 'absolute',
          top: 0,
          left: 0,
          width: '100%',
          transform: `translateY(${v.start}px)`,
        }"
        class="p-4 border-b"
      >
        <h3>{{ notes[v.index].title }}</h3>
        <p>{{ notes[v.index].summary }}</p>
      </div>
    </div>
  </div>
</template>

原理

效果:10000 条数据滚动如丝——DOM 里只有 15-25 个节点。


3. 图片懒加载:原生一行搞定

3.1 浏览器原生

<img src="photo.jpg" loading="lazy" alt="..." />

这就够了。Chrome 76+ / Firefox 75+ / Safari 15.4+ 都支持。移动端尤其友好——滚到才下载。

Vue 模板里

<img :src="note.cover" loading="lazy" :alt="note.title" />

3.2 img 宽高一定要写

<!-- ❌ 懒加载图片加载后页面会"跳"一下 -->
<img src="x.jpg" loading="lazy" />

<!-- ✅ 预留位置,加载完无跳动 -->
<img src="x.jpg" loading="lazy" width="400" height="300" />

<!-- 或用 CSS aspect-ratio -->
<img src="x.jpg" loading="lazy" class="aspect-[4/3] w-full" />

原因:浏览器不知道图片多大时留的位置是 0,加载后撑开——CLS(Cumulative Layout Shift,累积布局偏移)指标爆掉,体验差。

3.3 IntersectionObserver(进阶)

需要给背景图srcset、或做更复杂的"进入视口才做 X":

// src/directives/lazy-bg.ts
import type { Directive } from 'vue'

export const vLazyBg: Directive<HTMLElement, string> = {
  mounted(el, binding) {
    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) {
        el.style.backgroundImage = `url(${binding.value})`
        observer.disconnect()
      }
    })
    observer.observe(el)
  },
}
<div v-lazy-bg="'/hero.jpg'" class="h-96 bg-cover"></div>

VueUse 的 useIntersectionObserver / useElementVisibility 也能做到同样效果。


4. 组件懒加载:defineAsyncComponent

路由懒加载之外,组件级别也能懒加载:

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

const Editor = defineAsyncComponent({
  loader: () => import('@/components/MarkdownEditor.vue'),
  loadingComponent: { template: '<div>加载中...</div>' },
  delay: 200,           // 200ms 后才显示 loading
  timeout: 10000,
  errorComponent: { template: '<div>加载失败</div>' },
})
</script>

<template>
  <button @click="showEditor = true">打开编辑器</button>
  <Editor v-if="showEditor" />
</template>

适合

Java 对照:类似 Spring 的 @Lazy ——Bean 只有被真正用到时才初始化。


5. keep-alive:切页面不丢状态

场景:用户在 /notes 筛选了条件、滚动到半途,点某条进 /notes/123,返回时全没了,很烦。

<KeepAlive> 解决这个:

<!-- App.vue 或布局组件 -->
<router-view v-slot="{ Component, route }">
  <KeepAlive :include="['NoteList', 'UserList']">
    <component :is="Component" :key="route.fullPath" />
  </KeepAlive>
</router-view>

关键点

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

const list = ref<Note[]>([])

// onActivated: keep-alive 激活此组件时(进入)
onActivated(() => {
  // 比如重新拉数据
  loadList()
})

// onDeactivated: 离开但被缓存(没销毁)
onDeactivated(() => {
  // 清理定时器等
})
</script>

5.1 动态 include

const keepAliveList = ref(['NoteList'])

// 添加
keepAliveList.value.push('OtherPage')

// 移除(清缓存)
keepAliveList.value = keepAliveList.value.filter(n => n !== 'NoteList')

配合 route.meta.keepAlive

// router 配置
{ path: '/notes', component: NoteList, meta: { keepAlive: true } }

// 渲染时
<router-view v-slot="{ Component, route }">
  <KeepAlive>
    <component v-if="route.meta.keepAlive" :is="Component" />
  </KeepAlive>
  <component v-if="!route.meta.keepAlive" :is="Component" />
</router-view>

5.2 坑点


6. 减少重渲染:响应式相关

6.1 shallowRef / shallowReactive:对象不需要深度响应时

import { shallowRef } from 'vue'

// 大对象/第三方库实例,只在整体替换时响应
const chart = shallowRef<ECharts | null>(null)
chart.value = echarts.init(el) // 整体赋值时响应

// 深度属性变化不会触发响应
chart.value.someInternalProp = 'x' // 不响应(但 ECharts 自己处理)

适合

6.2 v-memo:跳过子树更新

<div v-for="item in list" :key="item.id" v-memo="[item.id, item.updated]">
  <HeavyComponent :data="item" />
</div>

只有 [item.id, item.updated] 数组里的值变了,这个 <div> 及子组件才重渲染。适合大列表里每项都有复杂子树的场景。

记忆:类似 React 的 React.memo 但更精确(你自己指定 dep)。

6.3 computed 的缓存

// ❌ 每次渲染都过滤一遍
<template>
  {{ notes.filter(n => n.status === 'published').length }}
</template>

// ✅ 依赖不变不重算
const publishedCount = computed(() =>
  notes.value.filter(n => n.status === 'published').length
)

computed 有缓存,依赖未变时 N 次访问都是同一个值。

6.4 watchEffect 的 flush 时机

watchEffect(() => {
  console.log(count.value)
}, { flush: 'post' }) // 'post' 在 DOM 更新后运行(默认 'pre')

常规业务不用碰,但知道有这个选项——post 适合需要访问最新 DOM 的场景(读元素尺寸等)。


7. 防抖(debounce)与节流(throttle)

7.1 区别

debounce throttle
触发方式 停下来 N ms 后触发 1 次 固定间隔 N ms 触发 1 次
典型场景 搜索窗口 resize 停止后计算 滚动鼠标移动实时更新
比喻 电梯:有人进来就重新计时 打卡机:N 秒内只记一次

7.2 直接用 VueUse

不自己写,用现成的:

import { refDebounced, useThrottleFn } from '@vueuse/core'

// 防抖 ref
const keyword = ref('')
const debouncedKeyword = refDebounced(keyword, 300)

watch(debouncedKeyword, (val) => {
  // 停下来 300ms 后才触发
  search(val)
})

// 节流函数
const onScroll = useThrottleFn(() => {
  console.log('每 100ms 最多触发一次')
}, 100)

7.3 什么时候用

Java 对照:类似 RxJava 的 debounce / throttleFirst / throttleLast——不同节奏的流量控制。


8. 滚动性能

8.1 滚动监听一定要 throttle

// ❌ 每次滚动触发一次,每秒可能跑 60+ 次
window.addEventListener('scroll', () => {
  updateHeader()
})

// ✅ 节流到每 100ms 最多一次
const onScroll = useThrottleFn(() => updateHeader(), 100)
window.addEventListener('scroll', onScroll)

8.2 will-change 开硬件加速

频繁动画的元素提前声明:

.animated-card {
  will-change: transform;
}

但别全站加——每个 will-change 元素独占一个 GPU 合成层,加多了吃显存。

8.3 passive 事件监听

window.addEventListener('scroll', handler, { passive: true })

告诉浏览器"我不会 preventDefault",浏览器就不会等你异步处理完再滚动——滚动流畅度提升明显。


9. 路由切换的过渡动画

页面切换时用 Transition(见过渡动画笔记)能让用户感觉快了(即使实际耗时一样):

<router-view v-slot="{ Component }">
  <Transition name="fade" mode="out-in">
    <component :is="Component" />
  </Transition>
</router-view>

原理:在"旧页面淡出"期间,新页面的加载看起来就是"过渡的一部分"而不是"卡在白屏"。体感优化的经典技巧


10. 长任务拆分:别阻塞主线程

JS 是单线程,一个 100ms 的同步循环会让页面完全无响应。大任务要拆

10.1 微任务 vs 宏任务

// ❌ 阻塞
function processLargeList(list) {
  for (const item of list) {
    heavyWork(item) // 每项 5ms,10000 项 = 50 秒卡死
  }
}

// ✅ 分片
async function processLargeListInChunks(list, chunkSize = 100) {
  for (let i = 0; i < list.length; i += chunkSize) {
    const chunk = list.slice(i, i + chunkSize)
    chunk.forEach(heavyWork)
    // 让出控制权,让浏览器渲染/响应用户
    await new Promise(resolve => requestIdleCallback(resolve))
  }
}

requestIdleCallback —— 浏览器空闲时再跑。没有紧急任务时执行你的工作,不会卡 UI

10.2 Web Worker:真正的多线程

计算密集(如前端加密、大量数据处理)用 Web Worker:

// worker.ts
self.addEventListener('message', (e) => {
  const result = heavyComputation(e.data)
  self.postMessage(result)
})

// 主线程
const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' })
worker.postMessage(bigData)
worker.onmessage = (e) => {
  console.log('结果:', e.data)
}

Vite 原生支持 TS/ESM 的 Worker。

Java 对照:类似开新线程跑耗时任务——浏览器里 Worker 独立线程,完全隔离主 UI。


11. 监测:Performance 面板

Chrome DevTools → Performance 标签 → 红色圆点录制 → 操作页面 → 停止:

看什么

例子:录制"滚动笔记列表",发现某个 formatDate 函数占了大头——把它改成 computed 缓存即可。

Java 对照:类似 JProfiler / Arthas 的火焰图——视觉定位热点。


12. 缓存策略

12.1 接口数据缓存

频繁切换页面拉同一份数据太浪费。两种思路:

A. Pinia store 存列表 + TTL

export const useNoteStore = defineStore('note', () => {
  const list = ref<Note[]>([])
  const lastFetchAt = ref(0)
  const TTL = 60 * 1000 // 1 分钟

  async function load(force = false) {
    const now = Date.now()
    if (!force && list.value.length && now - lastFetchAt.value < TTL) {
      return list.value // 直接用缓存
    }
    list.value = await noteApi.page(...)
    lastFetchAt.value = now
    return list.value
  }

  return { list, load }
})

B. 用数据请求库vue-query / SWR

import { useQuery } from '@tanstack/vue-query'

const { data } = useQuery({
  queryKey: ['notes'],
  queryFn: () => noteApi.page(...),
  staleTime: 60 * 1000,
  cacheTime: 5 * 60 * 1000,
})

自动缓存 + 后台刷新 + 多组件共享同一请求——复杂应用强推

12.2 浏览器缓存

Vite 产物自带 hash,配合 Cache-Control: max-age=31536000 长期缓存。index.html 要短缓存(如 no-cache)才能检测到新版本。Nginx 配:

location /assets/ {
  expires 1y;
  add_header Cache-Control "public, immutable";
}

location = /index.html {
  expires -1;
  add_header Cache-Control "no-cache";
}

13. 心智模型:发现 → 诊断 → 修

用户感觉慢:
  ↓
1. 用 Chrome DevTools 定位:
   - Performance 标签看主线程瓶颈
   - Network 标签看资源加载
   - Memory 标签看内存泄漏
  ↓
2. 对症下药:
   - 长列表 → 虚拟列表
   - 图片多 → loading="lazy"
   - 切页丢状态 → keep-alive
   - 重复渲染 → computed / shallowRef / v-memo
   - 卡主线程 → Web Worker / 分片
   - 过度请求 → 防抖 / 节流 / 缓存
  ↓
3. 验证:
   - Lighthouse 跑分
   - 真机测试
   - Sentry 看用户端数据

14. in4vue 的优化清单

按优先级:

  1. ✅ 路由懒加载(已有笔记讲过,第二天就能做)
  2. ✅ 图片 loading="lazy" —— 在笔记正文图片上加
  3. 🟡 笔记列表用 keep-alive(用户点进详情再返回常见)
  4. 🟡 搜索框 debounce —— 已在 CRUD 笔记写过
  5. 🟡 如果笔记超过 200 篇,列表考虑虚拟滚动
  6. 🟡 Markdown 编辑器用 defineAsyncComponent 懒加载(后台编辑页用)
  7. 🔵 AI 调用结果本地缓存(相同问题不重新请求)

蓝色的等产品做起来再说,别过度设计。


15. 常见坑点

现象 原因 解法
虚拟列表项高度不一,滚动抖 estimateSize 估不准 用 ResizeObserver 测实际高
keep-alive 之后返回页面数据还是旧的 数据没在 onActivated 里刷 该刷的 onActivated 里拉
图片 lazy loading 导致布局跳 没写 width/height 加 width/height 或 aspect-ratio
shallowRef 值改了视图不更新 修改内部属性而不是整体替换 .value = newObj 整体赋值
切换路由有白屏 懒加载 chunk 还在下载 加过渡动画 + 预加载
大列表点击卡 1 秒 每项都有昂贵计算 v-memoshallowReactive
内存越用越大 定时器/监听没清理 onUnmounted 清掉

小练习

  1. HomePage.vue 的笔记列表加 loading="lazy" 到图片
  2. 在路由配置里给笔记列表页加 meta.keepAlive: true,实现返回时保留滚动位置
  3. @tanstack/vue-virtual 做一个 5000 条的虚拟列表 demo
  4. refDebounced 替换笔记搜索框的手动搜索
  5. 用 DevTools Performance 录制一次列表滚动,看有无长任务

延伸阅读