前端性能优化:虚拟列表、懒加载、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 的虚拟 Table(el-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>
差异提醒:
el-table——标准表格,带插槽/格式化el-table-v2——虚拟化表格,API 不一样(columns 是数组配置,不是<el-table-column>插槽)
什么时候换:数据量超过 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>
原理:
- 外层
<div>撑一个和"整个列表高度"相同的占位 - 根据滚动位置算"现在应该看到哪几项"
- 只渲染那几个真实节点,其他都不存在
效果: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>
适合:
- 重型组件(Markdown 编辑器、代码编辑器、富文本、图表)
- 用户打开概率不高的组件(设置弹窗、帮助面板)
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>
关键点:
include列出要缓存的组件的 name(组件里defineOptions({ name: 'NoteList' }))- 不写
include会缓存所有,内存占用大 - 缓存的组件不会触发
onMounted,会走onActivated/onDeactivated
<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 坑点
name必须和组件一致:<script setup>里默认没有 name,要defineOptions({ name: 'NoteList' })- 跳详情再回:要手动清缓存的场景(如"编辑后列表要刷"),在编辑完成时清 keep-alive
- 内存:列表多了每个都 keep,内存会涨——只 keep 高频切换的
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 自己处理)
适合:
- ECharts / Monaco Editor 实例(第三方库内部有自己的响应机制)
- 大数组/大对象,只需要"替换"级别的响应
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 什么时候用
- 输入框搜索 —— debounce 300-500ms
- 滚动加载 —— throttle 100-200ms
- resize 重算布局 —— debounce 100ms
- 表单自动保存 —— debounce 1000ms
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 标签 → 红色圆点录制 → 操作页面 → 停止:
看什么:
- FPS:绿色越高越好(60 最佳)
- CPU:黄色/红色峰值 = 重任务
- Main:主线程的火焰图,宽的条就是慢的函数
- Frames:掉帧用红色标
例子:录制"滚动笔记列表",发现某个 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 的优化清单
按优先级:
- ✅ 路由懒加载(已有笔记讲过,第二天就能做)
- ✅ 图片
loading="lazy"—— 在笔记正文图片上加 - 🟡 笔记列表用 keep-alive(用户点进详情再返回常见)
- 🟡 搜索框 debounce —— 已在 CRUD 笔记写过
- 🟡 如果笔记超过 200 篇,列表考虑虚拟滚动
- 🟡 Markdown 编辑器用
defineAsyncComponent懒加载(后台编辑页用) - 🔵 AI 调用结果本地缓存(相同问题不重新请求)
蓝色的等产品做起来再说,别过度设计。
15. 常见坑点
| 现象 | 原因 | 解法 |
|---|---|---|
| 虚拟列表项高度不一,滚动抖 | estimateSize 估不准 |
用 ResizeObserver 测实际高 |
| keep-alive 之后返回页面数据还是旧的 | 数据没在 onActivated 里刷 |
该刷的 onActivated 里拉 |
| 图片 lazy loading 导致布局跳 | 没写 width/height | 加 width/height 或 aspect-ratio |
shallowRef 值改了视图不更新 |
修改内部属性而不是整体替换 | 用 .value = newObj 整体赋值 |
| 切换路由有白屏 | 懒加载 chunk 还在下载 | 加过渡动画 + 预加载 |
| 大列表点击卡 1 秒 | 每项都有昂贵计算 | v-memo 或 shallowReactive |
| 内存越用越大 | 定时器/监听没清理 | onUnmounted 清掉 |
小练习
- 给
HomePage.vue的笔记列表加loading="lazy"到图片 - 在路由配置里给笔记列表页加
meta.keepAlive: true,实现返回时保留滚动位置 - 用
@tanstack/vue-virtual做一个 5000 条的虚拟列表 demo - 用
refDebounced替换笔记搜索框的手动搜索 - 用 DevTools Performance 录制一次列表滚动,看有无长任务