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

React 生态 / React Ecosystem

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

2. React 组件渲染、协调与数据流 / React Components, Rendering, and Data Flow

3. Hooks 深度剖析:状态、副作用与并发模式 / Hooks Deep Dive: State, Effects, and Concurrency

4. 路由、表单与组件架构 / Routing, Forms, and Component Architecture

5. React 状态管理:Redux Toolkit 与 Zustand / React State Management: Redux Toolkit and Zustand

6. Next.js App Router:路由、渲染与工程边界 / Next.js App Router, Routing, Rendering, and Engineering Boundaries

7. Next.js 数据获取、缓存与变更 / Next.js Data Fetching, Caching, and Mutations

8. TanStack Query 与服务端状态管理 / TanStack Query and Server State Management

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

本页目录

React 测试、性能与生产工程 / React Testing, Performance, and Production Engineering ​

📅 创建时间:2026-07-28 🏷️ 标签:#React #Testing #Performance #Observability #DevOps #Vitest #Playwright #Sentry #WebVitals 📚 前置知识:[[06-nextjs-data-cache-mutations]] [[07-tanstack-query-server-state]]


📋 本章目标 ​

  • 建立三层测试金字塔(Vitest / Testing Library / Playwright),理解每层覆盖什么、不能替代什么
  • 掌握组件测试的语义查询原则(getByRole > getByTestId)、用户事件模拟、异步等待和网络 Mock 策略
  • 使用 React DevTools Profiler、Lighthouse CI 和 Core Web Vitals 定位并量化性能瓶颈
  • 通过打包分析工具、代码分割和 Tree Shaking 控制生产包体积
  • 集成 Sentry + ErrorBoundary 构建结构化错误监控体系,理解 Source Map 上传策略
  • 制定可执行的部署检查清单:环境变量、构建验证、性能回归、可观测性确认
  • 落实生产环境最佳实践:压缩与缓存、CDN、安全头、健康检查端点

第1部分:测试金字塔 —— 三层策略与边界 ​

1.1 为什么需要分层 ​

测试不是在"写不写"之间选择,而是在"花多大代价、捕捉哪类缺陷"之间权衡。把所有验证压在 E2E 上会导致 CI 运行数十分钟、定位根因困难;把所有验证压在单元测试上则无法发现组件交互和端到端协议错误。

┌─────────────────────────────────────────────────────────────┐
│                      测试金字塔                               │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│                      ┌─────────┐                            │
│                      │   E2E    │  Playwright               │
│                      │  5-10%  │  关键业务路径              │
│                     ┌┴─────────┴┐                           │
│                     │  集成/组件  │  Testing Library + MSW    │
│                     │   25-35%  │  用户行为 + API 交互      │
│                    ┌┴───────────┴┐                          │
│                    │   单元测试    │  Vitest                  │
│                    │   55-65%    │ 纯函数、Reducer、工具    │
│                    └─────────────┘                          │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

1.2 第一层:Vitest 单元测试(纯逻辑) ​

所有不依赖 React 组件渲染的代码都属于这一层:状态 reducer、数据校验函数、格式化工具、业务规则函数、自定义 Hook 的纯逻辑部分。

tsx
// project.reducer.test.ts
import { describe, it, expect } from 'vitest'
import { projectReducer } from './project.reducer'

describe('projectReducer', () => {
  it('应添加项目到列表头部', () => {
    const state = { projects: [{ id: '1', title: 'Alpha' }] }
    const next = projectReducer(state, {
      type: 'PROJECT_ADDED',
      payload: { id: '2', title: 'Beta' },
    })
    expect(next.projects).toHaveLength(2)
    expect(next.projects[0].id).toBe('2')
  })

  it('发送中状态应标记 loading 为 true', () => {
    const state = { status: 'idle' as const }
    const next = projectReducer(state, { type: 'FETCH_STARTED' })
    expect(next.status).toBe('loading')
  })
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

单元测试的标准:一次只测一个行为;不涉及 DOM、网络或文件系统;运行时间以毫秒计;失败时能迅速定位到具体函数和输入。

1.3 第二层:React Testing Library 组件/集成测试 ​

这一层测试的是用户可观察的行为:渲染内容、交互后状态变化、可访问性语义。核心原则是查询方式越接近用户使用方式,测试越有价值。

tsx
// LoginForm.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { LoginForm } from './LoginForm'

it('提交有效凭据后显示欢迎信息', async () => {
  const user = userEvent.setup()
  render(<LoginForm />)

  await user.type(screen.getByLabelText('邮箱'), 'user@example.com')
  await user.type(screen.getByLabelText('密码'), 'p@ssw0rd')
  await user.click(screen.getByRole('button', { name: '登录' }))

  expect(await screen.findByText('欢迎回来')).toBeVisible()
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

注意上面测试没有断言 isLoading 状态变量或 handleSubmit 是否被调用 —— 它只断言用户最终看到的结果。这意味着重构内部实现(比如从 useState 改为 useReducer)不会破坏测试。

1.4 第三层:Playwright 端到端测试 ​

E2E 测试验证真实浏览器中的完整用户流程:路由跳转、API 调用、页面渲染、浏览器 API(如剪贴板、localStorage)。Playwright 可以拦截网络请求(page.route)来模拟后端响应,避免依赖真实服务。

tsx
// checkout.spec.ts
import { test, expect } from '@playwright/test'

test('完整结账流程', async ({ page }) => {
  await page.route('**/api/cart', route =>
    route.fulfill({ body: JSON.stringify({ items: [{ id: 1, name: 'Widget' }] }) })
  )

  await page.goto('/cart')
  await page.click('button:has-text("结算")')
  await page.fill('input[name="address"]', '上海市浦东新区')
  await page.click('button:has-text("确认下单")')

  await expect(page.locator('.order-success')).toContainText('下单成功')
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

E2E 覆盖的关键路径应控制在 5-10 个场景:注册/登录、核心业务流程、支付/结算、权限边界。不要用 E2E 覆盖所有表单验证或边界条件 —— 那是单元和集成测试的职责。

1.5 三层边界的反模式 ​

┌─────────────────────────────────────────────────────────────┐
│               测试反模式 → 正确替代                            │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ✗ 用 E2E 测试"空邮箱提示"                                  │
│    → 用 Testing Library 测试表单验证态                       │
│                                                             │
│  ✗ 用组件测试覆盖所有 Reducer 分支                           │
│    → 用 Vitest 测试纯 Reducer 函数                           │
│                                                             │
│  ✗ 用 snapshot 测试"渲染未变"替代行为验证                    │
│    → 断言具体文本、角色和可见状态                             │
│                                                             │
│  ✗ 为每个 useEffect 单独写测试                              │
│    → 通过触发交互观察最终渲染结果来覆盖 Effect               │
│                                                             │
│  ✗ Mock 整个模块只为测试一个函数                             │
│    → 拆分纯逻辑与副作用,纯逻辑部分单独测试                   │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

第2部分:组件测试最佳实践 ​

2.1 语义查询优先级 ​

Testing Library 的查询方法按推荐度排列。越靠前的查询越接近用户(和辅助技术)与页面交互的方式,越能抵抗实现细节变更。

┌─────────────────────────────────────────────────────────────┐
│              查询优先级(高 → 低)                            │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. getByRole / findByRole                                  │
│     反映 ARIA 角色:button、heading、textbox、listitem       │
│     screen.getByRole('button', { name: '提交' })            │
│                                                             │
│  2. getByLabelText / findByLabelText                        │
│     匹配 <label> 关联的表单控件                              │
│     screen.getByLabelText('邮箱地址')                        │
│                                                             │
│  3. getByPlaceholderText                                    │
│     适用没有 label 但有 placeholder 的输入框                 │
│                                                             │
│  4. getByText / findByText                                  │
│     匹配显示的文本内容                                       │
│                                                             │
│  5. getByDisplayValue                                       │
│     匹配表单控件的当前值                                     │
│                                                             │
│  6. getByAltText                                            │
│     匹配图片 alt 属性                                       │
│                                                             │
│  7. getByTitle                                              │
│     匹配 title 属性(不推荐,屏幕阅读器不一定读取)          │
│                                                             │
│  8. getByTestId                                             │
│     最后手段:仅当以上都无法定位时使用                       │
│     data-testid="submit-btn"                                │
│                                                             │
└─────────────────────────────────────────────────────────────┘
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

getByTestId 是逃生舱,不是首选。当组件没有可访问的语义标记(没有 label、没有 role、没有文本)时才使用。如果你的代码里 getByTestId 占总查询 20% 以上,通常意味着可访问性需要改进。

2.2 userEvent vs fireEvent ​

┌─────────────────────────────────────────────────────────────┐
│              fireEvent  vs  userEvent                        │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  fireEvent.change(input, { target: { value: 'x' } })       │
│    → 直接派发单个 DOM 事件                                   │
│    → 不会触发 blur/focus/input 等关联事件                    │
│    → 不会模拟浏览器默认行为                                  │
│                                                             │
│  userEvent.type(input, 'x')                                 │
│    → 模拟完整用户输入序列:focus → keyDown → keyUp → input   │
│    → 触发所有关联事件(change、blur 等)                     │
│    → 更接近真实浏览器行为                                    │
│    → 需要 userEvent.setup() 初始化                           │
│                                                             │
│  原则:有 userEvent 就用 userEvent,                          │
│        除非需要精确控制单个事件的特殊场景                     │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
tsx
// ❌ fireEvent:缺少完整交互序列
fireEvent.change(screen.getByLabelText('搜索'), {
  target: { value: 'React' },
})
// 不会触发可能存在的 debounce、onFocus 等逻辑

// ✅ userEvent:模拟真实用户行为
const user = userEvent.setup()
await user.click(screen.getByLabelText('搜索'))   // 先聚焦
await user.keyboard('React')                       // 逐字符输入
await user.tab()                                   // 失焦触发 blur/change
1
2
3
4
5
6
7
8
9
10
11

2.3 异步等待三件套:findBy / waitFor / waitForElementToBeRemoved ​

tsx
// findBy*:带超时的查询(默认 1000ms),返回 Promise
const successMsg = await screen.findByText('操作成功')

// waitFor:等待任意断言成立,适合非 DOM 副作用
await waitFor(() => {
  expect(mockFn).toHaveBeenCalledWith({ id: '42' })
})

// waitForElementToBeRemoved:等待元素从 DOM 中消失
await waitForElementToBeRemoved(() => screen.queryByText('加载中...'))
1
2
3
4
5
6
7
8
9
10

关键区别:findBy* 内部使用 waitFor + getBy*,适用于等待元素出现;waitFor 更通用,适用于等待回调调用、状态变更等非 DOM 断言。不要用 sleep(500) 替代任何异步等待 —— 它让测试变慢且不可靠。

2.4 网络 Mock 策略:MSW vs jest.mock ​

生产级应用需要测试的不只是"API 返回了什么",而是"网络层的行为和时序"。直接 mock fetch 或 axios 实例会跳过请求构造、header 设置、错误序列化等关键环节。

┌─────────────────────────────────────────────────────────────┐
│              Mock 策略对比                                    │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  jest.mock('./api')                                         │
│  ├─ 替换整个模块                                             │
│  ├─ 无法验证请求体、header、调用次数                          │
│  ├─ 与测试文件紧耦合                                         │
│  └─ 不同测试间 mock 重置容易出错                              │
│                                                             │
│  MSW (Mock Service Worker)                                  │
│  ├─ 在 Service Worker 层拦截真实请求                         │
│  ├─ 可验证请求 URL、method、body、headers                    │
│  ├─ 可在组件测试、E2E 和本地开发间复用同一组 handler          │
│  └─ 支持网络错误、超时、响应序列等真实场景模拟                │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
tsx
// msw 请求处理:定义在独立文件,开发/测试复用
import { http, HttpResponse } from 'msw'

export const handlers = [
  http.get('/api/projects', () =>
    HttpResponse.json([{ id: '1', title: 'Project Alpha' }])
  ),

  http.post('/api/projects', async ({ request }) => {
    const body = await request.json()
    if (!body.title) {
      return HttpResponse.json({ error: '缺少标题' }, { status: 422 })
    }
    return HttpResponse.json({ id: '2', ...body }, { status: 201 })
  }),

  http.get('/api/projects/:id', ({ params }) =>
    HttpResponse.json({ id: params.id, title: 'Detail' })
  ),
]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
tsx
// 测试文件中用 server.use 覆盖特定场景
import { server } from '@/mocks/server'

it('网络错误时显示重试按钮', async () => {
  server.use(
    http.get('/api/projects', () => HttpResponse.error())
  )
  render(<ProjectList />)
  expect(await screen.findByText('加载失败,请重试')).toBeVisible()
})

it('422 校验错误时显示字段提示', async () => {
  server.use(
    http.post('/api/projects', () =>
      HttpResponse.json({ error: '标题已存在' }, { status: 422 })
    )
  )
  const user = userEvent.setup()
  render(<CreateProject />)
  await user.click(screen.getByRole('button', { name: '创建' }))
  expect(await screen.findByText('标题已存在')).toBeVisible()
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

2.5 请求竞态与取消测试 ​

MSW 可以精确控制响应时序来验证竞态行为:

tsx
it('快速切换搜索条件时只显示最后一次结果', async () => {
  const { deferred, resolve } = createDeferred()

  server.use(
    http.get('/api/search', () => deferred)
  )

  const user = userEvent.setup()
  render(<SearchPage />)

  await user.type(screen.getByRole('searchbox'), 'React')
  await user.clear(screen.getByRole('searchbox'))
  await user.type(screen.getByRole('searchbox'), 'Vue')

  // 只解析最后一个请求
  resolve(HttpResponse.json([{ name: 'Vue' }]))

  expect(await screen.findByText('Vue')).toBeVisible()
  // 第一个请求的结果不会出现
  expect(screen.queryByText('React')).not.toBeInTheDocument()
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

第3部分:性能剖析 —— 测量先于优化 ​

3.1 性能瓶颈分类模型 ​

在优化任何代码之前,先确定瓶颈属于哪一类。不同类别需要不同工具和策略。

┌─────────────────────────────────────────────────────────────┐
│              性能瓶颈分类 → 诊断工具                           │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  网络瀑布 / 请求瀑布                                         │
│  ├─ 工具:Chrome Network、Lighthouse、WebPageTest           │
│  ├─ 特征:串行请求、重复请求、过大 Payload                    │
│  └─ 策略:并行请求、缓存、BFF 聚合、GraphQL                   │
│                                                             │
│  JavaScript 解析与执行                                       │
│  ├─ 工具:Coverage Panel、Bundle Analyzer、Performance Tab  │
│  ├─ 特征:长任务(>50ms)、主线程阻塞、大第三方库               │
│  └─ 策略:代码分割、Tree Shaking、懒加载、Worker             │
│                                                             │
│  React 渲染范围                                              │
│  ├─ 工具:React DevTools Profiler、why-did-you-render       │
│  ├─ 特征:不必要的重渲染、Context 广播、大组件树              │
│  └─ 策略:memo、状态下沉、Context 拆分、Immutable            │
│                                                             │
│  DOM / CSS 布局                                              │
│  ├─ 工具:Performance Tab → Recalculate Style / Layout      │
│  ├─ 特征:强制同步布局、Layout Thrashing、大型 DOM 树        │
│  └─ 策略:虚拟列表、content-visibility、批量 DOM 操作        │
│                                                             │
│  服务端 TTFB                                                 │
│  ├─ 工具:Server-Timing 响应头、APM 工具                     │
│  ├─ 特征:慢数据库查询、上游超时、冷启动                       │
│  └─ 策略:缓存、连接池、边缘部署、Streaming                   │
│                                                             │
└─────────────────────────────────────────────────────────────┘
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

3.2 React DevTools Profiler ​

Profiler 是 React 性能分析的核心工具,不需要任何代码修改:

  1. 安装 React DevTools 浏览器扩展
  2. 切换到 Profiler 面板,点击录制按钮
  3. 执行目标交互(点击、输入、导航)
  4. 停止录制,分析火焰图和排名列表
┌─────────────────────────────────────────────────────────────┐
│              Profiler 火焰图阅读指南                          │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  每根火焰条宽度  = 该组件本次渲染耗时                         │
│  灰色火焰条      = 组件未重新渲染(memo 生效或 props 未变)  │
│  黄色/橙色火焰条  = 组件重新渲染了                            │
│                                                             │
│  优先关注:                                                  │
│  1. 渲染次数最多的组件(Ranked 视图顶部)                     │
│  2. 单次渲染耗时最长的组件                                   │
│  3. 父组件更新导致大量子组件不必要重渲染的情况                │
│  4. "Render reasons":是什么导致本次渲染                      │
│     - Props changed → 哪个 prop                              │
│     - Hooks changed → 哪个 Hook state 更新                   │
│     - Context changed → 哪个 Context.Provider 的值变了       │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

3.3 Lighthouse CI 集成 ​

将 Lighthouse 性能审计集成到 CI 流程中,阻止性能回归:

tsx
// .github/workflows/lighthouse.yml 核心配置
// lighthouse 配置文件:lighthouserc.js
module.exports = {
  ci: {
    collect: {
      url: ['http://localhost:3000/', 'http://localhost:3000/projects'],
      numberOfRuns: 3,
      startServerCommand: 'npm run start',
    },
    assert: {
      preset: 'lighthouse:recommended',
      assertions: {
        'categories:performance': ['error', { minScore: 0.9 }],
        'first-contentful-paint': ['error', { maxNumericValue: 2000 }],
        'largest-contentful-paint': ['error', { maxNumericValue: 3000 }],
        'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
        'total-blocking-time': ['error', { maxNumericValue: 300 }],
      },
    },
    upload: {
      target: 'temporary-public-storage',
    },
  },
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

3.4 Core Web Vitals:不止 LCP ​

┌─────────────────────────────────────────────────────────────┐
│           Core Web Vitals 三指标                              │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  LCP (Largest Contentful Paint)   目标:< 2.5s              │
│  ├─ 测量:最大可见内容元素渲染完成时间                        │
│  ├─ 优化:预加载关键图片/字体、服务端推送、CDN                │
│  └─ 注意:LCP 元素可能是图片、文本块或背景图                  │
│                                                             │
│  INP (Interaction to Next Paint)   目标:< 200ms            │
│  ├─ 测量:用户交互到下一帧绘制的延迟                          │
│  ├─ 优化:拆分长任务、减少主线程工作、使用 Web Worker         │
│  └─ 注意:INP 替代了 FID,衡量整个会话的交互延迟              │
│                                                             │
│  CLS (Cumulative Layout Shift)     目标:< 0.1              │
│  ├─ 测量:整个生命周期中意外布局偏移的累积                    │
│  ├─ 优化:给图片/视频/广告预留尺寸、字体加载策略              │
│  └─ 注意:用户交互触发的 0.5s 内的偏移不计入                  │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

在 Next.js 中收集 Web Vitals:

tsx
// app/layout.tsx — 使用 @next/third-parties 或 web-vitals 库
import { WebVitals } from '@next/third-parties/google'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="zh-CN">
      <body>{children}</body>
      <WebVitals
        onReport={({ id, name, value, rating }) => {
          // 发送到自定义分析端点或 Sentry
          fetch('/api/analytics/vitals', {
            method: 'POST',
            body: JSON.stringify({ id, name, value, rating }),
            keepalive: true,
          })
        }}
      />
    </html>
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

3.5 React.memo / useMemo 的实际收益测量 ​

memo、useMemo、useCallback 不是免费的性能优化。每次渲染时 React 需要比较依赖数组,memo 还需要浅比较所有 props。只有当比较成本 < 跳过渲染节省的成本时才划算。

tsx
// 测量 memo 的实际效果:在 Profiler 中对比
function ExpensiveList({ items }: { items: Item[] }) {
  // 假设这里有昂贵的派生计算
  const sorted = useMemo(() => {
    const start = performance.now()
    const result = items.toSorted(byPriority)
    const duration = performance.now() - start
    if (duration > 5) {
      console.warn(`[ExpensiveList] 排序耗时 ${duration.toFixed(1)}ms`)
    }
    return result
  }, [items])

  return <ul>{sorted.map(item => <ListItem key={item.id} item={item} />)}</ul>
}

// 性能优化的前置条件检查清单:
// ☐ 1. 已在 Profiler 中确认该组件确实在频繁重渲染
// ☐ 2. 已测量跳过渲染 vs 浅比较的成本
// ☐ 3. Props 大部分渲染中保持引用稳定
// ☐ 4. 子组件树足够大(跳过一次渲染省 1ms 以上)
// 如果以上任一条件不满足,不要加 memo
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

Context 导致的无效渲染是 memo 失效的主要场景。拆分 Context 比给所有消费者加 memo 更有效:

tsx
// ❌ 一个 Context 包含所有状态 → 任何字段变化都触发所有消费者
const AppContext = createContext({ user: null, theme: 'light', locale: 'zh' })

// ✅ 拆分为独立 Context → 只有相关消费者重渲染
const UserContext = createContext<User | null>(null)
const ThemeContext = createContext<'light' | 'dark'>('light')
const LocaleContext = createContext<string>('zh')
1
2
3
4
5
6
7

第4部分:打包分析与代码分割 ​

4.1 为什么包体积影响性能 ​

JavaScript 的代价不只在下载 —— 下载后还需解析、编译和执行。在低端设备上,1MB 的 JS 可能需要 1-2 秒才能执行完毕。包体积分析的目标是发现并消除未使用代码、重复依赖和过大的第三方库。

4.2 Bundle Analyzer 集成 ​

bash
# 安装
npm install -D @next/bundle-analyzer

# next.config.ts
import withBundleAnalyzer from '@next/bundle-analyzer'

const withAnalyzer = withBundleAnalyzer({
  enabled: process.env.ANALYZE === 'true',
})

export default withAnalyzer({
  // ... 其他配置
})
1
2
3
4
5
6
7
8
9
10
11
12
13
bash
# 运行分析
ANALYZE=true npm run build
1
2
┌─────────────────────────────────────────────────────────────┐
│          Bundle Analyzer 分析要点                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. 找最大的 chunk 块                                        │
│     → 是否有整个库被打进首屏包?                              │
│                                                             │
│  2. 找重复打包的模块                                         │
│     → 两个 chunk 包含同一库的不同版本?                       │
│     → 同一工具函数被重复打包到 5 个 chunk?                   │
│                                                             │
│  3. 找 node_modules 占比                                     │
│     → 第三方代码占总包体积比例是否合理?                      │
│     → 是否有可以替换的更小替代品?                            │
│                                                             │
│  4. 找 moment.js / lodash 全量引入                           │
│     → moment 所有 locale 都打进包了?                         │
│     → lodash 用 import { debounce } 而非 import debounce     │
│        from 'lodash/debounce'?                              │
│                                                             │
│  5. 找未 Tree Shaken 的导出                                  │
│     → CJS 模块不能被 Tree Shake                              │
│     → 带副作用(sideEffects)的模块跳过了 Tree Shaking       │
│                                                             │
└─────────────────────────────────────────────────────────────┘
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

4.3 动态导入与代码分割 ​

Next.js 通过文件系统路由自动进行路由级代码分割。对于组件级分割,使用 dynamic + Suspense。

tsx
import dynamic from 'next/dynamic'
import { Suspense } from 'react'

// 组件级代码分割:只在需要时加载
const HeavyChart = dynamic(() => import('./HeavyChart'), {
  loading: () => <ChartSkeleton />,
  ssr: false,  // 如果是纯客户端库(依赖 window),跳过 SSR
})

const MarkdownEditor = dynamic(() => import('./MarkdownEditor'), {
  loading: () => <EditorSkeleton />,
})

export function Dashboard() {
  return (
    <div>
      <SummaryCards />
      <Suspense fallback={<ChartSkeleton />}>
        <HeavyChart data={data} />
      </Suspense>
      <Suspense fallback={<EditorSkeleton />}>
        <MarkdownEditor content={content} />
      </Suspense>
    </div>
  )
}
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

分割的时机与粒度:

┌─────────────────────────────────────────────────────────────┐
│            代码分割的决策                                       │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  适合分割:                                                  │
│  ├─ 重型可视化库(echarts、d3、three.js)                    │
│  ├─ 富文本编辑器(Tiptap、Quill、Monaco)                    │
│  ├─ 复杂表单组件(多步骤表单)                                │
│  ├─ 模态框/抽屉内容(用户不打开就不加载)                     │
│  ├─ 非首屏的内容区域(折叠面板、Tab 内容)                    │
│  └─ 第三方集成(地图、视频播放器、聊天窗口)                  │
│                                                             │
│  不适合分割:                                                │
│  ├─ 首屏立即可见的内容(分割只会增加请求数)                  │
│  ├─ 很小的组件(<5KB,分割开销大于收益)                      │
│  ├─ 被多个路由共享的 UI 组件                                 │
│  └─ 已经在路由级分割的页面内的子组件(过度分割)              │
│                                                             │
│  粒度建议:                                                  │
│  ├─ 路由级:Next.js 自动处理(不需要手动做)                  │
│  ├─ 功能级:每个重型功能一个 chunk                           │
│  ├─ 库级:大第三方库独立 chunk                               │
│  └─ 避免:每个小组件各自一个 chunk(HTTP/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

4.4 Tree Shaking 的前提条件 ​

Tree Shaking 不是自动发生的。它要求:

  1. ESM 模块格式 —— import { X } from 'pkg' 而非 const X = require('pkg').X。CJS 是动态导入,打包器无法在构建时确定会用到哪些导出。
  2. package.json 声明 "sideEffects": false —— 告诉打包器"这个包的模块都没有副作用,可以安全删除未使用的导出"。有副作用(如 CSS 导入、polyfill 注入)的文件需要在数组中排除。
  3. 使用具名导入 —— import { debounce } from 'lodash-es' 而非 import _ from 'lodash'。
  4. 避免动态属性访问 —— obj[dynamicKey]() 这种写法打包器无法静态分析。
tsx
// ❌ 无法 Tree Shake
const _ = require('lodash')                 // CJS
import _ from 'lodash'                       // 默认导入整个库
import('some-module').then(m => m.fn())      // 动态导入全量

// ✅ 可以 Tree Shake
import { debounce } from 'lodash-es'         // 具名导入 + ESM
import debounce from 'lodash/debounce'       // 子路径导入
1
2
3
4
5
6
7
8

4.5 包体积预算 ​

在 CI 中设置包体积预算,阻止体积意外膨胀:

tsx
// bundlesize.config.js
module.exports = {
  files: [
    {
      path: './.next/static/chunks/**/*.js',
      maxSize: '250kB',     // 单个 chunk 限制
    },
    {
      path: './.next/static/chunks/framework*.js',
      maxSize: '120kB',     // 框架核心
    },
    {
      path: './.next/static/css/**/*.css',
      maxSize: '50kB',      // CSS 总大小
    },
  ],
  ci: {
    trackBranches: ['main'],
  },
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
json
// package.json
{
  "scripts": {
    "build": "next build",
    "analyze": "ANALYZE=true next build",
    "size": "bundlesize"
  }
}
1
2
3
4
5
6
7
8

第5部分:错误监控与结构化上报 ​

5.1 错误监控的四个层次 ​

┌─────────────────────────────────────────────────────────────┐
│              错误监控体系                                      │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  第1层:ErrorBoundary(React 组件树崩溃边界)                 │
│  ├─ 捕获渲染期间抛出的错误                                    │
│  ├─ 显示降级 UI 而非白屏                                      │
│  └─ 不能捕获:事件处理、异步代码、SSR、服务端错误             │
│                                                             │
│  第2层:全局错误监听(未捕获异常的最后防线)                   │
│  ├─ window.onerror / window.addEventListener('error')       │
│  ├─ window.addEventListener('unhandledrejection')           │
│  └─ 捕获 ErrorBoundary 无法覆盖的异步错误                    │
│                                                             │
│  第3层:Sentry SDK(结构化上报 + Breadcrumb)                 │
│  ├─ 自动收集:错误堆栈、用户操作轨迹、请求信息                │
│  ├─ 手动上报:try/catch 中 Sentry.captureException()        │
│  └─ 上下文丰富:用户 ID、路由、Feature Flag                  │
│                                                             │
│  第4层:可观测性平台(Metrics / Logs / Traces)               │
│  ├─ 服务端错误日志聚合(Pino + Vector / Datadog)             │
│  ├─ 分布式链路追踪(W3C Trace Context)                      │
│  └─ 告警规则(错误率阈值、P95 延迟、可用性)                  │
│                                                             │
└─────────────────────────────────────────────────────────────┘
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

5.2 ErrorBoundary 与 Sentry 集成 ​

tsx
// components/ErrorBoundary.tsx
'use client'

import { Component, type ReactNode } from 'react'
import * as Sentry from '@sentry/nextjs'

interface Props {
  children: ReactNode
  fallback?: ReactNode
  onError?: (error: Error, errorInfo: React.ErrorInfo) => void
}

interface State {
  hasError: boolean
  error: Error | null
}

export class ErrorBoundary extends Component<Props, State> {
  state: State = { hasError: false, error: null }

  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error }
  }

  componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
    // 结构化上报到 Sentry
    Sentry.withScope(scope => {
      scope.setTag('error_boundary', 'react')
      scope.setContext('react_error_info', {
        componentStack: errorInfo.componentStack ?? 'unknown',
      })
      Sentry.captureException(error)
    })

    this.props.onError?.(error, errorInfo)
  }

  render(): ReactNode {
    if (this.state.hasError) {
      return (
        this.props.fallback ?? (
          <div role="alert" className="error-boundary-fallback">
            <h2>出错了</h2>
            <p>页面遇到了意外错误,请刷新重试。</p>
            <button onClick={() => this.setState({ hasError: false, error: null })}>
              重试
            </button>
          </div>
        )
      )
    }
    return this.props.children
  }
}
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

应用级使用(在 layout 中包裹):

tsx
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="zh-CN">
      <body>
        <ErrorBoundary>
          {children}
        </ErrorBoundary>
      </body>
    </html>
  )
}
1
2
3
4
5
6
7
8
9
10
11
12

ErrorBoundary 的粒度:全局一个 + 关键功能区域各一个(如聊天面板、编辑器、图表)。某个区域崩溃不会导致整个页面不可用。

5.3 结构化错误上报规范 ​

每个错误上报时必须附带足够的上下文,但不能包含敏感信息。

tsx
// lib/error-reporting.ts
import * as Sentry from '@sentry/nextjs'

interface ErrorContext {
  userId?: string
  route: string
  feature: string
  action: string
  severity: 'fatal' | 'error' | 'warning'
  tags?: Record<string, string>
  extra?: Record<string, unknown>
}

export function reportError(error: Error, context: ErrorContext) {
  Sentry.withScope(scope => {
    scope.setTag('feature', context.feature)
    scope.setTag('severity', context.severity)
    scope.setTag('route', context.route)
    scope.setTag('action', context.action)

    if (context.userId) {
      scope.setUser({ id: context.userId })
    }

    if (context.tags) {
      Object.entries(context.tags).forEach(([key, value]) => {
        scope.setTag(key, value)
      })
    }

    if (context.extra) {
      scope.setExtras(sanitizeExtra(context.extra))
    }

    scope.setLevel(context.severity)
    Sentry.captureException(error)
  })
}

// 清除可能包含敏感数据的字段
function sanitizeExtra(extra: Record<string, unknown>): Record<string, unknown> {
  const sensitiveKeys = ['password', 'token', 'secret', 'authorization', 'cookie', 'ssn']
  const sanitized: Record<string, unknown> = {}

  for (const [key, value] of Object.entries(extra)) {
    if (sensitiveKeys.some(sk => key.toLowerCase().includes(sk))) {
      sanitized[key] = '[REDACTED]'
    } else if (typeof value === 'object' && value !== null) {
      sanitized[key] = sanitizeExtra(value as Record<string, unknown>)
    } else {
      sanitized[key] = value
    }
  }

  return sanitized
}
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

5.4 错误分级标准 ​

┌─────────────────────────────────────────────────────────────┐
│              错误分级与响应策略                                │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  FATAL(致命)                                               │
│  ├─ 定义:应用完全不可用,用户无法完成任何操作                │
│  ├─ 示例:页面白屏、无法登录、支付完全失败                    │
│  ├─ 响应:即时告警(PagerDuty/电话)+ 自动回滚               │
│  └─ SLA:5 分钟内响应,15 分钟内解决或回滚                   │
│                                                             │
│  ERROR(错误)                                               │
│  ├─ 定义:核心功能受损,但应用主体可用                        │
│  ├─ 示例:搜索失败、头像上传失败、特定页面报错                │
│  ├─ 响应:工作时间内解决 / 自动创建工单                      │
│  └─ SLA:24 小时内修复                                       │
│                                                             │
│  WARNING(警告)                                             │
│  ├─ 定义:非预期行为,但不影响用户完成核心任务                │
│  ├─ 示例:非关键 API 超时后被降级处理、图片懒加载失败          │
│  ├─ 响应:纳入下一迭代修复                                   │
│  └─ SLA:下个发布窗口                                        │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

5.5 Source Map 上传策略 ​

Source Map 是调试生产错误的必需品,但绝不能暴露在公开 URL 上。

┌─────────────────────────────────────────────────────────────┐
│              Source Map 安全上传流程                          │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  构建阶段:                                                  │
│  1. next build 生成 .map 文件                                │
│  2. CI 将 .map 上传到 Sentry(sentry-cli / webpack 插件)   │
│  3. 上传后删除构建产物中的 .map(或使用 hidden-source-map)  │
│                                                             │
│  发布后:                                                    │
│  4. 生产环境不包含 .map 文件                                 │
│  5. 用户报错时,Sentry 根据 release + bundle 版本匹配 .map   │
│  6. 错误堆栈展示原始源码位置                                  │
│                                                             │
│  安全要点:                                                  │
│  ├─ 使用 hidden-source-map(不添加 //# sourceMappingURL)    │
│  ├─ Sentry Auth Token 仅限 CI 使用,不提交到仓库             │
│  └─ 定期清理过期 Release 的 Source Map                       │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
tsx
// sentry.client.config.ts
import * as Sentry from '@sentry/nextjs'

Sentry.init({
  dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
  environment: process.env.NEXT_PUBLIC_APP_ENV ?? 'development',
  release: process.env.NEXT_PUBLIC_RELEASE_VERSION,

  // 采样率:生产环境不一定要 100% 采样
  tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 1.0,
  replaysSessionSampleRate: 0.1,
  replaysOnErrorSampleRate: 1.0,

  // 过滤不应上报的错误
  ignoreErrors: [
    'ResizeObserver loop limit exceeded',
    'Non-Error promise rejection captured with value: undefined',
  ],

  // 在发送前清理敏感数据
  beforeSend(event) {
    // 检查事件中是否有未清理的敏感数据
    if (event.request?.cookies) {
      delete event.request.cookies
    }
    return event
  },
})
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

第6部分:部署检查清单与生产最佳实践 ​

6.1 部署前检查清单 ​

┌─────────────────────────────────────────────────────────────┐
│              部署检查清单(Deployment Checklist)              │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ☐ 环境变量                                                  │
│  ├─ NEXT_PUBLIC_* 前缀变量不含密钥/私密配置                  │
│  ├─ 生产环境变量已在托管平台配置(Vercel/Cloudflare/Docker)  │
│  ├─ 非公开变量在客户端被替换为占位符(空字符串或 undefined) │
│  └─ .env.local 和 .env.production 区分明确                  │
│                                                             │
│  ☐ 构建验证                                                  │
│  ├─ next build 成功,无 warning(TS 严格模式通过)           │
│  ├─ Bundle Analyzer 报告已审阅,无异常大的 chunk             │
│  ├─ Tree Shaking 生效,无预期外全量引入                      │
│  └─ CSS/JS 输出已压缩(gzip/brotli)                        │
│                                                             │
│  ☐ 性能回归                                                  │
│  ├─ Lighthouse CI 断言通过(分数未下降)                     │
│  ├─ Bundle 体积预算未超过阈值                                │
│  ├─ 关键路径请求数未增加                                     │
│  └─ Core Web Vitals 模拟值在目标范围内                       │
│                                                             │
│  ☐ 可观测性                                                  │
│  ├─ Sentry DSN 配置正确,环境标识准确                        │
│  ├─ Source Map 已上传至 Sentry,本地产物中已删除             │
│  ├─ 健康检查端点返回 200(不含昂贵查询)                     │
│  ├─ 日志级别:生产环境设置为 info/warn/error                 │
│  └─ 关键 API 有结构化日志(含请求 ID 用于链路追踪)          │
│                                                             │
│  ☐ 数据库迁移                                                │
│  ├─ 迁移兼容旧版本代码(先部署兼容旧 Schema 的代码)         │
│  ├─ 回滚计划已测试(迁移可逆或新旧代码可共存)               │
│  └─ 大表迁移有锁表风险评估和低峰期执行计划                   │
│                                                             │
│  ☐ 回滚方案                                                  │
│  ├─ 回滚命令已文档化(一键回滚到上一 Release)               │
│  ├─ 前端静态资源多版本共存(CDN 上旧版本文件不被覆盖)        │
│  └─ 灰度发布:先 5% → 观察指标 → 50% → 全量                 │
│                                                             │
└─────────────────────────────────────────────────────────────┘
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

6.2 构建验证与性能回归脚本 ​

tsx
// scripts/verify-deploy.ts — 在部署后运行的验证脚本
import { expect, test } from '@playwright/test'

const BASE_URL = process.env.DEPLOY_URL ?? 'http://localhost:3000'

test.describe('部署后烟雾测试', () => {
  test('首页返回 200', async ({ request }) => {
    const resp = await request.get(BASE_URL)
    expect(resp.status()).toBe(200)
  })

  test('健康检查端点正常', async ({ request }) => {
    const resp = await request.get(`${BASE_URL}/api/health`)
    expect(resp.status()).toBe(200)
    const body = await resp.json()
    expect(body.status).toBe('ok')
  })

  test('关键页面无 JS 错误', async ({ page }) => {
    const errors: string[] = []
    page.on('pageerror', err => errors.push(err.message))

    await page.goto(BASE_URL)
    await page.goto(`${BASE_URL}/projects`)

    expect(errors).toEqual([])
  })

  test('关键路径可交互', async ({ page }) => {
    await page.goto(BASE_URL)
    // 验证核心导航可点击
    await expect(page.getByRole('link', { name: '项目' })).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

6.3 生产环境缓存策略 ​

┌─────────────────────────────────────────────────────────────┐
│            Next.js 静态资源缓存策略                            │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  文件类型           │ Cache-Control               │ 原因    │
│  ──────────────────┼─────────────────────────────┼─────    │
│  /_next/static/*   │ public, max-age=31536000,   │ 带      │
│  (JS/CSS chunks)   │ immutable                    │ hash    │
│                     │                             │ 永不变  │
│  /images/*         │ public, max-age=86400,       │ 内容    │
│  (静态图片)        │ stale-while-revalidate=604800│ 可能    │
│                     │                             │ 更新    │
│  /api/* (GET)      │ public, max-age=0,           │ API     │
│                     │ must-revalidate              │ 响应    │
│                     │ 或 s-maxage=60 (CDN 缓存)   │ 不应    │
│                     │                             │ 长缓存  │
│  / (HTML 页面)     │ 在 Vercel 使用 ISR 或        │ 页面    │
│                     │ stale-while-revalidate       │ 需要    │
│                     │                             │ 重新    │
│                     │                             │ 验证    │
│                                                             │
│  Next.js 自动为带 hash 的静态资源设置 immutable,             │
│  其他缓存头通过 next.config.ts 的 headers() 配置             │
│                                                             │
└─────────────────────────────────────────────────────────────┘
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
tsx
// next.config.ts — 缓存头配置
import type { NextConfig } from 'next'

const config: NextConfig = {
  async headers() {
    return [
      {
        source: '/_next/static/(.*)',
        headers: [
          {
            key: 'Cache-Control',
            value: 'public, max-age=31536000, immutable',
          },
        ],
      },
      {
        source: '/images/(.*)',
        headers: [
          {
            key: 'Cache-Control',
            value: 'public, max-age=86400, stale-while-revalidate=604800',
          },
        ],
      },
    ]
  },
}

export default config
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

6.4 CDN 配置要点 ​

  • 源站屏蔽:CDN 回源时携带 X-Forwarded-For 和自定义鉴权头。源站应只接受 CDN Edge IP 的请求,防止绕过 CDN 直接攻击源站。
  • 缓存键(Cache Key):确保缓存键包含影响响应内容的所有因素(如 Accept-Encoding、Host),排除不影响内容的参数(如 utm_*)。
  • 压缩:CDN 层开启 Brotli(优先)和 Gzip。Next.js 构建产物已压缩,但 CDN 可以进一步压缩动态 API 响应。
  • 区域路由:如果用户分布在全球,配置 CDN 将请求路由到最近的边缘节点或最近的数据中心(如 Fly.io、Cloudflare、Vercel Edge)。

6.5 安全响应头 ​

tsx
// next.config.ts — 安全头配置
async headers() {
  return [
    {
      source: '/(.*)',
      headers: [
        {
          key: 'X-Content-Type-Options',
          value: 'nosniff',
        },
        {
          key: 'X-Frame-Options',
          value: 'DENY',
        },
        {
          key: 'X-XSS-Protection',
          value: '0',  // 现代浏览器已废弃此头,设置为 0 禁用旧行为
        },
        {
          key: 'Referrer-Policy',
          value: 'strict-origin-when-cross-origin',
        },
        {
          key: 'Permissions-Policy',
          value: 'camera=(), microphone=(), geolocation=(), interest-cohort=()',
        },
        {
          key: 'Strict-Transport-Security',
          value: 'max-age=63072000; includeSubDomains; preload',
        },
        // CSP 是最重要也最复杂的头,建议先以 Report-Only 模式测试
        {
          key: 'Content-Security-Policy-Report-Only',
          value: [
            "default-src 'self'",
            "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js.sentry-cdn.com",
            "style-src 'self' 'unsafe-inline'",
            "img-src 'self' data: blob: https:",
            "font-src 'self'",
            "connect-src 'self' https://*.sentry.io https://*.ingest.sentry.io",
            "frame-ancestors 'none'",
            "form-action 'self'",
          ].join('; '),
        },
      ],
    },
  ]
}
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

6.6 健康检查端点 ​

健康检查是负载均衡器、Kubernetes、监控系统判断服务是否存活的关键。其设计原则是:快速、无副作用、反映真实服务状态。

tsx
// app/api/health/route.ts
export async function GET() {
  const checks: Record<string, { status: 'ok' | 'degraded' | 'down'; latencyMs: number }> = {}

  // 1. 自身进程健康(无需外部依赖)
  checks.self = { status: 'ok', latencyMs: 0 }

  // 2. 数据库连接(轻量查询,如 SELECT 1)
  const dbStart = Date.now()
  try {
    await db.$queryRaw`SELECT 1`
    checks.database = { status: 'ok', latencyMs: Date.now() - dbStart }
  } catch {
    checks.database = { status: 'down', latencyMs: Date.now() - dbStart }
  }

  // 确定整体状态
  const hasDown = Object.values(checks).some(c => c.status === 'down')
  const hasDegraded = Object.values(checks).some(c => c.status === 'degraded')

  const overallStatus = hasDown ? 'down' : hasDegraded ? 'degraded' : 'ok'

  return Response.json(
    {
      status: overallStatus,
      version: process.env.NEXT_PUBLIC_RELEASE_VERSION ?? 'unknown',
      uptime: process.uptime(),
      timestamp: new Date().toISOString(),
      checks,
    },
    {
      status: overallStatus === 'down' ? 503 : 200,
      headers: {
        'Cache-Control': 'no-store, no-cache, must-revalidate',
      },
    }
  )
}
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

关键原则:

  • DEEP 健康检查(含数据库)放在 /api/health 路径,不被频繁轮询
  • SHALLOW 健康检查(仅进程存活)可用单独的轻量端点,供负载均衡器高频使用
  • 不要在这里执行写入、复杂聚合或外部 HTTP 调用,否则健康检查本身会成为故障源

6.7 灰度发布与回滚策略 ​

┌─────────────────────────────────────────────────────────────┐
│              发布与回滚流程                                    │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  发布:                                                      │
│  1. CI 构建 → 产物上传 → 创建 Release (Sentry)              │
│  2. 5% 流量 → 监控 5 分钟(错误率、延迟、CWV)               │
│  3. 指标正常 → 50% → 监控 5 分钟                             │
│  4. 指标正常 → 100%                                          │
│  5. 任何阶段指标异常 → 立即回滚到上一 Release                 │
│                                                             │
│  回滚:                                                      │
│  1. 前端:CDN 切换回旧版本静态文件(或部署平台一键回滚)     │
│  2. 后端:数据库迁移需兼容旧版本代码                          │
│  3. 如果迁移不可逆 → 回滚 + 数据修复脚本                     │
│  4. 通知 Sentry 新 Release 已被回滚                          │
│                                                             │
│  静态资源共存:                                              │
│  ├─ 每个构建产物的 JS/CSS 文件名包含 content hash            │
│  ├─ CDN 上同时存在新旧版本的静态文件                         │
│  └─ HTML 决定引用哪个版本 → 切换 HTML 即切换版本              │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

核心总结 ​

┌─────────────────────────────────────────────────────────────┐
│         测试、性能与生产工程 核心原则                          │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  测试:                                                      │
│  ├─ 金字塔三层各有不可替代的职责                              │
│  ├─ 组件测试应围绕角色、标签和文本,而非实现细节              │
│  ├─ MSW 在网络层拦截,比模块级 Mock 更可靠                    │
│  └─ userEvent > fireEvent;findBy > sleep()                  │
│                                                             │
│  性能:                                                      │
│  ├─ 先分类瓶颈(网络 / JS / React / DOM / 服务端),再选工具  │
│  ├─ Profiler 告诉我"谁渲染了",Profiler 火焰图告诉我"为什么" │
│  ├─ memo 是优化工具,不是正确性工具;测量先于优化             │
│  └─ Lighthouse CI + Bundle Budget 在 CI 中阻止回归           │
│                                                             │
│  打包:                                                      │
│  ├─ Bundle Analyzer 可视化:哪里大、哪里重复、哪里浪费        │
│  ├─ dynamic() + Suspense 实现组件级代码分割                  │
│  └─ Tree Shaking 需要 ESM + sideEffects + 具名导入           │
│                                                             │
│  可观测性:                                                  │
│  ├─ ErrorBoundary(组件) + 全局监听 + Sentry(上报)        │
│  ├─ 错误分级(FATAL/ERROR/WARNING)决定响应策略              │
│  └─ Source Map 上传到 Sentry,绝不出现在生产 URL             │
│                                                             │
│  部署:                                                      │
│  ├─ 部署前有清单,部署后有烟雾测试                            │
│  ├─ 缓存策略按文件类型区分;安全头在 build 时配置            │
│  └─ 健康检查快速且无副作用;灰度发布 + 一键回滚              │
│                                                             │
└─────────────────────────────────────────────────────────────┘
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

核心原则:

  1. 测试可观察行为,不测试实现细节 —— 重构不应该破坏测试
  2. don't mock what you don't own —— 用 MSW 在网络层截获,而非 mock 第三方库内部
  3. 先测量,再优化 —— 不要在 Profiler 指示问题前就加 memo/useMemo
  4. 性能预算是 CI 断言,不是愿望清单 —— 超预算就是构建失败
  5. 错误必须携带上下文,但不能携带秘密 —— 结构化上报 + 清洗敏感字段
  6. Source Map 是调试毒药也是解药 —— 不上传就没法调试,公开暴露就泄漏源码
  7. 每次部署都有回滚方案 —— 静态文件多版本共存,数据库迁移向前兼容

章节测试 ​

测试1:测试分层 ​

一个"用户注册"功能需要测试以下场景。为每个场景选择最合适的测试层级(单元/集成/E2E),并说明理由。

  • A. 邮箱格式校验逻辑
  • B. 提交表单后 API 返回 409 "邮箱已注册"时显示错误提示
  • C. 用户从首页点击"注册" → 填写表单 → 提交 → 收到确认邮件链接的完整流程

测试2:语义查询 ​

以下测试查询使用了 getByTestId。请用更合适的语义查询重写,并说明你的选择。

tsx
render(<ProductCard product={product} />)
expect(screen.getByTestId('product-title')).toHaveTextContent('机械键盘')
expect(screen.getByTestId('product-price')).toHaveTextContent('¥299')
expect(screen.getByTestId('add-to-cart-btn')).toBeEnabled()
1
2
3
4

测试3:MSW vs jest.mock ​

简述为什么推荐用 MSW 替代 jest.mock('./api') 来测试 API 交互。至少列举两个理由。

测试4:memo 决策 ​

以下场景是否适合加 React.memo?请判断并说明理由。

  • A. 一个只显示文本的 <UserAvatar name={user.name} /> 组件,其父组件每秒更新一次(实时数据)。
  • B. 一个渲染 500 行表格的 <DataGrid rows={data} columns={cols} /> 组件,数据只在用户点击"查询"时才变化。
  • C. 一个 <Button onClick={handleClick}>提交</Button> 组件。

测试5:Tree Shaking ​

以下哪种导入方式可以被 Tree Shake?说明原因。

tsx
// (A)
import moment from 'moment'

// (B)
import { format } from 'date-fns'

// (C)
const { debounce } = require('lodash')

// (D)
import debounce from 'lodash/debounce'
1
2
3
4
5
6
7
8
9
10
11

测试6:ErrorBoundary ​

ErrorBoundary 不能捕获哪些类型的错误?如果需要在事件处理函数中捕获错误并上报 Sentry,应该怎么写?

测试7:缓存策略 ​

为以下三种资源推荐 Cache-Control 头,并解释原因:

  • A. /_next/static/chunks/abc123.js(带 content hash 的 JS bundle)
  • B. /api/projects(项目列表 API,数据每分钟可能更新)
  • C. /logo.svg(网站 Logo,仅在重新设计时更换)

参考答案 ​

测试1答案 ​

  • A → 单元测试(Vitest):纯函数逻辑,不涉及 DOM 或网络。直接 import 验证函数,给定输入断言输出,毫秒级运行。
  • B → 集成测试(Testing Library + MSW):需要渲染组件、模拟 API 返回 409、断言错误提示可见。属于用户可观察的组件行为。
  • C → E2E(Playwright):跨页面、跨系统的完整流程,涉及路由跳转、真实 API 调用(或拦截)、邮件确认等。这是关键业务路径,值得 E2E 覆盖。

测试2答案 ​

tsx
render(<ProductCard product={product} />)
// 标题通常是 heading
expect(screen.getByRole('heading', { name: '机械键盘' })).toBeInTheDocument()
// 价格是文本内容,可以直接用 getByText
expect(screen.getByText('¥299')).toBeInTheDocument()
// 按钮用 role + name
expect(screen.getByRole('button', { name: '加入购物车' })).toBeEnabled()
1
2
3
4
5
6
7

选择理由:heading/button 是 ARIA 隐式角色,反映元素语义;getByText 模拟用户阅读内容;这些都不依赖 data-testid 属性,重构 DOM 结构不影响测试。

测试3答案 ​

  1. MSW 拦截的是真实 HTTP 请求,能验证请求方法、URL、请求体和请求头是否正确构造。jest.mock 只替换模块导出,跳过了网络层的所有细节。
  2. MSW 的 handler 可以在测试、开发(浏览器 DevTools)和 Storybook 中复用,同一套 mock 数据保持一致性。jest.mock 与测试文件紧耦合。
  3. (额外)MSW 可以模拟网络故障(HttpResponse.error())、超时和响应序列,这些是 jest.mock 难以做到的。

测试4答案 ​

  • A. 不适合。父组件每秒更新,name 可能变化不大,但 <UserAvatar> 渲染成本极低(只是展示文本),memo 的浅比较成本可能比直接渲染还高。应该先看是否可以不每秒更新父组件。
  • B. 适合。500 行表格渲染成本高,且数据仅在用户主动查询时变化(props 稳定),跳过不必要渲染的收益远大于浅比较开销。
  • C. 不适合。Button 渲染成本极低。而且 handleClick 如果是内联函数,每次都是新引用,memo 完全无效。

测试5答案 ​

  • (A) 不能:moment 是 CJS 模块 + 默认导入整个库。
  • (B) 可以:date-fns 是 ESM 模块,具名导入 { format },打包器可以只保留 format 函数及其依赖。
  • (C) 不能:require() 是 CJS 语法,动态导入,打包器无法静态分析使用范围。
  • (D) 可以:子路径导入直接只引入 lodash/debounce 这一个函数(不依赖 Tree Shaking 机制,直接绕过了全量引入)。

测试6答案 ​

ErrorBoundary 使用 componentDidCatch / getDerivedStateFromError,只能捕获渲染期间、生命周期方法和构造函数中抛出的错误。以下场景不能捕获:

  • 事件处理函数中的错误(如 onClick)
  • 异步代码(setTimeout、Promise)
  • 服务端渲染(SSR)期间的错误
  • ErrorBoundary 自身抛出的错误

在事件处理中上报 Sentry:

tsx
async function handleSubmit() {
  try {
    await api.createProject(data)
  } catch (error) {
    Sentry.captureException(error, {
      tags: { feature: 'project_create', action: 'handleSubmit' },
    })
    // 同时更新 UI 显示错误提示
    setError(error instanceof Error ? error.message : '创建失败')
  }
}
1
2
3
4
5
6
7
8
9
10
11

测试7答案 ​

  • A. Cache-Control: public, max-age=31536000, immutable —— 文件名包含 content hash,内容永不变,可以永久缓存。immutable 指示浏览器不要在重新加载时发送条件请求。
  • B. Cache-Control: public, max-age=0, must-revalidate 或 s-maxage=60, stale-while-revalidate=120 —— 数据会变化,浏览器应始终向源站验证(或 CDN 最多缓存 60 秒)。禁止设备/代理长期缓存。
  • C. Cache-Control: public, max-age=86400, stale-while-revalidate=604800 —— Logo 很少更改,可以缓存一天。如果改了,用户一天内看到旧版也可接受。stale-while-revalidate 保证即使过期也能快速响应(后台更新)。

相关笔记 ​

  • [[06-nextjs-data-cache-mutations]] - Next.js 数据获取、缓存与服务端变更
  • [[07-tanstack-query-server-state]] - TanStack Query 与服务端状态管理
  • [[../01-cpp/04-modern-cpp/00-overview]] - 现代 C++(TypeScript 类型系统参考)

下一步学习 ​

  • [ ] 为你的项目配置 Lighthouse CI,确保性能分数不会下降
  • [ ] 引入 MSW 替代现有的 jest.mock API 调用
  • [ ] 在关键布局组件外包裹 ErrorBoundary
  • [ ] 运行 Bundle Analyzer,检查是否有意外的大依赖或重复打包
  • [ ] 阅读 Testing Library 常见错误
  • [ ] 阅读 Web Vitals 官方文档

学习状态:🟡 开始学习

最后更新于:

Pager
上一篇8. TanStack Query 与服务端状态管理 / TanStack Query and Server State Management

持续记录,持续成长

Copyright © Tidenflow