Skip to content

L30 · 性能优化 + E2E 测试 + Phase 3 总结 ​

🎯 本节目标:测量性能瓶颈,验证前后端改动,用 Playwright 跑通购物流程
📦 本节产出:性能记录方法 + 虚拟列表实验 + 商品缓存 + E2E 测试套件
🔗 前置钩子:L28 商城 SPA;L29 独立 SSR 对照实验
🔗 后续钩子:Phase 4 将深入 Vue 3 内部原理

本课回到原商城 client/ 和 server/。先固定浏览器、设备、网络条件、测试数据和构建模式,记录基线,再改变一个因素并复测。L29 的 SSR 是一种渲染选择,不是“最大的性能优化”。


1. 前端性能优化 ​

1.1 路由级代码拆分 ​

L21 之后的商城路由已经采用动态 import,保留现有路由、鉴权 meta 和重定向,只核对组件是否仍然懒加载:

typescript
// client/src/router/index.ts:现有路由示例,不替换整个 routes 数组
{ path: '/products', name: 'products', component: () => import('@/views/ProductListView.vue') },

动态 import 提供拆分边界;实际 chunk 还受共享依赖与构建配置影响,不能承诺“每个路由恰好一个独立文件”或“初始请求绝不加载其他代码”。使用 L18 的构建分析报告和浏览器 Network 检查初始脚本、后续路由请求、压缩后传输量。开发服务器的请求结构不能代替生产构建结果。

1.2 组件懒加载 ​

本节给实验页面添加按需打开的虚拟列表。组件、加载提示和错误提示都在这里定义,不引用课程里不存在的 ChartPanel / ProductReviews:

vue
<!-- 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;动态高度需要测量元素,不能照搬固定高度的偏移算法。

bash
# 在 client/ 中
npm install @tanstack/vue-virtual@3
vue
<!-- 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 的卡片图片上保留原来的图片来源与条件,添加浏览器懒加载和尺寸占位:

vue
<!-- 替换卡片原 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、ElementsDOM 数量、滚动长任务、设备条件
图片Network、布局偏移记录实际尺寸、传输量、LCP、CLS
静态缓存查看 Cache-Control、重复访问命中情况;HTML 与 hash 资源分开
gzip / Brotli检查 Content-Encoding服务端是否启用、实际传输量

不预填“减少 60%”“秒开”等结果。至少重复几次相同操作,报告范围和中位数;浏览器性能实验数据不能代替真实用户的体验分布。更改缓存、索引或拆分后,也要复测功能是否正确。


2. 后端性能优化 ​

2.1 数据库索引 ​

保留 L19 的完整 Product Schema,不能用一个缩减模型覆盖库存验证、图片等字段。对 L20 的“有效商品 + 分类 + 价格/ID 排序”,可以增加下面的候选索引:

typescript
// server/src/models/Product.ts:在导出 model 之前追加
productSchema.index({ isActive: 1, category: 1, price: 1, _id: 1 })

它适用于对应的查询组合,不保证覆盖没有 category 的全部商品排序。MongoDB 会考虑等值条件、排序与范围条件;索引也消耗磁盘并增加写入成本。L19 的文本索引用于 $text 查询,L20 的转义正则子串搜索不会自动使用它做全文检索。MongoDB ESR 索引原则

在测试数据库的 mongosh 中比较计划;不要只看是否创建了索引:

javascript
db.products.find({ isActive: true, category: 'E2E' })
  .sort({ price: 1, _id: 1 }).limit(12).explain('executionStats')

观察实际索引、是否有内存排序、nReturned、totalKeysExamined、totalDocsExamined。小数据集可能选择集合扫描,这不等于索引必然错误;应使用有代表性的数据与查询分布。

2.2 查询优化 ​

L20 已用 lean 和并行读取列表/总数,保持这个行为,不再重复替换:

typescript
// 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。不缓存订单、个人资料、鉴权失败或其他错误。缓存会让库存显示短暂陈旧,下单仍由事务重新检查;单进程示例不是分布式缓存。

typescript
// 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 之前调用一次即可。
typescript
// server/src/services/orderService.ts:placeOrder 的事务返回尾部
  }).then(order => {
    invalidateProductCache()
    return order
  })

不要在事务回调内失效。generation 还防止“写入已完成后,先前启动的列表请求才返回并把旧结果放回缓存”。直接改数据库或其他进程写入不会触发本进程的失效,旧数据可能持续到缓存到期;多进程需要共享缓存与失效协议。先测命中率、数据库压力和正确性,再决定是否保留缓存。


3. E2E 测试(Playwright) ​

3.1 安装与可重复的数据 ​

bash
# 在 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:

dotenv
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,避免与订单扫描同时修改库存:

typescript
// 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())
bash
# 在 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 密钥只在服务器文件中,前端不需要这些值。

typescript
// 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:

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 不会从前一个用例继承。

typescript
// 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 测试 ​

bash
# 在 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 提交 ​

通过检查后再记录阶段版本:

bash
git add .
git commit -m "L30: 性能实验与商城 E2E [Phase 3 完成]"
git tag phase-3-complete

🔗 → Phase 4:Vue 3 内部原理 ​

Phase 4 将深入响应式、虚拟 DOM、编译器和调度器,并在单独的版本边界内讨论 Vapor Mode。阅读源码时区分当前课程运行的稳定版、教学简化模型与实验功能。