模块四 · 性能、测试与表达
测试与部署:从 Vitest 到 nginx 上线
能测、能构建、能上线,才算一个完整的项目。这一节讲清三层测试的分工、组件测试的写法,以及分包、缓存头与 history fallback 这些上线必踩的坑。
读完这一节,你能做到
- 按测试金字塔决定"什么该写单测、什么该写组件测试、什么留给 E2E"。
- 用 Vitest + Vue Test Utils 测一个组合式函数与一个组件。
- 读懂一次生产构建的产物,做出合理的分包与缓存策略。
- 用 nginx 正确部署 history 路由,避免刷新 404。
一、三层测试各管什么
| 层次 | 工具 | 测什么 | 数量建议 | 运行速度 |
|---|---|---|---|---|
| 单元 | Vitest | 纯函数、组合式函数、工具库 | 最多 | 毫秒级 |
| 组件 | Vitest + Vue Test Utils | props 渲染、事件、插槽、交互 | 中等 | 百毫秒 |
| 端到端 | Playwright / Cypress | 真实浏览器里的关键流程 | 最少 | 秒级 |
原则是"测试行为,不测试实现":
- 组合式函数测输入输出,不测内部调用了几个
ref。 - 组件测"渲染出什么、点了之后发出什么事件",不测内部 data 结构。
- E2E 只覆盖登录、下单这类高价值主流程,不要用它补组件测试的缺口。
二、组合式函数的单测
组合式函数的好处之一就是可测:不依赖组件实例,直接调用即可。
// src/composables/useCounter.ts
import { ref, computed } from "vue";
export function useCounter(initial = 0, step = 1) {
const count = ref(initial);
const double = computed(() => count.value * 2);
const inc = () => (count.value += step);
return { count, double, inc };
}
// useCounter.spec.ts
import { describe, it, expect } from "vitest";
import { useCounter } from "@/composables/useCounter";
describe("useCounter", () => {
it("按步长递增并同步派生值", () => {
const { count, double, inc } = useCounter(1, 2);
inc();
expect(count.value).toBe(3);
expect(double.value).toBe(6);
});
});
三个要点:
- 组合式函数里如果用到了
onMounted这类生命周期,必须在组件上下文里调用,否则会有警告;这类函数要用组件测试而不是纯单测。 - 依赖
inject的函数要传默认值或在测试里provide。 - 涉及
nextTick的断言要await nextTick(),不要用setTimeout。
三、组件测试:只关心契约
import { mount } from "@vue/test-utils";
import { describe, it, expect } from "vitest";
import OrderRow from "@/components/OrderRow.vue";
describe("OrderRow", () => {
const order = { id: "1", no: "SO-1", amount: 99, status: "paid" };
it("渲染单号与金额", () => {
const wrapper = mount(OrderRow, { props: { order } });
expect(wrapper.text()).toContain("SO-1");
expect(wrapper.text()).toContain("99");
});
it("点击退款按钮时发出事件并带上 id", async () => {
const wrapper = mount(OrderRow, { props: { order } });
await wrapper.get('[data-test="refund"]').trigger("click");
expect(wrapper.emitted("refund")?.[0]).toEqual(["1"]);
});
it("已退款状态不显示退款按钮", () => {
const wrapper = mount(OrderRow, {
props: { order: { ...order, status: "refunded" } },
});
expect(wrapper.find('[data-test="refund"]').exists()).toBe(false);
});
});
五条实践:
- 用
data-test选择元素,不要依赖 class 与层级,样式一改测试就碎。 - 异步操作后
await:Vue 的更新是异步的,trigger之后不 await 会读到旧 DOM。 - 断言事件用
emitted,不要断言父组件状态。 - 插槽用
slots传入,验证渲染位置而不是字符串包含。 - 需要真实网络时用 MSW 或
vi.mock,不要 mockfetch的返回值细节,那是在测实现。
覆盖率不是目标。80% 的行覆盖率里如果全是 getter,价值为零;关键判断分支(空态、错误态、权限)覆盖到比数字更重要。
四、构建:产物里有什么
跑完 npm run build,dist/ 通常包含:
dist/
├─ index.html # 入口,引用带 hash 的 JS/CSS
└─ assets/
├─ index-a1b2c3.js # 业务代码
├─ vendor-4d5e6f.js # 依赖(若手动分包)
├─ OrderList-7g8h9i.js # 路由懒加载的 chunk
└─ index-j1k2l3.css
三条分包策略:
| 策略 | 做法 | 适用 |
|---|---|---|
| 按路由 | component: () => import(...) | 默认就该做 |
| 按依赖 | manualChunks 把 vue、图表库单独拆出 | 依赖体积大且更新频率低 |
| 按体积 | 把 >200KB 的重组件改成异步 | 富文本、图表、地图 |
build: {
rollupOptions: {
output: {
manualChunks: {
vue: ["vue", "vue-router", "pinia"],
charts: ["echarts"],
},
},
},
}
注意:分包不是越细越好。拆成几十个 chunk 会让请求数上升,HTTP/2 下影响小,但在弱网下仍会拖慢首屏。原则是把"很少变"和"经常变"分开,让缓存命中率最大化。
五、缓存头:比分包更容易被忽略
| 资源 | 缓存策略 | 原因 |
|---|---|---|
index.html | no-cache(协商缓存) | 必须能拿到最新入口 |
assets/*.[hash].js | max-age=31536000, immutable | 文件名带 hash,内容变则名变 |
| 图片/字体 | 长缓存 | 同上,靠 hash 或版本目录 |
| API 响应 | 业务决定 | 不要用前端静态缓存策略 |
最常见的事故是"发了新版本,用户仍是旧页面":因为 index.html 被 CDN 缓存了。规则只有一条:入口 HTML 永不长缓存,带 hash 的静态资源永久缓存。
六、部署:history 路由必须配 fallback
createWebHistory() 下,用户直接访问 /orders/1 时,服务器会去找 orders/1 这个文件,找不到就 404。解决办法是把未命中的路径回退到 index.html:
server {
listen 80;
server_name example.com;
root /var/www/intervue;
index index.html;
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
location = /index.html {
add_header Cache-Control "no-cache";
}
location / {
try_files $uri $uri/ /index.html;
}
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
}
逐段解释:
location /assets/长缓存,靠文件名 hash 保证更新。location = /index.html关掉长缓存,保证发版后能拿到新入口。try_files是 fallback 的核心:文件存在就返回,否则交给index.html由前端路由接管。gzip至少要覆盖 JS/CSS/JSON,文本资源能压到 1/3 左右。
如果用了 hash 路由,则不需要 fallback,但代价是 URL 难看、SEO 不友好。
七、上线检查清单
npm run build无类型错误、无警告。npm run preview本地验证产物,重点看动态 import 与静态资源路径。- 环境变量确认:生产用的
VITE_API_BASE不是测试地址。 index.html缓存头为no-cache。- history 路由 fallback 已配,直接访问深层链接不 404。
- sourcemap 策略明确:对内的错误监控需要,对外发布则不要暴露源码。
- 首屏资源体积与 LCP 有基线数字,发版后对比。
八、常见坑
- 只写组件测试,组合式函数零覆盖,重构时没有任何保护。
- 测试里断言 class 名与 DOM 层级,改样式就红一片。
trigger后不await,断言到旧 DOM。- 用
vi.mock把整个请求模块替换掉,测试通过但线上接口改了没人知道。 - 分包过细,首屏请求数翻倍。
index.html被 CDN 长缓存,发版后用户看到旧页面。- history 路由没配 fallback,刷新 404。
- 生产环境保留 sourcemap 并公开可访问,源码泄露。
九、动手任务:自写 → AI 写 → 对比 → 取舍
四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。
① 自己写
- 给一个组合式函数写 3 个用例,覆盖正常、边界与异常输入。
- 给一个列表项组件写测试:渲染、点击发事件、空态不渲染按钮。
- 用
manualChunks把 vue 与图表库拆开,对比构建前后 chunk 数量与首屏体积。 - 写一份 nginx 配置,部署后验证:首页、深层链接刷新、静态资源缓存头三项。
② 让 AI 写同一题
把第 2 题(组件测试)交给 AI,补一句 用 @vue/test-utils 按可访问名称查询,不测内部实现。
③ 对比要盯的点
- 它 mock 掉 composable 了吗?mock 了就等于没测组件。
- 断言是“某段文字出现”,还是“某个 class 存在”?后者绑死了实现。
- nginx 配置它给 history 模式 fallback 了吗?不给就一刷新 404。
④ 一句话取舍
只改实现、不改行为时,它写的测试会红吗?
十、自测
- 三层测试各自适合测什么?
- 组件测试为什么必须
await trigger? - 为什么
index.html不能长缓存? - history 模式刷新 404 的根因是什么?
- 分包的原则是什么?
参考答案要点:单元测逻辑、组件测契约、E2E 测主流程;Vue 更新是异步的;入口文件名不变,长缓存会锁死旧版本;服务器把路径当文件找;把少变与常变分开。
附:CI 流水线的最小结构
本地能跑不等于能上线。一个够用的流水线只要六步,顺序不能乱:
1. 安装依赖(严格按锁文件,缓存依赖目录)
2. lint(eslint + prettier --check)
3. 类型检查(vue-tsc --noEmit)
4. 单元测试(vitest run)
5. 构建(vite build)
6. 产物归档 / 部署
三条原则:
- 先快后慢:lint 与类型检查最便宜,放在最前面,几秒钟就能拦下大部分问题。
- 失败即停:第 3 步失败就不要跑第 5 步,浪费机器时间也掩盖首个错误。
- 缓存依赖:把包管理器的全局缓存目录挂上,命中后安装从分钟级降到秒级。
E2E 放不放进来取决于团队:主流程 E2E 建议在合并前跑一次,而不是每次提交都跑。可以让它依赖“预览环境部署成功”这一事件,既真实又稳定。
还有一条容易被忽略:构建产物体积要设预算。在 CI 里对比本次与主干的 chunk 体积,超过阈值就提示,而不是等上线后才发现首屏多了 300KB。预算写进流水线,比写在文档里有效得多。
配套题目
★ 越多越该先背;点题目可看完整答案与深入版。
- ★★★★☆ Vite 为什么冷启动快?Vue 项目怎么做多环境配置?
- ★★★★☆ Vue 项目怎么做构建体积优化和首屏优化?
- ★★★★☆ SSR、SSG、CSR 怎么选?各自的成本和边界是什么?
提示:读完可以顺手做几道对应题目,检验是否真的理解