L30 · 性能优化 + E2E 测试 + Phase 3 总结
🎯 本节目标:测量性能瓶颈,验证前后端改动,用 Playwright 跑通购物流程
📦 本节产出:性能记录方法 + 虚拟列表实验 + 商品缓存 + E2E 测试套件
🔗 前置钩子:L28 商城 SPA;L29 独立 SSR 对照实验
🔗 后续钩子:Phase 4 将深入 Vue 3 内部原理本课回到原商城 client/ 和 server/。先固定浏览器、设备、网络条件、测试数据和构建模式,记录基线,再改变一个因素并复测。L29 的 SSR 是一种渲染选择,不是“最大的性能优化”。
1. 前端性能优化
1.1 路由级代码拆分
L21 之后的商城路由已经采用动态 import,保留现有路由、鉴权 meta 和重定向,只核对组件是否仍然懒加载:
// client/src/router/index.ts:现有路由示例,不替换整个 routes 数组
{ path: '/products', name: 'products', component: () => import('@/views/ProductListView.vue') },动态 import 提供拆分边界;实际 chunk 还受共享依赖与构建配置影响,不能承诺“每个路由恰好一个独立文件”或“初始请求绝不加载其他代码”。使用 L18 的构建分析报告和浏览器 Network 检查初始脚本、后续路由请求、压缩后传输量。开发服务器的请求结构不能代替生产构建结果。
1.2 组件懒加载
本节给实验页面添加按需打开的虚拟列表。组件、加载提示和错误提示都在这里定义,不引用课程里不存在的 ChartPanel / ProductReviews:
<!-- client/src/views/PerformanceLabView.vue -->
<script setup lang="ts">
import { ref, defineAsyncComponent, defineComponent, h } from 'vue'
const opened = ref(false)
const VirtualProductList = defineAsyncComponent({
loader: () => import('@/components/VirtualProductList.vue'),
loadingComponent: defineComponent(() => () => h('p', '正在加载列表组件…')),
errorComponent: defineComponent(() => () => h('p', { role: 'alert' }, '组件加载失败,请刷新后重试')),
delay: 150,
timeout: 10000,
})
</script>
<template>
<main><h1>虚拟列表实验</h1>
<p>这里的 10000 条是假数据,用来观察 DOM 数量;商城商品页仍使用服务端分页。</p>
<button v-if="!opened" @click="opened = true">打开一万条数据实验</button>
<VirtualProductList v-else />
</main>
</template>在已有 routes 数组追加 { path: '/performance-lab', name: 'performance-lab', component: () => import('@/views/PerformanceLabView.vue') },用地址直接打开实验页。这个页面只展示本地生成数据。
1.3 虚拟列表
当测量表明大量 DOM 是瓶颈时,可以只挂载可见区域附近的行。本例固定行高 60px;动态高度需要测量元素,不能照搬固定高度的偏移算法。
# 在 client/ 中
npm install @tanstack/vue-virtual@3<!-- client/src/components/VirtualProductList.vue -->
<script setup lang="ts">
import { computed, ref, shallowRef } from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'
const parentRef = ref<HTMLDivElement | null>(null)
const items = shallowRef(Array.from({ length: 10000 }, (_, index) => ({ id: index, name: `演示商品 ${index + 1}` })))
const virtualizer = useVirtualizer(computed(() => ({
count: items.value.length,
getScrollElement: () => parentRef.value,
getItemKey: (index: number) => items.value[index]!.id,
estimateSize: () => 60,
overscan: 5,
})))
// Vue adapter 返回 Ref;在 script 中通过 .value 读取,在模板顶层自动解包。
const rows = computed(() => virtualizer.value.getVirtualItems())
const totalSize = computed(() => virtualizer.value.getTotalSize())
</script>
<template>
<div ref="parentRef" class="virtual-list" aria-label="虚拟商品列表" tabindex="0">
<div role="list" :style="{ height: `${totalSize}px`, position: 'relative' }">
<div v-for="row in rows" :key="String(row.key)" role="listitem"
:aria-setsize="items.length" :aria-posinset="row.index + 1"
:style="{
position: 'absolute', top: 0, left: 0, width: '100%',
height: `${row.size}px`, boxSizing: 'border-box',
transform: `translateY(${row.start}px)`,
}">
{{ items[row.index]?.name }}
</div>
</div>
</div>
</template>
<style scoped>
.virtual-list { height: 500px; overflow: auto; border: 1px solid #ccd; }
[role="listitem"] { padding: 18px; border-bottom: 1px solid #eee; }
</style>overscan 会在可见区域前后留缓冲,实际 DOM 数量随视口、边界和滚动位置变化。虚拟列表减少 DOM,但不会自动减少已下载数据,也会影响浏览器页内搜索、焦点和辅助技术读取;本例的只读实验不替换 L23 每页 12 条的商品列表。TanStack Vue Virtual 固定行示例
1.4 图片优化
L27 已把上传图片解码并转成 WebP。仍要测量尺寸、质量与下载体积;仅写 <picture> 不会自动压缩或生成不同分辨率的文件。
在 ProductListView 的卡片图片上保留原来的图片来源与条件,添加浏览器懒加载和尺寸占位:
<!-- 替换卡片原 img;这里仍在现有 product 的 v-for 内 -->
<img v-if="product.images[0]" :src="product.images[0]" :alt="product.name"
loading="lazy" decoding="async" width="320" height="240" />同时给 .card-image img 保留或添加 width: 100%; height: 100%; object-fit: cover,使占位比例与卡片设计一致。不要把首屏主要展示图一律设成 lazy;若它是 LCP 元素,延迟加载可能拖慢指标。需要 srcset 时,先实际生成不同宽度图片,再填写真实文件 URL,不能拿一个 WebP/JPEG 格式切换示例声称完成响应式尺寸优化。
1.5 性能检查清单
| 待验证项 | 观察方法 | 记录内容 |
|---|---|---|
| 路由/组件拆分 | 生产构建报告、Network | 初始和导航后加载的 chunk、字节数 |
| 未用依赖 | 构建分析、检查导入方式 | 依赖占比及修改后的实际差值 |
| 虚拟列表 | Performance、Elements | DOM 数量、滚动长任务、设备条件 |
| 图片 | Network、布局偏移记录 | 实际尺寸、传输量、LCP、CLS |
| 静态缓存 | 查看 Cache-Control、重复访问 | 命中情况;HTML 与 hash 资源分开 |
| gzip / Brotli | 检查 Content-Encoding | 服务端是否启用、实际传输量 |
不预填“减少 60%”“秒开”等结果。至少重复几次相同操作,报告范围和中位数;浏览器性能实验数据不能代替真实用户的体验分布。更改缓存、索引或拆分后,也要复测功能是否正确。
2. 后端性能优化
2.1 数据库索引
保留 L19 的完整 Product Schema,不能用一个缩减模型覆盖库存验证、图片等字段。对 L20 的“有效商品 + 分类 + 价格/ID 排序”,可以增加下面的候选索引:
// server/src/models/Product.ts:在导出 model 之前追加
productSchema.index({ isActive: 1, category: 1, price: 1, _id: 1 })它适用于对应的查询组合,不保证覆盖没有 category 的全部商品排序。MongoDB 会考虑等值条件、排序与范围条件;索引也消耗磁盘并增加写入成本。L19 的文本索引用于 $text 查询,L20 的转义正则子串搜索不会自动使用它做全文检索。MongoDB ESR 索引原则
在测试数据库的 mongosh 中比较计划;不要只看是否创建了索引:
db.products.find({ isActive: true, category: 'E2E' })
.sort({ price: 1, _id: 1 }).limit(12).explain('executionStats')观察实际索引、是否有内存排序、nReturned、totalKeysExamined、totalDocsExamined。小数据集可能选择集合扫描,这不等于索引必然错误;应使用有代表性的数据与查询分布。
2.2 查询优化
L20 已用 lean 和并行读取列表/总数,保持这个行为,不再重复替换:
// getProducts 中已有逻辑;query/sorts/sortKey/skip/limit 都由 L20 定义
const [products, total] = await Promise.all([
Product.find(query).sort(sorts[sortKey]).skip(skip).limit(limit).lean(),
Product.countDocuments(query),
])lean 跳过 Mongoose 文档实例化,返回普通对象,不能再调用文档 save。两个独立查询并行可减少等待,但收益取决于数据库负载,不能说串行“一定慢一倍”;两次查询也不是同一事务快照。若用 select 减少字段,要先定义新的列表 DTO,并同步前端:现在卡片加入购物车需要 stock/category 等 Product 字段,不能直接删掉它们。Mongoose 8 lean
2.3 接口缓存
下面的内存缓存只挂在所有用户看到相同数据的公开商品列表 GET。不缓存订单、个人资料、鉴权失败或其他错误。缓存会让库存显示短暂陈旧,下单仍由事务重新检查;单进程示例不是分布式缓存。
// server/src/middlewares/productCache.ts
import type { Request, Response, NextFunction } from 'express'
const cache = new Map<string, { body: unknown; expiresAt: number }>()
let generation = 0
export function invalidateProductCache() { generation++; cache.clear() }
export function productCache(ttlSeconds = 30) {
if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) throw new Error('缓存 TTL 必须大于 0')
return (req: Request, res: Response, next: NextFunction) => {
if (req.method !== 'GET') { next(); return }
const key = req.originalUrl
const hit = cache.get(key)
if (hit && hit.expiresAt > Date.now()) { res.json(hit.body); return }
cache.delete(key)
const startedAtGeneration = generation
const originalJson = res.json.bind(res)
res.json = (body: unknown) => {
const successful = body !== null && typeof body === 'object' && 'success' in body && body.success === true
if (res.statusCode === 200 && successful && generation === startedAtGeneration) {
// 上限 200 个 URL,避免不断变化的查询参数无限占内存。
if (!cache.has(key) && cache.size >= 200) {
const oldest = cache.keys().next().value
if (oldest !== undefined) cache.delete(oldest)
}
cache.set(key, { body, expiresAt: Date.now() + ttlSeconds * 1000 })
}
return originalJson(body)
}
next()
}
}接线要同时做读缓存和写后失效,否则管理员改商品后仍会命中旧列表:
- productRoutes.ts 添加
import { productCache } from '../middlewares/productCache',仅把列表路由改为router.get('/', productCache(30), getProducts),其余鉴权/校验保持。 - productController.ts 添加
import { invalidateProductCache } from '../middlewares/productCache'。在 createProduct 创建成功后、updateProduct/deleteProduct 确认找到并写入商品后调用,位置在发送响应之前;失败路径不失效。 - orderService.ts 也添加
import { invalidateProductCache } from '../middlewares/productCache'。placeOrder 的 transaction Promise 在提交后增加下面的 then;transitionOrder 已在 L28 增加了 then,在那个 then 的 sendNotification 之前调用一次即可。
// server/src/services/orderService.ts:placeOrder 的事务返回尾部
}).then(order => {
invalidateProductCache()
return order
})不要在事务回调内失效。generation 还防止“写入已完成后,先前启动的列表请求才返回并把旧结果放回缓存”。直接改数据库或其他进程写入不会触发本进程的失效,旧数据可能持续到缓存到期;多进程需要共享缓存与失效协议。先测命中率、数据库压力和正确性,再决定是否保留缓存。
3. E2E 测试(Playwright)
3.1 安装与可重复的数据
# 在 client/ 中
npm install -D @playwright/test@1
npx playwright install chromium测试使用专用数据库 vue_shop_e2e,不依赖某个现成账号或随机库存。复制 server/.env 为 server/.env.e2e,保留 L22 的两个不同 JWT 密钥,并将以下项目改成测试值;把 .env.e2e 加进 server/.gitignore:
MONGODB_URI=mongodb://127.0.0.1:27017/vue_shop_e2e?replicaSet=rs0
PORT=3002
CLIENT_URL=http://127.0.0.1:5174
NODE_ENV=test
ENABLE_MOCK_PAYMENT=true种子脚本明确拒绝其他数据库。它只重置该专用库中此测试账号的订单与 E2E 商品;运行前停止使用该测试库的 API,避免与订单扫描同时修改库存:
// server/scripts/seed-e2e.ts
import 'dotenv/config'
import mongoose from 'mongoose'
import bcrypt from 'bcryptjs'
import { connectDB } from '../src/config/db'
import User from '../src/models/User'
import Product from '../src/models/Product'
import Order from '../src/models/Order'
async function seed() {
const uri = process.env.MONGODB_URI || ''
if (new URL(uri).pathname !== '/vue_shop_e2e') throw new Error('只允许初始化 vue_shop_e2e')
await connectDB()
await User.init()
const user = await User.findOneAndUpdate({ email: 'e2e@example.test' }, { $set: {
name: 'E2E 顾客', password: await bcrypt.hash('E2e-password-123', 10), role: 'user',
} }, { upsert: true, new: true, runValidators: true })
if (!user) throw new Error('创建测试账号失败')
await Order.deleteMany({ user: user._id })
for (let index = 1; index <= 25; index++) {
const id = 'eeeeeeeeeeeeeeeeeeee' + index.toString(16).padStart(4, '0')
await Product.findOneAndUpdate({ _id: id }, { $set: {
name: `E2E 商品 ${String(index).padStart(2, '0')}`, description: '本地端到端测试商品',
price: 18.9 + index, category: 'E2E', stock: 20, images: [],
rating: 0, reviewCount: 0, isActive: true,
} }, { upsert: true, new: true, runValidators: true })
}
console.log('E2E 测试数据已准备')
}
seed().catch(error => { console.error(error); process.exitCode = 1 })
.finally(() => mongoose.disconnect())# 在 server/,先准备数据,再保持测试 API 运行(macOS/Linux shell)
DOTENV_CONFIG_PATH=.env.e2e npx tsx scripts/seed-e2e.ts
DOTENV_CONFIG_PATH=.env.e2e npm run dev另开终端执行 curl http://127.0.0.1:3002/api/health 确认 API 就绪。JWT 密钥只在服务器文件中,前端不需要这些值。
// client/playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
fullyParallel: false,
workers: 1, // 本课共享一套固定库存
retries: 0,
forbidOnly: !!process.env.CI,
reporter: [['list'], ['html', { open: 'never' }]],
use: { baseURL: 'http://127.0.0.1:5174', trace: 'retain-on-failure', screenshot: 'only-on-failure' },
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: {
command: 'npm run dev -- --host 127.0.0.1 --port 5174 --strictPort',
url: 'http://127.0.0.1:5174',
reuseExistingServer: false,
env: { VITE_API_URL: 'http://127.0.0.1:3002/api', VITE_SOCKET_URL: 'http://127.0.0.1:3002' },
},
})这是开发模式的功能测试配置,Playwright 负责前端服务的启动/停止;后端已经在上一步启动。reuseExistingServer: false 避免误用连接其他数据库的前端。性能测量另用生产构建。Playwright webServer
给 client/.gitignore 增加 playwright-report/、test-results/。在 L17 的 vitest.config.ts 的 test 下加 include: ['src/**/*.{test,spec}.{ts,tsx}'],避免 Vitest 误收 e2e/*.spec.ts。
E2E 不在 src/ 的类型检查范围内,再创建 client/tsconfig.e2e.json:
{
"extends": "@tsconfig/node22/tsconfig.json",
"compilerOptions": { "noEmit": true, "module": "ESNext", "moduleResolution": "Bundler", "types": ["node"] },
"include": ["playwright.config.ts", "e2e/**/*.ts"]
}3.2 核心测试用例
定位器对应 L22–L26 已有的 label、按钮文字和卡片 class,不依赖尚未定义的 data-testid。每个 test 默认有独立浏览器上下文,localStorage 不会从前一个用例继承。
// client/e2e/product-flow.spec.ts
import { test, expect } from '@playwright/test'
test('搜索商品只显示匹配结果', async ({ page }) => {
await page.goto('/products?category=E2E')
// 先注册等待,再触发;同时核对参数,避免误把初始列表响应当作搜索结果。
const searched = page.waitForResponse(response => {
const url = new URL(response.url())
return url.pathname === '/api/products' && url.searchParams.get('search') === 'E2E 商品 01' && response.status() === 200
})
await page.getByRole('textbox', { name: '搜索商品' }).fill('E2E 商品 01')
await searched
await expect(page.locator('.product-card')).toHaveCount(1)
await expect(page.getByRole('heading', { name: 'E2E 商品 01', exact: true })).toBeVisible()
})
test('翻页同步 URL 与实际商品', async ({ page }) => {
await page.goto('/products?category=E2E&sort=price')
await expect(page.getByRole('heading', { name: 'E2E 商品 01', exact: true })).toBeVisible()
await page.getByRole('button', { name: '2', exact: true }).click()
await expect(page).toHaveURL(/page=2/)
await expect(page.getByRole('heading', { name: 'E2E 商品 13', exact: true })).toBeVisible()
await expect(page.getByRole('heading', { name: 'E2E 商品 01', exact: true })).toHaveCount(0)
})
test('游客访问订单会跳转登录', async ({ page }) => {
await page.goto('/orders')
await expect(page).toHaveURL(/\/login\?redirect=/)
await expect(page.getByRole('heading', { name: '登录', exact: true })).toBeVisible()
})
test('购物车 → 登录回跳 → 下单 → 模拟付款 → 订单状态', async ({ page }) => {
await page.goto('/products/eeeeeeeeeeeeeeeeeeee0001')
await expect(page.getByRole('heading', { name: 'E2E 商品 01', exact: true })).toBeVisible()
await page.getByRole('button', { name: '加入购物车', exact: true }).click()
await page.getByRole('link', { name: '查看购物车', exact: true }).click()
await expect(page.locator('.cart-item')).toHaveCount(1)
await expect(page.locator('.total-price')).toHaveText('¥19.90')
await page.getByRole('button', { name: /去结算/ }).click()
await expect(page).toHaveURL(/\/login\?redirect=/)
await page.getByLabel('邮箱', { exact: true }).fill('e2e@example.test')
await page.getByLabel('密码', { exact: true }).fill('E2e-password-123')
await page.getByRole('button', { name: '登录', exact: true }).click()
await expect(page).toHaveURL(/\/checkout$/)
await page.getByLabel('收货人', { exact: true }).fill('测试顾客')
await page.getByLabel('电话', { exact: true }).fill('13800000000')
await page.getByLabel('城市', { exact: true }).fill('测试城市')
await page.getByLabel('详细地址', { exact: true }).fill('测试街道 1 号')
await page.getByRole('button', { name: '创建订单', exact: true }).click()
await expect(page).toHaveURL(/\/orders\/[a-f\d]{24}$/)
await expect(page.locator('article > p').first()).toHaveText('待支付')
await page.getByRole('link', { name: '去模拟支付', exact: true }).click()
await page.getByRole('button', { name: '模拟确认支付', exact: true }).click()
await expect(page.getByRole('heading', { name: '模拟支付已确认', exact: true })).toBeVisible()
await page.getByRole('link', { name: '查看订单最终状态', exact: true }).click()
await expect(page.locator('article > p').first()).toHaveText('已支付')
await expect(page.getByRole('heading', { name: /^订单 [a-f\d]{24}$/ })).toBeVisible()
})
test('虚拟列表只挂载窗口附近的行并能滚到末尾', async ({ page }) => {
await page.goto('/performance-lab')
await page.getByRole('button', { name: '打开一万条数据实验' }).click()
const list = page.getByLabel('虚拟商品列表', { exact: true })
await expect(list.getByText('演示商品 1', { exact: true })).toBeVisible()
expect(await list.getByRole('listitem').count()).toBeLessThan(50)
await list.evaluate(element => { element.scrollTop = element.scrollHeight })
await expect(list.getByText('演示商品 10000', { exact: true })).toBeVisible()
expect(await list.getByRole('listitem').count()).toBeLessThan(50)
})检查具体订单的状态段落,而不是时间线里一直存在的“已支付”标签,避免尚未付款也误通过。付款仍是 L26 的本机模拟,不发生真实扣款。这五个用例覆盖主流程与几个关键行为,不能替代服务端越权、库存并发、事务回滚和错误分支测试。
3.3 运行 E2E 测试
# 在 client/;保持上面的测试 API 运行,不要另外占用 5174
npx tsc -p tsconfig.e2e.json
npm run build
npx playwright test
npx playwright show-report测试失败先查看报告、截图和 trace,核对数据/接口/选择器;不要添加固定 sleep 掩盖竞态。需要重置数据时,停止测试 API、重新执行种子脚本,再启动它。功能测试通过后再在同样的数据集上做生产模式性能测量。
4. Phase 3 总结
| 技能 | 掌握标志 |
|---|---|
| Express + MongoDB | 能等待数据库就绪,并保留真实错误处理 |
| RESTful / Axios | 前后端方法、类型和 envelope 一致 |
| JWT | 能校验身份、刷新令牌并处理会话变化 |
| 分页搜索 | URL、草稿、防抖和请求结果保持一致 |
| 购物车 / 订单 | 前端金额是预估,后端事务决定库存与金额 |
| 模拟支付 | 不复活已取消订单,能处理幂等确认与过期 |
| 文件上传 | 限制大小/像素,真实解码,清理客户端资源 |
| Socket.IO | 通知在提交后发送,理解断线与投递边界 |
| SSR | 区分初始 HTML、hydration、缓存和请求隔离 |
| 性能 / E2E | 使用实测数据,并断言真实页面状态 |
课程完成的是本机教学商城及相邻实验。支付、退款物流、长期通知存储和生产会话管理仍有前文明确的边界;不把这份示例称为可直接上线的完整电商平台。
Git 提交
通过检查后再记录阶段版本:
git add .
git commit -m "L30: 性能实验与商城 E2E [Phase 3 完成]"
git tag phase-3-complete🔗 → Phase 4:Vue 3 内部原理
Phase 4 将深入响应式、虚拟 DOM、编译器和调度器,并在单独的版本边界内讨论 Vapor Mode。阅读源码时区分当前课程运行的稳定版、教学简化模型与实验功能。