Next.js 教程 Part 4 — 高级进阶

4 阅读11分钟

本册涵盖:B1 前端高级专题 · B2 事故 10 例 · B3 浏览器性能诊断 · B4 软实力


B1 - 前端高级专题

B1.1 Web Worker:把昂贵计算移出主线程

线索:用户点了"导出 1 万行 CSV",前端卡 5 秒,INP 红到爆。

主线程占着 = UI 全卡。任何 > 50ms 的 JS 计算都该考虑放 Worker。

// workers/csv.worker.ts
self.addEventListener("message", (e: MessageEvent<{ rows: Row[] }>) => {
  const csv = e.data.rows.map(r => Object.values(r).join(",")).join("\n");
  self.postMessage({ csv });
});
// 使用
"use client";
import { useState } from "react";

export function ExportButton({ rows }: { rows: Row[] }) {
  const [busy, setBusy] = useState(false);

  async function exportCsv() {
    setBusy(true);
    const worker = new Worker(new URL("../workers/csv.worker.ts", import.meta.url));
    worker.postMessage({ rows });
    worker.onmessage = (e) => {
      const blob = new Blob([e.data.csv], { type: "text/csv" });
      saveAs(blob, "export.csv");
      worker.terminate();
      setBusy(false);
    };
  }
  return <button onClick={exportCsv} disabled={busy}>{busy ? "..." : "Export"}</button>;
}

💡 new Worker(new URL(..., import.meta.url)) 让 webpack/Next 自动打包 worker。比手写 worker 路径稳。

⚠️ Worker 不能用 DOM,只能传可序列化数据(用 Transferable 可以零拷贝传 ArrayBuffer)。

Comlink 让 Worker 像普通对象用:

import * as Comlink from "comlink";
const api = Comlink.wrap<typeof workerApi>(new Worker(...));
const result = await api.heavyCompute(input); // 像调本地 async 一样

B1.2 复杂状态机:XState

简单状态用 useState / Zustand;5 个以上状态 + 复杂转移 → 用 XState。

import { createMachine, assign } from "xstate";
import { useMachine } from "@xstate/react";

const checkoutMachine = createMachine({
  id: "checkout",
  initial: "cart",
  context: { items: [], paymentMethod: null, error: null },
  states: {
    cart: {
      on: { CHECKOUT: { target: "shipping", guard: "hasItems" } },
    },
    shipping: {
      on: {
        SUBMIT: { target: "payment" },
        BACK: { target: "cart" },
      },
    },
    payment: {
      on: {
        PAY: { target: "processing" },
        BACK: { target: "shipping" },
      },
    },
    processing: {
      invoke: {
        src: "chargeService",
        onDone: { target: "success" },
        onError: { target: "failed", actions: assign({ error: ({ event }) => event.data }) },
      },
    },
    success: { type: "final" },
    failed: {
      on: { RETRY: { target: "payment" } },
    },
  },
}, {
  guards: { hasItems: ({ context }) => context.items.length > 0 },
});

export function Checkout() {
  const [state, send] = useMachine(checkoutMachine);
  // state.matches("payment") -> render payment UI
}

收益:

  • 状态 + 转移 + 守卫 + 副作用集中,不再散落 useEffect
  • XState Inspector 可视化整张图,PM / QA 看图都能讨论
  • 不可能进入不合法状态(类型保证)

💡 画出来 = 一半的 bug 暴露 用 XState Studio 把图画出来,80% 的"忘了处理的情况"会暴露给你。先画图,再写代码

B1.3 表格虚拟化:百万行也丝滑

pnpm add @tanstack/react-virtual
"use client";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useRef } from "react";

export function VirtualList({ items }: { items: Row[] }) {
  const parentRef = useRef<HTMLDivElement>(null);
  const v = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 40,
    overscan: 5,
  });

  return (
    <div ref={parentRef} className="h-[600px] overflow-auto">
      <div style={{ height: v.getTotalSize(), position: "relative" }}>
        {v.getVirtualItems().map((row) => (
          <div
            key={row.key}
            style={{ position: "absolute", top: 0, left: 0, width: "100%", height: row.size, transform: `translateY(${row.start}px)` }}
          >
            {items[row.index].name}
          </div>
        ))}
      </div>
    </div>
  );
}

💡 何时需要虚拟化? DOM 节点 > 500 就开始卡。> 5000 必虚拟化。表格、长 feed、聊天记录、搜索结果。

B1.4 离线优先(PWA)

pnpm add next-pwa @serwist/next

next.config.mjs:

import withSerwist from "@serwist/next";

export default withSerwist({ swSrc: "src/sw.ts", swDest: "public/sw.js" })(nextConfig);

src/sw.ts(基本):

import { defaultCache } from "@serwist/next/worker";
import { installSerwist } from "@serwist/sw";

installSerwist({
  precacheEntries: self.__SW_MANIFEST,
  skipWaiting: true,
  clientsClaim: true,
  navigationPreload: true,
  runtimeCaching: defaultCache,
});

要考虑:

  • 何时降级到离线 UI?网络慢/无网时
  • **离线时用户能干什么?**只读 / 暂存写操作待联网后同步
  • 缓存策略:静态资源 CacheFirst,API 数据 NetworkFirst + 短 TTL
  • 离线提交:Background Sync API

⚠️ PWA 是高承诺 Service Worker 一旦注册就长期驻留,写错了用户更新不到新版。开 PWA 前必须有 SW 版本管理 + skip-waiting + 通知用户刷新的方案。

B1.5 复杂动画与性能

60 FPS = 16ms 每帧预算

性能友好的动画:

  • 只动 transformopacity(GPU 加速)
  • 不动 width / height / top / left(触发 layout)
  • will-change 提示浏览器(节制使用,会占显存)
  • 避免动画期间引起 layout shift

Framer Motion 进阶:

import { motion, useScroll, useTransform } from "framer-motion";

export function HeroParallax() {
  const { scrollY } = useScroll();
  const y = useTransform(scrollY, [0, 500], [0, 150]);
  return <motion.div style={{ y }}>{/* ... */}</motion.div>;
}

💡 测量帧率:Chrome DevTools → Performance,看 FPS 曲线。掉到 30 以下要警惕。

B1.6 微前端(Module Federation)

何时需要?

  • 多团队独立发布同一个 Web 应用
  • 部分模块用不同框架(老 Vue + 新 Next)共存
  • 巨型应用(几百屏)
  • 是"组件复用",那是 npm 包的事

Next 现状:官方对 Module Federation 支持不完善。可选方案:

⚠️ 微前端是终极武器,不是默认选择 它解决的是组织问题,不是技术问题。如果你能用 npm workspaces 解决,别上微前端

B1.7 i18n 进阶:右到左、复数、命名空间

// next-intl 高级用法
const t = useTranslations("invoice");
t("items", { count }); // count=0/1/2+ 自动选 message

// messages/zh.json
{
  "invoice": {
    "items": "{count, plural, =0 {无商品} one {# 件商品} other {# 件商品}}"
  }
}

RTL(阿拉伯语等):

// app/[locale]/layout.tsx
const dir = locale === "ar" ? "rtl" : "ltr";
<html lang={locale} dir={dir}>

Tailwind RTL:pe-4 而非 pr-4(logical properties)。

💡 命名空间分模块:每个 feature 一个 namespace(auth.signIn / invoice.create),按 locale 分文件,懒加载只装该 locale 的命名空间 → bundle 小。

B1.8 表单"恶魔级"复杂度

复杂表单的痛点:

  • 数百字段、嵌套层级深
  • 字段之间联动(A 改 → B 重新加载选项)
  • 部分字段权限不同(只读 / 隐藏)
  • 大型选择器(异步加载、虚拟化)
  • 草稿保存、断点续填

策略:

  1. 拆分 Step Form(向导)→ 状态机控制
  2. 每 step 一个 sub-schema,Zod .pick() 出来
  3. 草稿持久化:useEffect debounce 写 localStorage / API
  4. 字段权限用 schema 上的 meta,渲染时按 meta 决定显示/锁定
  5. 联动useWatch 局部订阅,不要 form.watch() 全订阅
// 每 step 校验子 schema
const stepSchema = FullFormSchema.pick({ name: true, email: true });
const step1 = stepSchema.safeParse(form.getValues());

B1.9 表格状态:复杂查询参数

URL 状态(nuqs)+ TanStack Table:

import { useReactTable, getCoreRowModel, getSortedRowModel, getFilteredRowModel } from "@tanstack/react-table";
import { useQueryStates, parseAsString, parseAsInteger, parseAsArrayOf } from "nuqs";

export function DataTable() {
  const [params, setParams] = useQueryStates({
    q: parseAsString.withDefault(""),
    page: parseAsInteger.withDefault(1),
    sort: parseAsString.withDefault(""),
    cols: parseAsArrayOf(parseAsString).withDefault(["name","email"]),
  });

  const { data } = useQuery({
    queryKey: ["users", params],
    queryFn: () => api.list(params),
  });

  // ... table 用 data,sortingChange 同步到 params
}

💡 所有筛选 / 排序 / 列显示都进 URL → 用户可以分享、可以收藏、可以后退。

B1.10 SSE 与 WebSocket

实时通知 / 协作:

选型适用
SSE(Server-Sent Events)单向推送(通知、live feed),简单,自动重连
WebSocket双向高频(聊天、协作编辑)
TanStack Query 轮询频率低(每分钟一次的状态)

SSE 在 Next 中:

// app/api/events/route.ts
export const runtime = "nodejs";

export async function GET(req: NextRequest) {
  const stream = new ReadableStream({
    start(controller) {
      const send = (data: unknown) =>
        controller.enqueue(`data: ${JSON.stringify(data)}\n\n`);

      const interval = setInterval(() => send({ ts: Date.now() }), 5000);
      req.signal.addEventListener("abort", () => clearInterval(interval));
    },
  });
  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache, no-transform",
      Connection: "keep-alive",
    },
  });
}

客户端:

useEffect(() => {
  const es = new EventSource("/api/events");
  es.onmessage = (e) => setData(JSON.parse(e.data));
  return () => es.close();
}, []);

⚠️ K8s Ingress 必须关 proxy-buffering(09 章已强调)否则 SSE 没用。


B2 - 前端事故 10 例

案例 1:useEffect 死循环 → 浏览器卡死

现象:用户访问列表页,Chrome 标签卡到崩溃。

根因:

useEffect(() => {
  fetchData().then(setData);
}, [data]); // ❌ data 变 → effect 重跑 → setData → data 变...

修复:依赖数组改 [] 或正确的依赖([userId])。

预防:eslint-plugin-react-hooks 的 exhaustive-deps 必开。

案例 2:hydration mismatch → React 警告满屏

现象:Server 渲染 OK,client hydrate 时 React 报警:Text content does not match

根因:

function Time() {
  return <span>{new Date().toLocaleString()}</span>;
  // 服务端是 UTC,客户端是本地时区 → 字符串不一致
}

修复:

  • <time dateTime={iso}>{visibleStr}</time> + useEffect 客户端再格式化
  • suppressHydrationWarning(仅特定节点)
  • 或 server 端就用用户时区(从 cookie / header 读)

预防:CR 时看到任何"和当前时间 / 随机数 / 浏览器 only API" 渲染要警惕。

案例 3:"use client" 顶上 page.tsx → 首屏空白

现象:Lighthouse LCP 5 秒+,首屏巨大空白。

根因:某工程师把 "use client"page.tsx 顶部,整页变 client,RSC 优势全没,数据获取改成 useEffect → 首屏没数据

修复:page 保持 server,只把需要互动的子组件抽 client。

预防:Code Review 模板加一条"page.tsx 顶部不能有 "use client""。

案例 4:Image 没 width/height → CLS 飙红

现象:打开页面后内容跳来跳去,用户点击错位。

根因:用了 <img> 而非 <Image>,或 <Image> 没设 width/height。

修复:全部 next/image + 必须 width/height(或 fill + 父级 relative)。

预防:eslint-plugin-jsx-a11y / @next/next 的 no-img-element 必开。

案例 5:token 进了 localStorage → 被供应链攻击读

现象:某 npm 包(event-stream 类事件)被注入恶意代码,所有用户 token 被偷。

根因:为了"方便"把 access token 放 localStorage,JS 可读 → 恶意包可读。

修复:Cookie + HttpOnly + Secure + SameSite,client JS 拿不到。

预防:ESLint 自定义规则禁止 localStorage / sessionStorage 写关键字(token、auth、secret)。

案例 6:Sentry source map 上传失败 → 错误堆栈无法读

现象:Sentry 收到大量 minified 堆栈,看不到原始函数名。

根因:CI 上传 source map 步骤失败,但没 fail 整个 build → 部署照常进行。

修复:CI 上传 source map 改为强制成功;失败即整个发布失败。

预防:任何"辅助步骤但实际重要"的 CI step 都必须 continue-on-error: false

案例 7:CSP 加严 → 老 dashboard 全挂

现象:上线新 CSP 后,某个嵌入式 BI dashboard 不显示数据。

根因:dashboard 用了 inline script,新 CSP 禁了 unsafe-inline

修复:

  • 灰度发 CSP(用 Content-Security-Policy-Report-Only 先收集 30 天)
  • 看 violation 报告,把合法的 inline 改成 nonce / 移到外部文件
  • 再切到 enforcing

预防:任何安全头变更先 Report-Only,等 violation 归零再 enforce。

案例 8:RSC 缓存把私有数据透传给所有人

现象:用户 A 看到了用户 B 的私人订单。

根因:

async function getOrders() {
  return fetch(`${API}/orders`, { next: { revalidate: 60 } }).then(r => r.json());
  // ❌ revalidate=60 + 没区分用户 → 全局缓存了 A 的数据
}

修复:

  • 个性化 fetch 必须 cache: "no-store"
  • 或缓存 key 带 userId(next: { tags: [orders:${userId}] })+ revalidateTag
  • DataCache 默认不区分用户,忘了 = 全员共享

预防:

  • 封装 apiFetch,默认 cache: "no-store",显式开缓存才生效
  • Code Review 看到 next: { revalidate: ... } 必问"是否个性化"

教训:这是最危险的 RSC 缓存事故。涉及隐私 / 合规可能 SEV-0。

案例 9:Server Action 越权 → 任何登录用户可删任何资源

现象:Penetration test 发现,登录用户 A 调 deleteOrderAction({ id: B_order_id }) 成功删了 B 的订单。

根因:

"use server";
export async function deleteOrderAction(input: { id: string }) {
  await prisma.order.delete({ where: { id: input.id } }); // ❌ 没鉴权 + 没归属检查
}

修复:

export async function deleteOrderAction(input: { id: string }) {
  const session = await getSession();
  if (!session) throw new UnauthorizedError();
  const order = await prisma.order.findUnique({ where: { id: input.id } });
  if (!order || order.userId !== session.userId) throw new ForbiddenError();
  await prisma.order.delete({ where: { id: input.id } });
}

预防:CR 模板必勾"Server Action 是否鉴权 + 授权 + 输入校验";Sentry / 安全扫描自动检测无鉴权的 action。

案例 10:CDN 缓存了 logged-in 页面 → 显示别人的姓名

现象:用户 A 打开首页,看到欢迎"你好,Bob"(Bob 是另一个用户)。

根因:CDN 配置缓存了所有 HTML,RSC payload 里有用户姓名,缓存命中后所有人看到同一个用户名。

修复:

  • 个人化页面必须 Cache-Control: private, no-store
  • CDN allowlist:只缓存营销静态页 / _next/static/*,其他透传
  • 在 Next 里给个人化 page 设 dynamic = "force-dynamic"cache: "no-store"

预防:CDN 配置 review 必查"是否区分公开 / 个性化路径"。

教训:CDN + 登录页 = 高危组合。每加一层缓存都要问:"谁会被错误命中?"


B3 - 浏览器侧性能诊断

B3.1 排查心法(前端版)

1. 量化:是 LCP / INP / TTFB 哪个差?p75 多少?
2. 分层:网络/JS 解析/Render/Layout/Paint 哪一段慢?
3. 定位:Performance panel + Lighthouse + 真实用户数据
4. 验证:改一处压一处
5. 灰度上线,监控 Vitals 变化

B3.2 Chrome DevTools Performance 实战

录一段操作 → 看时间线:

  • Main 线程:每个长任务(> 50ms,红色三角)都是 INP 杀手
  • Network:大文件 / 慢请求
  • Paint:layout shift 在哪一帧发生
  • Memory:有无意外飙升

常见模式:

  • 黄色 Scripting 段巨长 → JS 卡 → 看是哪个函数(双击 stack)
  • 紫色 Rendering 段巨长 → DOM 太大或样式昂贵 → 看 layout / style 重算
  • 绿色 Painting 段巨长 → 渲染层过多 → 减少 will-change / 滤镜

B3.3 Lighthouse 关键指标

每个 PR 跑一次 Lighthouse CI:

- uses: treosh/lighthouse-ci-action@v11
  with:
    urls: |
      https://preview-${{ github.event.number }}.app.example.com/
      https://preview-${{ github.event.number }}.app.example.com/dashboard
    budgetPath: ./lighthouse-budget.json
    uploadArtifacts: true

lighthouse-budget.json:

[{
  "path": "/*",
  "resourceSizes": [
    { "resourceType": "script", "budget": 250 },
    { "resourceType": "total", "budget": 1500 }
  ],
  "timings": [
    { "metric": "interactive", "budget": 3500 },
    { "metric": "largest-contentful-paint", "budget": 2500 }
  ]
}]

预算超出 → CI fail。

B3.4 INP 调优

INP 衡量"最慢的交互"。最常见原因:

  1. 点击后大量 setState 触发重渲染
  2. 同步计算阻塞(序列化、加密、排序大数组)
  3. 第三方脚本占主线程
// 修法 1:把昂贵更新放低优先级
import { startTransition } from "react";
function handleClick() {
  setQuickResponse(value); // 立即响应
  startTransition(() => {
    setExpensiveFilter(value); // 让 React 在空闲时更新
  });
}

// 修法 2:debounce
const debouncedSet = useMemo(() => debounce(setFilter, 200), []);

// 修法 3:计算移到 Worker

B3.5 React DevTools Profiler

录一段操作 → 看哪些组件重渲染过多。

红黄色 = 渲染慢的组件。点进去看:

  • 重渲染原因(props 变化、parent 重渲、context 变化)
  • 渲染耗时

常见根因:

  • Context 频繁变化让所有消费者重渲
  • 父组件每次 new 一个对象作 prop → 子的 memo 失效
  • 没 key 或 key 不稳定的列表

💡 react-scan 实时高亮重渲染组件,比 Profiler 更直观,开发期推荐。

B3.6 内存:浏览器侧泄漏

线索:用户长时间使用后变卡,刷新就好。

工具:Chrome DevTools → Memory → Heap snapshot,对比两个时间点。

常见根因:

  • listener 未 cleanup(useEffect return 没 off)
  • timer 未 clear
  • WebSocket 未 close
  • 大对象进了全局 store 不清
useEffect(() => {
  const id = setInterval(tick, 1000);
  return () => clearInterval(id); // 必有
}, []);

useEffect(() => {
  const handler = () => {};
  window.addEventListener("resize", handler);
  return () => window.removeEventListener("resize", handler);
}, []);

B3.7 Bundle 优化进阶

Tree shaking 实战:

// ❌ 全量导入 lodash → bundle 70KB+
import _ from "lodash";
_.debounce(...)

// ✅ 按需(配合 lodash-es)→ 仅 5KB
import debounce from "lodash-es/debounce";

// ✅ 用 modern alternative
import { debounce } from "@reactuses/core";

代码分割:

// 重组件懒加载
const RichEditor = dynamic(() => import("./RichEditor"), {
  loading: () => <EditorSkeleton />,
  ssr: false, // 仅互动型组件
});

// 按路由自动分割(App Router 默认)

barrel file 陷阱:

// components/index.ts —— ❌
export * from "./Button";
export * from "./ChartHeavy";
// 一个 import 拉整个 barrel
// ✅ 直接 import
import { Button } from "@/components/Button";

Next 14+ optimizePackageImports 能自动优化某些 barrel,但仍以直接 import 为安全。

B3.8 字体优化

import { Inter } from "next/font/google";

const inter = Inter({
  subsets: ["latin"],
  display: "swap",
  preload: true,
  weight: ["400", "600"], // 只装必要字重
  variable: "--font-inter",
});

中文字体:

  • 全量字体 5MB+,不要全包
  • 子集化(subset-font 工具)按页面预编子集
  • 或用 woff2 + unicode-range 分片

B3.9 网络优化

  • HTTP/2 / HTTP/3(CDN 默认开)
  • <link rel="preconnect"> 给跨域第三方
  • <link rel="preload"> 给关键资源
  • Service Worker 缓存策略
  • 图片用 AVIF / WebP(next/image 自动)
// app/layout.tsx
<link rel="preconnect" href="https://api.example.com" />
<link rel="dns-prefetch" href="https://cdn.example.com" />

B3.10 压测前端:WebPageTest

不只是 Lighthouse。WebPageTest 能:

  • 在真实网络(3G / 4G)模拟
  • 多地域测试(瑞士 / 印度 / 巴西)
  • 同时跑 9 次取中位数(去抖动)
  • film strip 看渲染过程逐帧

月度 Vitals review 必跑 WebPageTest 关键页面。


B4 - 工程师软实力(前端版)

与后端 A5 章重叠,这里只补 前端特有 部分。

B4.1 与设计师协作

  • Figma → Code 直接 1:1 像素还原是常见错觉。设计稿是规格,不是实现细节,留出 token / 系统层差异
  • 设计 token(spacing / color / typography)与 Tailwind 配置同步,改一处全局生效
  • 复杂交互让设计师在 Figma 标"hover / focus / disabled / loading / error" 五态,别全靠口头沟通
// tailwind.config.ts 与设计 token 对齐
import { figmaTokens } from "./design-tokens.json";
export default {
  theme: {
    colors: figmaTokens.colors,
    spacing: figmaTokens.spacing,
  },
};

B4.2 与后端协作

  • OpenAPI / Zod schema 共享:前后端用同一份 schema(本教程的 @my-app/shared)
  • 错误模型一致:RFC 7807,字段错误统一映射(04 章)
  • breaking change 提前 2 周公告,加 Sunset header
  • 生产 bug 联调:前端报 x-request-id,后端查日志,5 分钟定位

B4.3 PR 描述模板(前端)

## 改动
- 加 / 改 / 删了什么

## 截图 / 录屏
- (必须)桌面 + 移动端 各一张
- 暗色 / 亮色 各一张(若涉及)

## 影响范围
- 视觉:有变化 / 无变化
- API:无 / 有(列出 endpoint)
- a11y:已 / 未验证
- 性能:bundle 增减,Lighthouse 跑过

## 验证
- [ ] 桌面 Chrome / Safari / Firefox
- [ ] 移动 iOS Safari / Android Chrome
- [ ] 暗色 / 亮色
- [ ] 键盘可达
- [ ] 屏幕阅读器朗读合理
- [ ] 网络慢速 3G 加载体验

## 风险
- 灰度策略 / 回滚方案

B4.4 评审清单(前端)

关注
正确性边界(空、null、长文本、超长 URL)
RSC 心智"use client" 是不是放对位置?最浅边界?
缓存个性化数据是否误缓存(B2 案例 8)?
安全dangerouslySetInnerHTML / token 存储 / Server Action 鉴权
性能bundle 增量?重渲染?N+1 fetch?
a11ylabel / aria / 键盘 / 对比度
i18n写死中文?换行?复数?
移动端触摸目标 ≥ 44px?safe-area?横竖屏?

B4.5 设计前先做"5 个核心问题"

任何复杂 UI 开干前自问:

  1. 加载状态什么样?(骨架屏 / spinner / 占位)
  2. 空数据什么样?(引导操作 / 解释)
  3. 错误什么样?(可重试 / 联系客服 / 错误 ID)
  4. 数据量极端时什么样?(0 / 1 / 100 / 10000 条)
  5. 慢网络什么样?(3G / 离线)

💡 70% 的前端 bug 来自这 5 种状态没考虑全。设计师常常只画"happy path",剩下的全靠你想到。

B4.6 学习路径(前端)

  • 基础:HTML 语义 / CSS layout(flex / grid)/ JS 异步 / TS 进阶
  • 框架:React 文档(尤其新的)、Next.js 源码 examples
  • 性能:web.dev 课程、Chrome DevTools 文档
  • a11y:WCAG 2.2、A11y Project、屏幕阅读器实操
  • 进阶:Server Components RFC、React Compiler、Dan Abramov 博客
  • 设计:Refactoring UI 这本书

B4.7 高级前端 vs 中级前端

维度中级高级
用 React写组件知道何时 RSC / Client / Worker
状态管理用 Redux / Zustand知道何时 URL / Server / Local / Shared
性能跑 Lighthouse真实用户 Vitals + 定位优化
a11y听过键盘 + 屏幕阅读器都测过
设计还原 Figma与设计师协商 token 体系
安全知道 XSS知道 RSC 缓存可能泄露、CSP nonce、SSRF
上线推 main灰度 / 监控 / 回滚自动化

结语

到这里整个教程(11 章 + 4 章附录)结束。

如果你只能记住一件事,记这个:

代码是工具,判断力才是壁垒。 框架会换、库会过时、最佳实践会演进。但"知道决策的代价、知道失败的形态、知道何时该说不"这套判断力,在哪门技术下都通用。

高级工程师不是"会更多框架的中级工程师",是"在同样问题上能给出更深、更全、更长期的答案的工程师"。

祝你写出值得自己 5 年后回看不脸红的代码。