Next.js 教程 Part 3 — 性能、测试与上线

3 阅读14分钟

本册涵盖: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 / heightfill(防 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 的常用手段
  1. client 化最小化:首屏让 RSC,只把互动元素抽成 client(02 章)
  2. 代码分割:dynamic(() => import("./Heavy"), { ssr: false }) 把不在首屏的 client 组件懒加载
  3. 替换重依赖:moment → dayjs,lodash → lodash-es 选择性 import,date-fns 按模块 import
  4. 去 polyfill:browserslist 适度,Next 自动按 target 调
  5. 第三方脚本:next/script strategy="lazyOnload",避免阻塞主线程
  6. 图片不要 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-invalidaria-describedby
  • 跳过链接(<a href="#main">跳到主内容</a>)
10.2 模态 / 抽屉 / 下拉

用 Radix(shadcn 都有封装),自带:

  • 打开时 focus trap
  • ESC 关闭
  • 关闭后 focus 回触发元素
  • aria-modalrole="dialog"

⚠️ 自己手写模态几乎一定有 a11y bug。Radix 解决 95%。

10.3 ARIA 用法基本规则
  1. 能用语义 HTML 就不用 ARIA(<button> 而不是 <div role="button">)
  2. 不要重复语义(<button aria-label="Click me">Click me</button> 多余)
  3. 动态内容用 live region:<div aria-live="polite">Saved.</div>
  4. 隐藏装饰元素: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} vs tabIndex={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)

延伸阅读


07 - 测试金字塔(前端视角)

目标:在 Next.js 应用里写真有用的测试。每种测试的价值、边界、工具、写法。

1. 心智模型(前端版)

                    /\
                   /  \         E2E (Playwright)
                  /----\        慢、贵、易碎,关键链路 < 30 个
                 /      \
                /--------\      集成 (Vitest + Testing Library)
               /          \     组件 + 交互,**主战场**
              /------------\
             /              \   单元 (Vitest)
            /----------------\  纯逻辑、工具函数、自定义 hook

💡 前端别迷信"组件单测" 单独测 <Button /> 没意义(它就是个 button)。测业务组件(<UserForm />)和用户能感知的行为(填错邮箱看到红字、提交后跳到列表)更有价值。

2. 工具栈

类型工具备选
RunnerVitestJest
组件测试@testing-library/react + jsdom/happy-dom
MockMSW(网络层)、vi.mock(模块)nock
E2EPlaywrightCypress
a11y@axe-core/playwrightjest-axe
视觉回归Playwright screenshot 或 ChromaticPercy

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 优先,远离 getByTestId
  • userEvent 模拟真实用户(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 选择器优先级(必背)

按可靠性从高到低:

  1. getByRole(button / link / textbox),最贴近用户和无障碍
  2. getByLabel(表单字段)
  3. getByText(可见文本)
  4. getByTestId(data-testid)—— 最后才用
  5. ❌ CSS / XPath —— 易碎
9.5 等待与稳定性
// ✅ web-first assertion 自带等待
await expect(page.getByText("Saved")).toBeVisible();

// ❌ 不要 waitForTimeout(...),flake 源
9.6 数据准备

E2E 不要走 UI 注册 准备数据 → 慢且脆弱。

  • 调后端的 __test__/seed endpoint(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 的 clock API
  • 网络抖动:MSW / 路由 mock
  • 动画 / 字体:截图前等加载 / mask 动态元素
  • 顺序依赖:每个测试独立 setup
  • 随机数据:seed faker,不用 Math.random()

修法:第一次 flake 立刻修,不要 retry 掩盖。

14. 速记卡

想测
纯函数 / hookVitest + Testing Library renderHook
业务组件交互Vitest + Testing Library + userEvent
外部 HTTPMSW
完整用户流程Playwright
服务端组件抽数据函数 → 单测;UI → E2E
Server Action抽业务函数 → 单测;鉴权/revalidate → E2E
a11yaxe in Playwright + ESLint jsx-a11y
视觉回归Playwright screenshot + mask 动态区

延伸阅读


08 - 可观测性与监控(前端视角)

目标:让任何线上前端问题在 5 分钟内能定位:谁、什么页面、什么浏览器、什么操作、什么错误。覆盖错误监控、性能监控、用户行为。

1. 前端可观测性的三件事

关注工具关键指标
错误Sentry browserJS error rate、ErrorBoundary 触发率
性能Web Vitals + RUMLCP / 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 dataLighthouse、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 exportCDN 即托管,无服务器失去 RSC 动态能力、Server Action纯静态站点

💡 形态切换没那么贵 Next.js 同一份代码可以无改动地跑在 Vercel 或 Docker。默认先 Vercel,业务成熟再迁 K8s

2. Vercel 部署要点

连 GitHub repo 后基本自动。注意:

2.1 环境变量
  • Production / Preview / Development 三套独立
  • Secret(API_URLSESSION_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 revalidaterevalidateTag/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:正确转发 SIGTERM
  • NEXT_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 / SSRFServer 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 或 CSP frame-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 beforeSendauthorization / cookie header
  • 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 或 CSP frame-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. 必读资源


后记

到这里,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 站点做出来"。这部分解决"做出来之后,你怎么应对极端场景、规模、跨团队的真实挑战"。