本册涵盖:06 性能与无障碍 · 07 测试金字塔 · 08 可观测性 · 09 部署 · 10 安全 Checklist
06 - 性能与无障碍
目标:让站点在真实用户那里真的快且人人能用。Vitals、字体、图片、bundle、a11y、键盘、对比度。
1. Core Web Vitals(p75 真实用户)
| 指标 | 含义 | 良好 | 需改进 | 差 |
|---|---|---|---|---|
| LCP(Largest Contentful Paint) | 最大内容绘制 | < 2.5s | < 4s | ≥ 4s |
| INP(Interaction to Next Paint) | 交互到下一帧 | < 200ms | < 500ms | ≥ 500ms |
| CLS(Cumulative Layout Shift) | 累计布局偏移 | < 0.1 | < 0.25 | ≥ 0.25 |
| TTFB(Time to First Byte) | 服务端响应 | < 800ms | < 1.8s | ≥ 1.8s |
| FCP(First Contentful Paint) | 首次内容 | < 1.8s | < 3s | ≥ 3s |
真实用户(field data)优先于实验室(lab data)。Chrome User Experience Report、Web Vitals 上报是金标准。
2. 收集 Vitals
// app/_components/web-vitals.tsx
"use client";
import { useReportWebVitals } from "next/web-vitals";
export function WebVitals() {
useReportWebVitals((metric) => {
const body = JSON.stringify(metric);
if (navigator.sendBeacon) navigator.sendBeacon("/api/metrics/vitals", body);
else fetch("/api/metrics/vitals", { body, method: "POST", keepalive: true });
});
return null;
}
// app/layout.tsx
import { WebVitals } from "./_components/web-vitals";
export default function RootLayout({ children }) {
return <html><body><WebVitals />{children}</body></html>;
}
后端 /api/metrics/vitals 接收后写到 Prometheus / Datadog / 自建。
3. 图片:next/image 必用
import Image from "next/image";
<Image
src="/hero.jpg"
alt="Product hero"
width={1200}
height={630}
priority // 首屏图必加
sizes="(max-width: 768px) 100vw, 50vw" // 响应式
/>
要点:
- 必须给
width / height或fill(防 CLS) - 首屏图加
priority(LCP 候选) sizes告诉浏览器在不同视口要多大,Next 生成对应 srcset- 自动 AVIF / WebP / lazy load
⚠️ 不给 width/height = 布局偏移 图片加载前占 0 高度,加载后撑开 → 内容下移 → CLS 飙升。Next 强制要求其中之一,除非用
fill(此时父元素要position: relative)。
3.1 远程图域名白名单(next.config.mjs)
images: {
remotePatterns: [
{ protocol: "https", hostname: "cdn.example.com" },
{ protocol: "https", hostname: "**.cloudfront.net" },
],
}
⚠️ 没有 allowlist → 任何远程 URL 都能让你的服务器代为下载并转码 = 被滥用做盗链 / DoS。
4. 字体:next/font(零 CLS)
// app/layout.tsx
import { Inter } from "next/font/google";
import localFont from "next/font/local";
const inter = Inter({ subsets: ["latin"], display: "swap" });
const han = localFont({
src: [{ path: "./fonts/Han-Variable.woff2", weight: "100 900" }],
display: "swap",
});
export default function RootLayout({ children }) {
return (
<html lang="zh" className={`${inter.variable} ${han.variable}`}>
<body>{children}</body>
</html>
);
}
要点:
display: "swap"避免 FOIT(font invisible too long)- 自托管:Next 在 build 时下载 Google 字体到本地,生产时无外部请求
- subset 精简:中文只装中文 subset(不然 5MB 起)
💡 零 CLS 原理 Next 算字体度量(metrics override),与系统字体匹配,文字布局位置在加载前后像素级一致。
5. Bundle 体量管理
5.1 分析工具
pnpm add -D @next/bundle-analyzer
// next.config.mjs
import withBundleAnalyzer from "@next/bundle-analyzer";
const analyzer = withBundleAnalyzer({ enabled: process.env.ANALYZE === "true" });
export default analyzer(nextConfig);
ANALYZE=true pnpm build
会打开 client / server bundle 的 treemap。
5.2 在 CI 里加预算门槛
pnpm add -D size-limit @size-limit/preset-app
// package.json
"size-limit": [
{ "name": "Total app JS", "path": ".next/static/chunks/**/*.js", "limit": "250 KB" },
{ "name": "First load JS", "path": ".next/static/chunks/main-*.js", "limit": "100 KB" }
]
CI:pnpm size-limit,超出即 fail。
5.3 减小 bundle 的常用手段
- client 化最小化:首屏让 RSC,只把互动元素抽成 client(02 章)
- 代码分割:
dynamic(() => import("./Heavy"), { ssr: false })把不在首屏的 client 组件懒加载 - 替换重依赖:moment → dayjs,lodash → lodash-es 选择性 import,date-fns 按模块 import
- 去 polyfill:
browserslist适度,Next 自动按 target 调 - 第三方脚本:
next/script strategy="lazyOnload",避免阻塞主线程 - 图片不要 base64 内联(撑大 bundle)
import dynamic from "next/dynamic";
const Editor = dynamic(() => import("./RichEditor"), {
ssr: false,
loading: () => <EditorSkeleton />,
});
⚠️
{ ssr: false }是放弃 SSR 的承诺 该组件首屏不在 HTML 里,SEO 看不到。只对纯互动型组件(富文本、图表、map)用。展示型组件不要ssr: false。
6. 第三方脚本
import Script from "next/script";
// 关键(用户登录后才需要 → afterInteractive 比 default 更晚)
<Script src="https://example.com/analytics.js" strategy="afterInteractive" />
// 非关键(广告、客服)
<Script src="https://example.com/chat.js" strategy="lazyOnload" />
// 内联代码块(GA 初始化)
<Script id="ga-init" strategy="afterInteractive">{`...gtag init...`}</Script>
strategy 选择:
beforeInteractive:页面交互前(几乎不用,会拖慢)afterInteractive(默认):页面可交互后lazyOnload:浏览器空闲时worker:Partytown,跑在 Web Worker(实验性)
💡 Analytics 用 lazyOnload 即可 数据丢一点点采集没关系,用户感知更重要。INP 因为 GTM 卡顿是常见事故。
7. 流式渲染(RSC streaming)
让"慢的部分慢慢补,快的部分先到":
export default function Page() {
return (
<>
<FastHeader />
<Suspense fallback={<Skeleton />}>
<SlowDataBlock />
</Suspense>
</>
);
}
<SlowDataBlock /> 数据 1s 才来 → 浏览器先看到 FastHeader + Skeleton,1s 后再补上真内容。TTFB 与 LCP 解耦。
8. React Compiler(2024-2025 进 stable)
pnpm add -D babel-plugin-react-compiler
// next.config.mjs
experimental: {
reactCompiler: true,
}
自动 memo useCallback / useMemo,不再手写。几乎免费的性能优化。注意:稳定性还在追,生产开前看官方进度。
9. 缓存策略与性能(回顾 03 章)
| 想要 | 怎么做 |
|---|---|
| 静态营销页 | 默认即可,自动 SSG |
| 公开内容定期更新 | revalidate: 60 |
| 私密页面 | cache: "no-store" + 适当 client 缓存 |
| 用户操作后即时更新 | revalidateTag / revalidatePath |
💡 缓存是性能第一杠杆。优化代码 10% 不如让 90% 请求走缓存。
10. 无障碍(a11y)
10.1 基线必做
- 所有交互元素能键盘到达(Tab / Shift+Tab / Enter / Space / Esc)
- 表单元素必须有
<label>关联(htmlFor+id或包裹) <img alt>必填(装饰图alt="")- 颜色对比 ≥ 4.5:1(普通文本)
- focus ring 不要
outline: none不替换 - 表单错误用
aria-invalid和aria-describedby - 跳过链接(
<a href="#main">跳到主内容</a>)
10.2 模态 / 抽屉 / 下拉
用 Radix(shadcn 都有封装),自带:
- 打开时 focus trap
- ESC 关闭
- 关闭后 focus 回触发元素
aria-modal、role="dialog"
⚠️ 自己手写模态几乎一定有 a11y bug。Radix 解决 95%。
10.3 ARIA 用法基本规则
- 能用语义 HTML 就不用 ARIA(
<button>而不是<div role="button">) - 不要重复语义(
<button aria-label="Click me">Click me</button>多余) - 动态内容用 live region:
<div aria-live="polite">Saved.</div> - 隐藏装饰元素:
aria-hidden="true"(图标、伪元素)
10.4 工具
- 开发期:
eslint-plugin-jsx-a11y(01 章已开) - 测试期:
@axe-core/playwright(在 E2E 中跑) - 设计期:Chrome DevTools Lighthouse / WAVE 浏览器扩展
// e2e/a11y.spec.ts
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
test("home is accessible", async ({ page }) => {
await page.goto("/");
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
11. 键盘交互细节
- Tab 顺序与视觉顺序一致(用 source order,不要
tabIndex乱搞) - Esc 关闭弹层(Radix 自动)
- Enter 提交表单(原生)
- 方向键导航列表(下拉菜单、Tab 组件):Radix 已实现
- Space 触发按钮:用
<button>自动
⚠️
tabIndex={-1}vstabIndex={0}
-1:不在 Tab 顺序,但可编程聚焦(element.focus())。常用于模态、跳过链接目标0:加入 Tab 顺序。- 正数:永远不用(打乱顺序,a11y 灾难)
12. 移动端适配
- viewport:
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> - 触摸目标 ≥ 44x44px(WCAG 2.5.5)
- 避免 hover-only 交互(touch 设备没有 hover)
- iOS 安全区:
env(safe-area-inset-*) - 测试:Chrome DevTools 设备模拟 + 真机至少 1 台 iOS + 1 台 Android
13. 性能 + a11y 上线 checklist
- Vitals 数据(LCP / INP / CLS / TTFB)在目标范围
- 首屏 JS < 200KB gzip
- 首屏图加
priority,其他懒加载 - 字体
display=swap+ 自托管 - 无 hydration mismatch 警告
- 暗色模式无 FOUC
- 整站键盘可达,Tab 顺序合理
- 颜色对比通过 axe
- 表单 label / aria 完备
- 移动端真机测试通过
- CI 有 size-limit 门槛
- CI 有 a11y 测试(axe)
延伸阅读
- web.dev — Vitals
- Next.js Performance
- A11y Project Checklist
- WCAG 2.2 Quick Reference
- Inclusive Components by Heydon Pickering
07 - 测试金字塔(前端视角)
目标:在 Next.js 应用里写真有用的测试。每种测试的价值、边界、工具、写法。
1. 心智模型(前端版)
/\
/ \ E2E (Playwright)
/----\ 慢、贵、易碎,关键链路 < 30 个
/ \
/--------\ 集成 (Vitest + Testing Library)
/ \ 组件 + 交互,**主战场**
/------------\
/ \ 单元 (Vitest)
/----------------\ 纯逻辑、工具函数、自定义 hook
💡 前端别迷信"组件单测" 单独测
<Button />没意义(它就是个 button)。测业务组件(<UserForm />)和用户能感知的行为(填错邮箱看到红字、提交后跳到列表)更有价值。
2. 工具栈
| 类型 | 工具 | 备选 |
|---|---|---|
| Runner | Vitest | Jest |
| 组件测试 | @testing-library/react + jsdom/happy-dom | — |
| Mock | MSW(网络层)、vi.mock(模块) | nock |
| E2E | Playwright | Cypress |
| a11y | @axe-core/playwright | jest-axe |
| 视觉回归 | Playwright screenshot 或 Chromatic | Percy |
3. 配置
vitest.config.ts:
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import { fileURLToPath } from "node:url";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: ["./test/setup.ts"],
globals: true,
coverage: {
provider: "v8",
reporter: ["text", "html", "lcov"],
exclude: ["**/*.config.*", "**/dist/**", "**/.next/**"],
thresholds: { lines: 70, branches: 70, functions: 70, statements: 70 },
},
},
resolve: {
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
},
});
test/setup.ts:
import "@testing-library/jest-dom/vitest";
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
afterEach(() => cleanup());
// 全局 MSW
import { server } from "./msw-server";
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
4. 单元测试
4.1 纯函数
// src/lib/format-money.ts
export function formatMoney(cents: number, currency: "USD" | "CNY"): string {
return new Intl.NumberFormat("en", { style: "currency", currency }).format(cents / 100);
}
// src/lib/format-money.spec.ts
import { describe, expect, it } from "vitest";
import { formatMoney } from "./format-money";
describe("formatMoney", () => {
it("formats USD", () => {
expect(formatMoney(1099, "USD")).toBe("$10.99");
});
});
4.2 自定义 hook
import { renderHook, act } from "@testing-library/react";
import { useCounter } from "./useCounter";
it("increments", () => {
const { result } = renderHook(() => useCounter(0));
act(() => result.current.inc());
expect(result.current.count).toBe(1);
});
⚠️ hook 测试别测内部实现 测它"暴露给调用者的行为"。不要
expect(internalRef).toBe(...)。
5. 组件 + 交互测试
// src/components/UserForm.spec.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { UserForm } from "./UserForm";
describe("UserForm", () => {
it("shows validation error for invalid email", async () => {
const user = userEvent.setup();
render(<UserForm />);
await user.type(screen.getByLabelText(/email/i), "not-an-email");
await user.click(screen.getByRole("button", { name: /submit/i }));
expect(await screen.findByText(/valid email/i)).toBeInTheDocument();
});
it("submits valid data", async () => {
const onSubmit = vi.fn();
const user = userEvent.setup();
render(<UserForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText(/email/i), "a@b.c");
await user.type(screen.getByLabelText(/name/i), "Alice");
await user.type(screen.getByLabelText(/password/i), "longenoughpassword");
await user.click(screen.getByRole("button", { name: /submit/i }));
expect(onSubmit).toHaveBeenCalledWith({
email: "a@b.c", name: "Alice", password: "longenoughpassword",
});
});
});
要点:
getByRole/getByLabelText优先,远离getByTestIduserEvent模拟真实用户(fireEvent是低级 API,少用)- 断言用户能感知的事(屏幕上有什么文字)
6. 网络 mock:MSW
// test/msw-server.ts
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";
export const server = setupServer(
http.get("/api/users", () =>
HttpResponse.json({ data: [{ id: "u1", name: "Alice" }] }),
),
http.post("/api/users", async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: "new", ...body }, { status: 201 });
}),
);
onUnhandledRequest: "error" 让任何未 mock 的调用测试失败,逼你显式声明所有外部接触。
7. RSC 测试现状
坏消息:Server Component 目前没有官方测试方案(异步组件、use server 等)。社区共识:
- 把数据获取逻辑抽成纯函数,在 server component 里只调用,单测纯函数
- 复杂 server 行为用 E2E 覆盖(Playwright 跑真实页面)
- Client Component 用 Testing Library
// ❌ 这样测会很尴尬
const result = await UserPage({ params: { id: "u1" } });
// ✅ 抽出数据函数,单测它;UI 留给 E2E
export async function getUserViewModel(id: string) { ... }
8. Server Action 测试
Action 本质是函数,直接调:
import { createUserAction } from "@/app/users/actions";
it("rejects invalid input", async () => {
const result = await createUserAction({ email: "x", name: "", password: "" });
expect(result.ok).toBe(false);
});
但 Action 内部用 cookies() / headers() / revalidatePath() 时单测不好跑(Next 运行时 API)。建议:
- 抽出业务逻辑成纯函数,Action 是壳
- Action 的"鉴权 + revalidate"行为留给 E2E
9. E2E:Playwright
9.1 配置
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 4 : undefined,
reporter: [["html", { open: "never" }], ["github"]],
use: {
baseURL: process.env.E2E_BASE_URL ?? "http://localhost:3000",
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
// 关键路径再加移动浏览
// { name: "Mobile Safari", use: { ...devices["iPhone 14"] } },
],
webServer: process.env.CI ? undefined : {
command: "pnpm build && pnpm start",
port: 3000,
reuseExistingServer: true,
},
});
9.2 页面对象模式
// e2e/pages/login.page.ts
import type { Page } from "@playwright/test";
export class LoginPage {
constructor(private page: Page) {}
async goto() { await this.page.goto("/login"); }
async login(email: string, password: string) {
await this.page.getByLabel("Email").fill(email);
await this.page.getByLabel("Password").fill(password);
await this.page.getByRole("button", { name: "Sign in" }).click();
await this.page.waitForURL("/dashboard");
}
}
9.3 测试用例
import { test, expect } from "@playwright/test";
import { LoginPage } from "./pages/login.page";
test("user can login", async ({ page }) => {
const login = new LoginPage(page);
await login.goto();
await login.login("alice@example.com", "password123!");
await expect(page.getByText("Welcome, Alice")).toBeVisible();
});
9.4 选择器优先级(必背)
按可靠性从高到低:
getByRole(button/link/textbox),最贴近用户和无障碍getByLabel(表单字段)getByText(可见文本)getByTestId(data-testid)—— 最后才用- ❌ CSS / XPath —— 易碎
9.5 等待与稳定性
// ✅ web-first assertion 自带等待
await expect(page.getByText("Saved")).toBeVisible();
// ❌ 不要 waitForTimeout(...),flake 源
9.6 数据准备
E2E 不要走 UI 注册 准备数据 → 慢且脆弱。
- 调后端的
__test__/seedendpoint(staging 才开) - 或直接调 prisma(若 monorepo 有访问权限)
9.7 a11y 测试
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
test("home is accessible", async ({ page }) => {
await page.goto("/");
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
10. 视觉回归(可选)
// Playwright 截图比对
await expect(page).toHaveScreenshot("home.png", {
maxDiffPixelRatio: 0.01,
fullPage: true,
});
第一次跑生成基线,后续 PR 截图与基线比对,变化超阈值 fail。
⚠️ 视觉回归 flake 来源极多(字体加载、动画、emoji、滚动条)。先 mask 动态区域:
await expect(page).toHaveScreenshot({ mask: [page.locator(".timestamp"), page.locator(".user-avatar")], });
11. 覆盖率门槛与误区
coverage: {
thresholds: { lines: 70, branches: 70, functions: 70, statements: 70 },
}
⚠️ 不要追求 100% 覆盖率 70-80% 是合理目标。剩下 20-30% 是 trivial getter、防御性代码、错误兜底分支。100% 的代价是写废测试,这些测试会拖累 refactor。
💡 看分支覆盖比看行覆盖重要 一个
if (x && y)行覆盖 100% 不代表四种组合都测了。branches阈值是更严格的护栏。
12. CI 集成
jobs:
test-unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version-file: .nvmrc }
- run: corepack enable
- run: pnpm install --frozen-lockfile
- run: pnpm test
test-e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version-file: .nvmrc }
- run: corepack enable
- run: pnpm install --frozen-lockfile
- run: pnpm exec playwright install --with-deps chromium
- run: pnpm test:e2e
- if: failure()
uses: actions/upload-artifact@v4
with: { name: playwright-report, path: playwright-report/ }
13. Flaky 测试零容忍
Flaky(有时过有时不过)= 团队对测试失去信任 = 测试无用。
常见原因 + 应对:
- 时间相关:
vi.useFakeTimers、Playwright 的clockAPI - 网络抖动:MSW / 路由 mock
- 动画 / 字体:截图前等加载 / mask 动态元素
- 顺序依赖:每个测试独立 setup
- 随机数据:seed faker,不用
Math.random()
修法:第一次 flake 立刻修,不要 retry 掩盖。
14. 速记卡
| 想测 | 用 |
|---|---|
| 纯函数 / hook | Vitest + Testing Library renderHook |
| 业务组件交互 | Vitest + Testing Library + userEvent |
| 外部 HTTP | MSW |
| 完整用户流程 | Playwright |
| 服务端组件 | 抽数据函数 → 单测;UI → E2E |
| Server Action | 抽业务函数 → 单测;鉴权/revalidate → E2E |
| a11y | axe in Playwright + ESLint jsx-a11y |
| 视觉回归 | Playwright screenshot + mask 动态区 |
延伸阅读
- Kent C. Dodds, The Testing Trophy
- Testing Library 文档
- MSW 文档
- Playwright Best Practices
08 - 可观测性与监控(前端视角)
目标:让任何线上前端问题在 5 分钟内能定位:谁、什么页面、什么浏览器、什么操作、什么错误。覆盖错误监控、性能监控、用户行为。
1. 前端可观测性的三件事
| 关注 | 工具 | 关键指标 |
|---|---|---|
| 错误 | Sentry browser | JS error rate、ErrorBoundary 触发率 |
| 性能 | Web Vitals + RUM | LCP / INP / CLS / TTFB(p75 真实用户) |
| 行为 | 自建埋点 / PostHog / Vercel Analytics | 转化漏斗、关键事件 |
💡 "前端可观测性"和"后端可观测性"是两件事 后端可观测面向 SRE,关心 QPS / 延迟 / 错误率。前端可观测面向产品 + 工程,关心用户实际体验。两边数据要能用
requestId串起来,但分开维护。
2. Sentry 接入
pnpm dlx @sentry/wizard@latest -i nextjs
向导会生成三个配置文件:
sentry.client.config.ts:
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
tracesSampleRate: 0.05,
replaysSessionSampleRate: 0,
replaysOnErrorSampleRate: 1.0,
environment: process.env.NEXT_PUBLIC_ENV ?? "production",
release: process.env.NEXT_PUBLIC_APP_VERSION,
integrations: [
Sentry.replayIntegration({
maskAllText: true,
blockAllMedia: true,
}),
],
beforeSend(event) {
if (event.request?.headers) {
delete event.request.headers["authorization"];
delete event.request.headers["cookie"];
}
return event;
},
});
sentry.server.config.ts(Node runtime,跑 Server Components 和 Actions):
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.SENTRY_DSN,
tracesSampleRate: 0.05,
environment: process.env.NODE_ENV,
release: process.env.APP_VERSION,
integrations: [Sentry.httpIntegration()],
});
sentry.edge.config.ts(middleware):
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.SENTRY_DSN,
tracesSampleRate: 0.05,
});
💡 Source map 上传必做 没 source map = Sentry 给你的是打包后的代码,基本不可读。Sentry 向导会自动配 build 时上传,确认 CI 里
SENTRY_AUTH_TOKEN已设。释放时与 release 标签关联,Sentry 才能"这是 v1.2.3 引入的新错误"判断。
3. ErrorBoundary 与 Sentry 联动
// app/global-error.tsx
"use client";
import * as Sentry from "@sentry/nextjs";
import { useEffect } from "react";
export default function GlobalError({ error }: { error: Error & { digest?: string } }) {
useEffect(() => { Sentry.captureException(error); }, [error]);
return (
<html>
<body>
<h2>出错了</h2>
<p>错误号:{error.digest}</p>
</body>
</html>
);
}
// app/(app)/error.tsx
"use client";
import * as Sentry from "@sentry/nextjs";
import { useEffect } from "react";
export default function Error({ error, reset }: Props) {
useEffect(() => { Sentry.captureException(error); }, [error]);
return (...);
}
💡
error.digest关联前后端 Next 在 server 端崩溃时给一个digest(短哈希),客户端看到这个 digest;服务端日志里也有同样的 digest。用户报 bug 时告诉你 digest,你直接搜后端日志。
4. 用户上下文与面包屑
// 用户登录后
Sentry.setUser({ id: user.id, email: user.email });
Sentry.setTag("tenant", user.tenantId);
// 用户登出
Sentry.setUser(null);
// 关键业务事件加面包屑
Sentry.addBreadcrumb({
category: "checkout",
message: "user clicked pay",
level: "info",
data: { orderId, amount },
});
⚠️ 不要把 PII 一股脑塞 Sentry(身份证、银行卡)。
maskAllText默认开,但自定义事件 / 面包屑要自己脱敏。
5. Session Replay(选用)
Sentry / LogRocket / FullStory 都提供"回放用户操作"功能。设置:
replaysSessionSampleRate: 0, // 不主动录所有 session(贵)
replaysOnErrorSampleRate: 1.0, // 出错的 session 100% 录
integrations: [
Sentry.replayIntegration({
maskAllText: true, // 文本 mask
maskAllInputs: true, // 输入 mask
blockAllMedia: true, // 图片视频屏蔽
networkDetailAllowUrls: [/\/api\//], // 只收集自家 API 的 network
}),
],
⚠️ Replay 默认会录所有 DOM 和 network 强 mask 是隐私底线。含 PII 的页面(健康、金融)建议关 replay。
6. Web Vitals → 后端
// app/_components/web-vitals.tsx
"use client";
import { useReportWebVitals } from "next/web-vitals";
interface Payload {
name: string;
value: number;
rating: "good" | "needs-improvement" | "poor";
id: string;
navigationType: string;
path: string;
}
export function WebVitals() {
useReportWebVitals((metric) => {
const payload: Payload = {
name: metric.name,
value: metric.value,
rating: metric.rating,
id: metric.id,
navigationType: metric.navigationType,
path: window.location.pathname,
};
if (navigator.sendBeacon) {
navigator.sendBeacon("/api/metrics/vitals", JSON.stringify(payload));
} else {
fetch("/api/metrics/vitals", { method: "POST", body: JSON.stringify(payload), keepalive: true });
}
});
return null;
}
后端把它们写到 Prometheus(histogram),Grafana 看 p75 / p95 / p99,按 URL / 设备 / 区域分维度。
💡 为什么不直接用 Vercel Analytics? 简单项目可以。一旦你想自定义维度(按 tenant / 业务版本 / feature flag 切片),自建是必须。
7. 自建埋点
业务事件(转化漏斗、关键操作)用自家事件系统:
// lib/track.ts
"use client";
export function track(event: string, props?: Record<string, unknown>) {
const payload = {
event,
props,
ts: Date.now(),
sessionId: getSessionId(),
userId: getUserId(),
path: location.pathname,
};
navigator.sendBeacon?.("/api/events", JSON.stringify(payload));
}
// 用法
track("checkout.started", { orderId, amount });
track("checkout.paid", { orderId, amount });
💡 批量 + sampling:每事件单发会撑爆请求数。可在 client 端缓冲 1s 或 5 条触发一次 batch 发送(
PendingQueue)。
8. Console 与 Network 上下文
Sentry 默认会把 console.error 和最近 N 次 network 请求作为 breadcrumb 附在错误事件里。但:
- 不要把敏感 cookie / header / body 进 breadcrumb
- 关闭过度详细:
integrations: [
Sentry.browserApiErrorsIntegration({
setTimeout: true, setInterval: true, requestAnimationFrame: true, XMLHttpRequest: true,
}),
],
9. 真实用户 vs 实验室
| 数据 | 来源 | 用途 |
|---|---|---|
| Field data | 真实用户上报 | 决策依据,SLO 用 |
| Lab data | Lighthouse、WebPageTest | 调试、回归检测 |
两个数据应该接近;如果 field 比 lab 差很多 → 用户设备/网络条件比你测试机器差(常态)。
10. 性能告警
Web Vitals 阈值:
alert: PoorLCP_Rate
expr: (sum(rate(web_vitals_count{name="LCP",rating="poor"}[10m])) / sum(rate(web_vitals_count{name="LCP"}[10m]))) > 0.1
for: 15m
LCP 差的比例 > 10% 持续 15 分钟 → 报警。
11. 与后端链路打通
每个用户请求带 x-request-id(从 middleware 注入,01 章已铺垫)。Sentry event 自动带它:
// 在请求链路上设 request id
Sentry.configureScope((scope) => {
scope.setTag("request_id", requestId);
});
后端日志、trace、Sentry event 共享同一个 request_id → 一个工单串到底。
12. 开发期工具
- React DevTools:profiler 看哪些组件重渲染过多
- Next.js DevTools:
next dev --inspect,Chrome 看 server 端 trace - Bundle Analyzer(06 章)
- Lighthouse(Chrome DevTools)
react-scan:开发期自动高亮重渲染组件
13. 速记卡
| 想知道 | 看 |
|---|---|
| 某用户出了什么错 | Sentry by userId |
| 用户操作回放 | Sentry Replay(出错 session) |
| 首屏慢 | Web Vitals dashboard 按页面切片 |
| 某 release 是否变差 | Sentry release comparison |
| 转化漏斗 | 自建埋点 + 数据仓库 |
| 重渲染过多 | React DevTools Profiler |
| Bundle 哪里大 | bundle-analyzer |
14. 上线 checklist
- Sentry 三端(client / server / edge)都接好
- Source map 上传配在 CI
- Release 与 git sha 绑定
- ErrorBoundary(
global-error.tsx+ 段级error.tsx)上报 Sentry - PII 脱敏(
beforeSend、Replay mask、breadcrumb) - Web Vitals 上报到自家或第三方
- 关键业务事件埋点
- 与后端 trace / log 通过
x-request-id关联 - Sentry 告警规则:错误率突增、新 issue、release regression
延伸阅读
09 - 部署:Docker / Vercel / K8s
目标:把 Next.js 应用"自动、可观、可回滚地"送上生产。本章覆盖 Vercel 一键托管、Docker 自建、K8s 部署、CDN、灰度。
1. 部署形态对比
| 形态 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| Vercel | 零配置、最佳实践内置、边缘网络、ISR/streaming 完美支持 | 价格高、ToB 合规 / 数据驻留有限制 | 早期、营销/SaaS 默认选择 |
| Self-host Docker on K8s | 完全可控、合规友好、与现有基建集成 | 自己负责 CDN / 缓存 / SSR scaling | 中后期、强合规、已有 K8s 基础 |
| Static export | CDN 即托管,无服务器 | 失去 RSC 动态能力、Server Action | 纯静态站点 |
💡 形态切换没那么贵 Next.js 同一份代码可以无改动地跑在 Vercel 或 Docker。默认先 Vercel,业务成熟再迁 K8s。
2. Vercel 部署要点
连 GitHub repo 后基本自动。注意:
2.1 环境变量
- Production / Preview / Development 三套独立
- Secret(
API_URL、SESSION_SECRET)不要勾"Public" NEXT_PUBLIC_*才会注入到 client bundle
2.2 Edge / Node Runtime
- Page / Route Handler 顶部
export const runtime = "edge"让它跑在 Edge Network(冷启动 ms 级、就近) - 但 Edge 不支持 Node 原生模块(
fs、部分 crypto API、Prisma) - 大多 page 留默认 Node runtime
2.3 ISR + revalidate
Vercel 原生支持 fetch revalidate 和 revalidateTag/Path,无需额外配置。多 region 部署时,revalidate 跨 region 同步(Vercel 帮你做)。
2.4 自定义域名 + HTTPS
- 域名 DNS 指向 Vercel
- 证书自动签发与续期
- 强制 HTTPS 默认开
2.5 限制(本教程提醒)
- Serverless function 单次执行 10s(Hobby)/ 60s(Pro)/ 900s(Enterprise)—— 长任务别在 Next 里
- Server Action body 默认 1MB,可调到 2MB
- Image Optimization 有月度配额
3. Docker 自建:多阶段构建
利用 output: "standalone"(01 章 next.config.mjs 已配)。
Dockerfile:
## syntax=docker/dockerfile:1.7
FROM node:20.11.1-alpine AS base
RUN apk add --no-cache libc6-compat tini
RUN corepack enable
WORKDIR /app
## ---- 依赖层 ----
FROM base AS deps
COPY pnpm-lock.yaml package.json pnpm-workspace.yaml ./
COPY apps/web/package.json apps/web/
COPY packages/shared/package.json packages/shared/
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm fetch --frozen-lockfile
## ---- 构建层 ----
FROM base AS build
ARG NEXT_PUBLIC_APP_URL
ARG NEXT_PUBLIC_SENTRY_DSN
ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL
ENV NEXT_PUBLIC_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN
COPY --from=deps /pnpm /pnpm
COPY . .
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile --offline
RUN pnpm --filter @my-app/web build
## ---- 运行层 ----
FROM node:20.11.1-alpine AS runner
RUN apk add --no-cache tini
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
COPY --from=build /app/apps/web/.next/standalone ./
COPY --from=build /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=build /app/apps/web/public ./apps/web/public
USER 1000
EXPOSE 3000
ENTRYPOINT ["tini", "--"]
CMD ["node", "apps/web/server.js"]
要点:
- 多阶段:runner 镜像 ~80MB(
standalone只带运行时需要的依赖) - BuildKit cache mount 加速 pnpm
tini作 PID 1:正确转发 SIGTERMNEXT_PUBLIC_*必须在 build 时存在(它们被内联到 client bundle)- non-root(
USER 1000)
⚠️
NEXT_PUBLIC_*是 build-time 变量 不同环境(staging / prod)需要不同镜像或在运行时用占位符 + 启动脚本替换。最简办法:每个环境 build 自己的镜像。
3.1 .dockerignore
node_modules
**/node_modules
**/.next
**/dist
.git
.env*
**/coverage
playwright-report
test-results
4. GitHub Actions 完整 CI/CD
.github/workflows/ci.yml:
name: CI
on:
pull_request:
push:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version-file: .nvmrc }
- run: corepack enable
- uses: actions/cache@v4
with:
path: ~/.local/share/pnpm/store
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
- run: pnpm test
- run: pnpm size-limit
- run: pnpm build
env:
NEXT_PUBLIC_APP_URL: https://app.example.com
API_URL: http://localhost:3001
SESSION_SECRET: ${{ secrets.CI_SESSION_SECRET }}
e2e:
needs: quality
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version-file: .nvmrc }
- run: corepack enable
- run: pnpm install --frozen-lockfile
- run: pnpm exec playwright install --with-deps chromium
- run: pnpm test:e2e
- if: failure()
uses: actions/upload-artifact@v4
with: { name: playwright-report, path: playwright-report/ }
build-image:
needs: [quality, e2e]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
ghcr.io/${{ github.repository_owner }}/web:sha-${{ github.sha }}
ghcr.io/${{ github.repository_owner }}/web:main
build-args: |
NEXT_PUBLIC_APP_URL=${{ vars.NEXT_PUBLIC_APP_URL }}
NEXT_PUBLIC_SENTRY_DSN=${{ secrets.NEXT_PUBLIC_SENTRY_DSN }}
cache-from: type=gha,scope=web
cache-to: type=gha,mode=max,scope=web
provenance: true
sbom: true
- uses: aquasecurity/trivy-action@master
with:
image-ref: ghcr.io/${{ github.repository_owner }}/web:sha-${{ github.sha }}
severity: HIGH,CRITICAL
exit-code: '1'
deploy-staging:
needs: build-image
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- run: |
helm upgrade --install web ./infra/k8s/charts/web \
--namespace staging --create-namespace \
--set image.tag=sha-${{ github.sha }} \
--wait --timeout 5m
5. Kubernetes 部署
infra/k8s/charts/web/templates/deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}
labels: { app: web, version: {{ .Values.image.tag }} }
spec:
replicas: {{ .Values.replicaCount }}
revisionHistoryLimit: 5
strategy:
type: RollingUpdate
rollingUpdate: { maxUnavailable: 0, maxSurge: 1 }
selector:
matchLabels: { app: web }
template:
metadata:
labels: { app: web, version: {{ .Values.image.tag }} }
spec:
terminationGracePeriodSeconds: 30
securityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 1000
seccompProfile: { type: RuntimeDefault }
containers:
- name: web
image: "{{ .Values.image.repo }}:{{ .Values.image.tag }}"
imagePullPolicy: IfNotPresent
ports: [{ containerPort: 3000, name: http }]
env:
- name: NODE_ENV
value: production
- name: PORT
value: "3000"
- name: APP_VERSION
value: {{ .Values.image.tag }}
- name: API_URL
valueFrom: { secretKeyRef: { name: web-secrets, key: API_URL } }
- name: SESSION_SECRET
valueFrom: { secretKeyRef: { name: web-secrets, key: SESSION_SECRET } }
- name: SENTRY_DSN
valueFrom: { secretKeyRef: { name: web-secrets, key: SENTRY_DSN } }
resources:
requests: { cpu: 100m, memory: 256Mi }
limits: { cpu: 1000m, memory: 1024Mi }
readinessProbe:
httpGet: { path: /api/health, port: http }
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 3
livenessProbe:
httpGet: { path: /api/health, port: http }
initialDelaySeconds: 30
periodSeconds: 15
failureThreshold: 4
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 5"]
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: ["ALL"] }
app/api/health/route.ts:
import { NextResponse } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET() {
return NextResponse.json({ status: "ok", at: new Date().toISOString() });
}
要点:
maxUnavailable: 0+maxSurge: 1:零停机preStop sleep 5:K8s 摘 endpoint 是异步的,sleep 一会儿等 LB 真摘掉再退readOnlyRootFilesystem:Next 写不到磁盘(.next/cache在 build 时已固化)- HPA:基于 CPU,Next 通常 CPU 敏感
5.1 Service + Ingress
apiVersion: v1
kind: Service
metadata: { name: web }
spec:
selector: { app: web }
ports: [{ port: 80, targetPort: http }]
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: web
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
nginx.ingress.kubernetes.io/proxy-buffering: "off" # 流式 SSR 必须
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
spec:
ingressClassName: nginx
tls: [{ hosts: [app.example.com], secretName: web-tls }]
rules:
- host: app.example.com
http:
paths:
- { path: /, pathType: Prefix, backend: { service: { name: web, port: { number: 80 } } } }
⚠️
proxy-buffering: off极重要 不关 buffering,Nginx 会等响应完整再发给客户端 → RSC 流式渲染失效,用户看不到"先 fallback 后补丁"的效果。
5.2 HPA
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: web }
spec:
scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: web }
minReplicas: 3
maxReplicas: 20
metrics:
- type: Resource
resource: { name: cpu, target: { type: Utilization, averageUtilization: 70 } }
6. 静态资源 CDN
Next 的 _next/static/* 是不可变文件(带 hash)。前置 CDN 缓存它们:
用户 ─► CDN(缓存 _next/static/* 一年)
│
└─► 回源 ─► Next pods(只处理动态请求)
CDN 配置:
_next/static/**→ Cache 1 year,immutable_next/image?**→ Cache 1 day(图片优化)- 其他 → 透传(Next 自己决定缓存)
Vercel 自动做。自建用 CloudFront / Cloudflare 在 K8s Ingress 前。
7. Schema 迁移与发布(若 Next 直连 DB)
形态 B 时,Next 需要做迁移。不要在容器启动时跑迁移(多副本并发问题)。
用 K8s Job 在 Helm pre-upgrade 钩子里跑:
apiVersion: batch/v1
kind: Job
metadata:
name: web-migrate-{{ .Release.Revision }}
annotations:
"helm.sh/hook": pre-upgrade
spec:
template:
spec:
containers:
- name: migrate
image: "{{ .Values.image.repo }}:{{ .Values.image.tag }}"
command: ["pnpm", "prisma", "migrate", "deploy"]
(本教程默认形态 A,Next 不连 DB,没这步。)
8. 灰度发布
8.1 流量百分比(Argo Rollouts)
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata: { name: web }
spec:
strategy:
canary:
steps:
- setWeight: 5
- pause: { duration: 10m }
- setWeight: 25
- pause: { duration: 10m }
- setWeight: 50
- pause: { duration: 5m }
- setWeight: 100
8.2 用户白名单 / 哈希(更可控)
在 middleware 里用 cookie 决定走哪个版本:
// middleware.ts
const variant = chooseVariant(req); // 基于 cookie / userId hash
const res = NextResponse.rewrite(new URL(variant === "next" ? "/_next-build" : "/_main-build", req.url));
res.cookies.set("variant", variant, { maxAge: 3600 });
return res;
更简单的做法:feature flag(GrowthBook / Unleash / LaunchDarkly),在组件里根据 flag 渲染新/旧 UI。比基础设施级灰度灵活得多。
const isNewCheckout = await flags.isEnabled("new-checkout", { userId });
return isNewCheckout ? <CheckoutV2 /> : <CheckoutV1 />;
9. 回滚
helm history web -n production
helm rollback web 42 -n production --wait
未演练的回滚 = 不存在的回滚。每月演练一次。
10. 部署 checklist
-
output: "standalone"已开 - Dockerfile 多阶段、non-root、tini、镜像 < 300MB
-
NEXT_PUBLIC_*在 build args / Vercel env 里正确注入 - CI 含 lint / typecheck / test / size-limit / build / scan
- 镜像 SBOM + provenance 已生成
- K8s probe 配齐,readiness 5s,liveness 30s
- HPA min ≥ 2(避免单点)
- Ingress 关
proxy-buffering(流式 SSR) - 静态资源走 CDN 1 年缓存
- Source map 自动上传到 Sentry,与 release 关联
- Helm rollback 命令演练过
- 灰度方案就绪(流量 / 用户哈希 / feature flag)
延伸阅读
10 - 安全加固与上线 Checklist(前端视角)
目标:把本教程涉及的前端安全点收敛成可勾选清单。Next.js 让前端工程师也写真正在服务器跑的代码,本章覆盖客户端 + 服务端两侧。
1. 心智:前端的真实攻击面
| 面 | 风险 | 章节 |
|---|---|---|
| XSS | 恶意脚本读 cookie、token、做事 | 本章 §3 / §7 |
| CSRF | 借助登录用户做未授权写 | 本章 §4 |
| Token 窃取 | localStorage 被劫 npm 包读 | 本章 §5 |
| 服务端 RCE / SSRF | Server Action / Route Handler 滥用 | 本章 §6 |
| 供应链 | 恶意 npm 包 | 本章 §10 |
| 隐私 | PII 进日志 / Replay / 第三方 | 本章 §9 |
| 信息披露 | 错误堆栈 / Source map 暴露 | 本章 §8 |
| Clickjacking / MIME / 嗅探 | 浏览器边缘攻击 | 本章 §2 |
2. HTTP 安全头(next.config.mjs)
async headers() {
return [{
source: "/(.*)",
headers: [
{ key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains; preload" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
{ key: "Cross-Origin-Opener-Policy", value: "same-origin" },
],
}];
}
- HSTS:
max-age=31536000; includeSubDomains; preload(上 HSTS preload 列表前确认所有子域 HTTPS) - TLS 1.2 最低,优先 1.3
- 证书自动续期(Vercel 自动 / 自建用 cert-manager)
- HTTP → HTTPS 强制跳转(Ingress / Vercel 自动)
-
X-Frame-Options: DENY或 CSPframe-ancestors: 'none' -
X-Content-Type-Options: nosniff -
Referrer-Policy: strict-origin-when-cross-origin -
Permissions-Policy关闭不用的 API
3. Content Security Policy(CSP)
CSP 是 XSS 的最强缓解。用 nonce 模式(Next 14+ 原生支持):
// middleware.ts
import { NextResponse, type NextRequest } from "next/server";
export function middleware(req: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
const cspHeader = `
default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
style-src 'self' 'unsafe-inline';
img-src 'self' blob: data: https:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
connect-src 'self' https://api.example.com https://*.sentry.io;
`.replace(/\s{2,}/g, " ").trim();
const headers = new Headers(req.headers);
headers.set("x-nonce", nonce);
headers.set("content-security-policy", cspHeader);
const res = NextResponse.next({ request: { headers } });
res.headers.set("content-security-policy", cspHeader);
return res;
}
// app/layout.tsx
import { headers } from "next/headers";
import Script from "next/script";
export default function RootLayout({ children }: { children: React.ReactNode }) {
const nonce = headers().get("x-nonce") ?? undefined;
return (
<html>
<body>
{children}
<Script src="https://analytics.example.com/ga.js" nonce={nonce} strategy="afterInteractive" />
</body>
</html>
);
}
- CSP 不含
'unsafe-eval' -
script-src用 nonce 或 hash;不要'unsafe-inline' -
style-src暂可'unsafe-inline'(Tailwind / Next 内联很多),长期目标移除 -
frame-ancestors: 'none' -
object-src: 'none' -
base-uri: 'self' -
form-action: 'self'(防止表单被劫到外站) -
connect-src显式 allowlist(API、Sentry、CDN)
⚠️ CSP 是上线检查重灾区 一开
'unsafe-eval'/'unsafe-inline'等于关闭 XSS 防护。但移除它们后老代码 / 第三方脚本可能挂,迭代式上线:先 Report-Only 模式(Content-Security-Policy-Report-Only)收集违规,再切到 Enforce。
4. CSRF 防护(Server Actions + Cookie 模式)
- Next 14+ Server Actions 默认校验
Origin === Host(不要在 config 关掉) - 自定义 Route Handler 的 POST/PUT/PATCH/DELETE 需要自己加 CSRF token(双提交 cookie 或 SameSite)
- Cookie 必须
SameSite=lax(默认)或strict,不要none除非有强需求 - 写操作要求登录,GET 不能改状态
// Cookie 设置
reply.setCookie("sid", sid, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
});
5. Token / Session 存储
- 不要把 token 存 localStorage / sessionStorage(XSS 可读)
- 不要在 client-side 状态(Zustand / Redux)里持久化 token
- 必须用 HttpOnly + Secure Cookie 存 session id 或 access token
- Refresh token 一次性 + 旋转(后端职责,前端只调
/refresh端点) - Token 在 server-side 通过 cookie 转发到 API(03 章
apiFetch)
// ✅
const token = cookies().get("access_token")?.value;
// ❌
const token = localStorage.getItem("token");
6. Server Action / Route Handler 加固
(04 章已强调,这里收尾)
- 每个 Action / Route Handler 起手做鉴权
- 输入用 Zod
.safeParse,失败返回结构化错误 - 不抛 raw error 给客户端(防 stack trace 信息披露)
- 关键写操作:鉴权 + 授权 + 限流 + 幂等 + 审计 log 五件套
- 文件上传走预签名 URL,Action 不接大 body
- SSRF 防御:用户传入的 URL 不直接 fetch,allowlist 协议 + 解析 DNS + 检查不在内网 CIDR
- Webhook 接收(Route Handler):验签(HMAC + timestamp),
timingSafeEqual比较
// SSRF allowlist 示例
function isSafeUrl(u: string): boolean {
const url = new URL(u);
if (!["https:", "http:"].includes(url.protocol)) return false;
if (["localhost", "127.0.0.1"].includes(url.hostname)) return false;
// 还需校验 DNS 解析的 IP 不在内网 CIDR(用 ssrf-req-filter 之类的库)
return true;
}
7. 输出与 XSS
- React 默认转义 → 大多 XSS 自动防御
-
dangerouslySetInnerHTML全局 grep,每处审计;必须用时配 DOMPurify 清洗 - 用户内容渲染前 sanitize(尤其富文本)
- Markdown 渲染用
remark系列时确保关闭 raw HTML
// ❌ 危险
<div dangerouslySetInnerHTML={{ __html: userContent }} />
// ✅ 清洗后
import DOMPurify from "isomorphic-dompurify";
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userContent) }} />
- 服务端清洗优先:用
isomorphic-dompurify在 Server Component / Action 中清洗后再发给客户端 - URL 字段 allowlist 协议:
javascript:/data:不应能放进<a href>
function safeHref(url: string): string {
try {
const u = new URL(url, "https://example.com");
return ["http:", "https:", "mailto:"].includes(u.protocol) ? url : "#";
} catch { return "#"; }
}
8. 信息披露
- 不要把 stack trace 渲染给用户:用
error.tsx友好兜底,详情上报 Sentry - 生产 source map 上传到 Sentry,但不公开到客户端(默认 Next
productionBrowserSourceMaps: false;若开,Vercel / CDN 限制访问) - 服务端返回的 error 不带后端 SQL / 内部 ID
- 不要在
console.log留敏感信息(浏览器 console 是公开的) - 注释里的 TODO 别写"用户 xxx 的临时密码 xxx"
9. 隐私 + PII
- Sentry
beforeSend删authorization/cookieheader - Sentry Replay
maskAllText/maskAllInputs/blockAllMedia - 第三方脚本(Analytics、广告)用 SRI 或 CSP 白名单 + 阅读隐私协议
- 用户数据导出 / 删除 入口(GDPR / 中国个保法)
- Cookie 同意横幅(目标地区有要求时)
- PII 不进自家 analytics 事件(用 userId,不传 email/phone)
10. 依赖 & 供应链
-
pnpm audit/ Snyk / Dependabot 每周扫 -
pnpm-lock.yaml入 git(防 typosquatting) - CI 拒绝合并含 HIGH+ 漏洞的 PR
- Docker 镜像扫(Trivy)
- 生成 SBOM、SLSA provenance(09 章)
- 新依赖加入需要 review:维护活跃度、下载量、签名
- 不要安装"看起来很相似"的包名(eslint-pulgin-* 这类常见 typo)
- Subresource Integrity(SRI):
<script src="..." integrity="sha384-...">
11. iframe 与跨源
- 你的页面被 iframe?
X-Frame-Options: DENY或 CSPframe-ancestors - 你 iframe 第三方?
<iframe sandbox="allow-scripts allow-same-origin">(最小权限) -
target="_blank"链接必加rel="noopener noreferrer"(防 tabnabbing) -
postMessage通信 严格校验event.origin
// 危险:
window.addEventListener("message", (e) => {
doSomething(e.data); // ❌ 任何来源都能触发
});
// 安全:
window.addEventListener("message", (e) => {
if (e.origin !== "https://trusted.example.com") return;
doSomething(e.data);
});
12. 移动端 / 嵌入式 webview
- App webview 嵌你的页面?约定 origin 白名单
- Universal Link / Deep Link 校验签名,不要直接跳
intent:// - In-app browser 中 cookie / localStorage 行为可能与主浏览器不同,测试
13. middleware 安全注意
- middleware 不要查 DB(Edge runtime 不支持 + 性能)
- middleware 决策基于 cookie 签名 / JWT 验签(stateless)
- middleware 拒绝 = redirect 到 /login,不要 404(避免泄露受保护页面是否存在)
- middleware 也要校验 CSRF / Origin / Referer(若不交给 Action 框架兜底)
14. 渐进增强 / 降级
- 关键表单(登录、付款)JS 关闭也能用(Server Action 原生
<form action>即可) - 关键内容在 RSC payload 里(SEO + 无 JS 也可见)
- 错误页 / 404 即使 client JS 崩了也能显示(Server Component)
15. 与后端协作的安全边界
后端会替你做的:
- 鉴权(签发 / 撤销 token)
- 业务授权(行级权限、CASL)
- 数据库 RLS
- 业务输入校验
- API 限流
- Webhook 验签后转发
前端仍要做的:
- Action 鉴权(纵深防御)
- Zod 校验(UX + 减少无效后端调用 + 防绕过)
- XSS / CSRF 防御
- 客户端存储不出现 token
- CSP / 安全头(浏览器侧防御)
- 错误信息脱敏(不让前端代码泄露后端细节)
💡 "前端只是 UI" 是错的 Next 14 之后前端工程师写真正在服务器跑的代码(RSC / Server Actions / Route Handlers / middleware)。这部分按服务端工程对待。
16. 上线发布的安全门(每次 release)
-
gitleaks无命中 -
pnpm audit无 HIGH+ - 容器扫描无 HIGH+
- SAST(Semgrep / SonarQube)无新增 high
- 测试覆盖率达标
- size-limit 通过
- CHANGELOG / release notes 已更
- Sentry release 关联 git sha,source map 已传
17. 事故响应
SEV-1(前端):
- 用户无法登录 / 关键页面整体白屏 / 全站性能崩坏
- 数据/token 泄露事件
响应:
1. 5 分钟内 on-call 确认 + 在 #incident 频道宣布
2. 第一选择是回滚(Vercel 或 K8s)
3. 同步频率:每 30 分钟更新
4. 解决后 48h 内 post-mortem
18. 上线总检查清单
| 大类 | 完成 |
|---|---|
| 传输层(HSTS / TLS) | ☐ |
| 安全头(XFO / nosniff / Referrer) | ☐ |
| CSP(nonce 模式) | ☐ |
| CSRF / SameSite | ☐ |
| Token 存储(HttpOnly Cookie) | ☐ |
| Action / Route Handler 加固 | ☐ |
| 输出与 XSS(DOMPurify) | ☐ |
| 信息披露(error.tsx / source map) | ☐ |
| 隐私 / PII / Replay mask | ☐ |
| 依赖 / 供应链 | ☐ |
| iframe / 跨源 | ☐ |
| middleware 安全 | ☐ |
| 渐进增强 | ☐ |
| 发布门 | ☐ |
| 响应流程 | ☐ |
19. 必读资源
- OWASP Top 10
- OWASP Cheat Sheets(尤其 XSS / CSRF / Session Management / Content Security Policy)
- Next.js Security 文档
- Web Hypertext Application Technology Working Group — referer/cors/cookie 规范
后记
到这里,11 篇 Next.js 前端教程结束。
回看你应该掌握:
- 从零搭出可演进的 Next 14 工程
- App Router + RSC 心智清晰
- 数据获取与四层缓存掌握自如
- Server Actions + 表单的完整方案
- 状态分层、UI 体系、i18n
- 性能预算(Vitals)与无障碍
- 测试金字塔(前端版)
- 前端可观测性(Sentry + Vitals + 自建)
- Docker / K8s / Vercel 三种部署方式
- 一份"前端工程师必须知道的安全清单"
工程师真正的本事不是"会用 X 框架",而是知道:
- 这个决策的代价是什么
- 什么时候用 A,什么时候用 B
- 这个失败会怎样,我能怎么发现、怎么恢复
- 哪些是不可逆决策,哪些是可以下次再改的
希望这套教程帮你形成判断,而不是教你照搬。代码是工具,判断力才是壁垒。
祝上线顺利,Vitals 长红,CLS 永远 < 0.1。
附录 — 高级进阶
前 11 章解决"怎么把 Next.js 站点做出来"。这部分解决"做出来之后,你怎么应对极端场景、规模、跨团队的真实挑战"。