Vue 测试、性能与生产工程 / Vue Testing, Performance, and Production Engineering
📅 创建时间:2026-07-28 🏷️ 标签:#Vue #Nuxt #Testing #Vitest #Playwright #Performance #DevOps #Sentry 📚 前置知识:[[04-pinia-state-management]] [[06-nuxt-data-server-cache]]
📋 本章目标
- 掌握 Vue 项目的三层测试策略:Vitest 单元测试、Vue Test Utils 组件测试、Playwright E2E 测试
- 能够为 Composable、Pinia Store、组件交互和异步流程编写可靠的测试
- 理解 Nuxt 测试的特殊性:SSR 上下文、Server API、Runtime Config 和 fixtures 管理
- 深入 Vue 编译器优化机制:静态提升、Patch Flag、Block Tree 及其对运行时性能的影响
- 掌握
defineAsyncComponent、KeepAlive、v-memo、虚拟滚动等性能优化手段 - 能够在 Nuxt 项目中落地图片优化、字体优化、代码分割、懒加载和 CDN 缓存策略
- 学会使用构建分析工具定位包体积问题并执行依赖审计
- 理解 Node.js 部署、静态生成和 Edge Functions 三种部署模式的适用场景与权衡
- 建立 Sentry 错误追踪体系:Source Map 上传、错误分层、Vue 与 Nuxt 集成
专题扩展
第1部分:测试金字塔 —— Vue 版三层策略
1.1 为什么 Vue 需要专属测试分层
Vue 应用中的代码单元存在天然的"响应式上下文依赖"——一个 Composable 可能依赖 ref、watch、生命周期钩子、inject 甚至 Nuxt 的 useFetch。如果按传统前端测试方式将所有东西 mock 掉,测试就失去了验证"响应式系统正确运转"的意义。
Vue 的测试策略必须区分三个层级:
┌─────────────────────────────────────────────────────────────┐
│ Vue 测试金字塔 —— 三层策略 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────┐ │
│ │ E2E │ ← Playwright │
│ │ 5-10% │ 完整用户流程 │
│ └────┬────┘ 真实浏览器 │
│ │ │
│ ┌──────┴──────┐ │
│ │ 组件测试 │ ← Vue Test Utils │
│ │ 20-30% │ mount + 交互 + 断言 │
│ └──────┬──────┘ Props/Emits/Slots │
│ │ │
│ ┌─────────────┴─────────────┐ │
│ │ 单元测试 │ │
│ │ 60-70% │ ← Vitest │
│ │ Composable / Utils │ 纯逻辑 + 响应式 │
│ │ Pinia Store / 工具函数 │ 快速 + 隔离 │
│ └───────────────────────────┘ │
│ │
│ 原则:下层速度更快、更稳定、更容易定位问题 │
│ 不要用 E2E 覆盖所有边界条件 —— 那是单元测试的工作 │
│ │
└─────────────────────────────────────────────────────────────┘1.2 各层职责边界
| 层级 | 关注点 | 工具链 | 不该做的事 |
|---|---|---|---|
| 单元 | 纯函数、Composable 逻辑、Pinia Action/Getter、工具函数 | Vitest | 不 mount 组件、不访问 DOM |
| 组件 | Props/Emits/Slots 契约、用户交互、条件渲染、可访问性 | @vue/test-utils | 不发起真实网络请求、不测 E2E 流程 |
| E2E | 关键用户路径、跨页面流程、SSR 水合、真实 API | Playwright | 不测纯逻辑边界条件、不过度 mock |
1.3 Vitest 配置要点
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
// 使用 jsdom 或 happy-dom 模拟浏览器环境
environment: 'jsdom',
// 全局 API(describe/it/expect 无需导入)
globals: true,
// 每次测试文件运行前执行 setup
setupFiles: ['./test/setup.ts'],
// CSS 处理:测试中不关心样式
css: false,
},
})// test/setup.ts —— 全局 mock 和配置
import { config } from '@vue/test-utils'
// 关闭 Vue Test Utils 的 console 警告(按需)
config.global.stubs = {
transition: false,
'router-link': true,
}第2部分:Vue 组件测试模式
2.1 mount vs shallowMount
┌─────────────────────────────────────────────────────────────┐
│ mount() vs shallowMount() 决策树 │
├─────────────────────────────────────────────────────────────┤
│ │
│ shallowMount(Comp) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Comp │ │
│ │ ├── <stub-ChildA /> ← 子组件被 stub,不渲染内部 │ │
│ │ ├── <stub-ChildB /> │ │
│ │ └── <stub-ChildC /> │ │
│ │ │ │
│ │ ✅ 快:只测当前组件自身行为 │ │
│ │ ✅ 隔离:子组件变更不影响测试 │ │
│ │ ❌ 不验证父子交互的真实行为 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ mount(Comp) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Comp │ │
│ │ ├── ChildA (完整渲染) │ │
│ │ │ └── GrandChild (完整渲染) │ │
│ │ ├── ChildB (完整渲染) │ │
│ │ └── ChildC (完整渲染) │ │
│ │ │ │
│ │ ✅ 真实:验证父子组件间的完整交互 │ │
│ │ ❌ 慢:深层渲染成本高 │ │
│ │ ❌ 耦合:子组件变更可能破坏测试 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 默认原则:新组件优先 shallowMount │
│ 涉及插槽、provide/inject、emit 链路时切换到 mount │
│ │
└─────────────────────────────────────────────────────────────┘2.2 查找元素的正确姿势
import { mount, shallowMount } from '@vue/test-utils'
import UserCard from './UserCard.vue'
const wrapper = mount(UserCard, {
props: { user: { name: 'Alice', role: 'admin' } },
})
// ✅ 优先级 1:按用户可见文本查找(最稳定)
wrapper.getByText('Alice')
wrapper.findByText('Alice') // findByText 不存在时抛错
// ✅ 优先级 2:按 role + accessible name 查找
wrapper.getByRole('button', { name: 'Edit Profile' })
// ✅ 优先级 3:按 data-testid(有节制地使用)
wrapper.get('[data-testid="user-card"]')
// ⚠️ 优先级 4:按 CSS 选择器(容易因重构而断裂)
wrapper.get('.user-card .name')
// ✅ 查找子组件实例
wrapper.findComponent({ name: 'UserAvatar' })
wrapper.getComponent(UserAvatar)
// ❌ 避免:按 Vue 内部属性断言
// expect(wrapper.vm._props.user.name).toBe('Alice')2.3 触发事件与表单交互
import { mount } from '@vue/test-utils'
import SearchForm from './SearchForm.vue'
describe('SearchForm', () => {
it('emits search event with trimmed query on submit', async () => {
const wrapper = mount(SearchForm)
// 输入文本
const input = wrapper.getByRole('textbox', { name: 'Search' })
await input.setValue(' Vue Testing ')
// 提交表单
await wrapper.getByRole('button', { name: 'Search' }).trigger('click')
// 或者触发原生 submit 事件
// await wrapper.get('form').trigger('submit.prevent')
// 断言 emit
expect(wrapper.emitted('search')).toBeTruthy()
expect(wrapper.emitted('search')![0]).toEqual(['Vue Testing'])
// 断言 emit 次数
expect(wrapper.emitted('search')).toHaveLength(1)
})
it('clears input after successful search', async () => {
const wrapper = mount(SearchForm)
await wrapper.getByRole('textbox').setValue('test')
await wrapper.getByRole('button', { name: 'Search' }).trigger('click')
// 验证 input 已清空
const input = wrapper.getByRole('textbox') as HTMLInputElement
expect(input.element.value).toBe('')
})
})2.4 异步测试:nextTick 与 flushPromises
import { mount, flushPromises } from '@vue/test-utils'
import { nextTick } from 'vue'
import AsyncList from './AsyncList.vue'
describe('AsyncList', () => {
it('renders items after async fetch', async () => {
const wrapper = mount(AsyncList)
// 初始状态:loading
expect(wrapper.getByText('Loading...')).toBeTruthy()
// 方法一:flushPromises —— 等待所有 pending Promise 完成
await flushPromises()
// 方法二:nextTick —— 等待 Vue 完成一次 DOM 更新
// await nextTick()
expect(wrapper.getByText('Loading...')).toBeFalsy()
expect(wrapper.findAllByRole('listitem')).toHaveLength(3)
})
it('handles fetch error gracefully', async () => {
// 使用 vi.mock 在测试文件顶部 mock 数据源
const wrapper = mount(AsyncList)
await flushPromises()
expect(wrapper.getByText(/error/i)).toBeTruthy()
expect(wrapper.emitted('error')).toBeTruthy()
})
})2.5 Mock 策略
┌─────────────────────────────────────────────────────────────┐
│ Vue 测试 Mock 策略层级 │
├─────────────────────────────────────────────────────────────┤
│ │
│ │ 层级 │ 手段 │ 适用场景 │
│ ├────────┼──────────────────┼─────────────────────────────┤
│ │ 1 (优) │ vi.mock(模块) │ Composable、工具函数、API │
│ │ │ │ 完全控制返回值和时间线 │
│ ├────────┼──────────────────┼─────────────────────────────┤
│ │ 2 │ MSW (Mock │ 网络请求级 mock,组件不感知 │
│ │ │ Service Worker) │ 适合 E2E 和集成测试 │
│ ├────────┼──────────────────┼─────────────────────────────┤
│ │ 3 │ global.provide │ 注入 mock 的 provide 值 │
│ │ │ (mount 选项) │ 适合测试 inject 的场景 │
│ ├────────┼──────────────────┼─────────────────────────────┤
│ │ 4 (劣) │ 直接修改 │ 几乎不应该使用 │
│ │ │ window.fetch │ 难以恢复,测试间相互污染 │
│ │
│ 关键原则:mock 的是"边界",不是"实现细节" │
│ — mock HTTP 请求,不 mock fetch 函数的内部实现 │
│ — mock Pinia Store 暴露的方法,不 mock defineStore │
│ │
└─────────────────────────────────────────────────────────────┘// vi.mock 示例 —— 在测试文件顶层声明(自动提升)
import { describe, it, expect, vi } from 'vitest'
// mock 整个模块
vi.mock('@/composables/useAuth', () => ({
useAuth: vi.fn(() => ({
user: ref({ id: '1', name: 'Test User' }),
isAuthenticated: computed(() => true),
login: vi.fn(),
logout: vi.fn(),
})),
}))2.6 组件测试完整示例
<!-- ConfirmDialog.vue -->
<script setup lang="ts">
import { ref } from 'vue'
const props = withDefaults(defineProps<{
title: string
message: string
confirmLabel?: string
loading?: boolean
}>(), {
confirmLabel: 'Confirm',
loading: false,
})
const emit = defineEmits<{
confirm: []
cancel: []
}>()
</script>
<template>
<div role="dialog" aria-labelledby="dialog-title">
<h2 id="dialog-title">{{ title }}</h2>
<p>{{ message }}</p>
<button
:disabled="loading"
@click="emit('confirm')"
>
{{ loading ? 'Processing...' : confirmLabel }}
</button>
<button
:disabled="loading"
@click="emit('cancel')"
>
Cancel
</button>
</div>
</template>// ConfirmDialog.spec.ts
import { mount } from '@vue/test-utils'
import { describe, it, expect } from 'vitest'
import ConfirmDialog from './ConfirmDialog.vue'
describe('ConfirmDialog', () => {
const defaultProps = {
title: 'Delete Item',
message: 'Are you sure you want to delete this item?',
}
it('renders title and message', () => {
const wrapper = mount(ConfirmDialog, { props: defaultProps })
expect(wrapper.getByRole('dialog')).toBeTruthy()
expect(wrapper.getByText('Delete Item')).toBeTruthy()
expect(wrapper.getByText('Are you sure you want to delete this item?')).toBeTruthy()
})
it('emits confirm when confirm button is clicked', async () => {
const wrapper = mount(ConfirmDialog, { props: defaultProps })
await wrapper.getByRole('button', { name: 'Confirm' }).trigger('click')
expect(wrapper.emitted('confirm')).toHaveLength(1)
expect(wrapper.emitted('cancel')).toBeFalsy()
})
it('emits cancel when cancel button is clicked', async () => {
const wrapper = mount(ConfirmDialog, { props: defaultProps })
await wrapper.getByRole('button', { name: 'Cancel' }).trigger('click')
expect(wrapper.emitted('cancel')).toHaveLength(1)
expect(wrapper.emitted('confirm')).toBeFalsy()
})
it('disables buttons and shows loading text when loading', () => {
const wrapper = mount(ConfirmDialog, {
props: { ...defaultProps, loading: true },
})
const confirmBtn = wrapper.getByRole('button', { name: 'Processing...' })
expect(confirmBtn.element.disabled).toBe(true)
const cancelBtn = wrapper.getByRole('button', { name: 'Cancel' })
expect(cancelBtn.element.disabled).toBe(true)
})
it('renders custom confirm label', () => {
const wrapper = mount(ConfirmDialog, {
props: { ...defaultProps, confirmLabel: 'Yes, Delete' },
})
expect(wrapper.getByRole('button', { name: 'Yes, Delete' })).toBeTruthy()
})
})第3部分:Composable 测试
3.1 withSetup 模式
Composable 必须在 Vue 的 setup 上下文中运行才能使用生命周期钩子和响应式 API。withSetup 模式创建一个最小化的 Vue 应用实例作为测试宿主:
// test/utils/withSetup.ts
import { createApp } from 'vue'
/**
* 在隔离的 Vue 应用实例中运行 composable,
* 返回 composable 的返回值用于断言。
*/
export function withSetup<T>(composable: () => T): T {
let result!: T
const app = createApp({
setup() {
result = composable()
// 不渲染模板,立即返回
return () => null
},
})
app.mount(document.createElement('div'))
// 测试结束后卸载以触发清理
return result
}3.2 Composable 测试示例
// useCounter.ts
import { ref, computed, onUnmounted } from 'vue'
export function useCounter(initial = 0) {
const count = ref(initial)
const doubled = computed(() => count.value * 2)
let intervalId: ReturnType<typeof setInterval> | null = null
function increment() { count.value++ }
function decrement() { count.value-- }
function startAutoIncrement(ms = 1000) {
stopAutoIncrement()
intervalId = setInterval(() => count.value++, ms)
}
function stopAutoIncrement() {
if (intervalId !== null) {
clearInterval(intervalId)
intervalId = null
}
}
onUnmounted(() => stopAutoIncrement())
return { count, doubled, increment, decrement, startAutoIncrement, stopAutoIncrement }
}// useCounter.spec.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { nextTick } from 'vue'
import { useCounter } from './useCounter'
import { withSetup } from '@/test/utils/withSetup'
describe('useCounter', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('initializes with given value', () => {
const { count } = withSetup(() => useCounter(5))
expect(count.value).toBe(5)
})
it('increments and decrements', () => {
const { count, increment, decrement } = withSetup(() => useCounter(0))
increment()
expect(count.value).toBe(1)
decrement()
expect(count.value).toBe(0)
})
it('doubled is reactive derived value', () => {
const { count, doubled, increment } = withSetup(() => useCounter(3))
expect(doubled.value).toBe(6)
increment() // count → 4
expect(doubled.value).toBe(8)
})
it('auto increments at interval', () => {
const { count, startAutoIncrement } = withSetup(() => useCounter(0))
startAutoIncrement(1000)
vi.advanceTimersByTime(3000) // 3 ticks
expect(count.value).toBe(3)
})
it('stops auto increment', () => {
const { count, startAutoIncrement, stopAutoIncrement } = withSetup(() => useCounter(0))
startAutoIncrement(1000)
vi.advanceTimersByTime(2000)
expect(count.value).toBe(2)
stopAutoIncrement()
vi.advanceTimersByTime(5000)
expect(count.value).toBe(2) // 不再增长
})
})3.3 涉及 provide/inject 的 Composable
// useCurrentUser.ts —— 依赖父组件 provide 的 theme
import { inject, ref, type Ref } from 'vue'
export function useCurrentUser() {
const locale = inject<Ref<string>>('locale', ref('en'))
const user = ref({ name: 'Guest', preferences: { locale: locale.value } })
return { user, locale }
}// useCurrentUser.spec.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { ref, defineComponent } from 'vue'
import { useCurrentUser } from './useCurrentUser'
// 对于依赖 provide/inject 的 composable,
// 通过 mount 一个临时组件来提供上下文
function mountComposable(composable: () => unknown, options?: {
provide?: Record<string, unknown>
}) {
let result: unknown
const TestComponent = defineComponent({
setup() {
result = composable()
return () => null
},
})
mount(TestComponent, {
global: { provide: options?.provide },
})
return result!
}
describe('useCurrentUser', () => {
it('uses provided locale', () => {
const { user, locale } = mountComposable(
() => useCurrentUser(),
{ provide: { locale: ref('zh') } },
) as ReturnType<typeof useCurrentUser>
expect(locale.value).toBe('zh')
})
it('falls back to default locale', () => {
const { locale } = mountComposable(
() => useCurrentUser(),
) as ReturnType<typeof useCurrentUser>
expect(locale.value).toBe('en')
})
})3.4 Composable 测试清单
必须覆盖的场景:
┌─────────────────────────────────────────────────────────────┐
│ Composable 测试必备清单 │
├─────────────────────────────────────────────────────────────┤
│ │
│ □ 正常路径:给定合法输入,输出符合预期 │
│ □ 边界值:空值、零值、极大值、负数 │
│ □ 响应式输入变化:传入 ref 改变后输出同步更新 │
│ □ 错误路径:异步操作失败时的错误状态和降级行为 │
│ □ 竞态取消:快速连续调用时,旧请求的结果被丢弃 │
│ □ 清理:onUnmounted 中取消订阅、清除计时器、断开连接 │
│ □ SSR 安全:不在 setup 阶段访问 window/document │
│ □ 类型安全:TypeScript 类型推断正确,无 any 逃逸 │
│ │
└─────────────────────────────────────────────────────────────┘第4部分:Nuxt 测试
4.1 Nuxt 测试的特殊性
┌─────────────────────────────────────────────────────────────┐
│ Nuxt 测试 vs 纯 Vue 测试的差异 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 纯 Vue 组件测试: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ mount(Comp) → 没有路由、没有 auto-import │ │
│ │ 需要手动提供 router、store 等 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ Nuxt 测试环境 (@nuxt/test-utils): │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ mount(Comp) → 拥有完整 Nuxt 上下文 │ │
│ │ ✅ auto-import 可用 │ │
│ │ ✅ useRoute/useRouter 可用 │ │
│ │ ✅ useFetch/useAsyncData 可用 │ │
│ │ ✅ Runtime Config 可用 │ │
│ │ ✅ Server API 可直接测试 │ │
│ │ ✅ SSR 渲染与 hydration 可验证 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 关键代价:Nuxt 测试环境启动更重,但测试真实性大幅提高 │
│ │
└─────────────────────────────────────────────────────────────┘4.2 @nuxt/test-utils 配置
// nuxt.config.ts 中的测试相关配置
export default defineNuxtConfig({
// 测试期间自动注入的配置
$test: {
// 测试环境下使用内存数据库或 mock
runtimeConfig: {
databaseUrl: 'postgresql://localhost:5432/test_db',
},
},
})// test/setup-nuxt.ts
import { vi } from 'vitest'
// 为所有测试文件提供一致的 mock
vi.stubGlobal('useRuntimeConfig', () => ({
public: { apiBase: 'http://localhost:3000' },
// secret 字段不在 public 中,测试中不可访问
}))4.3 Server API 测试
// server/api/projects/[id].get.ts
import { describe, it, expect } from 'vitest'
import { createEvent } from 'h3'
describe('GET /api/projects/[id]', () => {
it('returns 404 for non-existent project', async () => {
const event = createEvent(
new Request('http://localhost/api/projects/non-existent-id'),
)
// 模拟认证上下文
event.context.user = { id: 'user-1', role: 'viewer' }
await expect(
getProjectById(event),
).rejects.toMatchObject({ statusCode: 404 })
})
it('returns sanitized project view for authorized user', async () => {
const event = createEvent(
new Request('http://localhost/api/projects/proj-001'),
)
event.context.user = { id: 'user-1', role: 'editor' }
const response = await getProjectById(event)
// 验证输出不泄露敏感字段
expect(response).not.toHaveProperty('internalNotes')
expect(response).not.toHaveProperty('costCenter')
expect(response).toMatchObject({
id: 'proj-001',
name: expect.any(String),
owner: { id: 'user-1', name: expect.any(String) },
})
})
it('rejects unauthenticated requests', async () => {
const event = createEvent(
new Request('http://localhost/api/projects/proj-001'),
)
// 不设置 event.context.user
await expect(
getProjectById(event),
).rejects.toMatchObject({ statusCode: 401 })
})
})4.4 E2E 测试 —— Playwright + Nuxt
┌─────────────────────────────────────────────────────────────┐
│ Playwright + Nuxt E2E 架构 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ playwright.config.ts │ │
│ │ ┌─────────────────────────────────────────────────┐ │ │
│ │ │ webServer: { │ │ │
│ │ │ command: 'nuxi build && nuxi preview', │ │ │
│ │ │ port: 3000, │ │ │
│ │ │ reuseExistingServer: !process.env.CI, │ │ │
│ │ │ } │ │ │
│ │ │ use: { │ │ │
│ │ │ baseURL: 'http://localhost:3000', │ │ │
│ │ │ } │ │ │
│ │ └─────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ 测试流程: │
│ ┌──────────┐ ┌───────────┐ ┌───────────┐ │
│ │ 启动 Nuxt │ → │ Playwright│ → │ 执行用户 │ │
│ │ (生产构建)│ │ 连接浏览器│ │ 操作流程 │ │
│ └──────────┘ └───────────┘ └───────────┘ │
│ │
│ 测试场景应覆盖: │
│ □ 首屏 SSR 内容完整性 │
│ □ 客户端 hydration 无警告 │
│ □ 路由导航与页面过渡 │
│ □ 表单提交 → Server API → 页面更新 │
│ □ 认证流程(登录 → 受保护页面 → 登出) │
│ □ 错误页面与降级 UI │
│ │
└─────────────────────────────────────────────────────────────┘// e2e/projects.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Projects Page', () => {
test('displays project list from SSR', async ({ page }) => {
await page.goto('/projects')
// 验证 SSR 渲染的 HTML 中已包含项目列表
// (不是客户端 fetch 后才渲染的空壳)
const projectCards = page.locator('[data-testid="project-card"]')
await expect(projectCards.first()).toBeVisible()
// 计数应该 > 0 且来自首屏 HTML
const count = await projectCards.count()
expect(count).toBeGreaterThan(0)
})
test('navigates to project detail', async ({ page }) => {
await page.goto('/projects')
await page.locator('[data-testid="project-card"]').first().click()
// 验证 URL 变化
await expect(page).toHaveURL(/\/projects\/[a-z0-9-]+/)
// 验证详情页内容
await expect(page.locator('h1')).not.toBeEmpty()
})
test('create project flow', async ({ page }) => {
await page.goto('/projects')
// 点击创建按钮
await page.getByRole('button', { name: 'New Project' }).click()
// 填写表单
await page.getByLabel('Project Name').fill('E2E Test Project')
await page.getByLabel('Description').fill('Created by Playwright')
// 提交
await page.getByRole('button', { name: 'Create' }).click()
// 验证成功提示
await expect(page.getByText('Project created')).toBeVisible()
// 验证重定向
await expect(page).toHaveURL(/\/projects\/[a-z0-9-]+/)
})
test('handles server error gracefully', async ({ page }) => {
// 模拟 Server API 返回 500
await page.route('**/api/projects**', route =>
route.fulfill({ status: 500, body: JSON.stringify({ error: 'Internal Server Error' }) }),
)
await page.goto('/projects')
// 验证错误 UI
await expect(page.getByText(/something went wrong/i)).toBeVisible()
await expect(page.getByRole('button', { name: /retry/i })).toBeVisible()
})
})4.5 Fixtures 管理
// e2e/fixtures/auth.fixture.ts
import { test as base } from '@playwright/test'
// 扩展 Playwright 基础 test,提供认证状态
export const test = base.extend<{
authenticatedPage: typeof base.prototype
}>({
authenticatedPage: async ({ page }, use) => {
// 通过 API 获取 token(比通过 UI 登录更快更稳定)
await page.goto('/api/auth/test-login', { waitUntil: 'commit' })
// 等待 cookie 设置完毕
await page.waitForTimeout(500)
await use(page)
},
})
export { expect } from '@playwright/test'第5部分:Vue 性能优化
5.1 编译器优化机制
┌─────────────────────────────────────────────────────────────┐
│ Vue 3 编译器优化 —— 三大核心机制 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 静态提升 (Static Hoisting) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 模板中的纯静态节点在 render 函数外创建, │ │
│ │ 组件每次重渲染时复用同一个 VNode,跳过 diff │ │
│ │ │ │
│ │ <div> │ │
│ │ <h1>Welcome</h1> ← 静态:提升到 render 外 │ │
│ │ <p>{{ dynamic }}</p> ← 动态:每次创建新 VNode │ │
│ │ </div> │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 2. Patch Flag (补丁标记) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 每个动态绑定被标记为特定 flag,diff 时跳过无关比较 │ │
│ │ │ │
│ │ <div :class="c" :id="id">{{ text }}</div> │ │
│ │ ↑ CLASS ↑ PROPS ↑ TEXT │ │
│ │ │ │
│ │ 生成的 VNode 携带 PatchFlag = CLASS | PROPS | TEXT │ │
│ │ diff 时只检查这三种类型,忽略 style/event 等无关项 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 3. Block Tree (区块树) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 将模板按结构稳定性切分为 Block │ │
│ │ │ │
│ │ <div> │ │
│ │ <Static /> ← Block Root │ │
│ │ <div v-if="show"> ← 条件分支 = 新 Block │ │
│ │ <p>{{ msg }}</p> ← Block 内动态节点 │ │
│ │ </div> │ │
│ │ <List :items="items"> ← List 内部是独立 Block │ │
│ │ ... │ │
│ │ </List> │ │
│ │ </div> │ │
│ │ │ │
│ │ diff 时只遍历 Block 内的动态节点,跳过 Block Root │ │
│ │ 这种扁平化遍历消除了传统递归 diff 的深层开销 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 结论:Vue 3 模板编译后的 diff 与手写 render 函数的 │
│ Virtual DOM diff 有本质不同 —— Block Tree 是 O(动态节点数) │
│ 而非 O(总节点数) │
│ │
└─────────────────────────────────────────────────────────────┘5.2 defineAsyncComponent —— 路由级与组件级懒加载
// 路由级懒加载(最常见)
const routes = [
{
path: '/dashboard',
// 构建时拆分为独立 chunk
component: () => import('@/pages/Dashboard.vue'),
},
{
path: '/reports',
component: () => import('@/pages/Reports.vue'),
},
]
// 组件级懒加载 —— 重型组件按需加载
import { defineAsyncComponent } from 'vue'
const HeavyChart = defineAsyncComponent({
loader: () => import('@/components/HeavyChart.vue'),
// 加载中显示的组件
loadingComponent: ChartSkeleton,
// 加载失败显示的组件
errorComponent: ChartError,
// 延迟展示 loading 的时间(ms),避免闪烁
delay: 200,
// 超时时间(ms)
timeout: 10000,
// 定义组件为可暂停(配合 Suspense)
suspensible: true,
})<!-- 配合 Suspense 使用 -->
<template>
<Suspense>
<template #default>
<HeavyChart :data="chartData" />
</template>
<template #fallback>
<ChartSkeleton />
</template>
</Suspense>
</template>5.3 KeepAlive —— 缓存组件实例
┌─────────────────────────────────────────────────────────────┐
│ KeepAlive 工作原理 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 不使用 KeepAlive: │
│ ┌──────────┐ 切换 ┌──────────┐ 切回 ┌──────┐ │
│ │ Tab A │ ──────────→ │ Tab B │ ──────────→ │ Tab A│ │
│ │ mounted │ unmount │ mounted │ unmount │ 重新 │ │
│ │ 状态丢失 │ │ │ │mount │ │
│ └──────────┘ └──────────┘ └──────┘ │
│ │
│ 使用 KeepAlive: │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ <KeepAlive :include="['TabA', 'TabB']" :max="5"> │ │
│ │ <component :is="currentTab" /> │ │
│ │ </KeepAlive> │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────┐ 切换 ┌──────────┐ 切回 ┌──────┐ │
│ │ Tab A │ ──────────→ │ Tab B │ ──────────→ │ Tab A│ │
│ │ mounted │ deactivated │ mounted │ deactivated │ acti-│ │
│ │ 状态保留 │ (缓存到内存)│ │ (A 保留) │ vated│ │
│ └──────────┘ └──────────┘ └──────┘ │
│ │
│ 适用场景:Tab 切换、列表→详情→返回列表保留滚动位置 │
│ 注意:max 限制缓存数量,超出时 LRU 淘汰最久未访问的实例 │
│ 生命周期:onActivated / onDeactivated(替代 mounted 逻辑) │
│ │
└─────────────────────────────────────────────────────────────┘5.4 v-memo —— 跳过子树更新
<template>
<!-- v-memo 接收依赖数组,只有依赖变化时才重新渲染子树 -->
<!-- 适用于:大列表中只在选中状态变化时更新的行 -->
<div
v-for="item in largeList"
:key="item.id"
v-memo="[item.id === selectedId]"
>
<!-- 大部分行 selected=false 不变,跳过 VNode 创建和 diff -->
<ExpensiveRow :item="item" :selected="item.id === selectedId" />
</div>
</template>// v-memo 也适用于手动优化条件区块
// 当 list 引用不变时,整个区块不被重新创建5.5 大型列表虚拟滚动
<!-- 使用 @tanstack/vue-virtual 实现虚拟滚动 -->
<script setup lang="ts">
import { useVirtualizer } from '@tanstack/vue-virtual'
import { ref, computed } from 'vue'
const props = defineProps<{
items: Array<{ id: string; label: string }>
}>()
const parentRef = ref<HTMLElement>()
const virtualizer = useVirtualizer(
computed(() => ({
count: props.items.length,
getScrollElement: () => parentRef.value,
estimateSize: () => 48, // 每行预估高度
overscan: 5, // 上下各多渲染 5 项
})),
)
</script>
<template>
<div
ref="parentRef"
class="virtual-scroll-container"
style="height: 600px; overflow: auto;"
>
<div
:style="{
height: `${virtualizer.getTotalSize()}px`,
position: 'relative',
}"
>
<div
v-for="virtualRow in virtualizer.getVirtualItems()"
:key="virtualRow.key"
:style="{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}"
>
<RowComponent
:item="items[virtualRow.index]"
:data-index="virtualRow.index"
/>
</div>
</div>
</div>
</template>5.6 性能优化清单
// ✅ 大型只读数据使用 shallowRef
const hugeConfig = shallowRef(loadMassiveConfig()) // 不深度代理
// ✅ 避免在模板中调用方法产生新引用
// ❌ <Child :options="buildOptions(item)" /> — 每次渲染新对象
// ✅ <Child :options="cachedOptions" /> — 稳定的引用
const cachedOptions = computed(() => buildOptions(currentItem.value))
// ✅ 合理使用 v-once 渲染一次性静态内容
// <div v-once>{{ expensiveComputation }}</div>
// ✅ 避免深度 watch 大对象
// ❌ watch(largeObj, cb, { deep: true }) — 递归遍历所有属性
// ✅ watch(() => largeObj.specific.field, cb) — 精确监听
// ✅ 第三方大型库隔离到客户端
// 在 Nuxt 插件中标记为 client-only:
// export default defineNuxtPlugin({
// name: 'heavy-chart-lib',
// setup() { /* ... */ },
// }, { client: true })第6部分:Nuxt 性能、构建、部署与错误追踪
6.1 Nuxt 性能优化全景
┌─────────────────────────────────────────────────────────────┐
│ Nuxt 性能优化 —— 六层架构 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 图片层 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ @nuxt/image → 自动格式转换(WebP/AVIF)、尺寸裁剪、 │ │
│ │ 响应式 srcset、懒加载、模糊占位符 │ │
│ │ │ │
│ │ <NuxtImg src="/hero.jpg" format="avif" │ │
│ │ sizes="sm:100vw md:50vw lg:400px" │ │
│ │ loading="lazy" placeholder /> │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 2. 字体层 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ @nuxt/fonts → 本地托管字体文件、子集化 (subset)、 │ │
│ │ font-display: swap、预加载关键字体 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 3. 代码层 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 动态 import() → 路由级拆分 + 组件级拆分 │ │
│ │ Lazy 前缀 → <LazyHeavyComponent /> 自动懒加载 │ │
│ │ 客户端插件 → 大型库不进入 SSR bundle │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 4. Hydration 层 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ LazyHydration → 延迟水合非关键组件 │ │
│ │ ClientOnly → 完全跳过 SSR 的组件 │ │
│ │ 减少 SSR payload → 只序列化客户端需要的数据 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 5. 网络层 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ CDN 缓存 → 静态资源带内容哈希 + 长缓存 │ │
│ │ 边缘缓存 → SWR (stale-while-revalidate) │ │
│ │ 预加载 → <NuxtLink prefetch> 智能预取 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 6. 数据层 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 并行请求 → 避免嵌套 await 瀑布 │ │
│ │ SSR payload 最小化 → 只返回 View Model 而非实体 │ │
│ │ 缓存策略 → routeRules 定义每条路由的缓存行为 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘6.2 Nuxt 代码分割策略
// nuxt.config.ts
export default defineNuxtConfig({
// 控制构建产物的分割粒度
vite: {
build: {
rollupOptions: {
output: {
// 手动分组:把重型依赖拆分到独立 chunk
manualChunks(id) {
if (id.includes('node_modules/echarts')) {
return 'echarts'
}
if (id.includes('node_modules/three')) {
return 'three'
}
if (id.includes('node_modules/@sentry')) {
return 'sentry'
}
},
},
},
},
},
})<!-- Nuxt 内置 Lazy 前缀:自动异步加载 -->
<template>
<!-- 只有当组件进入视口或被条件渲染时才加载 -->
<LazyMapViewer
v-if="showMap"
:center="coordinates"
/>
<!-- 等同于手动写法 -->
<!-- <MapViewer v-if="showMap" :center="coordinates" /> -->
<!-- 但 Nuxt 自动检测 Lazy 前缀并应用 defineAsyncComponent -->
</template>// 客户端专属插件:重型浏览器 SDK 不污染 SSR bundle
// plugins/chart.client.ts
export default defineNuxtPlugin(() => {
// ECharts 只在客户端加载
return {
provide: {
chart: {
async init(container: HTMLElement) {
const echarts = await import('echarts')
return echarts.init(container)
},
},
},
}
})6.3 打包分析
┌─────────────────────────────────────────────────────────────┐
│ Nuxt 构建分析工作流 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 生成分析报告: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ $ npx nuxi analyze │ │
│ │ → 启动 rollup-plugin-visualizer │ │
│ │ → 浏览器打开 treemap 可视化 │ │
│ │ → 识别体积异常的 chunk │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 2. 包体积审计 check list: │
│ □ 是否有整个 moment.js?→ 替换为 date-fns 或 dayjs │
│ □ 是否有 lodash 全量引入?→ import { debounce } from │
│ 'lodash-es' 而非 import _ from 'lodash' │
│ □ 是否有未使用的重型依赖?→ npx depcheck 检测 │
│ □ 是否有图标库全量导入?→ 按需导入或用 SVG sprite │
│ □ i18n 语言包是否按需加载?→ 懒加载语言文件 │
│ │
│ 3. Tree shaking 生效条件: │
│ □ 包在 package.json 中声明 sideEffects: false │
│ □ 使用 ES Module 导入语法 (import { X } from 'pkg') │
│ □ 没有动态 require() 或 side-effectful 顶层代码 │
│ │
│ 4. 依赖审计: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ $ npx depcheck │ │
│ │ → Unused dependencies: │ │
│ │ * axios (已迁移到 $fetch) │ │
│ │ * moment (已迁移到 date-fns) │ │
│ │ → Missing dependencies: │ │
│ │ (none) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘6.4 部署方案
// nuxt.config.ts —— 渲染模式决定部署策略
export default defineNuxtConfig({
// 按路由定义不同的渲染策略
routeRules: {
// 静态生成:适合内容不变的页面
'/': { prerender: true },
'/blog/**': { swr: 3600 }, // ISR: 1小时陈旧再验证
'/docs/**': { swr: 86400 }, // ISR: 1天
// SSR:适合个性化页面
'/dashboard/**': { ssr: true },
// CSR only:适合登录后的工具页面
'/app/**': { ssr: false },
// 纯静态
'/api/_health': { cors: true },
},
nitro: {
// 部署预设
preset: 'node-server', // 或 'cloudflare-pages' / 'vercel-edge'
},
})┌─────────────────────────────────────────────────────────────┐
│ 三种部署模式的决策矩阵 │
├─────────────────────────────────────────────────────────────┤
│ │
│ │ 模式 │ 命令 │ 适合场景 │
│ ├────────────┼──────────────────┼──────────────────────┤
│ │ Node.js │ nuxi build │ 需要完整 Node API │
│ │ 服务端 │ → PM2 / Docker │ WebSocket、文件系统 │
│ │ │ │ 长连接、复杂中间件 │
│ ├────────────┼──────────────────┼──────────────────────┤
│ │ 静态生成 │ nuxi generate │ 纯内容站点、文档站 │
│ │ │ → CDN 托管 │ 零服务端成本 │
│ │ │ │ 构建时数据已确定 │
│ ├────────────┼──────────────────┼──────────────────────┤
│ │ Edge │ nuxi build │ 全球低延迟 │
│ │ Functions │ → CF Workers / │ 无服务器运维 │
│ │ │ Vercel Edge │ 有限 Node API 子集 │
│ │
│ 通用部署检查清单: │
│ □ Runtime Config 的私有字段不进入 public │
│ □ CSP、CORS、安全头已审查 │
│ □ 静态资源带内容哈希 + 长缓存 │
│ □ 数据库迁移向前向后兼容 │
│ □ 健康检查端点 (/api/_health) 可用 │
│ □ 灰度发布和回滚策略已定义 │
│ │
└─────────────────────────────────────────────────────────────┘6.5 Docker 部署示例
# Dockerfile —— 多阶段构建
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npx nuxi build
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/.output ./
EXPOSE 3000
ENV NITRO_HOST=0.0.0.0
ENV NITRO_PORT=3000
CMD ["node", "server/index.mjs"]6.6 错误追踪 —— Sentry 集成
┌─────────────────────────────────────────────────────────────┐
│ Sentry 错误追踪架构 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 错误分层 │ │
│ │ │ │
│ │ 第1层:前端异常 │ │
│ │ ┌───────────────────────────────────────────────┐ │ │
│ │ │ @sentry/vue → Vue 错误边界捕获 │ │ │
│ │ │ - 组件渲染异常 │ │ │
│ │ │ - 未处理的 Promise rejection │ │ │
│ │ │ - 水合不匹配警告 │ │ │
│ │ │ - 用户交互触发的错误 │ │ │
│ │ └───────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ 第2层:服务端异常 │ │
│ │ ┌───────────────────────────────────────────────┐ │ │
│ │ │ @sentry/nuxt → Nitro Server 错误捕获 │ │ │
│ │ │ - Server API 异常 │ │ │
│ │ │ - 数据库查询失败 │ │ │
│ │ │ - SSR 渲染错误 │ │ │
│ │ │ - 上游 API 调用超时 │ │ │
│ │ └───────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ 第3层:关联与上下文 │ │
│ │ ┌───────────────────────────────────────────────┐ │ │
│ │ │ - Trace ID 串联前端 → Server API → 数据库 │ │ │
│ │ │ - 构建 SHA 标记发布版本 │ │ │
│ │ │ - Source Map 上传实现可读堆栈 │ │ │
│ │ │ - Breadcrumbs 记录用户操作路径 │ │ │
│ │ └───────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘// plugins/sentry.client.ts —— 客户端 Sentry 初始化
import * as Sentry from '@sentry/vue'
export default defineNuxtPlugin((nuxtApp) => {
const router = useRouter()
const config = useRuntimeConfig()
Sentry.init({
app: nuxtApp.vueApp,
dsn: config.public.sentryDsn,
environment: config.public.environment,
// 关联 Git 版本
release: config.public.releaseVersion,
// 采样率(生产环境降低以控制成本)
tracesSampleRate: config.public.environment === 'production' ? 0.1 : 1.0,
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
// Vue 集成选项
trackComponents: true, // 追踪组件生命周期
timeout: 2000,
// 数据清理:过滤敏感字段
beforeSend(event) {
// 移除 Cookie、Token、密码
if (event.request?.cookies) {
delete event.request.cookies
}
if (event.request?.headers?.['Authorization']) {
event.request.headers['Authorization'] = '[Redacted]'
}
return event
},
integrations: [
Sentry.browserTracingIntegration({ router }),
Sentry.replayIntegration({
maskAllText: false,
blockAllMedia: true,
}),
],
})
})// plugins/sentry.server.ts —— 服务端 Sentry 初始化
import * as Sentry from '@sentry/nuxt'
export default defineNitroPlugin((nitroApp) => {
const config = useRuntimeConfig()
Sentry.init({
dsn: config.sentryDsn, // 私密 DSN,不进入 public
environment: config.environment,
release: config.releaseVersion,
tracesSampleRate: 0.2,
// 只追踪 Server API,不追踪静态资源请求
integrations: [
Sentry.nitroIntegration(),
],
})
})6.7 Source Map 上传
// nuxt.config.ts —— 构建后自动上传 Source Map
export default defineNuxtConfig({
sourcemap: {
// 服务端:上传真实 source map
server: true,
// 客户端:只生成,上传到 Sentry 后删除
client: 'hidden',
},
hooks: {
'build:done': async () => {
// 构建完成后上传 source map 到 Sentry
const { execSync } = await import('node:child_process')
const release = process.env.CF_PAGES_COMMIT_SHA || 'dev'
execSync(
`npx sentry-cli releases files ${release} upload-sourcemaps ` +
`.output/public/_nuxt --url-prefix "~/_nuxt"`,
{ stdio: 'inherit' },
)
},
},
})6.8 生产观测日志规范
// server/utils/logger.ts
export interface LogEntry {
timestamp: string
level: 'info' | 'warn' | 'error'
traceId: string
route: string
userId?: string // 不记录姓名、邮箱等 PII
statusCode?: number
durationMs?: number
message: string
error?: {
name: string
message: string
// 绝不包含 stack trace(Source Map 已处理)
// 绝不包含敏感数据
}
}
// 每条日志必须过滤:
// ❌ Cookie、Authorization Header、Password、Token、SSN、Credit Card
// ✅ Trace ID、Route、Status Code、Duration、Error Name/Message核心总结
总结1:测试金字塔不是教条,是效率原则
底层多写、高层少写。Vitest 单元测试覆盖 60-70% 的边界条件和逻辑分支,Vue Test Utils 聚焦组件契约(Props/Emits/Slots),Playwright 只验证关键用户流程。当你在 E2E 中试图覆盖所有错误场景时,停下来想一想——这个断言能不能在 Vitest 中完成?
总结2:Vue 编译器的优化比你手动操作更可靠
静态提升、Patch Flag、Block Tree —— Vue 3 在编译期已经处理了大多数常见的性能问题。不要在 templates 中手工优化 diff,而是利用编译器特性:保持 Props 稳定引用、合理使用 v-once 和 v-memo、让 defineAsyncComponent 处理懒加载边界。
总结3:Nuxt 测试的价值在上下文真实性
@nuxt/test-utils 的启动成本值得付出——它验证的是"在真实 Nuxt 上下文中你的代码能否正常工作"。auto-import、路由、SSR payload、Runtime Config —— 这些在纯 Vue 测试中 mock 掉的东西,恰恰是最容易出错的集成点。
总结4:性能优化的第一性原理
优化前先测量 → 定位真正的瓶颈 → 用最小干预解决问题 → 再次测量验证
猜测瓶颈 确定性优化
❌ "我觉得这里慢" ✅ "Lighthouse 报告
TBT 3.2s,来自
echarts 的 480KB
| 首屏加载"总结5:生产环境的三个支柱
部署(正确的渲染模式 + CDN + 缓存策略)、观测(结构化日志 + Trace ID + Sentry 错误追踪)、安全(Runtime Config 隔离 + 认证授权 + 数据清理)。缺任何一根柱子,生产质量都是幻觉。
章节测试
测试1:mount vs shallowMount
以下哪个场景最适合使用 shallowMount? A. 测试一个表单组件,需要验证提交按钮的 disabled 状态 B. 测试父组件通过 slot 向子组件传递的内容 C. 测试一个多层嵌套的列表组件,需要验证所有层级的数据绑定 D. 测试一个通过 provide/inject 传递数据的父子组件链路
测试2:Patch Flag
Vue 3 编译器为 <div :class="c" :id="id"></div> 生成的 Patch Flag 包含哪些类型? A. 只有 CLASS —— 因为 class 是最常用的动态绑定 B. CLASS | STYLE | TEXT —— 包含所有可能的动态类型 C. CLASS | PROPS | TEXT —— 对应模板中的三种动态绑定 D. FULL_PROPS —— 因为有两个属性绑定就触发全量比较
测试3:Composable 清理
以下 Composable 存在什么隐患?
export function usePolling(fn: () => Promise<void>, interval = 5000) {
const timer = setInterval(fn, interval)
return { stop: () => clearInterval(timer) }
}A. 没有任何隐患,代码正确 B. 缺少 onUnmounted 清理,组件卸载后定时器继续运行 C. clearInterval 不应该在返回函数中调用 D. setInterval 应该在 onMounted 中调用
测试4:Nuxt 渲染模式
一个 SaaS 产品的 /pricing 页面每小时更新一次价格数据,需要良好 SEO,但不需要实时数据。最佳渲染策略是什么? A. ssr: true —— 每次请求都服务端渲染 B. ssr: false —— 纯客户端渲染,用 useFetch 获取数据 C. swr: 3600 —— SSR + ISR,缓存 1 小时后后台重新验证 D. prerender: true —— 构建时静态生成
测试5:Sentry beforeSend
Sentry 的 beforeSend 钩子中必须过滤哪些数据?(多选) A. Cookie 和 Authorization Header B. 用户输入的搜索关键词 C. 服务端返回的堆栈跟踪 D. 请求体中的密码字段 E. Trace ID
测试6:KeepAlive
以下关于 KeepAlive 的说法哪个是错误的? A. max 属性限制最大缓存实例数,超出时 LRU 淘汰 B. 被缓存的组件触发 onActivated 和 onDeactivated 而非 onMounted/onUnmounted C. KeepAlive 会缓存组件的 DOM 和响应式状态 D. KeepAlive 默认缓存所有动态组件,不需要手动指定 include/exclude
测试7:构建分析
运行 npx nuxi analyze 后发现 vendor chunk 中有完整的 lodash (70KB)。以下修复方案哪个最有效? A. 在 nuxt.config.ts 中设置 vite.build.minify: true B. 将 import _ from 'lodash' 改为 import { debounce } from 'lodash-es' C. 将 lodash 移到 devDependencies D. 使用 defineAsyncComponent 懒加载所有使用 lodash 的组件
参考答案
测试1答案
答案:A。shallowMount 适合测试组件自身行为(按钮 disabled 逻辑)。选项 B/C/D 都涉及父子交互,需要 mount 来验证真实的子组件渲染行为。
测试2答案
答案:C。:class 生成 CLASS flag,:id 生成 PROPS flag, 生成 TEXT flag。Patch Flag 是精确的——编译器只标记实际存在的动态绑定类型,不会过度标记。
测试3答案
答案:B。setInterval 在 Composable 调用时立即启动,但组件卸载时没有 onUnmounted(() => clearInterval(timer)) 来清理。即使返回了 stop() 函数,如果没有外部代码调用它,定时器会永远运行,可能导致内存泄漏和对已卸载组件状态的访问。
测试4答案
答案:C。swr: 3600(stale-while-revalidate)是最佳选择。它提供 SSR(SEO 友好),同时缓存 1 小时。缓存期间直接返回缓存的 HTML,后台异步重新生成新版本。选项 D 需要每次价格变更都重新构建,不现实;选项 A 浪费服务端资源;选项 B 牺牲 SEO。
测试5答案
答案:A 和 D。Cookie 和 Authorization Header 包含认证凭据,密码字段包含用户秘密——这两类绝不应离开浏览器。搜索关键词可以保留(用于上下文),堆栈跟踪正是 Sentry 要收集的数据,Trace ID 用于关联不做清洗。
测试6答案
答案:D。KeepAlive 默认缓存所有动态组件(未指定 include/exclude 时全部缓存)。这本身不是错误,但如果动态渲染的组件很多,不设 max 会导致内存持续增长。此外 KeepAlive 缓存的是组件实例(包括响应式状态和 VNode),而不是 DOM。
测试7答案
答案:B。lodash-es 是 ES Module 版本且声明了 sideEffects: false,Tree shaking 可以移除未使用的函数。选项 A 只是压缩但不减少函数数量;选项 C 不影响构建产物;选项 D 不能解决 vendors chunk 体积问题。
相关笔记
- [[04-pinia-state-management]] — Pinia Store 的测试策略
- [[06-nuxt-data-server-cache]] — Nuxt 数据层的缓存设计直接影响性能
- [[05-nuxt-routing-and-rendering]] — 渲染模式选择是性能优化的前提
- [[02-reactivity-and-composition-api]] — Composable 测试的基础
- [[../01-javascript-and-typescript/02-typescript-type-system]] — 测试中的类型安全
下一步学习
- [ ] 在你的项目中搭建 Vitest + Vue Test Utils 并为 3 个核心组件编写测试
- [ ] 配置 Playwright E2E,覆盖至少一个关键用户流程(登录 → 核心操作 → 结果验证)
- [ ] 运行
npx nuxi analyze分析你的 Nuxt 项目,找出体积前三的 chunk 并优化 - [ ] 集成 Sentry,验证 Source Map 上传和前端/服务端错误都能正确关联到源码行
- [ ] 为你的项目编写一份生产部署检查清单(渲染模式、安全头、健康检查、数据库迁移)
学习状态:🟡 开始学习