vault backup: 2026-04-29 23:36:32

This commit is contained in:
2026-04-29 23:36:32 +08:00
parent 7d41fbcd6a
commit c695463715
9 changed files with 1823 additions and 297 deletions
+214 -20
View File
@@ -9,6 +9,42 @@ create time: 2026-04-29 22:14
React 性能优化的核心原则是 **"减少不必要的渲染"**。本文档从诊断工具到具体手段,提供完整的调优方法论。
> [!question] 思考:为什么 React 会慢?
> 大多数情况下,应用变慢不是因为 React 本身慢,而是因为 **它重新渲染了不该渲染的组件**。
> 一个常见的陷阱:父组件 state 变化 → 所有子组件重新执行 render(即使它们的 props 没变)。
> 我们的目标:让 React 只渲染真正需要更新的部分。
### 优化决策流程图
```mermaid
flowchart TD
A[页面卡顿?] --> B{使用 Profiler 定位}
B --> C[频繁重渲染?]
B --> D[首屏加载慢?]
B --> E[滚动掉帧?]
C --> F[props 不变却渲染?]
C --> G[状态更新频率过高?]
F --> H["React.memo / useMemo"]
G --> I["useCallback / 防抖节流"]
D --> J[打包体积过大?]
D --> K[某些功能很少用?]
J --> L["Code Splitting / Tree Shaking"]
K --> M["Dynamic Import"]
E --> N[列表项很多?]
N --> O["虚拟列表 Virtual Scroll"]
style H fill:#61DAFB,color:#000
style L fill:#61DAFB,color:#000
style O fill:#61DAFB,color:#000
```
> [!warning] 第一条原则:先测量,再优化
> 没有 Profiler 数据的优化都是猜。先用 React DevTools 或 Chrome Performance 确认瓶颈在哪,
> 然后**针对性地解决**,而不是盲目地在每个组件上加 memo。
## 性能诊断工具箱
```mermaid
@@ -22,7 +58,7 @@ graph TB
C --> G["定位重渲染的组件和原因"]
D --> H["分析主线程阻塞时段"]
E --> I["整体 LCP/FID/CLS 评分"]
E --> I["整体 LCP/INP/CLS 评分"]
F --> J["生产环境真实用户数据"]
style C fill:#61DAFB,color:#000
@@ -54,6 +90,64 @@ const ExpensiveList = React.memo(({ items }: { items: Item[] }) => {
> - 父组件每次传新对象/函数作 prop → memo 无效
> - 简单列表(几十项以内)不需要 memo
## 缓存计算与回调 — useMemo / useCallback
React.memo 解决的是"组件不重新渲染",而 `useMemo` 和 `useCallback` 解决的是**"值不重新生成"**。
### useMemo —— 缓存昂贵的计算结果
```tsx
function SearchPage({ items, keyword }: { items: string[]; keyword: string }) {
// 过滤逻辑只在 items 或 keyword 变化时重新执行
const filtered = useMemo(() => {
console.log("filtering..."); // 仅依赖变化时才打印
return items.filter(item => item.includes(keyword));
}, [items, keyword]);
return <List data={filtered} />;
}
```
> [!tip] useMemo 的本质
> 它不是"性能捷径",而是**跳过不必要的计算**。对于 O(n) 以下的轻量操作,useMemo 反而增加内存开销。
> **适用场景**:大数据量处理、深度嵌套对象的派生计算、API 请求参数校验。
### useCallback —— 缓存函数引用
```tsx
function Parent() {
const [count, setCount] = useState(0);
// count 变化时才创建新函数,Child 用 React.memo 包裹时不会被重渲染
const handleClick = useCallback(() => {
setCount(c => c + 1);
}, []);
return (
<>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<Child onAction={handleClick} /> {/* 不受 count 变化影响 */}
</>
);
}
```
> [!important] 什么时候需要 useCallback?
> 仅在以下两种场景中使用:
> 1. 函数传给 `React.memo` 包裹的子组件,避免父状态变化导致子组件重渲染
> 2. 作为其他 Hook(如 `useEffect`)的依赖项
>
> 否则——不加更简单,也更容易维护。
---
> [!question] 思考:三个 memo 的关系
> - **React.memo**: 阻止组件树 re-render(组件级别)
> - **useMemo**: 缓存返回值(值级别)
> - **useCallback**: 缓存函数引用(等价于 `useMemo(() => fn, deps)`)
>
> 它们共同目标是:**缩小变更传播范围**。当 state 更新时,让影响尽可能少地扩散到子树。
## 虚拟列表(Virtual Scrolling)
当列表项超过数百条时,虚拟滚动通过只渲染可视区域内的 DOM 元素来大幅降低内存占用:
@@ -103,39 +197,69 @@ function VirtualList({ items }: { items: string[] }) {
## Code Splitting
Code Splitting 的核心理念:**用户不需要一次性下载所有代码**。按需加载可以显著降低首屏时间。
### 路由级拆分
```tsx
// Next.js App Router(内置)
const AdminPage = lazy(() => import("./pages/Admin"));
import { lazy, Suspense } from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
// vite + React Router
const Settings = lazy(() => import(/* vite: preload */ "./pages/Settings"));
// 每个路由对应一个 chunk,用户只下载当前页面对应的代码
const Home = lazy(() => import("./pages/Home"));
const Admin = lazy(() => import("./pages/Admin"));
function App() {
return (
<BrowserRouter>
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/admin" element={<Admin />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
```
> [!note] Next.js 的特殊之处
> Next.js 基于文件系统自动实现路由级 Code Splitting,**无需手动 lazy()**。
> 在 `/app` 目录中,每个页面文件就是一个独立的 route segment → 自动拆包。
### 组件级拆分
对体积大、使用频率低的第三方库进行动态导入:
```tsx
import dynamic from "next/dynamic";
// 不加载图表库直到真正需要
// SSR 关闭——图表库依赖 window,服务端没有
const Chart = dynamic(() => import("recharts"), { ssr: false });
// 或带 loading fallback
const HeavyEditor = dynamic(() => import("@monaco-editor/react"), {
loading: () => <p>Loading editor...</p>,
ssr: false,
});
// 带 loading fallback——提升感知体验
const HeavyEditor = dynamic(
() => import("@monaco-editor/react"),
{ loading: () => <p>正在加载编辑器...</p>, ssr: false }
);
// React 原生写法——配合 Suspense
const MapComponent = lazy(() => import("./MapComponent"));
// 可指定 preloadStrategy——预加载策略
const PrefetchModal = lazy(
() => import("./HeavyModal")
);
```
### Bundle 分析与优化
```bash
# 安装插件
npm install --save-dev rollup-plugin-visualizer
# 或在 Next.js 中使用 next-bundle-analyzer
# Next.js 项目
npx next-bundle-analyzer
# 生成可视化报告
npx run build && npx visualizer
# Vite / Webpack 通用方案
npm install --save-dev rollup-plugin-visualizer
npm run build && npx visualizer
```
> [!tip] Bundle 大小目标
@@ -145,13 +269,83 @@ npx run build && npx visualizer
> | 单个 chunk | < 300KB |
> | JS Total | < 500KB(SPA)/ < 200KB(PWA) |
> [!tip] 减小 bundle 的三个高效手段
> 1. **替换大库**——`lodash` → `lodash-es`(tree-shaking 友好),或直接用原生 API
> 2. **检查重复依赖**——`npm ls react` 看有没有多份 React 实例
> 3. **静态资源不进 bundle**——图片、视频等上传到 CDN,URL 写在代码里
## Lighthouse 关键指标调优
| 指标 | 含义 | 优化方向 |
|------|------|----------|
| **FCP**(First Contentful Paint) | 首次内容绘制 | 减小首屏 HTML/JS 体积 |
| **LCP**(Largest Contentful Paint) | 最大内容绘制 | 图片懒加载、预加载关键资源 |
| **INP**(Interaction to Next Paint) | 交互响应延迟 | useTransition、删除同步 heavy work |
| **CLS**(Cumulative Layout Shift) | 布局偏移 | 预留图片宽高、避免字体闪烁 |
| **FCP**(First Contentful Paint) | 首次内容绘制 | 减小首屏 HTML/JS 体积、使用 CDN |
| **LCP**(Largest Contentful Paint) | 最大内容绘制 | 图片懒加载、预加载关键资源、优先渲染 |
| **INP**(Interaction to Next Paint) | 交互响应延迟(取代 FID) | useTransition、删除同步 heavy work、Web Worker 分流 |
| **CLS**(Cumulative Layout Shift) | 布局偏移 | 预留图片宽高、避免字体闪烁、占位广告容器 |
## React 并发优化 —— startTransition / useDeferredValue
React 18 引入的并发特性是解决 **"交互卡顿"** 最直接的手段。它们让 React 能够**优先级调度**。
```tsx
import { useState, startTransition } from "react";
function SearchApp() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
// ❌ 普通 setState:阻塞 UI,用户输入时感知到延迟
// setSearchResults(heavySearch(query));
// ✅ startTransition:标记为低优先级,不阻塞当前输入
function handleChange(e: string) {
setQuery(e); // 高优先级——立即更新
startTransition(() => {
setResults(heavySearch(e)); // 低优先级——可以中断
});
}
return <input value={query} onChange={handleChange} />;
}
```
### 与 Suspense 配合的最佳实践
```tsx
// 路由级懒加载 + 过渡状态
<Suspense fallback={<Spinner />}>
<Routes>
<Route path="/dashboard" element={
<Dashboard />
} />
</Routes>
</Suspense>
```
> [!tip] startTransition vs useDeferredValue 的选择
> | 场景 | 推荐方案 |
> |------|----------|
> | 表单输入 → 搜索结果 | `startTransition` |
> | 长列表滚动 → 非关键区域降级 | `useDeferredValue` |
> | 异步数据加载(路由级) | `Suspense` + `lazy` |
> | 复杂计算(不在渲染中) | Web Worker / TanStack Query |
---
> [!summary] 性能优化 checklist
> - [ ] 用 Profiler 确认瓶颈位置
> - [ ] 高频重渲染组件加 `React.memo`
> - [ ] 派生计算用 `useMemo`,回调用 `useCallback`
> - [ ] 大数据列表用虚拟滚动
> - [ ] 大库和低频页面做 Code Splitting
> - [ ] 耗时操作放进 `startTransition`
> - [ ] Lighthouse 评分达标?Web Vitals 监控上线?
## 关联笔记
- [[06-性能优化 Hooks]] — useMemo、useCallback、useTransition 等 Hook 的详细用法
- [[13-并发特性]] — React 18 并发渲染机制深入解析
- [[04-State 与不可变性]] — State 更新模式对性能的影响
- [[09-状态管理]] — 全局状态管理的性能考量(Zustand / Redux / Jotai)
- [[14-Serverside Rendering]] — SSR / RSC 的首屏性能优势
- [[16-测试]] — 性能回归测试:React Testing Library + Performance Assertions
+187 -5
View File
@@ -146,11 +146,84 @@ describe("<UserCard />", () => {
});
```
### 快照测试
> [!tip] Snapshot 的定位
> 快照不是单元测试的替代品——它检测的是**UI 结构意外变化**。
> 适用于不会频繁变化的展示型组件(如仪表盘卡片、文章详情页)。
> 不适用于动态数据多的列表或表单。
```tsx
import { render } from "@testing-library/react";
import { UserDetail } from "./UserDetail";
it("渲染与之前一致", () => {
const { container } = render(<UserDetail user={mockUser} />);
expect(container).toMatchSnapshot();
});
```
> [!warning] 快照的陷阱
> - 时间戳、随机 ID 等动态内容会导致每次快照不同 → 用 `sanitize` 处理
> - 盲目更新快照(`-u`)等于放弃测试价值 → **每次 diff 都要人工审查**
> - 配合确定性断言一起使用,不要只做快照断言
### 查询方法速查表
> [!question] 思考:getBy、queryBy、findBy 我该用哪个?
> React Testing Library 提供三类查询,它们的**行为差异直接决定测试的健壮性**。
| 前缀 | 匹配不到时 | 典型场景 |
|------|-----------|---------|
| `getBy*` | **抛异常**(测试失败) | 验证元素**必须存在** |
| `queryBy*` | 返回 `null` | 验证元素**不应存在**(配合 `.not.toBeInTheDocument()`) |
| `findBy*` | **超时后抛异常** | 等待**异步出现**的元素(内部自动 await) |
```tsx
// getBy — 必须有,没有就失败(同步)
const submitBtn = screen.getByRole("button", { name: /submit/i });
// queryBy — 验证"不存在"(同步,推荐优先于 findBy 做否定断言)
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
// findBy — 等待异步出现的元素(内部封装了 waitFor + polling)
const successMsg = await screen.findByText("Saved!");
// ✅ 避免:手动写 waitFor + getBy(findBy 更简洁)
// await waitFor(() => expect(screen.getByText("Saved!")).toBeInTheDocument());
// getAllBy — 匹配多个元素
const checkboxes = screen.getAllByRole("checkbox");
expect(checkboxes).toHaveLength(3);
```
> [!tip] 查询优先级原则
> 按以下顺序选择查询方式,越靠前的优先级越高:
>
> `role` → `label` → `text` → `testID` → `screen`(最后手段)
>
> **永远不要**用 `container.querySelector(".css-class")`——样式变了测试就挂了。
---
## Mock 异步操作
### vi.mock() — 模块级 Mock
```tsx
// 内联 Mock,隔离 API 依赖
vi.mock("../api/user", () => ({
getUser: vi.fn().mockResolvedValue({ id: 1, name: "Alice" }),
updateUser: vi.fn().mockResolvedValue(undefined),
}));
```
### Mock Fetch / Axios
```tsx
it("loading 态在请求完成后消失", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
// 推荐:用 vi.spyOn 包装全局 fetch
vi.spyOn(global, "fetch").mockResolvedValueOnce({
ok: true,
json: async () => [{ id: 1, name: "Test" }],
} as Response);
@@ -167,7 +240,7 @@ it("loading 态在请求完成后消失", async () => {
});
it("网络错误显示错误提示", async () => {
vi.mocked(fetch).mockRejectedValueOnce(new Error("Network error"));
vi.spyOn(global, "fetch").mockRejectedValueOnce(new Error("Network error"));
render(<TodoList />);
@@ -177,6 +250,13 @@ it("网络错误显示错误提示", async () => {
});
```
> [!note] API Mock 方案选型
> - **vi.mock() / vi.spyOn**:适合单元测试,快速隔离依赖,但无法模拟完整网络流程
> - **MSW (Mock Service Worker)**:通过 Service Worker 拦截真实网络请求,适用于集成测试;与生产行为最接近
> - **建议**:组件层用 vi.mock;集成流用 MSW
---
## Hook 测试
```tsx
@@ -188,9 +268,10 @@ describe("useDebounce", () => {
afterEach(() => vi.useRealTimers());
it("值不变时返回原始值", () => {
const { result } = renderHook(({ value }) => useDebounce(value, 300), {
initialProps: { value: "hello", delay: 300 },
});
const { result } = renderHook(
({ value }) => useDebounce(value, 300),
{ initialProps: { value: "hello" } }
);
expect(result.current).toBe("hello");
});
@@ -210,6 +291,100 @@ describe("useDebounce", () => {
});
```
> [!tip] Hook 测试的关键模式
> - **时间控制**:用 `vi.useFakeTimers()` + `act(() => vi.advanceTimersByTime(n))` 精确控制异步时序,不依赖真实等待
> - **cleanup**:每个 `beforeEach` 对应独立的渲染上下文,`rerender` 模拟 props 更新时的行为
> - **不要断言内部变量**:只检查 `result.current`(返回值),就像组件只暴露 props 一样
---
## 集成测试模式
```tsx
// 模拟一个完整的用户操作流程
describe("<SignupForm /> — 集成测试", () => {
it("完整注册流程:填写 → 提交 → 跳转", async () => {
// 1. Mock API 响应
vi.mock("../api/auth", () => ({
register: vi.fn().mockResolvedValue({ token: "abc123" }),
}));
// 2. 渲染表单
render(<SignupForm />);
// 3. 模拟用户输入(userEvent 更符合真实行为)
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
await userEvent.type(emailInput, "alice@example.com");
await userEvent.type(passwordInput, "SecurePass123!");
// 4. 提交
const submitBtn = screen.getByRole("button", { name: /sign up/i });
await userEvent.click(submitBtn);
// 5. 验证结果
await screen.findByText("Account created!");
});
it("重复邮箱提示错误", async () => {
vi.mock("../api/auth", () => ({
register: vi.fn().mockRejectedValue(new Error("Email already exists")),
}));
render(<SignupForm />);
const emailInput = screen.getByLabelText(/email/i);
await userEvent.type(emailInput, "exists@example.com");
await userEvent.click(screen.getByRole("button", { name: /sign up/i }));
await screen.findByText("Email already exists");
});
});
```
> [!important] 集成测试的设计要点
> - **一次测一个完整流程**,不要拆成原子步骤——这样即使中间环节变动,只要最终行为一致就不需要改测试
> - **Mock 所有外部依赖**(API、localStorage、WebSocket),但不 mock 内部逻辑
> 集成测试的目标是:**确认多个组件协同工作后,用户得到了正确的反馈**
---
## 错误边界测试
```tsx
import { ErrorBoundary } from "./ErrorBoundary";
describe("<ErrorBoundary />", () => {
// 让被包裹的组件抛出错误
function BrokenComponent() {
throw new Error("Render failed");
}
it("捕获渲染错误并显示 fallback UI", () => {
render(
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<BrokenComponent />
</ErrorBoundary>
);
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
});
it("未发生错误时正常渲染子内容", () => {
render(
<ErrorBoundary>
<span>Safe content</span>
</ErrorBoundary>
);
expect(screen.getByText("Safe content")).toBeInTheDocument();
});
});
```
---
## E2E 测试选择
| 工具 | 适用场景 | 特点 |
@@ -222,4 +397,11 @@ describe("useDebounce", () => {
> - E2E 只应覆盖**关键用户旅程**(登录、下单、支付)
> - 不要为每个页面的每个字段写 E2E——那属于集成测试的范畴
---
## 关联笔记
- [[07-自定义 Hooks]] — 自定义 Hook 的设计与测试模式
- [[09-状态管理]] — 全局状态管理(Zustand / Redux Toolkit)的测试策略
- [[13-并发特性]] — Suspense、Transitions 的测试注意事项
- [[15-性能优化]] — 性能回归测试:React Testing Library + Performance Assertions
+308 -57
View File
@@ -38,8 +38,18 @@ graph TB
## 语义化 HTML(最重要的一条)
> [!tip] 黄金法则
> **能用原生元素,绝不用 div + onClick。** 原生 HTML 元素天生具备键盘交互、屏幕阅读器支持和 SEO 友好能力。这是可访问性的基石,比任何 ARIA 属性都重要。
思考:为什么一个 `<div>` 加上 `onClick` 事件后,仍然不能代替 `<button>`?
答案涉及三个层面:
1. **键盘** — div 不在 Tab 焦点流中,Enter/Space 不会触发点击
2. **屏幕阅读器** — 读作"按钮,未绑定", 用户不知道它是干什么的
3. **搜索引擎** — 无法识别为可交互元素
```tsx
// ❌ 滥用 div + onClick
// ❌ 滥用 div + onClick——视觉上是按钮,实质上什么都不是
<div className="btn" onClick={() => navigate("/home")}>Home</div>
<div className="link" onClick={() => goTo("/about")}>About</div>
@@ -51,67 +61,142 @@ graph TB
### 常用语义标签对照表
| 功能 | 错误写法 | 正确写法 |
|------|---------|---------|
| 按钮行为 | `<div onClick>` | `<button>` |
| 链接跳转 | `<span onClick=navigate>` | `<a href>` |
| 表单输入 | `<div contentEditable>` | `<input>`/`<textarea>` |
| 弹窗 | `<div className="modal">` | `<dialog>` 或 role="dialog" |
| 导航区 | `<div class="nav">` | `<nav>` |
| 侧边栏 | `<aside class="sidebar">` | `<aside>` |
| 功能 | 错误写法 | 正确写法 | 原因 |
|------|---------|---------|------|
| 按钮行为 | `<div onClick>` | `<button>` | 原生键盘交互 |
| 链接跳转 | `<span onClick=navigate>` | `<a href>` | 右键打开新标签、Ctrl+点击 |
| 表单输入 | `<div contentEditable>` | `<input>`/`<textarea>` | 输入法兼容、自动填充 |
| 弹窗 | `<div className="modal">` | `<dialog>` 或 role="dialog" | 焦点管理、Escape 关闭 |
| 导航区 | `<div class="nav">` | `<nav>` | 跳过导航快捷入口 |
| 侧边栏 | `<aside class="sidebar">` | `<aside>` | 页面结构角色识别 |
| 主内容 | `<main className="content">` | `<main>` | 直达主内容快捷键 |
## aria 属性体系
## ARIA 属性体系
> [!warning] ARIA 铁律(ARIA Gods)
> 1. **不要使用 ARIA** —— 用原生语义化元素解决一切
> 2. **不要将原生交互改为 `role="presentation"`** —— 会破坏已有无障碍支持
> 3. **所有 ARIA 可用状态和属性必须有对应原生支持** —— 不要用 ARIA 模拟原生行为
ARIA 是最后一道防线,用于当原生 HTML 无法满足需求时。核心原则:**优先使用原生语义,ARIA 作为补充**。
### aria-label:为无声元素赋予名称
给没有可见文本的图标、装饰性按钮添加屏幕阅读器可读的描述。
```tsx
// 1. aria-label —— 给无文本内容的图标添加描述
// 关闭按钮——只有图标,需要 label 告诉用户功能
<button aria-label="关闭对话框">
<CloseIcon />
</button>
// 2. aria-describedby —— 关联说明文字
// 搜索图标按钮
<button aria-label="搜索商品">
<SearchIcon />
</button>
```
> **注意**:`aria-label` 会完全替代元素的可见文本(如果有),屏幕阅读器只朗读 label 内容。如果已有清晰可见文本,优先用 `aria-labelledby` 关联。
### aria-describedby:提供补充说明
当字段需要额外解释文字时使用,屏幕阅读器会在朗读输入框名称后继续朗读描述。
```tsx
<input
aria-label="邮箱地址"
aria-describedby="email-hint"
/>
<p id="email-hint">请使用注册时使用的邮箱</p>
// 3. aria-live —— 动态内容变化通知屏幕阅读器
{/* 效果:屏幕阅读器会读 "邮箱地址,请使用注册时使用的邮箱" */}
```
### aria-live:动态内容变化通知
让屏幕阅读器自动感知并朗读动态变化的内容区域。适用于 Toast 消息、表单验证错误、实时搜索结果等场景。
```tsx
<div aria-live="polite">
{formErrors.length > 0 && (
<p>{formErrors.join("、")}</p>
<ul>
{formErrors.map((err, i) => <li key={i}>{err}</li>)}
</ul>
)}
</div>
{/* polite = 等待空闲再朗读;assertive = 立即打断 */}
```
// 4. aria-expanded —— 折叠面板展开状态
<button aria-expanded={isExpanded} onClick={() => setIsExpanded(!isExpanded)}>
> [!note] polite vs assertive
> - **polite**(默认)— 等待用户当前操作空闲后再朗读,不打断用户
> - **assertive** — 立即中断当前朗读,优先播报紧急信息
>
> 非紧急提示(如"保存成功")用 polite;安全警告、支付确认用 assertive。
### aria-expanded:折叠面板展开状态
对可展开/收起的元素(手风琴、下拉菜单、折叠面板),标记当前展开状态。用户打开或关闭时,屏幕阅读器会自动读出 "已展开" 或 "已折叠"。
```tsx
<button
aria-expanded={isExpanded}
onClick={() => setIsExpanded(!isExpanded)}
aria-controls="panel-content"
>
{isExpanded ? "收起" : "展开"}
<ChevronIcon rotated={isExpanded ? 180 : 0} />
</button>
// 5. aria-hidden — 装饰性元素隐藏给屏幕阅读器
<img src="decorative-pattern.png" alt="" aria-hidden="true" />
<div id="panel-content" hidden={!isExpanded}>
{/* 面板内容 */}
</div>
```
> **技巧**:配合 `aria-controls` 指向被控制的内容区域 ID,帮助屏幕阅读器建立两者之间的关系。
### aria-hidden:隐藏装饰性元素
将纯装饰性的 SVG 图标或对视觉重要但屏幕阅读器不应朗读的图片隐藏。
```tsx
/* 装饰性背景图——不想让屏幕阅读器朗读文件名 */
<img src="decorative-pattern.png" alt="" aria-hidden="true" />
/* SVG 图标中,textContent 已经通过 aria-label 表达了 */
<svg aria-hidden="true" focusable="false">
<path d="M..." />
</svg>
```
> [!question] 思考:什么时候应该用 `aria-hidden="true"`,什么时候应该用 `alt=""`?
> - `aria-hidden`:告诉辅助技术"忽略这个元素"(用于重复信息,如文本旁已标注功能的图标)
> - `alt=""`(空 alt):告诉屏幕阅读器"这是一张装饰图片"(用于图片本身是装饰性的)
> - 两者有时可以配合使用
## 键盘导航
> [!warning] 核心原则
> **默认 Tab 顺序 = DOM 顺序。** 绝大多数情况下,不要通过 JavaScript 改变自然的页面阅读顺序。手动设置 `tabIndex` 是例外而非常规。
### Tab Order 管理
```tsx
// 默认 Tab 顺序 = DOM 顺序,不要随意改变!
// ✅ 在 Tab 流中(正常用户应该能用 Tab 到达的元素)
<NavButton tabIndex={0} />
// 需要自定义时:
// ✅ 用 tabIndex 控制焦点位置(仅必要场景)
<NavButton tabIndex={0} /> {/* 在 Tab 流中 */}
<FloatingActionButton tabIndex={-1} /> {/* 仅程序化聚焦 */}
// ✅ 仅程序化聚焦(浮动按钮、Toast 等不需要 Tab 的元素)
<FloatingActionButton tabIndex={-1} />
// ❌ 不要设置负 tabIndex 阻止所有键盘操作
// ❌ 永远不要全局禁用键盘——这剥夺了所有用户的操作能力
<div tabIndex={-1} onKeyDown={handler}>...</div>
```
// 自定义键盘快捷键
### 自定义快捷键 Hook
```tsx
function useKeyboardShortcut(key: string, handler: () => void) {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === key && !isInputFocused()) { // 避免在输入框中触发
// 避免在输入框、文本域等中触发快捷键
if (e.key === key && !isInputFocused()) {
handler();
}
};
@@ -121,47 +206,71 @@ function useKeyboardShortcut(key: string, handler: () => void) {
}
```
> **解释**:这段代码的关键在于 `!isInputFocused()` 检查。如果不加这个判断,用户在输入密码或搜索词时按下快捷键,会导致意外行为。例如用户在搜索框中输入 "s",如果同时触发了某个搜索提交快捷键,会打断用户的正常输入。
### Focus Trap(模态框焦点陷阱)
打开模态对话框时,必须确保 Tab 键的焦点不会跑到背景内容上。焦点应该在弹窗内的可交互元素之间循环。
> [!note] 为什么需要 Focus Trap?
> 想象你打开了一个确认删除弹窗,此时按 Tab 焦点跑到了背景的菜单链接上——用户完全不知道自己在操作什么。Focus Trap 将焦点锁在弹窗内部形成闭环:
```mermaid
flowchart LR
A["Tab 在弹窗内"] --> B["到达最后元素"]
B -->|Shift+Tab| C["回到第一个元素"]
B -->|Tab| D["回到第一个元素\n(焦点循环)"]
C -->|"Tab"| A
style A fill:#4FC08D,color:#000
style D fill:#F5A87D,color:#000
style C fill:#61DAFB,color:#000
```
实现要点:
```tsx
import { useRef, useEffect } from "react";
function Modal({ isOpen, onClose, children }: Props) {
const modalRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
if (!isOpen) return;
// 收集弹窗内所有可聚焦元素
const focusableElements = modalRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstEl = focusableElements?.[0] as HTMLElement;
const lastEl = focusableElements?.[focusableElements.length - 1] as HTMLElement;
// Tab 键循环逻辑
const handleTab = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
if (e.shiftKey) {
// Shift+Tab:从第一个元素回到最后一个
if (document.activeElement === firstEl) {
e.preventDefault();
lastEl.focus();
}
} else {
// Tab:从最后一个元素回到第一个
if (document.activeElement === lastEl) {
e.preventDefault();
firstEl.focus();
}
}
};
document.addEventListener("keydown", handleTab);
firstEl?.focus();
return () => document.removeEventListener("keydown", handleTab);
}, [isOpen]);
if (!isOpen) return null;
return (
<dialog ref={modalRef} open onClose={onClose}>
{children}
@@ -170,6 +279,13 @@ function Modal({ isOpen, onClose, children }: Props) {
}
```
> **解释**:这个 Hook 做了三件事:
> 1. **自动聚焦首个元素** —— 打开弹窗后立即将焦点放到第一个可交互元素,让用户无需按 Tab 即可操作
> 2. **Tab 循环** —— Shift+Tab 从第一个回到最后一个,Tab 从最后一个回到第一个
> 3. **清理监听器** —— 关闭弹窗时移除事件监听,防止内存泄漏
>
> 实际项目中推荐直接使用 [`@radix-ui/react-dialog`](https://www.radix-ui.com/primitives/docs/dialog/dialog) 或 MUI/Chakra 等组件库,它们已经内置了 Focus Trap 和大量 edge case 处理。
## 颜色与视觉
> [!tip] 设计检查清单
@@ -191,48 +307,183 @@ button:focus-visible {
}
```
## React 特定点
## React 中的关键注意点
### 表单字段关联
屏幕阅读器依赖 `<label>` 元素来告知用户当前输入框的作用。这是最基础的无障碍要求,但也是最容易被忽视的。
```tsx
// 1. Suspense fallback 要提供有意义的 loading 文案
<Suspense fallback={<p>Loading profile...</p>}>
<Profile />
</Suspense>
// 2. Form 字段必须有 label
// ✅ 显式关联:通过 htmlFor / id 配对(适合复杂布局)
<label htmlFor="username">用户名:</label>
<input id="username" name="username" />
{/* 或隐式关联 */}
// ✅ 隐式关联:input 嵌套在 label 内部(最简单可靠)
<label>
用户名:
<input name="username" />
</label>
// 3. 图标按钮必须有 aria-label
// ❌ 缺少 label——屏幕阅读器只会读 "编辑文本,未命名"
<input name="username" placeholder="请输入用户名" />
{/* placeholder ≠ label!placeholder 消失后用户不知道该填什么 */}
```
> **解释**:`placeholder` 属性仅作为提示文字使用,当用户开始输入时它就会消失。而 `label` 始终可见且与控件绑定,是屏幕阅读器的主要信息来源。两者可以配合使用,但不能互相替代。
### 路由切换后的焦点管理
SPA 路由切换时,页面内容虽然变了,但 URL 也变了——屏幕阅读器不会自动将焦点移到新页面的顶部。需要手动处理:
```tsx
import { useEffect } from "react";
import { useLocation } from "react-router-dom";
function FocusManager() {
const location = useLocation();
useEffect(() => {
// 回到顶部 + 将焦点移到主内容区域
window.scrollTo(0, 0);
document.getElementById("main-content")?.focus();
}, [location.pathname]);
return null; // 这是一个纯逻辑组件,不渲染任何 UI
}
// HTML 中设置锚点
<main id="main-content" tabIndex={-1}>
{/* 路由内容会在这里替换 */}
</main>
```
> **解释**:这个模式对 SPA 项目尤为重要。当用户在侧边栏点击链接跳转到新页面时,屏幕阅读器用户可能仍在听旧页面的内容——因为他们不知道焦点实际上还留在原地。将焦点主动移到 `<main>` 元素上,等于告诉屏幕阅读器 "页面已经切换了,请开始朗读新内容"。
>
> Next.js 等框架内置了此行为,无需手动实现。
### Suspense fallback 可读性
```tsx
<Suspense fallback={<p>Loading profile...</p>}>
<Profile />
</Suspense>
{/* ✅ 有意义的加载文案,而非空白的 spinner */}
<Suspense fallback={<p>Loading user profile data...</p>}>
<Profile />
</Suspense>
/* ❌ 无意义的 loading —— 无法帮助等待中的用户理解发生了什么 */
<Suspense fallback={<div className="spinner" />} >
<Profile />
</Suspense>
```
### 图标按钮的可访问性
所有只有图标的按钮都必须提供描述性文本。视觉用户看到图标就明白功能,但屏幕阅读器用户只能听到一串字符。
```tsx
<Button aria-label="删除这条消息">
<TrashIcon />
</Button>
// 4. 路由切换后,屏幕阅读器应知道页面变了
// Next.js Router 自动处理;SPA 项目中可手动聚焦顶部
useEffect(() => {
window.scrollTo(0, 0);
document.getElementById("main-content")?.focus();
}, [location.pathname]);
{/* 如果同时有可见文本,不需要 aria-label */}
<Button onClick={() => deleteMessage(id)}>
<TrashIcon />
删除
</Button>
```
### 跳过导航链接
长页面或复杂的导航结构应该提供一个"跳过到主内容"的快捷入口。这是 WCAG 2.4.1 的要求。
```tsx
// 放在页面最顶部,通常用 CSS 隐藏,获得焦点时才显示
<a
href="#main-content"
className="skip-link"
style={{
position: "absolute",
top: "-40px",
left: "0",
background: "#000",
color: "#fff",
padding: "8px 16px",
zIndex: 9999,
}}
onFocus={(e) => (e.currentTarget.style.top = "0")}
onBlur={(e) => (e.currentTarget.style.top = "-40px")}
>
跳到主内容
</a>
<main id="main-content" tabIndex={-1}>
{/* 页面内容 */}
</main>
```
## 测试辅助技术兼容性
| 工具 | 用途 |
|------|------|
| axe DevTools | Chrome/Firefox 扩展,自动检测 a11y 问题 |
| Lighthouse | 生成 a11y 评分报告 |
| NVDA / VoiceOver | 免费屏幕阅读器(Win / Mac) |
| wAI11y | Jest/Vitest 断言库 |
> [!tip] 分层测试策略
> 自动化测试只是第一道防线——快速捕捉低 hanging fruit。但要真正保证无障碍体验,需要三层组合:
```mermaid
graph LR
A["🛡️ 自动化 lint 规则\n(eslint-plugin-jsx-a11y)"] -->|覆盖 ~30%| B["🤖 自动化测试\n(wAI11y / axe-core)"]
B -->|覆盖 ~40%| C["⌨️ 键盘 + 屏幕阅读器\n手动测试"]
C -->|发现剩余 ~30%| D["👥 真实残障用户测试"]
style A fill:#A0AEC0,color:#000
style B fill:#61DAFB,color:#000
style C fill:#4FC08D,color:#000
style D fill:#ED8936,color:#fff
```
### 自动化检测工具
| 工具 | 用途 | 集成方式 |
|------|------|---------|
| axe DevTools | Chrome/Firefox 扩展,手动审计页面 | 浏览器安装 |
| Lighthouse | CI 中生成 a11y 评分报告 | `npx lighthouse --view` |
| eslint-plugin-jsx-a11y | 编码时实时报错 | ESLint 插件 |
| wAI11y | Jest/Vitest 中的无障碍断言库 | npm 包 |
### wAI11y 基础用法
```tsx
// 单元测试示例
import { render, screen } from "@testing-library/react";
import { accessibilityChecks } from "wai-aria";
it("关闭按钮应有关闭描述", () => {
render(<Modal onClose={() => {}} isOpen />);
const closeBtn = screen.getByRole("button", { name: /关闭/i });
// 检查元素是否有正确的 aria 角色和状态
expect(closeBtn).toHaveAttribute("aria-label", "关闭对话框");
expect(closeBtn).toHaveAccessibleName();
});
```
### 手动测试清单
在发布前,务必用以下组合手动验证:
> [!example] 人工测试步骤
> 1. **纯键盘遍历** — 关掉鼠标,只用 Tab / Shift+Tab / Arrow Keys / Enter / Escape 操作完整流程
> 2. **屏幕阅读器体验** — 开启 NVDA(Windows)或 VoiceOver(Mac),浏览核心页面
> 3. **缩放测试** — 浏览器缩放至 200%,确认无内容被遮挡或水平滚动
> 4. **对比度检查** — 使用 axe DevTools 验证所有文本对比度达标
> 5. **深色模式** — 切换系统深色模式后重新走一遍流程
> [!question] 思考:为什么自动化工具只能覆盖约 30% 的可访问性问题?
> - 自动化能检测缺少 alt、颜色对比不足等技术问题
> - 但无法判断交互逻辑是否合理、ARIA 含义是否正确表达、Tab 顺序是否符合直觉——这些需要人工测试
## 关联笔记
- [[3. 生态工具篇/08-路由管理]] — 路由切换时的焦点管理策略
- [[5. 工程实践篇/16-测试]] — 使用 wAI11y 编写可访问性断言测试
- [[5. 工程实践篇/15-性能优化]] — 无障碍组件的性能考量
@@ -23,74 +23,86 @@ create time: 2026-04-29 22:17
| `ref = React.createRef()` | `useRef` |
| `shouldComponentUpdate` | `React.memo` / `useMemo` / `useCallback` |
| Context.Consumer | `useContext` |
| 派生计算值 | `useMemo(() => expensiveCalc(data), [data])` |
| 传递函数避免重渲染 | `useCallback(fn, deps)` |
### 实际迁移示例
### 性能相关 Hooks 迁移
在实际迁移中,Class 组件往往隐含了 `shouldComponentUpdate` 或手动优化逻辑。用 Hooks 重写时需要显式声明:
```tsx
// ❌ Before: Class Component
class UserProfile extends React.Component<{ userId: string }> {
state = { user: null, loading: true };
componentDidMount() {
fetchUser(this.props.userId).then(user => {
this.setState({ user, loading: false });
});
}
componentDidUpdate(prevProps) {
if (prevProps.userId !== this.props.userId) {
this.setState({ loading: true });
fetchUser(this.props.userId).then(user => this.setState({ user, loading: false }));
}
}
componentWillUnmount() {
// nothing to clean up here
}
// ❌ Before: Class Component — 隐式全量渲染
class TodoList extends React.Component {
state = { filter: "", todos: [] };
// shouldComponentUpdate 需要手动写,否则每次都全量渲染
render() {
const { user, loading } = this.state;
return loading ? <Spinner /> : <div>{user.name}</div>;
return (
<div>
<input onChange={e => this.setState({ filter: e.target.value })} />
{/* todos 每次都会重新创建新数组引用,子列表无法优化 */}
<TodoItems todos={this.state.todos.filter(t => t.text.includes(this.state.filter))} />
</div>
);
}
}
// ✅ After: Functional Component + Hooks
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
// ✅ After: useMemo + useCallback 显式控制
function TodoList() {
const [filter, setFilter] = useState("");
const todos = useTodos(); // custom hook
useEffect(() => {
let cancelled = false;
setLoading(true);
fetchUser(userId).then(u => {
if (!cancelled) setUser(u);
}).finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; }; // cleanup
}, [userId]); // userId 变化时自动重新 fetch
// 只在 todos/filter 变化时重新过滤
const filteredTodos = useMemo(
() => todos.filter(t => t.text.includes(filter)),
[todos, filter]
);
if (loading) return <Spinner />;
return <div>{user?.name}</div>;
// setFilter 引用稳定,不会导致子组件无效重渲染
const handleFilter = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setFilter(e.target.value);
}, []);
return (
<div>
<input onChange={handleFilter} />
<TodoItems todos={filteredTodos} />
</div>
);
}
```
> [!tip] 何时使用 useMemo vs useCallback
> - **memorize value** → `useMemo`: 用于昂贵计算结果或防止子组件 props 引用变化
> - **memorize function** → `useCallback`: 用于传递给子组件回调或作为 effect 依赖
> - **不要过早优化**: 没有 profiling 数据支持时,默认不用 memo——可读性优先
### 迁移工具推荐
> [!note] react-codemod 自动化迁移
> Facebook 官方提供的 codemod 可以批量转换大部分 Class → Function 代码:
> ```bash
> npx jscodeshift -t node_modules/react-codemod/transforms/class-to-hook.js src/
> ```
> ⚠️ Codemod 只做机械转换,`useEffect` cleanup、`useCallback` 依赖数组等仍需人工审查。
> 建议将 `git diff --name-only` 结果加入 Code Review。
> [!warning] 迁移 Checklist
> - [ ] 所有 `componentDidMount/Update/Unmount` 转译为 useEffect
> - [ ] State 合并:将互不相关的 state 拆分,减少不必要的重渲染
> - [ ] 移除 `this.` —— 检查所有闭包中的变量引用
> - [ ] Props 类型从 class props 改为 interface 解构
> - [ ] 审查 `useEffect` 依赖数组,避免 stale closure
> - [ ] 为传给子组件的函数添加 `useCallback`(如经 profiling 确认需要)
## React Router v6 → v7 迁移
```mermaid
graph LR
A["v6 BrowserRouter"] -->|"Switch → Routes"| B["v7 Routes"]
C["v6 <Route path='/' element={} />"] -->|"path prop 简化"| D["v7 { path: '/', element: {} }"]
E["v6 <Redirect/>"] -->|"navigate(-1)/replace"| F["v7 <Navigate replace />"]
G["v6 Outlet"] -->|"保留"| H["v7 Outlet — 位置不变"]
A["v6 BrowserRouter + Switch"] -->|"Routes + createBrowserRouter"| B["v7 Data Router"]
C["v6 <br/>Route path prop"] -->|"简化"| D["v7 route config object"]
E["v6 <br/>Redirect"] -->|"Navigate replace"| F["v7 Navigate"]
G["v6 Outlet"] -->|"保留,位置不变"| H["v7 Outlet"]
style A fill:#F4DBD6,color:#000
style B fill:#61DAFB,color:#000
@@ -106,15 +118,80 @@ graph LR
| `useHistory` | `useNavigate` | 名称统一 |
| `<Route component={Page}>` | `<Route element={<Page />} >` | JSX 方式 |
| `match.params` | `useParams()` | hook 方式 |
| `useLocation()` | 同 v6 | `location.key` 不再等于 `"default"` |
### Data Router 配置示例
v7 引入 **Data Router** 概念,路由配置从 JSX 变为纯数据驱动:
```tsx
// ❌ v6: JSX 声明式路由
import { BrowserRouter, Routes, Route } from "react-router-dom";
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="users/:id" element={<Profile />} />
</Route>
</Routes>
</BrowserRouter>
);
}
// ✅ v7: createBrowserRouter 数据驱动
import { createBrowserRouter, RouterProvider } from "react-router-dom";
const router = createBrowserRouter([
{
path: "/",
element: <Layout />,
children: [
{ index: true, element: <Home /> },
{ path: "users/:id", element: <Profile /> },
],
},
]);
function App() {
return <RouterProvider router={router} />;
}
```
> [!note] 为什么 Data Router?
> Data Router 将路由状态与 React 状态分离,使得导航、加载、错误处理可以脱离组件树独立管理。这为后续 Loader / Action / Error Boundary 等数据功能奠定了基础。如果需要保留 JSX 写法,可以使用 `createRoutesFromElements` 桥接。
```tsx
// 折中方案:用 JSX 创建 route config
import { createBrowserRouter, createRoutesFromElements, Route } from "react-router-dom";
const router = createBrowserRouter(
createRoutesFromElements(
<Route path="/" element={<App />}>
<Route index element={<Home />} />
<Route path="about" element={<About />} />
</Route>
)
);
```
> [!warning] 常见陷阱
> - `useParams()` 在 route 未匹配时返回空对象 `{}` 而非 undefined,需要显式判断
> - `useSearchParams()` 行为与 v6 一致,但 URL 解析基于 Data Router
> - 嵌套路由的 Outlet 必须渲染在父元素中,否则子路由不会显示
## React 17 → 18 兼容性
React 18 是一个 **渐进式** 升级,不需要整体重写。核心变更如下:
| 注意事项 | 说明 |
|----------|------|
| `ReactDOM.render` 已废弃 | 改用 `createRoot().render()` |
| `ReactDOM.unmountComponentAtNode` 已废弃 | 改用 `root.unmount()` |
| 事件处理在微任务中执行 | `event.persist()` 已移除 |
| StrictMode 双重渲染 | 开发环境 effect 先 mount → unmount → remount(用于检测) |
| 事件处理在微任务中执行 | 批处理行为改变;`event.persist()` 已移除 |
| StrictMode 双重渲染 | 开发环境 effect 先 mount → unmount → remount(用于检测不安全 effect) |
| 自动批处理扩展 | React 事件、promise、setTimeout 等全部自动批处理 |
```tsx
// React 17
@@ -127,57 +204,256 @@ const root = createRoot(document.getElementById("root"));
root.render(<App />);
```
### Suspense on Waterfall & Streaming SSR
React 18 的 Suspense 可以配合 `SuspenseList` 和 streaming SSR 实现渐进式加载:
```tsx
// Server Component 场景(Next.js App Router)
// 外层 UI 立即呈现,内部大组件用 Suspense 包裹懒加载
export default function Page() {
return (
<main>
<Header /> // 立即渲染
<Navigation /> // 立即渲染
<Suspense fallback={<DashboardSkeleton />}>
<HeavyChart data={data} /> // 等待数据加载后渲染
</Suspense>
</main>
);
}
```
### useTransition & useDeferredValue
React 18 引入两个新的 concurrency primitives,用于优化用户体验:
```tsx
import { useState, useTransition, useDeferredValue } from "react";
function SearchPage() {
const [query, setQuery] = useState("");
const [isPending, startTransition] = useTransition();
// 输入更新是"普通"优先级,结果渲染是"低"优先级
const handleInput = (value: string) => {
setQuery(value); // 保持输入响应
startTransition(() => {
doExpensiveSearch(value); // 不阻塞输入
});
};
return (
<>
<input value={query} onChange={e => handleInput(e.target.value)} />
{isPending && <Spinner />}
<SearchResults query={query} />
</>
);
}
```
### createPortal 变更
v18 起 `createPortal` 的宿主容器不再要求必须是 DOM 树中的元素——这意味着可以在 off-screen 容器中创建 portal,延迟挂载到 DOM:
```tsx
// v17: 必须先将容器添加到 DOM
const container = document.createElement("div");
document.body.appendChild(container);
const portal = createRoot(container);
// v18: 可以直接创建,稍后再挂载
const container = document.createElement("div");
const portal = createRoot(container);
// ... later ...
document.body.appendChild(container.children[0]);
```
### React 18 升级 Checklist
> [!note] React 17 → 18 升级清单
> - [ ] `ReactDOM.render` → `createRoot().render()`
> - [ ] `StrictMode` 下确认无副作用泄漏(effect unmount/remount 测试)
> - [ ] 检查第三方库是否声明了 `react@^18` peerDep
> - [ ] `useTransition` / `useDeferredValue` 可逐步引入到高延迟场景
> - [ ] 流式 SSR 项目需验证 hydration 兼容性
> - [ ] `act()` 警告更严格,确保测试中 await act()
## Next.js Pages → App Router 迁移
### 目录与 API 对照表
| Pages Router | App Router |
|-------------|-----------|
| `pages/` 目录 | `app/` 目录 |
| `_document.tsx` | `app/layout.tsx` |
| `_document.tsx` | **不再需要**,由框架自动生成 HTML |
| `_app.tsx` | `app/layout.tsx`(根布局) |
| `getServerSideProps` | async Page 组件 |
| `getStaticProps` | async Page + `revalidate` |
| `next/router` | `next/navigation` |
| `getInitialProps` | **不支持**,需用 Server Component 替代 |
| `_middleware.ts` | `middleware.ts` |
| Dynamic Route `[slug]` | `[slug]/page.tsx` |
### 核心差异:Server Components vs Client Components
App Router 中默认页面组件是 **React Server Component (RSC)**:
```tsx
// Pages Router
// ❌ Pages Router: getServerSideProps — 必须显式导出
export async function getServerSideProps() {
const data = await api.getData();
return { props: { data } };
}
// App Router
async function Page() {
const data = await api.getData();
function Page({ data }) {
return <View data={data} />;
}
// ✅ App Router: async 组件直接运行在服务端
async function Page() {
const data = await api.getData();
// data 不会序列化发送到客户端,减少 bundle size
return <View data={data} />;
}
// ⚡ 需要交互的组件用 "use client" 标记为 Client Component
"use client";
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
```
### Loading UI & Error Boundary
Pages Router 需要手动实现 loading 和 error 页面;App Router 内置支持:
```tsx
// app/dashboard/loading.tsx — 过渡 UI 自动展示
export default function Loading() {
return <div className="animate-pulse">Loading dashboard...</div>;
}
// app/dashboard/error.tsx — boundary 捕获错误,无需 try/catch
"use client";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return (
<div>
<h2>Something went wrong!</h2>
<button onClick={() => reset()}>Try again</button>
</div>
);
}
```
### Middleware 变化
```ts
// ❌ Pages Router: _middleware.ts — 匹配规则有限
import { NextResponse } from "next/server";
export function middleware(req) {
if (!req.cookies.get("auth")) {
return NextResponse.redirect("/login");
}
}
// ✅ App Router: middleware.ts — 更强大的匹配和响应能力
import { NextResponse } from "next/server";
export function middleware(req) {
const token = req.cookies.get("auth")?.value;
if (!token && !req.nextUrl.pathname.startsWith("/login")) {
return NextResponse.redirect(new URL("/login", req.url));
}
return NextResponse.next(); // 放行
}
export const config = { matcher: "/dashboard/:path*" }; // 只匹配需要的路由
```
### 迁移路径建议
> [!tip] 渐进式迁移策略
> - **方案 A(推荐)**:在 `next.config.js` 中设置 `experimental.appDir = true`,双模式并行,按页面逐个迁移到 `app/`
> - **方案 B**:新项目直接使用 App Router,老项目保持 Pages Router 不动
> - 注意:`getInitialProps` **无法**迁移到 App Router,需要用 fetch + 缓存替代
## Major Version 升级通用流程
```mermaid
flowchart TD
A["发现新版本发布"] --> B["阅读 Changelog / Breaking Changes"]
B --> C{"影响范围评估"}
A["New Version Released"] --> B["Read Changelog / Breaking Changes"]
B --> C{"Impact Assessment"}
C --> D["仅 minor version bump"]
C --> E["API 变更 / 新配置项"]
C --> F["架构性改动"]
C --> D["Minor version bump"]
C --> E["API changes / new config"]
F["Architecture change"]
D --> G["直接升级 + CI 验证"]
E --> H["逐个文件修复"]
F --> I["分阶段迁移 + 并行验证"]
D --> G["Upgrade + CI verify"]
E --> H["Fix per file"]
F --> I["Phased migration + parallel test"]
G --> J["更新依赖 + 运行测试"]
G --> J["Update deps + run tests"]
H --> J
I --> J
J --> K["Staging 环境验证"]
K --> L["灰度发布 / Feature Flag"]
L --> M["全量上线"]
J --> K["Staging verification"]
K --> L["Canary release / Feature Flag"]
L --> M["Full rollout"]
style F fill:#F5A87D,color:#000
style I fill:#F5A87D,color:#000
```
### 通用升级最佳实践
> [!quote] 经验法则
> **永远不要在同一次 PR 中升级依赖 + 重构业务逻辑。** 如果测试失败,你无法判断是版本破坏还是你的代码有问题。分步进行:先升级跑通 CI,再逐步优化。
| 实践 | 说明 |
|------|------|
| **锁定大版本** | `package.json` 中使用 `"^18.2.0"` 而非 `"*"`,防止意外拉取 patch 外的变更 |
| **CI 前置** | 升级后首先确保所有测试通过(含 snapshot test) |
| **逐个升级** | 先修 core(React),再修生态(Router、Redux 等),最后修 UI 库 |
| **Feature Flag** | 对新路由或新页面用 feature flag 控制,观察指标后再全量开放 |
| **监控告警** | 上线后关注 Error Boundary 捕获的异常数量和性能指标(LCP、FCP) |
## 常见陷阱汇总
> [!danger] Class → Function 常见陷阱
> - **stale closure**: `useEffect` 中没有把依赖放入数组,导致闭包中读取到旧值
> ```tsx
> // ❌ name 变化时 effect 不会重新执行
> useEffect(() => { console.log(name); }, []);
> // ✅ 正确写法
> useEffect(() => { console.log(name); }, [name]);
> ```
> - **过度使用 memo**: `React.memo` / `useMemo` / `useCallback` 本身有比较成本, profiling 确认瓶颈后再加
> - **忽略 cleanup**: `useEffect` 返回的 cleanup 函数必须清除定时器、取消 fetch、移除 listener
> [!danger] Router 常见陷阱
> - Data Router 下 `navigate()` 不会自动停止正在进行的 loader fetch,需要 `AbortController`
> - v7 的 `future.v7_relativeSplatPath` 改变了 splat route 的路径解析规则
> - 嵌套路由中忘记在父元素渲染 `<Outlet />` 导致子路由不显示
> [!danger] React 18 常见陷阱
> - StrictMode 双重渲染不是 bug,是有意为之的检测机制——利用它发现 effect cleanup 问题
> - `ReactDOM.render` 移除后老代码直接报错,需要用 codemod 批量替换
> - `act()` 包装缺失会导致测试异步状态更新丢失,务必 `await act(async () => {...})`
> [!danger] Next.js 常见陷阱
> - Client Component 中不能直接 `import` Server Component
> - 动态路由组 `(group)` 只影响 URL 路径,不影响目录结构
> - App Router 中的 layout 会跨页面保持状态,需用 `key` 强制重建
## 关联笔记
- [[hhs/REACT/2. Hooks 篇/05-核心 Hooks]]
- [[hhs/REACT/3. 生态工具篇/08-路由管理]]
- [[hhs/REACT/4. 进阶篇/13-并发特性]]
- [[hhs/REACT/4. 进阶篇/14-Serverside Rendering]]
- [[hhs/REACT/5. 工程实践篇/15-性能优化]]
- [[hhs/REACT/5. 工程实践篇/16-测试]]