第6章 6.3 性能优化:懒加载 + 虚拟列表

上章回顾:上一章我们学会了用 Vitest + Vue Test Utils 给 Vue 项目写单元测试,就像给汽车做全面体检一样,能帮我们提前发现 Bug,保证代码质量。

本章预告:但你有没有想过,万一汽车油箱太大,加满油要等 10 分钟呢?这一章我们来聊聊如何让 Vue 应用「跑得更快、加载更少」,像给汽车装上涡轮增压器一样!


🎯 开场 3 分钟:为什么要学这个?

你有没有遇到过这些情况?

  • 网页加载要等好几秒:点开一个页面,白屏转圈圈,急死人了
  • 往下滚动一个长列表,手机直接卡死:比如微信朋友圈,往上刷 1000 条动态,手机烫得能煎蛋
  • 首屏加载了一大堆用不到的东西:用户还没看到内容,就已经等半天了

这些问题的根源往往是:一次性加载了太多东西

举个例子来类比一下:

你去超市买东西,错误做法是:推着购物车把整个超市逛完,把所有商品都装进购物车,然后回到收银台结账。

正确做法是:只拿你需要\n\nSimple tech illustration expla\n\nAI comic creation scene, creat\n\n的东西,结账时才去拿——这就是「懒加载」的核心思想。

本章学完,你将掌握:
1. 路由懒加载:页面按需加载,不一口气加载整个应用
2. 组件懒加载:大组件(如图文编辑器、图表)可以慢慢来
3. 虚拟列表:10000 条数据也能丝滑滚动,像开了跑车一样


🧱 基础 25 分钟:核心概念

什么是懒加载?

懒加载(Lazy Loading),说白了就是「能晚点做的事,绝不提前做」

想象你是个老板,有 10 个员工:
- 不用懒加载:一个任务来了,你让 10 个人同时冲上去,结果办公室挤成一团
- 用懒加载:任务来了,只派需要的人去,其他人继续待命

Vue 中的懒加载,就是让组件和路由按需加载,而不是一开始就把所有代码都下载下来。

1. 路由懒加载

先来看一个最常见的场景:用户访问 /admin 页面,才加载管理员相关的代码。

// 不用懒加载(一次性全部加载)
import Admin from './views/Admin.vue'
import Home from './views/Home.vue'
const routes = [
{ path: '/', component: Home },
{ path: '/admin', component: Admin }
]
// 用懒加载(按需加载)
const routes = [
{ path: '/', component: () => import('./views/Home.vue') },
{ path: '/admin', component: () => import('./views/Admin.vue') }
]

对比一下:
- import('./views/Home.vue') 返回一个 Promise
- 只有用户访问 / 路径时,浏览器才会去下载 Home.vue 的代码
- Admin.vue 的代码?用户没访问 /admin,浏览器就不管它

生活类比:就像点外卖,你不点宫保鸡丁,商家就不会做宫保鸡丁。

2. 组件懒加载 with defineAsyncComponent

有时候同一个页面里,有些组件很大,但不是每个用户都会用到。比如文章详情页的「打赏组件」:

import { defineAsyncComponent } from 'vue'

// 普通引入(立即加载)
import BigChart from './BigChart.vue'

// 懒加载(等需要的时候再加载)
const LazyChart = defineAsyncComponent(() => import('./BigChart.vue'))

然后在模板里用它:

<template>
<div>
<h1>数据分析</h1>
<!-- 用户点击按钮才加载图表组件 -->
<button @click="showChart = true">显示图表</button>
<LazyChart v-if="showChart" />
</div>
</template>

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

const showChart = ref(false)

// 定义懒加载的图表组件
const LazyChart = defineAsyncComponent(() => import('./BigChart.vue'))
</script>

这段代码的意思是:
- defineAsyncComponent 接收一个返回 Promise 的函数
- 只有当 showChart 变成 true 时,Vue 才会去加载 BigChart.vue
- 图表组件的代码不会在页面首次加载时就下载

3. 路由懒加载 + 路由守卫

有时候你需要权限验证,只有管理员才能访问某个页面:

const routes = [
{
path: '/admin',
// 路由懒加载 + 权限检查
component: () => import('./views/Admin.vue'),
beforeEnter: (to, from, next) => {
  // 检查用户是否有管理员权限
  if (localStorage.getItem('isAdmin') === 'true') {
    next()  // 允许访问
  } else {
    next('/login')  // 没权限,跳转登录页
  }
}
}
]

4. 虚拟列表:10000 条数据也不卡

这是本章的重头戏!

想象你有一本书,里面有 10000 页,但每页只能看到 10 行字。

不用虚拟列表:把 10000 页全部打印出来,摆在你面前——你的桌子肯定被淹没了。

用虚拟列表:只打印你正在看的那几页,看完一页再打印下一页——桌子干干净净。

虚拟列表的核心原理:只渲染可视区域的内容

先安装 vue-virtual-scroller

npm install vue-virtual-scroller

然后注册插件:

// main.js
import { createApp } from 'vue'
import App from './App.vue'
import VirtualScroller from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'

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

使用虚拟列表展示 10000 条数据:

<template>
<div class="container">
<h1>虚拟列表演示:10000 条数据</h1>

<!-- RecycleScroller 会在后台复用 DOM 节点 -->
<RecycleScroller
  class="scroller"
  :items="bigList"
  :item-size="60"
  key-field="id"
  v-slot="{ item }"
>
  <div class="item">
    <span class="id">#{{ item.id }}</span>
    <span class="name">{{ item.name }}</span>
    <span class="desc">{{ item.description }}</span>
  </div>
</RecycleScroller>
</div>
</template>

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

// 生成本章 10000 条假数据
const bigList = ref(
Array.from({ length: 10000 }, (_, i) => ({
id: i + 1,
name: `用户_${i + 1}`,
description: `这是第 ${i + 1} 条数据的描述信息,用来演示虚拟列表的效果`
}))
)
</script>

<style scoped>
.container {
height: 500px;
}

.scroller {
height: 100%;
}

.item {
height: 50px;
display: flex;
align-items: center;
gap: 10px;
padding: 5px 10px;
border-bottom: 1px solid #eee;
}

.id {
font-weight: bold;
color: #42b983;
width: 60px;
}

.name {
font-weight: 600;
width: 100px;
}

.desc {
color: #666;
}
</style>

代码解释:
- RecycleScroller 是虚拟列表组件,只渲染可视区域的内容
- :items="bigList" 绑定要展示的数据(10000 条)
- :item-size="60" 每行高度是 60 像素
- v-slot="{ item }" 获取每一条数据的渲染模板

5. 动态虚拟列表项大小

如果列表项高度不固定怎么办?可以用 DynamicScroller

<template>
<DynamicScroller
:items="items"
:min-item-size="50"
key-field="id"
class="scroller"
>
<template v-slot="{ item, index, active }">
  <DynamicScrollerItem
    :item="item"
    :active="active"
    :size-dependencies="[item.description]"
  >
    <div class="item" :style="{ height: item.height + 'px' }">
      {{ item.text }}
    </div>
  </DynamicScrollerItem>
</template>
</DynamicScroller>
</template>

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

// 每个条目高度不同
const items = ref(
Array.from({ length: 1000 }, (_, i) => ({
id: i,
text: `这是第 ${i} 条动态高度的列表项`,
height: 50 + (i % 5) * 30  // 高度在 50-170 之间变化
}))
)
</script>

🔥 实战 35 分钟:3 个递进的小项目

项目 1(5 分钟):路由懒加载初体验

目标:创建一个有两个页面的 Vue 应用,体验路由懒加载。

完整代码

// router/index.js
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
{
path: '/',
// 懒加载写法
component: () => import('../views/Home.vue')
},
{
path: '/about',
component: () => import('../views/About.vue')
},
{
path: '/contact',
component: () => import('../views/Contact.vue')
}
]

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

export default router
<!-- App.vue -->
<template>
<nav>
<router-link to="/">首页</router-link> |
<router-link to="/about">关于</router-link> |
<router-link to="/contact">联系</router-link>
</nav>
<router-view />
</template>

<script setup>
</script>
<!-- views/Home.vue -->
<template>
<h1>首页</h1>
<p>我是首页内容,加载很快!</p>
</template>
<!-- views/About.vue -->
<template>
<h1>关于页面</h1>
<p>我是关于页面,代码独立打包,按需加载!</p>
</template>
<!-- views/Contact.vue -->
<template>
<h1>联系页面</h1>
<p>我是联系页面,也会被懒加载~</p>
</template>

运行结果

用户访问 /       → 加载 Home.vue(约 2KB)
用户访问 /about  → 加载 About.vue(约 1KB)
用户访问 /contact → 加载 Contact.vue(约 1KB)

解释:每个页面的代码独立打包,用户只下载他真正访问的页面,首屏加载飞快。


项目 2(15 分钟):电商商品列表 + 虚拟列表

目标:模拟一个电商后台,展示 5000 个商品,用虚拟列表丝滑滚动。

数据文件 products.json

[
{ "id": 1, "name": "iPhone 15", "price": 6999, "stock": 100, "category": "手机" },
{ "id": 2, "name": "MacBook Pro", "price": 14999, "stock": 50, "category": "电脑" }
]

完整代码

<!-- ProductList.vue -->
<template>
<div class="product-container">
<h1>商品列表(虚拟列表)</h1>
<p>共 {{ products.length }} 个商品,当前滚动流畅不卡顿!</p>

<!-- 使用虚拟列表 -->
<RecycleScroller
  class="scroller"
  :items="products"
  :item-size="72"
  key-field="id"
  v-slot="{ item }"
>
  <div class="product-item">
    <div class="product-info">
      <span class="product-name">{{ item.name }}</span>
      <span class="product-category">{{ item.category }}</span>
    </div>
    <div class="product-price">¥{{ item.price }}</div>
    <div class="product-stock" :class="{ low: item.stock < 20 }">
      库存: {{ item.stock }}
    </div>
  </div>
</RecycleScroller>
</div>
</template>

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

const products = ref([])

// 模拟从 API 加载 5000 条商品数据
onMounted(async () => {
// 实际项目中这里会是 fetch('/api/products')
const allProducts = []
const categories = ['手机', '电脑', '平板', '耳机', '手表', '相机']
const names = ['iPhone', 'Galaxy', 'Mate', 'Mi', 'OPPO', 'vivo']

for (let i = 1; i <= 5000; i++) {
const category = categories[i % categories.length]
const name = names[i % names.length]
allProducts.push({
  id: i,
  name: `${name} ${Math.floor(i / 6) + 1}`,
  price: Math.floor(1000 + Math.random() * 10000),
  stock: Math.floor(Math.random() * 200),
  category: category
})
}

products.value = allProducts
})
</script>

<style scoped>
.product-container {
height: 600px;
display: flex;
flex-direction: column;
}

.scroller {
flex: 1;
height: 500px;
}

.product-item {
height: 62px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 20px;
border-bottom: 1px solid #eee;
}

.product-info {
display: flex;
flex-direction: column;
}

.product-name {
font-weight: bold;
font-size: 16px;
}

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

.product-price {
font-weight: bold;
color: #ff6600;
}

.product-stock.low {
color: #ff4444;
font-weight: bold;
}
</style>

运行结果

页面加载时间:约 200ms(只渲染可视区域 ~10 条)
滚动 5000 条数据:60fps 流畅不卡
内存占用:稳定在 ~50MB(而非 500MB)

解释RecycleScroller 只渲染可视区域的 10 条左右数据,滚动时回收复用 DOM 节点,内存占用极低。


项目 3(15 分钟):懒加载 + 虚拟列表组合实战

目标:做一个「文章阅读器」,首页展示文章列表(虚拟列表),点击文章后懒加载文章详情组件。

完整代码

<!-- ArticleReader.vue -->
<template>
<div class="reader">
<h1>📚 文章阅读器</h1>

<!-- 模式切换 -->
<div class="mode-switch">
  <button @click="currentView = 'list'" :class="{ active: currentView === 'list' }">
    文章列表
  </button>
  <button @click="currentView = 'detail'" :class="{ active: currentView === 'detail' }">
    阅读模式
  </button>
</div>

<!-- 文章列表(虚拟列表) -->
<div v-if="currentView === 'list'" class="list-view">
  <p>共 {{ articles.length }} 篇文章,用虚拟列表流畅滚动</p>

  <RecycleScroller
    class="scroller"
    :items="articles"
    :item-size="80"
    key-field="id"
    v-slot="{ item }"
  >
    <div class="article-item" @click="openArticle(item)">
      <div class="article-title">{{ item.title }}</div>
      <div class="article-meta">
        <span>{{ item.author }}</span>
        <span>{{ item.readTime }} 分钟阅读</span>
      </div>
      <div class="article-preview">{{ item.preview }}</div>
    </div>
  </RecycleScroller>
</div>

<!-- 文章详情(懒加载) -->
<div v-else-if="currentView === 'detail'" class="detail-view">
  <button @click="currentView = 'list'" class="back-btn">← 返回列表</button>

  <!-- 懒加载文章详情组件 -->
  <Suspense>
    <template #default>
      <ArticleDetail :article="currentArticle" />
    </template>
    <template #fallback>
      <div class="loading">正在加载文章内容...</div>
    </template>
  </Suspense>
</div>
</div>
</template>

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

// 懒加载文章详情组件
const ArticleDetail = defineAsyncComponent(() => import('./ArticleDetail.vue'))

const currentView = ref('list')
const currentArticle = ref(null)

// 模拟 1000 篇文章数据
const articles = ref(
Array.from({ length: 1000 }, (_, i) => ({
id: i + 1,
title: `Vue3 学习笔记 ${i + 1}:${[' reactivity', ' composables', ' teleport', ' suspense'][i % 4]}`,
author: `作者_${(i % 50) + 1}`,
readTime: Math.floor(3 + Math.random() * 10),
preview: `这是第 ${i + 1} 篇文章的预览内容,讲解了 Vue3 的核心知识点...`,
content: `这是第 ${i + 1} 篇文章的完整内容,篇幅较长,包含代码示例和详细解释...`
}))
)

function openArticle(article) {
currentArticle.value = article
currentView.value = 'detail'
}
</script>

<style scoped>
.reader {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}

.mode-switch {
margin: 20px 0;
display: flex;
gap: 10px;
}

.mode-switch button {
padding: 10px 20px;
border: none;
background: #eee;
cursor: pointer;
border-radius: 5px;
}

.mode-switch button.active {
background: #42b983;
color: white;
}

.scroller {
height: 500px;
}

.article-item {
padding: 15px;
border-bottom: 1px solid #eee;
cursor: pointer;
transition: background 0.2s;
}

.article-item:hover {
background: #f9f9f9;
}

.article-title {
font-weight: bold;
font-size: 16px;
margin-bottom: 5px;
}

.article-meta {
font-size: 12px;
color: #999;
margin-bottom: 8px;
display: flex;
gap: 15px;
}

.article-preview {
font-size: 14px;
color: #666;
}

.back-btn {
margin-bottom: 20px;
padding: 8px 16px;
background: #42b983;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
}

.loading {
padding: 40px;
text-align: center;
color: #999;
}
</style>
<!-- ArticleDetail.vue -->
<template>
<article class="article-detail">
<h1>{{ article.title }}</h1>
<div class="meta">
  <span>作者:{{ article.author }}</span>
  <span>{{ article.readTime }} 分钟阅读</span>
</div>
<div class="content">
  <p v-for="n in 20" :key="n">
    {{ article.content }}这是第 {{ n }} 段内容,详细解释了 Vue3 的相关概念。
    实际项目中,这里的内容会从服务器获取,包含完整的代码示例和配图说明。
  </p>
</div>
</article>
</template>

<script setup>
defineProps({
article: {
type: Object,
required: true
}
})
</script>

<style scoped>
.article-detail {
padding: 20px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}

.meta {
margin: 15px 0;
padding: 10px 0;
border-top: 1px solid #eee;
border-bottom: 1px solid #eee;
color: #666;
font-size: 14px;
display: flex;
gap: 20px;
}

.content p {
line-height: 1.8;
margin: 15px 0;
}
</style>

预期效果

1. 首页展示 1000 篇文章列表,滚动流畅(虚拟列表)
2. 点击文章后,ArticleDetail 组件才加载(懒加载)
3. 加载中显示 "正在加载文章内容..."(Suspense fallback)

解释:这个项目组合了懒加载和虚拟列表两种技术:
- 列表页用虚拟列表渲染 1000 条数据
- 详情页用 defineAsyncComponent 懒加载
- 用 Suspense 包裹实现加载状态


💪 进阶 20 分钟:常见坑 + 性能小贴士

坑 1:路由懒加载后页面切换变慢

错误写法

// 所有路由同时定义,所有组件同时下载
const routes = [
{ path: '/', component: () => import('./views/Home.vue') },
{ path: '/a', component: () => import('./views/A.vue') },
{ path: '/b', component: () => import('./views/B.vue') },
// ...100 个路由
]

正确写法

// 用注释分包,清晰看到代码分割
const routes = [
{
path: '/',
component: () => import(/* webpackChunkName: "home" */ './views/Home.vue')
},
{
path: '/admin',
// 只有 admin 路由才加载这个大的组件
component: () => import(/* webpackChunkName: "admin" */ './views/Admin.vue')
}
]

坑 2:虚拟列表高度计算错误

错误

<RecycleScroller :item-size="不确定的高度">

正确:确保每行高度一致,或者用 DynamicScroller

<DynamicScroller :min-item-size="50">

坑 3:Suspense 配合异步组件没效果

错误

<template>
<!-- 缺少 Suspense 包裹 -->
<AsyncComponent />
</template>

正确

<template>
<Suspense>
<template #default>
  <AsyncComponent />
</template>
<template #fallback>
  <LoadingSpinner />
</template>
</Suspense>
</template>

坑 4:虚拟列表里用了绝对定位

错误

<RecycleScroller>
<div style="position: absolute"> <!-- 虚拟列表不支持绝对定位 --> </div>
</RecycleScroller>

正确:用 gridflex 布局。

坑 5:大数据渲染用错了组件

错误:给 10000 条数据用普通 v-for

<div v-for="item in bigList" :key="item.id">
<!-- 浏览器要创建 10000 个 DOM 节点,卡死你 -->
</div>

正确:用 RecycleScroller 只渲染可见区域:

<RecycleScroller :items="bigList" :item-size="50">

性能小贴士:预加载即将访问的路由

// router/index.js
import { preloadRoute } from 'vue-router'

// 用户停留在首页时,预加载 admin 路由
onMounted(() => {
setTimeout(() => {
router.preloadRoute('/admin')
}, 3000)  // 3 秒后预加载
})

调试技巧:用 Network 面板看懒加载

打开浏览器开发者工具 → Network → 勾选 JS

  1. 访问首页,看到只加载了 Home.xxx.js
  2. 点击「关于」链接,看到新增了 About.xxx.js
  3. 这样就能验证懒加载是否生效

✏️ 练习题 + 作业题

练习题(10 分钟)

练习 1(2 分钟):路由懒加载入门
- 输入:把下面代码改成懒加载写法

import Product from './views/Product.vue'
const routes = [{ path: '/product', component: Product }]
  • 预期输出:使用 () => import() 语法
  • 提示:component 的值改成返回 Promise 的函数

练习 2(2 分钟):加个判断
- 输入:在练习 1 的基础上,只有当用户已登录时才加载 Product 组件
- 预期输出:用 Promise 结合条件判断
- 提示:可以用 localStorage.getItem('token') 判断

练习 3(3 分钟):给列表加虚拟列表
- 输入:有一个 5000 条数据的数组 items,用 RecycleScroller 展示
- 预期输出:滚动流畅,不卡顿
- 提示:记得注册 vue-virtual-scroller 插件

练习 4(3 分钟):组合懒加载 + 虚拟列表
- 输入:用懒加载加载一个详情组件,里面有虚拟列表
- 预期输出:点击才加载详情,列表滚动流畅
- 提示:参考项目 3 的结构

练习 5(挑战题,5 分钟):修复报错
- 输入:下面代码运行时报错 Cannot read property 'id' of undefined

<RecycleScroller :items="list" :item-size="50" v-slot="{ item }">
<div>{{ item.id }}</div>
</RecycleScroller>
  • 预期输出:修复后正常显示
  • 提示:检查 list 是否为空数组,或添加 v-if

作业题(30 分钟-2 小时)

作业:做一个「图片画廊 + 懒加载预览」

  • 需求描述:做一个图片展示工具,首页用虚拟列表展示 1000 张图片缩略图,点击后懒加载查看大图

  • 功能点:
    1. 用 RecycleScroller 展示 1000 张图片(用占位图)
    2. 点击图片后用 defineAsyncComponent 懒加载大图预览组件
    3. 用 Suspense 显示加载状态

  • 加分项:
    1. 实现图片懒加载(滚动到可视区域才加载缩略图)
    2. 添加图片筛选功能(按类别筛选)

  • 验收标准:

  • 能跑起来
  • 滚动 1000 张图片不卡顿(60fps)
  • 点击图片能加载大图预览

  • 提交方式:评论区贴代码或 GitHub 链接


📚 总结 + 资源

本文学到的 3 个核心点

  1. 路由懒加载:用 () => import() 让每个页面独立打包,首屏加载飞快
  2. 组件懒加载defineAsyncComponent 让大组件按需加载,节省内存
  3. 虚拟列表RecycleScroller 只渲染可见区域,10000 条数据也不卡

延伸学习资源

互动钩子

你的项目有没有遇到过「列表太卡」或者「首屏加载慢」的问题?当时是怎么解决的?评论区聊聊你的血泪史,老粉优先回复!也欢迎贴上你的作业链接,大家一起交流学习 🚀


下章预告:学会了性能优化,你的 Vue 应用已经跑得飞快了。但有没有想过一个问题——网页内容是怎么出现在搜索引擎里的?下一章我们要聊一个很有意思的话题:服务端渲染 SSR,让搜索引擎也能读懂你的 Vue 应用!

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。