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 第一层:Vitest 单元测试(纯逻辑)
所有不依赖 React 组件渲染的代码都属于这一层:状态 reducer、数据校验函数、格式化工具、业务规则函数、自定义 Hook 的纯逻辑部分。
// 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')
})
})单元测试的标准:一次只测一个行为;不涉及 DOM、网络或文件系统;运行时间以毫秒计;失败时能迅速定位到具体函数和输入。
1.3 第二层:React Testing Library 组件/集成测试
这一层测试的是用户可观察的行为:渲染内容、交互后状态变化、可访问性语义。核心原则是查询方式越接近用户使用方式,测试越有价值。
// 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()
})注意上面测试没有断言 isLoading 状态变量或 handleSubmit 是否被调用 —— 它只断言用户最终看到的结果。这意味着重构内部实现(比如从 useState 改为 useReducer)不会破坏测试。
1.4 第三层:Playwright 端到端测试
E2E 测试验证真实浏览器中的完整用户流程:路由跳转、API 调用、页面渲染、浏览器 API(如剪贴板、localStorage)。Playwright 可以拦截网络请求(page.route)来模拟后端响应,避免依赖真实服务。
// 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('下单成功')
})E2E 覆盖的关键路径应控制在 5-10 个场景:注册/登录、核心业务流程、支付/结算、权限边界。不要用 E2E 覆盖所有表单验证或边界条件 —— 那是单元和集成测试的职责。
1.5 三层边界的反模式
┌─────────────────────────────────────────────────────────────┐
│ 测试反模式 → 正确替代 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ✗ 用 E2E 测试"空邮箱提示" │
│ → 用 Testing Library 测试表单验证态 │
│ │
│ ✗ 用组件测试覆盖所有 Reducer 分支 │
│ → 用 Vitest 测试纯 Reducer 函数 │
│ │
│ ✗ 用 snapshot 测试"渲染未变"替代行为验证 │
│ → 断言具体文本、角色和可见状态 │
│ │
│ ✗ 为每个 useEffect 单独写测试 │
│ → 通过触发交互观察最终渲染结果来覆盖 Effect │
│ │
│ ✗ Mock 整个模块只为测试一个函数 │
│ → 拆分纯逻辑与副作用,纯逻辑部分单独测试 │
│ │
└─────────────────────────────────────────────────────────────┘第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" │
│ │
└─────────────────────────────────────────────────────────────┘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, │
│ 除非需要精确控制单个事件的特殊场景 │
│ │
└─────────────────────────────────────────────────────────────┘// ❌ 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/change2.3 异步等待三件套:findBy / waitFor / waitForElementToBeRemoved
// findBy*:带超时的查询(默认 1000ms),返回 Promise
const successMsg = await screen.findByText('操作成功')
// waitFor:等待任意断言成立,适合非 DOM 副作用
await waitFor(() => {
expect(mockFn).toHaveBeenCalledWith({ id: '42' })
})
// waitForElementToBeRemoved:等待元素从 DOM 中消失
await waitForElementToBeRemoved(() => screen.queryByText('加载中...'))关键区别: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 │
│ └─ 支持网络错误、超时、响应序列等真实场景模拟 │
│ │
└─────────────────────────────────────────────────────────────┘// 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' })
),
]// 测试文件中用 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()
})2.5 请求竞态与取消测试
MSW 可以精确控制响应时序来验证竞态行为:
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()
})第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 │
│ │
└─────────────────────────────────────────────────────────────┘3.2 React DevTools Profiler
Profiler 是 React 性能分析的核心工具,不需要任何代码修改:
- 安装 React DevTools 浏览器扩展
- 切换到 Profiler 面板,点击录制按钮
- 执行目标交互(点击、输入、导航)
- 停止录制,分析火焰图和排名列表
┌─────────────────────────────────────────────────────────────┐
│ Profiler 火焰图阅读指南 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 每根火焰条宽度 = 该组件本次渲染耗时 │
│ 灰色火焰条 = 组件未重新渲染(memo 生效或 props 未变) │
│ 黄色/橙色火焰条 = 组件重新渲染了 │
│ │
│ 优先关注: │
│ 1. 渲染次数最多的组件(Ranked 视图顶部) │
│ 2. 单次渲染耗时最长的组件 │
│ 3. 父组件更新导致大量子组件不必要重渲染的情况 │
│ 4. "Render reasons":是什么导致本次渲染 │
│ - Props changed → 哪个 prop │
│ - Hooks changed → 哪个 Hook state 更新 │
│ - Context changed → 哪个 Context.Provider 的值变了 │
│ │
└─────────────────────────────────────────────────────────────┘3.3 Lighthouse CI 集成
将 Lighthouse 性能审计集成到 CI 流程中,阻止性能回归:
// .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',
},
},
}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 内的偏移不计入 │
│ │
└─────────────────────────────────────────────────────────────┘在 Next.js 中收集 Web Vitals:
// 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>
)
}3.5 React.memo / useMemo 的实际收益测量
memo、useMemo、useCallback 不是免费的性能优化。每次渲染时 React 需要比较依赖数组,memo 还需要浅比较所有 props。只有当比较成本 < 跳过渲染节省的成本时才划算。
// 测量 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 以上)
// 如果以上任一条件不满足,不要加 memoContext 导致的无效渲染是 memo 失效的主要场景。拆分 Context 比给所有消费者加 memo 更有效:
// ❌ 一个 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')第4部分:打包分析与代码分割
4.1 为什么包体积影响性能
JavaScript 的代价不只在下载 —— 下载后还需解析、编译和执行。在低端设备上,1MB 的 JS 可能需要 1-2 秒才能执行完毕。包体积分析的目标是发现并消除未使用代码、重复依赖和过大的第三方库。
4.2 Bundle Analyzer 集成
# 安装
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({
// ... 其他配置
})# 运行分析
ANALYZE=true npm run build┌─────────────────────────────────────────────────────────────┐
│ 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 │
│ │
└─────────────────────────────────────────────────────────────┘4.3 动态导入与代码分割
Next.js 通过文件系统路由自动进行路由级代码分割。对于组件级分割,使用 dynamic + Suspense。
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>
)
}分割的时机与粒度:
┌─────────────────────────────────────────────────────────────┐
│ 代码分割的决策 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 适合分割: │
│ ├─ 重型可视化库(echarts、d3、three.js) │
│ ├─ 富文本编辑器(Tiptap、Quill、Monaco) │
│ ├─ 复杂表单组件(多步骤表单) │
│ ├─ 模态框/抽屉内容(用户不打开就不加载) │
│ ├─ 非首屏的内容区域(折叠面板、Tab 内容) │
│ └─ 第三方集成(地图、视频播放器、聊天窗口) │
│ │
│ 不适合分割: │
│ ├─ 首屏立即可见的内容(分割只会增加请求数) │
│ ├─ 很小的组件(<5KB,分割开销大于收益) │
│ ├─ 被多个路由共享的 UI 组件 │
│ └─ 已经在路由级分割的页面内的子组件(过度分割) │
│ │
│ 粒度建议: │
│ ├─ 路由级:Next.js 自动处理(不需要手动做) │
│ ├─ 功能级:每个重型功能一个 chunk │
│ ├─ 库级:大第三方库独立 chunk │
│ └─ 避免:每个小组件各自一个 chunk(HTTP/2 也无法拯救) │
│ │
└─────────────────────────────────────────────────────────────┘4.4 Tree Shaking 的前提条件
Tree Shaking 不是自动发生的。它要求:
- ESM 模块格式 ——
import { X } from 'pkg'而非const X = require('pkg').X。CJS 是动态导入,打包器无法在构建时确定会用到哪些导出。 - package.json 声明
"sideEffects": false—— 告诉打包器"这个包的模块都没有副作用,可以安全删除未使用的导出"。有副作用(如 CSS 导入、polyfill 注入)的文件需要在数组中排除。 - 使用具名导入 ——
import { debounce } from 'lodash-es'而非import _ from 'lodash'。 - 避免动态属性访问 ——
obj[dynamicKey]()这种写法打包器无法静态分析。
// ❌ 无法 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' // 子路径导入4.5 包体积预算
在 CI 中设置包体积预算,阻止体积意外膨胀:
// 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'],
},
}// package.json
{
"scripts": {
"build": "next build",
"analyze": "ANALYZE=true next build",
"size": "bundlesize"
}
}第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 延迟、可用性) │
│ │
└─────────────────────────────────────────────────────────────┘5.2 ErrorBoundary 与 Sentry 集成
// 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
}
}应用级使用(在 layout 中包裹):
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN">
<body>
<ErrorBoundary>
{children}
</ErrorBoundary>
</body>
</html>
)
}ErrorBoundary 的粒度:全局一个 + 关键功能区域各一个(如聊天面板、编辑器、图表)。某个区域崩溃不会导致整个页面不可用。
5.3 结构化错误上报规范
每个错误上报时必须附带足够的上下文,但不能包含敏感信息。
// 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
}5.4 错误分级标准
┌─────────────────────────────────────────────────────────────┐
│ 错误分级与响应策略 │
├─────────────────────────────────────────────────────────────┤
│ │
│ FATAL(致命) │
│ ├─ 定义:应用完全不可用,用户无法完成任何操作 │
│ ├─ 示例:页面白屏、无法登录、支付完全失败 │
│ ├─ 响应:即时告警(PagerDuty/电话)+ 自动回滚 │
│ └─ SLA:5 分钟内响应,15 分钟内解决或回滚 │
│ │
│ ERROR(错误) │
│ ├─ 定义:核心功能受损,但应用主体可用 │
│ ├─ 示例:搜索失败、头像上传失败、特定页面报错 │
│ ├─ 响应:工作时间内解决 / 自动创建工单 │
│ └─ SLA:24 小时内修复 │
│ │
│ WARNING(警告) │
│ ├─ 定义:非预期行为,但不影响用户完成核心任务 │
│ ├─ 示例:非关键 API 超时后被降级处理、图片懒加载失败 │
│ ├─ 响应:纳入下一迭代修复 │
│ └─ SLA:下个发布窗口 │
│ │
└─────────────────────────────────────────────────────────────┘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 │
│ │
└─────────────────────────────────────────────────────────────┘// 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
},
})第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% → 全量 │
│ │
└─────────────────────────────────────────────────────────────┘6.2 构建验证与性能回归脚本
// 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()
})
})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() 配置 │
│ │
└─────────────────────────────────────────────────────────────┘// 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 config6.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 安全响应头
// 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('; '),
},
],
},
]
}6.6 健康检查端点
健康检查是负载均衡器、Kubernetes、监控系统判断服务是否存活的关键。其设计原则是:快速、无副作用、反映真实服务状态。
// 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',
},
}
)
}关键原则:
- 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 即切换版本 │
│ │
└─────────────────────────────────────────────────────────────┘核心总结
┌─────────────────────────────────────────────────────────────┐
│ 测试、性能与生产工程 核心原则 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 测试: │
│ ├─ 金字塔三层各有不可替代的职责 │
│ ├─ 组件测试应围绕角色、标签和文本,而非实现细节 │
│ ├─ 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 时配置 │
│ └─ 健康检查快速且无副作用;灰度发布 + 一键回滚 │
│ │
└─────────────────────────────────────────────────────────────┘核心原则:
- 测试可观察行为,不测试实现细节 —— 重构不应该破坏测试
- don't mock what you don't own —— 用 MSW 在网络层截获,而非 mock 第三方库内部
- 先测量,再优化 —— 不要在 Profiler 指示问题前就加 memo/useMemo
- 性能预算是 CI 断言,不是愿望清单 —— 超预算就是构建失败
- 错误必须携带上下文,但不能携带秘密 —— 结构化上报 + 清洗敏感字段
- Source Map 是调试毒药也是解药 —— 不上传就没法调试,公开暴露就泄漏源码
- 每次部署都有回滚方案 —— 静态文件多版本共存,数据库迁移向前兼容
章节测试
测试1:测试分层
一个"用户注册"功能需要测试以下场景。为每个场景选择最合适的测试层级(单元/集成/E2E),并说明理由。
- A. 邮箱格式校验逻辑
- B. 提交表单后 API 返回 409 "邮箱已注册"时显示错误提示
- C. 用户从首页点击"注册" → 填写表单 → 提交 → 收到确认邮件链接的完整流程
测试2:语义查询
以下测试查询使用了 getByTestId。请用更合适的语义查询重写,并说明你的选择。
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()测试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?说明原因。
// (A)
import moment from 'moment'
// (B)
import { format } from 'date-fns'
// (C)
const { debounce } = require('lodash')
// (D)
import debounce from 'lodash/debounce'测试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答案
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()选择理由:heading/button 是 ARIA 隐式角色,反映元素语义;getByText 模拟用户阅读内容;这些都不依赖 data-testid 属性,重构 DOM 结构不影响测试。
测试3答案
- MSW 拦截的是真实 HTTP 请求,能验证请求方法、URL、请求体和请求头是否正确构造。
jest.mock只替换模块导出,跳过了网络层的所有细节。 - MSW 的 handler 可以在测试、开发(浏览器 DevTools)和 Storybook 中复用,同一套 mock 数据保持一致性。
jest.mock与测试文件紧耦合。 - (额外)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:
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 : '创建失败')
}
}测试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.mockAPI 调用 - [ ] 在关键布局组件外包裹 ErrorBoundary
- [ ] 运行 Bundle Analyzer,检查是否有意外的大依赖或重复打包
- [ ] 阅读 Testing Library 常见错误
- [ ] 阅读 Web Vitals 官方文档
学习状态:🟡 开始学习