Skip to content
Gains Summary
Main Navigation 首页 / Home
C++ 编程 / C++ Programming
系统与高性能 / Systems & Performance
Web 开发 / Web Development
人工智能 / Artificial Intelligence
工业软件 / Industrial Software
其他内容 / Other Topics
C++ 编程 / C++系统与性能 / SystemsWeb 开发 / Web人工智能 / AI工业软件 / Industrial

外观

Sidebar Navigation

← Web 开发 / Web Development

Vue 生态 / Vue Ecosystem

1. Vue 生态知识体系 / Vue Ecosystem Knowledge System

2. Vue 组件、模板与编译原理 / Vue Components, Templates, and Compilation

3. Vue 3 响应式系统与 Composition API / Vue 3 Reactivity System and Composition API

4. Vue Router、表单与组件架构 / Vue Router, Forms, and Component Architecture

5. Pinia 状态管理与持久化 / Pinia State Management and Persistence

6. Nuxt 路由、渲染与项目结构 / Nuxt Routing, Rendering, and Project Structure

7. Nuxt 数据获取、Server API 与缓存 / Nuxt Data Fetching, Server APIs, and Caching

8. Vue 测试、性能与生产工程 / Vue Testing, Performance, and Production Engineering

本页目录

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 集成

专题扩展 ​

  • Vue 编译优化原理
  • Playwright 测试模式

第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
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

1.2 各层职责边界 ​

层级关注点工具链不该做的事
单元纯函数、Composable 逻辑、Pinia Action/Getter、工具函数Vitest不 mount 组件、不访问 DOM
组件Props/Emits/Slots 契约、用户交互、条件渲染、可访问性@vue/test-utils不发起真实网络请求、不测 E2E 流程
E2E关键用户路径、跨页面流程、SSR 水合、真实 APIPlaywright不测纯逻辑边界条件、不过度 mock

1.3 Vitest 配置要点 ​

ts
// 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,
  },
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ts
// test/setup.ts —— 全局 mock 和配置
import { config } from '@vue/test-utils'

// 关闭 Vue Test Utils 的 console 警告(按需)
config.global.stubs = {
  transition: false,
  'router-link': true,
}
1
2
3
4
5
6
7
8

第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           │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

2.2 查找元素的正确姿势 ​

ts
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')
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

2.3 触发事件与表单交互 ​

ts
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('')
  })
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

2.4 异步测试:nextTick 与 flushPromises ​

ts
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()
  })
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

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          │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
ts
// 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(),
  })),
}))
1
2
3
4
5
6
7
8
9
10
11
12

2.6 组件测试完整示例 ​

vue
<!-- 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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
ts
// 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()
  })
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57

第3部分:Composable 测试 ​

3.1 withSetup 模式 ​

Composable 必须在 Vue 的 setup 上下文中运行才能使用生命周期钩子和响应式 API。withSetup 模式创建一个最小化的 Vue 应用实例作为测试宿主:

ts
// 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
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

3.2 Composable 测试示例 ​

ts
// 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 }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
ts
// 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) // 不再增长
  })
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61

3.3 涉及 provide/inject 的 Composable ​

ts
// 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 }
}
1
2
3
4
5
6
7
8
9
10
ts
// 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')
  })
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

3.4 Composable 测试清单 ​

必须覆盖的场景:

┌─────────────────────────────────────────────────────────────┐
│               Composable 测试必备清单                         │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  □ 正常路径:给定合法输入,输出符合预期                      │
│  □ 边界值:空值、零值、极大值、负数                          │
│  □ 响应式输入变化:传入 ref 改变后输出同步更新               │
│  □ 错误路径:异步操作失败时的错误状态和降级行为               │
│  □ 竞态取消:快速连续调用时,旧请求的结果被丢弃              │
│  □ 清理:onUnmounted 中取消订阅、清除计时器、断开连接        │
│  □ SSR 安全:不在 setup 阶段访问 window/document             │
│  □ 类型安全:TypeScript 类型推断正确,无 any 逃逸            │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14

第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 测试环境启动更重,但测试真实性大幅提高        │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

4.2 @nuxt/test-utils 配置 ​

ts
// nuxt.config.ts 中的测试相关配置
export default defineNuxtConfig({
  // 测试期间自动注入的配置
  $test: {
    // 测试环境下使用内存数据库或 mock
    runtimeConfig: {
      databaseUrl: 'postgresql://localhost:5432/test_db',
    },
  },
})
1
2
3
4
5
6
7
8
9
10
ts
// test/setup-nuxt.ts
import { vi } from 'vitest'

// 为所有测试文件提供一致的 mock
vi.stubGlobal('useRuntimeConfig', () => ({
  public: { apiBase: 'http://localhost:3000' },
  // secret 字段不在 public 中,测试中不可访问
}))
1
2
3
4
5
6
7
8

4.3 Server API 测试 ​

ts
// 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 })
  })
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48

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                                        │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
ts
// 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()
  })
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61

4.5 Fixtures 管理 ​

ts
// 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'
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

第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(总节点数)                                           │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49

5.2 defineAsyncComponent —— 路由级与组件级懒加载 ​

ts
// 路由级懒加载(最常见)
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,
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
vue
<!-- 配合 Suspense 使用 -->
<template>
  <Suspense>
    <template #default>
      <HeavyChart :data="chartData" />
    </template>
    <template #fallback>
      <ChartSkeleton />
    </template>
  </Suspense>
</template>
1
2
3
4
5
6
7
8
9
10
11

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 逻辑)  │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

5.4 v-memo —— 跳过子树更新 ​

vue
<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>
1
2
3
4
5
6
7
8
9
10
11
12
ts
// v-memo 也适用于手动优化条件区块
// 当 list 引用不变时,整个区块不被重新创建
1
2

5.5 大型列表虚拟滚动 ​

vue
<!-- 使用 @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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52

5.6 性能优化清单 ​

ts
// ✅ 大型只读数据使用 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 })
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

第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 定义每条路由的缓存行为         │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49

6.2 Nuxt 代码分割策略 ​

ts
// 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'
            }
          },
        },
      },
    },
  },
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
vue
<!-- Nuxt 内置 Lazy 前缀:自动异步加载 -->
<template>
  <!-- 只有当组件进入视口或被条件渲染时才加载 -->
  <LazyMapViewer
    v-if="showMap"
    :center="coordinates"
  />

  <!-- 等同于手动写法 -->
  <!-- <MapViewer v-if="showMap" :center="coordinates" /> -->
  <!-- 但 Nuxt 自动检测 Lazy 前缀并应用 defineAsyncComponent -->
</template>
1
2
3
4
5
6
7
8
9
10
11
12
ts
// 客户端专属插件:重型浏览器 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)
        },
      },
    },
  }
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

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)                                             │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

6.4 部署方案 ​

ts
// 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'
  },
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
┌─────────────────────────────────────────────────────────────┐
│              三种部署模式的决策矩阵                            │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  │ 模式        │ 命令              │ 适合场景              │
│  ├────────────┼──────────────────┼──────────────────────┤
│  │ 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) 可用                          │
│  □ 灰度发布和回滚策略已定义                                  │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

6.5 Docker 部署示例 ​

dockerfile
# 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"]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

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 记录用户操作路径                 │  │   │
│  │  └───────────────────────────────────────────────┘  │   │
│  └─────────────────────────────────────────────────────┘  │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
ts
// 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,
      }),
    ],
  })
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
ts
// 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(),
    ],
  })
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

6.7 Source Map 上传 ​

ts
// 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' },
      )
    },
  },
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

6.8 生产观测日志规范 ​

ts
// 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

核心总结 ​

总结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     
                                          | 首屏加载"
1
2
3
4
5
6
7

总结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 存在什么隐患?

ts
export function usePolling(fn: () => Promise<void>, interval = 5000) {
  const timer = setInterval(fn, interval)
  return { stop: () => clearInterval(timer) }
}
1
2
3
4

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 上传和前端/服务端错误都能正确关联到源码行
  • [ ] 为你的项目编写一份生产部署检查清单(渲染模式、安全头、健康检查、数据库迁移)

学习状态:🟡 开始学习

最后更新于:

Pager
上一篇7. Nuxt 数据获取、Server API 与缓存 / Nuxt Data Fetching, Server APIs, and Caching

持续记录,持续成长

Copyright © Tidenflow