diff --git a/hhs/REACT/1. 基础篇/01-环境搭建与项目结构.md b/hhs/REACT/1. 基础篇/01-环境搭建与项目结构.md new file mode 100644 index 0000000..95b3166 --- /dev/null +++ b/hhs/REACT/1. 基础篇/01-环境搭建与项目结构.md @@ -0,0 +1,218 @@ +--- +tags: [React, TypeScript, Frontend, Vite] +create time: 2026-04-29 22:00 +--- + +# 环境搭建与项目结构 + +## 概述 + +本文档作为 React 开发系列的起点,介绍从零搭建 **React + TypeScript** 开发环境的全过程。内容包括:主流脚手架工具的选型对比、Vite 项目初始化与核心配置、推荐的目录结构与文件命名规范。完成本章后,你将拥有一个开箱即用的工程化项目骨架,为后续学习组件开发、状态管理和路由打下基础。 + +> [!tip] 前置知识 +> - 已安装 **Node.js 18+**(推荐 LTS 版本) +> - 熟悉基本的终端操作和 npm 命令 +> - 了解 JavaScript/TypeScript 基本语法 + +## 选型决策:Vite vs CRA vs Next.js + +> [!question] 思考:为什么不再推荐使用 Create React App? + +Create React App 已经停止维护,其 Webpack 构建在大型项目中存在明显的冷启动慢、HMR 速度慢等问题。以下是三种方案的横向对比: + +```mermaid +graph TD + A[项目类型] --> B["纯 SPA"] + A --> C["SSR / 全栈应用"] + B --> D[Vite + React] + C --> E[Next.js App Router] + D --> F["适合场景:前端主导、后端提供 API"] + E --> G["适合场景:SEO 优先、服务端渲染需求"] + + style D fill:#61DAFB,color:#000 + style E fill:#000 +``` + +### Vite 方案(本文档主推) + +Vite 基于原生 ES Modules + esbuild,实现毫秒级热更新。相比传统的 Webpack 方案,开发服务器启动时间从数十秒降至亚秒级。 + +```bash +# 创建项目(选择 react-ts 模板) +npm create vite@latest my-app -- --template react-ts +cd my-app + +# 安装依赖 +npm install + +# 启动开发服务器(自动打开浏览器) +npm run dev +``` + +### Vite 核心配置 + +`vite.config.ts` 是开发服务器的中枢,通常需要配置路径别名、代理和插件: + +```ts +// vite.config.ts +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react-swc' +import path from 'path' + +export default defineConfig({ + plugins: [react()], // SWC 编译器比 Babel 更快 + resolve: { + alias: { + '@': path.resolve(__dirname, 'src'), // @ 别名指向 src 目录 + }, + }, + server: { + proxy: { + '/api': 'http://localhost:8080', // 开发时代理后端请求 + }, + }, +}) +``` + +> [!note] 关键配置说明 +> - **SWC vs Babel**:`@vitejs/plugin-react-swc` 使用 Rust 编写的 SWC 编译器,编译速度显著优于传统 Babel 方案 +> - **路径别名**:配合 `tsconfig.json` 中的 `paths` 配置,可以消除项目中深层的相对导入(`../../../utils` → `@/utils`) +> - **代理**:本地开发时将 `/api` 开头的请求转发到后端服务,避免跨域问题 + +### Next.js 方案 + +```bash +# --app: 使用 App Router(推荐) +# --typescript: 启用 TypeScript +# --tailwind: 集成 Tailwind CSS +# --src-dir: 代码放在 src 目录 +npx create-next-app@latest my-app --typescript --app --tailwind --src-dir +``` + +> [!warning] 选型建议 +> - 如果你的项目以 **SEO、首屏加载速度、全栈能力** 为核心需求,选 Next.js +> - 如果团队更擅长 **纯前端开发**,后端由独立 API 服务提供,Vite + React 是更轻量合理的选择 + +## 目录结构 + +### 标准组织方式 + +推荐的 React + TypeScript 项目目录结构如下: + +``` +src/ +├── assets/ # 静态资源(图片、字体等) +├── components/ # 通用 UI 组件(无状态或低状态) +│ ├── Button/ +│ │ ├── Button.tsx +│ │ ├── Button.test.tsx +│ │ └── index.ts +│ └── Modal/ +├── hooks/ # 自定义 Hooks +├── pages/ # 页面级组件 +├── routes/ # 路由配置 +├── stores/ # 状态管理(Zustand / Redux) +├── types/ # TypeScript 类型定义 +├── utils/ # 工具函数 +├── App.tsx # 根组件 +└── main.tsx # 入口文件 +``` + +### 模块依赖关系 + +理解各目录之间的引用关系对维护项目至关重要: + +```mermaid +graph LR + MAIN[main.tsx] --> APP[App.tsx] + APP --> PAGES[pages - 页面组件] + APP --> ROUTES[routes - 路由配置] + PAGES --> COMPONENTS[components - UI组件] + PAGES --> HOOKS[hooks - 自定义Hooks] + PAGES --> STORES[stores - 状态管理] + COMPONENTS --> HOOKS + COMPONENTS --> UTILS[utils - 工具函数] + COMPONENTS --> TYPES[types - 类型定义] + STORES --> TYPES + ROUTES --> PAGES +``` + +> [!note] 依赖方向原则 +> - **单向依赖**:上层模块(pages/stores)依赖下层模块(components/hooks/utils),反向依赖会导致循环引用 +> - **types 是基础设施**:被所有层共享,但绝不反过来依赖其他业务模块 + +> [!tip] 目录组织原则 +> - **按功能而非按类型**:大型项目推荐使用 Feature-Sliced Design 或 Atomics 模式,将组件、Hook、样式、测试放在同一目录下 +> - **Barrel Export(统一导出)**:每个子目录通过 `index.ts` 导出统一接口,简化上游 import 路径 + +## 关键配置文件 + +### tsconfig.json 核心选项 + +```jsonc +{ + "compilerOptions": { + "target": "ES2020", // 目标 JS 版本 + "module": "ESNext", // 模块系统 + "jsx": "react-jsx", // React 17+ 自动导入 JSX transform + "strict": true, // 开启严格模式 + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] // 路径别名 + } + } +} +``` + +### ESLint + Prettier 组合 + +推荐使用 **ESLint flat config**(`eslint.config.js`),这是 ESLint 9 引入的新格式: + +```js +// eslint.config.js (flat config) +import js from '@eslint/js' +import tseslint from 'typescript-eslint' +import reactHooks from 'eslint-plugin-react-hooks' +import globals from 'globals' + +export default tseslint.config( + js.configs.recommended, + { + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + globals: { ...globals.browser }, + }, + plugins: { + 'react-hooks': reactHooks, + }, + rules: { + ...reactHooks.configs.recommended.rules, + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + }, + } +) +``` + +> [!tip] ESLint 迁移提示 +> - 旧版 `.eslintrc.*` 格式的配置文件可以通过 `eslint --init` 自动迁移为 flat config +> - `@typescript-eslint` 同时接管了 TypeScript 特有规则和 JavaScript 规则,无需再单独安装 `eslint-plugin-typescript` +> - React 19 的 ESLint 插件正在逐步推出新特性,建议保持版本同步更新 + +## 开发习惯建议 + +| 工具 | 用途 | 推荐版本 | +|------|------|----------| +| Vite | 构建 & HMR | ^6.x | +| TypeScript | 类型安全 | ^5.x | +| ESLint | 代码质量 | ^9.x (flat config) | +| Prettier | 格式化 | ^3.x | +| Vitest | 单元测试 | ^3.x | + +## 关联笔记 + +- [[hhs/REACT/README]] +- [[hhs/REACT/1. 基础篇/02-JSX 语法]] +- [[hhs/REACT/3. 生态工具篇/08-路由管理]] +- [[hhs/REACT/5. 工程实践篇/16-测试]] diff --git a/hhs/REACT/1. 基础篇/02-JSX 语法.md b/hhs/REACT/1. 基础篇/02-JSX 语法.md new file mode 100644 index 0000000..7b7dfe9 --- /dev/null +++ b/hhs/REACT/1. 基础篇/02-JSX 语法.md @@ -0,0 +1,296 @@ +--- +tags: [React, JSX, TSX, Frontend] +create time: 2026-04-29 22:01 +--- + +# JSX 语法 + +## 概述 + +JSX(JavaScript XML)是 React 的核心语法糖。它让开发者能够在 JavaScript 中直接编写类似 HTML 的结构,经编译后转化为 `React.createElement()` 调用。理解 JSX 的底层机制是掌握 React 渲染原理的基础。 + +## JSX 的本质 + +JSX 并不是模板字符串,编译后会变成普通的 JavaScript 对象: + +```jsx +// 你写的 JSX +const element =

Hello, World!

; + +// 编译后的产物 +const element = React.createElement( + "h1", + { className: "title" }, + "Hello, World!" +); +``` + +> [!warning] 关键点 +> - `React.createElement` 返回的是一个 **Plain Object**(虚拟 DOM 节点),不是真实的 DOM 元素 +> - 这也是为什么 JSX 不会直接操作浏览器 API,而是由 React 的 Reconciler 统一管理 + +## 表达式嵌入 + +在 JSX 中用 `{}` 包裹 JavaScript 表达式: + +```jsx +function FormattedDate({ date }) { + return ( + <> +

Hello!

+

今天 {date.toLocaleDateString()}.

+ {/* 条件:JSX 不能用 if-else,用三元运算符或逻辑与 */} + {date.getTime() < Date.now() &&

日期已过期

} + {/* 数组渲染:每个元素需要 key prop */} + {[1, 2, 3].map(n => ( + {n} + ))} + + ); +} +``` + +### 允许与不允许的表达式 + +| ✅ 允许 | ❌ 不允许 | +|---------|----------| +| `{variable}` | `{if (x) ...}` — JSX 中不支持语句 | +| `{arr.map(...)}` | `{for (let i...)}` — 同上 | +| `{cond && }` | `{func()}` — 函数无返回值时渲染 `undefined`(不报错但不生效)| +| `{null}`, `{false}` | `{""}`, `{0}` — 会渲染空字符串或数字 `0`,常见陷阱 | + +> [!question] 为什么 `{cond ? : func()}` 能跑? +> - 这是合法的 JS 表达式——只要 `func()` 返回一个值即可 +> - 但建议写成 `{cond ? : }`,保持视觉一致性,避免混用 JSX 和函数调用 + +> [!note] null / undefined / boolean vs "" / 0 +> - `null`、`undefined`、`true`、`false` 渲染结果为空——这是有意设计 +> - `0` 和 `""` 会输出到 DOM,常见陷阱 + +## 条件渲染模式 + +```jsx +// 模式1:&& 短路(推荐用于简单场景) +{isLoggedIn && } + +// 模式2:三元运算符(适合两种视图切换) +{isLoading ? : } + +// 模式3:提取为组件(复杂条件逻辑) +return ; + +// 模式4:IIFE(极少使用,仅临时调试) +{(function () { + if (!data) return ; + return ; +})()} +``` + +## 列表渲染与 Key + +```tsx +interface TodoItem { + id: number; + text: string; + completed: boolean; +} + +function TodoList({ items }: { items: TodoItem[] }) { + return ( +
    + {items.map((item) => ( +
  • + + {item.text} +
  • + ))} +
+ ); +} +``` + +> [!question] 思考:为什么 React 要求列表项必须有 key? +> - Key 帮助 React 识别哪些条目变了/增了/删了,从而最小化重渲染次数 +> - 不建议用 index 作为 key(当列表顺序变化时会导致状态错乱) + +## JSX 事件绑定 + +```jsx +function ClickCounter() { + const [count, setCount] = useState(0); + + // 驼峰命名,不要写 onClick="handler()" + const handleClick = () => setCount(c => c + 1); + + // 阻止默认行为 + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + }; + + return ( +
+ +
+ +
+
+ ); +} +``` + +### 事件传参 + +当需要向事件处理函数传递参数时,有两种写法: + +```tsx +interface TodoItemProps { + id: number; + onDelete: (id: number) => void; +} + +function TodoItem({ id, onDelete }: TodoItemProps) { + return ( + + ); +} +``` + +> [!warning] 常见陷阱 +> - ~~`onClick={onDelete(id)}`~~ ❌ — 会在渲染时立即执行 `onDelete`,而非点击时执行 +> - ~~`onClick={onDelete}`~~ — 缺少参数,不知道要删除哪个 +> - ✅ **正确姿势**:`() => onDelete(id)` — 用箭头函数延迟执行并捕获参数 +> - 💡 如果不需要参数,直接传引用即可:`onClick={handleClick}`(不要加括号) + +### 常用合成事件类型 + +```ts +type MouseEvents = React.MouseEvent; +type FormEvents = React.FormEvent; +type ChangeEvents = React.ChangeEvent; +type KeyboardEvents = React.KeyboardEvent; +``` + +> [!tip] TypeScript 事件类型的推导技巧 +> - 不传泛型时:`event.target` 类型为 `EventTarget`(信息最少) +> - 传入具体元素后:`event.currentTarget` 可以精确定位到对应 DOM 类型 +> - 💡 **最佳实践**:优先使用 `currentTarget` 而非 `target`,避免类型收窄问题 + +## JSX 基本规则 + +写 JSX 时有几条硬性规则需要遵守: + +```tsx +// 规则1:自闭合标签 —— 无子元素的元素必须加 / +头像 + + +// 规则2:className 替代 class +
内容
+//
❌ — "class"是JS保留字 + +// 规则3:style 接收对象而非字符串 +
样式
+// 驼峰命名:fontSize, backgroundColor, gridColumn 等 + +// 规则4:单根节点或 Fragment +const Card = ({ title, children }) => ( + <> {/* ✅ Fragment:不产生额外 DOM 节点 */} +

{title}

+
{children}
+ +); +``` + +> [!note] key 属性的唯一要求 +> - `key` 只在数组上下文中有效——它是 React 的元数据,不会被传递给组件 +> - 在自定义组件上使用 key 会报错:**key 只能在列表子元素上使用** + +## Props 传递方式 + +```tsx +interface CardProps { + title: string; + className?: string; + children: React.ReactNode; +} + +function Card({ title, className = "", children }: CardProps) { + return ( +
+

{title}

+ {children} +
+ ); +} + +// 展开 props +const attrs = { className: "card", title: "详情" }; +; + +// 动态属性名 +
+ +// 受控 props(父组件控制子组件内部 state) + // ⚡ Spread Operator 将对象解包为独立的 props + +// 局部覆盖:父 prop 优先级低于子 prop(后者覆盖前者) + // autoFocus 额外传入,其余来自 inputProps +``` + +### 回调 Prop(子 → 父通信) + +```tsx +// 子组件通过回调将数据或事件通知父组件 +
console.log("收到表单数据:", data)} /> +// 💡 父组件将处理函数作为 prop 传入,子组件在合适时机调用它 +``` + +### Render Item Prop(列表渲染定制) + +```tsx +// 父组件告诉子组件:"每个 item 长什么样" + } +/> +// 📌 比完整的 Render Props 模式更轻量的写法 +``` + +## Children 与 Composition + +```tsx +// Card 接收 children(任意 JSX 内容),灵活度远超固定 string prop +function Card({ children, header }: { children: React.ReactNode; header: string }) { + return ( +
+

{header}

+
{children}
+
+ ); +} + +// 使用示例:children 可以包含任意合法的 JSX + +

姓名:张三

+

年龄:25

+
+ +// Fragment —— 不产生额外 DOM 节点,用于包裹兄弟元素 +function ListItem({ text, icon }: { text: string; icon: React.ReactNode }) { + return ( +
  • + <> {/* ⚡ Fragment 缩写语法 */} + {icon} + {text} + +
  • + ); +} +``` + +> [!question] children 的类型为什么是 React.ReactNode 而不是 JSX.Element? +> - `JSX.Element` 只代表 React 元素(如 `
    `、``) +> - `React.ReactNode` 范围更广:包含字符串、数字、Fragment、数组、甚至 null/undefined +> - 💡 如果用 `JSX.Element`,当子元素是纯文本或数组时会报错 + +## React.memo —— 减少不必要的重渲染 + +```tsx +// memo 对组件做了一层"浅比较"包装:只有 props 变化时才重新渲染 +const UserItem = React.memo(function UserItem({ name, age }: { name: string; age: number }) { + // 即使父组件其他 state 变化,只要 name 和 age 不变,就不会重渲染 + return
  • {name},{age} 岁
  • ; +}); + +// 自定义比较函数(仅在需要深比较时使用,一般不需要) +const compare = (prev: UserItemProps, next: UserItemProps) => prev.name === next.name && prev.age === next.age; +const UserItemDeep = React.memo(UserItem, compare); +``` + +> [!tip] memo 的性能权衡 +> - 适用于**频繁重渲染但 props 稳定**的叶子组件 +> - 过度使用会增加维护成本和内存开销——先用 Profile 工具定位瓶颈,再决定是否加 memo + +## forwardRef —— 访问子组件 DOM 节点 + +```tsx +// 父组件需要通过 ref 操作子组件内部的 DOM(如聚焦输入框) +const FancyInput = forwardRef(({ placeholder }, ref) => { + return ; +}); + +// 使用方可以拿到原生 input 的 ref +function Form() { + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); // 💡 挂载后自动聚焦 + }, []); + + return ; +} +``` + +> [!important] forwardRef 的使用场景 +> 不是所有组件都需要 forwardRef。**优先通过 props 解决问题**(可控组件模式)。只在以下场景才需要: +> - 需要暴露 DOM API(聚焦、测量、滚动等) +> - 封装底层 UI 库组件时需要透传 ref + +## 受控组件模式(Controlled Components) + +```tsx +// 组件的内部 state 由外部 props 控制 —— 单一数据源原则 +function ControlledInput({ value, onChange }: { value: string; onChange: (v: string) => void }) { + return ( + onChange(e.target.value)} // 🔑 变化时通知外部更新 + placeholder="输入内容" + /> + ); +} + +// 父组件持有唯一真实状态 +function SearchPage() { + const [query, setQuery] = useState(""); + return ( + <> + +

    搜索关键词:{query || "(空)"}

    + + ); +} +``` + +> [!note] 受控 vs 非受控 +> - **受控**:state 在父组件中管理,数据流向清晰(✅ 推荐大多数场景) +> - **非受控**:state 在子组件内部管理,通过 ref 读取(⚡ 适合简单的表单快速实现) + +## Context 初探(轻量级全局状态) + +Context 是 React 内置的跨组件树通信机制,适合传递那些"多处用到但不应逐层透传"的数据。 + +```tsx +// 创建 Context,初始值通常设为占位值(实际值由 Provider 注入) +const ThemeContext = createContext<"light" | "dark">("light"); + +function App() { + const [theme, setTheme] = useState<"light" | "dark">("light"); + + return ( + + {/* 其下所有子组件(不限层级)都可以通过 useContext 消费 */} + + + + ); +} + +function Toggle() { + const theme = useContext(ThemeContext); // 无需中间层透传 + return ; +} +``` + +> [!warning] Context 的性能陷阱 +> - Provider value 如果是**新对象**,每次渲染都是不同的引用 → 所有消费者全部重渲染 +> - ✅ 解决:用 `useReducer` 返回稳定的 `{state, dispatch}` 对象,保持引用一致 +> - ✅ 解决:拆分 Context,避免一个大 Context 塞入过多数据 + +## 组件组合的进阶模式 + +### Compound Components(复合组件) + +当一个组件的子项之间存在**隐式共享状态**时,使用复合组件模式: + +```tsx +// 手风琴组件:Tab 之间共享 activeIndex,但父组件无需关心 +function Accordion({ children }: { children: React.ReactNode }) { + const [activeIndex, setActiveIndex] = useState(null); + + return ( + +
    {children}
    +
    + ); +} + +function AccordionTab({ title, children }: { title: string; children: React.ReactNode }) { + const ctx = useContext(AccordionContext); + // 💡 每个 Tab 自己从 Context 里拿状态,父组件不用逐一传递 + const isActive = ctx.activeIndex !== null; + return ( +
    ctx.setActiveIndex(ctx.activeIndex)}> +

    {title}

    + {isActive &&
    {children}
    } +
    + ); +} + +// 对外暴露子组件属性,方便用户使用 +Accordion.Tab = AccordionTab; + +// 使用:状态在 Tabs 间共享,父组件只需包裹 + + ... + ... + +``` + +> [!question] 什么时候用 compound components? +> 当你的一组子组件需要**彼此感知对方的状态**,又不想让父组件来协调时。典型场景:Tabs/Accordion/SwitchGroup/Table。 + +### Composition Flow + +```mermaid +sequenceDiagram + participant Parent as 父组件 + participant Child as 子组件 + participant DOM as DOM + + Parent->>Child: 传入 props + children(JSX 树) + Note over Parent,Child: Props 描述配置,Children 描述内容 + Child->>DOM: 根据 props 渲染特定结构 + Child->>Child: 插入 children 到指定位置 + Note over Child: 组合 = 配置 + 内容的分离 +``` + +> [!tip] 组合 vs 继承 +> React 的设计哲学是 **组合优于继承**: +> - JS 的 class 继承会导致紧耦合和脆弱的基类依赖 +> - React 组合让每个组件保持独立,行为通过 props/children/Callbacks 灵活拼装 +> - 💡 如果想共享逻辑,优先考虑 Custom Hooks 而非继承 + +## 关联笔记 + +- [[REACT/1. 基础篇/01-环境搭建与项目结构]] — React 项目初始化与目录规范 +- [[REACT/1. 基础篇/02-JSX 语法]] — JSX 表达式、事件绑定、条件渲染 +- [[REACT/1. 基础篇/04-State 与不可变性]] — State 设计原则与更新模式 +- [[REACT/2. Hooks 篇/05-核心 Hooks]] — useState/useEffect/useContext/useRef 原理 +- [[REACT/2. Hooks 篇/07-自定义 Hooks]] — 逻辑复用与常见 Hook 封装 +- [[REACT/4. 进阶篇/11-组件通信模式]] — Props Drill / Context / 状态管理库方案对比 +- [[REACT/3. 生态工具篇/09-状态管理]] — Context API vs Zustand vs Redux Toolkit +- [[REACT/5. 工程实践篇/15-性能优化]] — React.memo / 虚拟列表 / Code Splitting diff --git a/hhs/REACT/1. 基础篇/04-State 与不可变性.md b/hhs/REACT/1. 基础篇/04-State 与不可变性.md new file mode 100644 index 0000000..df8e673 --- /dev/null +++ b/hhs/REACT/1. 基础篇/04-State 与不可变性.md @@ -0,0 +1,317 @@ +--- +tags: [React, State, Immutability, Hooks, Frontend] +create time: 2026-04-29 22:03 +--- + +# State 与不可变性 + +## 概述 + +State 是 React 组件的数据源。**不可变性**(Immutability)是 React 状态管理的基石——React 通过引用比较判断 state 是否变化,只有创建了新的引用才会触发重渲染。理解这一点,就能避免 "数据变了但 UI 没更新" 这一经典 Bug。 + +## 为什么必须遵守不可变性? + +```mermaid +graph LR + A["setState(newState)"] --> B{"old === new?"} + B -->|不同| C["触发重渲染 ✅"] + B -->|相同| D["跳过渲染 ❌ 数据已变但 UI 不变"] + + style C fill:#4FC08D,color:#fff + style D fill:#F5A87D,color:#000 +``` + +> [!question] 思考:为什么 React 用浅比较而不是深比较? +> +> - **性能**:深比较复杂度为 O(n),大型嵌套对象代价极高;浅比较 O(1) +> - **职责划分**:React 负责高效检测变化,开发者负责正确生成新引用 +> - **历史教训**:Redux / MobX 等库的对比证明,强制不可变性让调试和追踪变更成为可能 + +> [!note] 核心原则 +> React 只认引用地址,不认内容。`{a: 1}` 和 `{a: 1}` 在 JS 中是两个不同的对象,`===` 为 `false`。因此只要每次创建新引用,React 就会检测到变化。 + +## useState 基础用法 + +```tsx +const [count, setCount] = useState(0); +const [user, setUser] = useState<{ name: string; age: number }>({ name: "", age: 0 }); +const [items, setItems] = useState([]); + +// setState 可以接收值或 updater 函数 +setCount(c => c + 1); // 推荐:基于旧值时用 updater,避免闭包陷阱 +``` + +### Updater 函数模式 + +当新 state 依赖旧 state 时,必须使用 updater 形式: + +```tsx +// 场景:快速连续点击,用值可能导致丢失更新 +handleClick = () => { + setCount(count + 1); // ❌ 如果 count 被缓存,可能只+1 + setCount(count + 1); +}; + +handleClick = () => { + setCount(c => c + 1); // ✅ 每次都拿到最新值,两次点击正确 +2 + setCount(c => c + 1); +}; +``` + +> [!tip] 最佳实践 +> 养成习惯:**凡是新值依赖于旧值,一律用 updater 函数**。即使当前看起来没问题,也能预防未来重构引入的闭包问题。 + +## 不可变更新模式 + +### 数组操作 + +```tsx +const [list, setList] = useState(["a", "b", "c"]); + +// 添加末尾 +setList(prev => [...prev, "d"]); + +// 插入开头 +setList(prev => ["new", ...prev]); + +// 删除(按 index) +setList(prev => prev.filter((_, i) => i !== targetIndex)); + +// 更新指定元素 +setList(prev => prev.map((item, i) => + i === targetIndex ? { ...item, completed: true } : item +)); + +// 安全排序(sort() 原地修改!必须先拷贝) +setList(prev => [...prev].sort((a, b) => a.id - b.id)); +``` + +> [!warning] 常见陷阱 +> +> ```tsx +> // ❌ sort()/reverse()/push()/splice() 都原地修改原数组! +> list.sort(); // list 引用不变 +> setList(list); // React 跳过渲染 +> +> // ✅ 先拷贝再操作 +> setList([...list].sort()); +> ``` + +### 对象更新 + +```tsx +const [form, setForm] = useState({ + profile: { name: "", email: "" }, + settings: { theme: "light", notifications: true }, +}); + +// 顶层字段 —— 展开一层即可 +setForm(prev => ({ ...prev, title: "新标题" })); + +// 嵌套一层 —— 逐层展开 +setForm(prev => ({ + ...prev, + profile: { ...prev.profile, name: "新名字" }, +})); +``` + +> [!warning] 多层嵌套会非常冗长,这是手动管理复杂 state 的天然缺陷 +> +> 如果你的嵌套超过两层,说明应该考虑 `useReducer` 或外部状态管理库了。 + +## 状态提升 + +> [!abstract] 什么是状态提升? +> React 官方提出的设计模式:将共享状态移动到最先需要它的共同父组件中,子组件通过 props 读取,通过回调 prop 请求变更。 + +```mermaid +flowchart TD + subgraph A["状态提升前 ❌"] + S1[CounterA 独立 state] + S2[CounterB 独立 state] + S3[总和无法计算] + S1 --> S3 + S2 --> S3 + end + + subgraph B["状态提升后 ✅"] + P[App 持有 state] + C1[CounterA via props] + C2[CounterB via props] + Total["显示总和"] + P --> C1 + P --> C2 + P --> Total + end +``` + +```tsx +// 状态提升后 +function App() { + const [countA, setCountA] = useState(0); + const [countB, setCountB] = useState(0); + + return ( + <> +

    总和: {countA + countB}

    + + + + ); +} +``` + +> [!info] 何时使用状态提升? +> - 两个或多个组件需要同步同一份数据 +> - 一个组件的交互需要影响其他组件的行为 +> - 没有更好的替代方案(如 Context、状态管理库)之前,先从状态提升开始 + +## useReducer:管理复杂 state + +当 state 逻辑变复杂时,`useReducer` 比 `useState(updater)` 更清晰: + +```tsx +interface State { + items: Item[]; + status: "idle" | "loading" | "error"; + error?: string; +} +type Action = + | { type: "ADD"; payload: Item } + | { type: "SET_LOADING" } + | { type: "SET_ERROR"; payload: string }; + +function reducer(state: State, action: Action): State { + switch (action.type) { + case "ADD": return { ...state, items: [...state.items, action.payload] }; + case "SET_LOADING": return { ...state, status: "loading" }; + case "SET_ERROR": return { ...state, status: "error", error: action.payload }; + default: return state; // 未匹配的动作返回原 state + } +} + +function MyComponent() { + const [state, dispatch] = useReducer(reducer, initialState); + + return ( +
    + {state.status === "loading" && } + {state.status === "error" && {state.error}} + {state.items.map(item => ( + dispatch({ type: "REMOVE", payload: item.id })} /> + ))} +
    + ); +} +``` + +> [!tip] useReducer 的优势 +> +> 1. **动作语义化**:`dispatch({ type: "ADD" })` 比 `setState()` 更能表达意图 +> 2. **逻辑集中**:所有变更规则集中在 reducer 函数里,方便测试 +> 3. **批量关联更新**:一个 action 可以同时更新多个相互关联的子值 +> 4. **DevTools 友好**:配合 Redux DevTools 可实现时间旅行调试 + +### useState vs useReducer 决策表 + +| 维度 | 选 useState | 选 useReducer | +|------|------------|--------------| +| state 类型 | 简单标量(number, boolean, string) | object / array,结构较复杂 | +| 下一个 state | 不依赖旧值,或直接给出 | 依赖旧值,且有分支逻辑 | +| 变更来源 | 单一触发点 | 多个可能的 action 类型 | +| 子值联动 | 无需联动 | 一次操作需更新多个子值 | +| 可测试性 | 不需要单独测试 | reducer 是纯函数,可独立测试 | + +> [!example] 同一个场景的两种写法对比 +> +> 假设实现一个计数器,支持 increment/decrement/reset: +> +> ```tsx +> // useState 版本 +> const [n, setN] = useState(0); +> // reset 需要知道初始值,不够自包含 +> +> // useReducer 版本 +> function reducer(state, action) { +> switch (action.type) { +> case "INCREMENT": return { n: state.n + 1 }; +> case "DECREMENT": return { n: state.n - 1 }; +> case "RESET": return { n: INITIAL_VALUE }; // self-contained +> } +> } +> ``` + +## 什么时候不应该用 State? + +> [!important] 误区:把所有数据都塞进 state +> +> 不是所有数据都需要 state。以下情况应优先考虑替代方案: + +```tsx +// ❌ 不该存为 state:可以从 props 或其他 state 计算得出 +const [fullName, setFullName] = useState(""); +useEffect(() => setFullName(`${firstName} ${lastName}`), [firstName, lastName]); + +// ✅ 直接在渲染中计算 +const fullName = `${firstName} ${lastName}`; +``` + +```tsx +import { useRef } from "react"; + +// ❌ 不该存为 state:需要跨渲染保持可变引用(定时器 ID、DOM 节点、WebSocket) +let timerId = useRef(); // 不能在渲染期间赋值 + +// ✅ 用 useRef 存储非渲染相关的可变值 +const timerRef = useRef(); +timerRef.current = setTimeout(() => {}, 1000); + +// ⚠️ 注意:修改 ref.current 不会触发重渲染! +``` + +```tsx +import { useMemo, useCallback } from "react"; + +// ❌ 过度优化:每个小值都用 useMemo/useCallback +const [n, setN] = useState(0); +const doubled = useMemo(() => n * 2, [n]); // 多此一举 + +// ✅ useMemo/useCallback 只在昂贵计算或传给 memo 子组件时使用 +``` + +## 性能注意事项 + +> [!summary] 本节要点:state 的每一次 setter 调用都会触发当前组件重渲染,优化的核心思路是减少不必要的渲染和降低渲染成本。 + +1. **拆分 state** — 不相关的状态不要放在同一个 state 变量里,避免一处变更导致整个大对象所在组件全部重渲染 + +2. **惰性初始化** — `useState(() => computeExpensiveValue())` 中的工厂函数只在首次挂载时执行,适合初始化开销大的场景 + +3. **稳定引用** — 组件函数本身在每次渲染都是新引用,如需传给子组件,优先用 `useCallback`;跨渲染保存可变引用用 `useRef` + +```tsx +// 惰性初始化 +const [cache, setCache] = useState>(() => new Map()); + +// useRef 存储 callback(配合 memo 使用) +const callbackRef = useRef(callback); +callbackRef.current = callback; +// 子组件中通过 callbackRef.current 访问 +``` + +> [!tip] React 18 批处理升级 +> +> React 18 起,**所有状态更新都自动批处理**,包括: +> - Promise 回调内的 setState +> - setTimeout 内的 setState +> - 原生事件处理器内的 setState +> +> 不再需要 `unstable_batchedUpdates`,也不再需要担心手动批处理的问题。 + +## 关联笔记 + +- [[hhs/REACT/1. 基础篇/03-组件与 Props]] +- [[hhs/REACT/2. Hooks 篇/05-核心 Hooks]] +- [[hhs/REACT/2. Hooks 篇/06-性能优化 Hooks]] +- [[hhs/REACT/4. 进阶篇/11-组件通信模式]] +- [[hhs/REACT/5. 工程实践篇/15-性能优化]] diff --git a/hhs/REACT/2. Hooks 篇/05-核心 Hooks.md b/hhs/REACT/2. Hooks 篇/05-核心 Hooks.md new file mode 100644 index 0000000..71ef945 --- /dev/null +++ b/hhs/REACT/2. Hooks 篇/05-核心 Hooks.md @@ -0,0 +1,495 @@ +--- +tags: [React, Hooks, useState, useEffect, useContext, useRef, Frontend] +create time: 2026-04-29 22:04 +--- + +# 核心 Hooks + +## 概述 + +Hook 是 React 16.8 引入的特性,让函数组件拥有 State、副作用管理、上下文消费等原本只有 Class 组件才具备的能力。 + +> [!question] 思考:在 Hook 出现之前,Class 组件有哪些痛点? + +| 痛点 | 表现 | Hook 的解法 | +|------|------|-------------| +| **状态逻辑复用** | 用 HOC / Render Props 嵌套过深("回调地狱") | 自定义 Hook 直接抽离状态逻辑 | +| **职责分散** | 同一逻辑被拆分到 `componentDidMount`、`componentDidUpdate`、`componentWillUnmount` | 同一 `useEffect` 内组合关联逻辑 + cleanup | +| **this 指向混乱** | 需要手动 bind、箭头函数或类字段属性 | 函数组件没有 `this`,闭包天然解决 | +| **组件臃肿** | 大组件难以阅读和维护 | 按关注点拆分为多个 Hook | + +Hook 的设计哲学可以总结为一句话:**把"组件做什么"和"组件什么时候做"解耦**。 + +本文聚焦四个核心 Hook —— **useState、useEffect、useContext、useRef**,并在此基础上介绍性能优化 Hook 与自定义 Hook 实战。 + +--- + +## Hook 执行规则 + +> [!warning] 两条铁律 +> 1. **只在最顶层调用 Hook** — 不能在循环、条件、嵌套函数中调用 +> 2. **只在 React 函数组件或自定义 Hook 中调用** — 不能在其他普通 JS 函数中调用 + +```tsx +// ❌ 违反规则:条件调用 Hook +function Component({ loaded }) { + if (loaded) { + const [data, setData] = useState(null); // 崩溃! + } +} + +// ✅ 正确:条件逻辑放在 Hook 内部 +function Component({ loaded }) { + const [data, setData] = useState(null); + useEffect(() => { + if (!loaded) return; + fetch("/api/data").then(setData); + }, [loaded]); +} +``` + +### Hook 的执行顺序依赖调用顺序 + +React 内部用一个数组存储每个组件的 Hook 状态。每次渲染时,**按声明顺序依次取出对应 Hook 的状态**。这就是为什么必须在顶层、固定顺序调用。 + +```tsx +function Counter() { + const [count, setCount] = useState(0); // Hook #0 + const theme = useContext(ThemeContext); // Hook #1 + const btnRef = useRef(); // Hook #2 + + // 每次渲染都按这个顺序取状态,不能颠倒或删除 +} +``` + +--- + +## useState + +### 基础用法 + +```tsx +const [state, setState] = useState(initialValue); +``` + +当初始值计算开销较大时,使用惰性初始化避免重复计算: + +```tsx +const [items, setItems] = useState(() => loadFromStorage()); // 仅首次渲染执行 +``` + +### 关键行为 + +> [!tip] 核心认知 +> setState 是**异步合并**的 —— 你传给它的不是当前 state,而是基于"即将更新"的旧值计算新值。 + +| 场景 | 行为 | +|------|------| +| 对象/数组作为 state | **引用比较**——必须创建新对象/数组才会触发重渲染 | +| 连续多次 setState | React 18 自动合并为一次 re-render | +| 在非 React 代码中调用 | 使用 `startTransition` / `flushSync` 控制 | + +```tsx +function BadCounter() { + const [count, setCount] = useState(0); + + // ❌ 闭包陷阱:count 始终捕获定义时的 0 + setTimeout(() => setCount(count + 1), 1000); +} + +function GoodCounter() { + const [count, setCount] = useState(0); + + // ✅ 使用 updater 函数,获取最新值 + setTimeout(() => setCount(c => c + 1), 1000); +} +``` + +### 不可变更新模式 + +```tsx +const [list, setList] = useState([{ id: 1, done: false }]); + +// ❌ 直接修改 —— 不会触发重渲染 +list[0].done = true; +setList(list); + +// ✅ 创建新引用 —— 正确触发更新 +setList(prev => prev.map(item => item.id === 1 ? { ...item, done: true } : item)); +``` + +> [!note] 为什么要有不可变性? +> React 通过**浅比较引用**来判断是否需要重渲染。如果原地修改了原有对象,引用地址不变,React 认为"没变化"就跳过了 DOM 更新。这正是 React 高效的原因之一。 + +--- + +## useEffect + +### 基本模型 + +```tsx +useEffect(() => { + // 副作用逻辑 + return () => { /* cleanup */ }; // 可选清理函数 +}, [dependencies]); // 依赖数组 +``` + +### 三种行为模式 + +```tsx +// 无依赖数组 → 每次渲染后执行(几乎不推荐) +useEffect(() => { console.log("every render"); }); + +// 空数组 [] → 仅挂载时执行一次 +useEffect(() => { + console.log("mount once"); + return () => console.log("unmount"); +}, []); + +// 有依赖项 → 任一依赖变化时重新执行 +useEffect(() => { + const sub = api.subscribe(userId); + return () => sub.unsubscribe(); +}, [userId]); // userId 变了 → 先 clean 旧的 → 再执行新的 +``` + +### useEffect 生命周期时序 + +> [!abstract] useEffect 的生命周期可以用下面的时序图理解: + +```mermaid +sequenceDiagram + participant R as React + participant E as Effect + participant C as Cleanup + + R->>E: 渲染完成,执行 effect + Note over E: side effect 运行
    (数据请求、DOM 操作等) + R->>R: 再次渲染... + + alt 依赖项变化 + R->>C: 先执行上一次 effect 的 cleanup + R->>E: 执行新的 effect + else 依赖项未变化 + R->>R: 跳过本次 effect + end +``` + +> [!question] 思考:cleanup 函数何时执行? +> - 组件卸载时 +> - **下一次 effect 执行前**(前提是依赖项发生了变化) +> 这意味着你可以用同一个 effect 同时处理"订阅 → 取消 → 重新订阅"整个流程。 + +### 常见副作用类型 + +```tsx +// 数据获取 +useEffect(() => { + let cancelled = false; + fetch("/api/data") + .then(res => res.json()) + .then(data => { if (!cancelled) setData(data); }); + + return () => { cancelled = true; }; +}, []); + +// DOM 事件监听 +useEffect(() => { + const handleResize = () => setWidth(window.innerWidth); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); +}, []); + +// 定时轮询(用 ref 避免把 fetchData 写入依赖数组导致频繁清理) +useEffect(() => { + const fetchData = () => {/* polling logic */}; + let mounted = true; + + const timer = setInterval(async () => { + if (!mounted) return; + try { + const res = await fetch("/api/data"); + const json = await res.json(); + setData(json); + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))); + } + }, 5000); + + return () => { mounted = false; clearInterval(timer); }; +}, []); +``` + +### 副作用分类指南 + +> [!summary] 哪些该放 useEffect?哪些不该? + +| 应该用 useEffect | 不应该用 useEffect | +|------------------|---------------------| +| 数据获取、订阅、定时器 | JSX 渲染中的同步逻辑 | +| 操作 DOM(focus、测量) | 阻止表单提交、路由跳转 | +| 第三方库集成 | **事件处理函数体内**(直接用 onClick 即可) | + +--- + +## useMemo & useCallback + +这两个 Hook 都是**记忆化**工具,核心目标相同:**缓存计算结果,避免不必要的重新计算或子组件重渲染**。 + +### useMemo —— 缓存计算结果 + +```tsx +const sortedItems = useMemo(() => items.sort(comparator), [items]); +``` + +> [!important] useMemo 不等于"不用每次都算" +> `useMemo` 本身也有开销(缓存对比),它只是给开发者一个"提示 React:结果可以被复用"。**只有在计算昂贵或在渲染期间产生高成本时才有意义。** + +### useCallback —— 缓存函数引用 + +```tsx +const handleClick = useCallback( + (id: string) => { deleteItem(id); }, + [deleteItem] // deleteItem 变化时返回新函数 +); +``` + +### 两者关系与选择 + +```mermaid +quadrantChart + title "useMemo vs useCallback 使用决策" + x-axis "少用" --> "常用" + y-axis "缓存计算" --> "缓存引用" + "useCallback": [0.7, 0.9] + "useMemo": [0.3, 0.9] + "纯计算,传给子组件": [0.7, 0.5] + "纯值,渲染中使用": [0.3, 0.5] +``` + +| Hook | 缓存什么 | 典型场景 | +|------|----------|----------| +| `useMemo` | 计算后的**值** | 列表排序、过滤、复杂运算 | +| `useCallback` | 函数**引用** | 传给 `React.memo` 包裹的子组件 | + +### 三者联合:防止过度重渲染 + +```tsx +function Parent() { + const [text, setText] = useState(""); + + // 缓存 handler 引用 —— 避免 Child 因 props.fn 变化而重渲染 + const handleSubmit = useCallback( + (e: React.FormEvent) => { alert(text); }, + [text] + ); + + // 缓存 computed 值 —— 避免每次渲染都重新 sort/filter + const filteredUsers = useMemo( + () => users.filter(u => u.name.includes(text)), + [users, text] + ); + + return ; +} +``` + +> [!example] 配合 React.memo 的效果 +> ```tsx +> // Child 仅在 props 引用变化时才重渲染 +> const Child = React.memo(({ data, onSubmit }) => ( +>
    {/* ... */}
    +> )); +> ``` +> 如果没有 `useCallback` 和 `useMemo`,即使 `data` 和 `onSubmit` 内容没变,每次父组件渲染都会生成新引用,导致 Child 无效重渲染。 + +--- + +## useContext + +### 基本概念 + +Context 解决的是"跨层级传参"问题,无需逐层透传 props(又称 prop drilling)。 + +```tsx +const ThemeContext = createContext<"light" | "dark">("light"); + +function App() { + return ( + +
    {/* 不需要 Header 向 Button 传递 theme */} +
    + + ); +} + +function Button() { + const theme = useContext(ThemeContext); // 任意深度消费 + return ; +} +``` + +### 最佳实践:封装自定义 Hook + +```tsx +const ThemeContext = createContext<"light" | "dark">("light"); + +// ❌ 不要到处写 useContext(ThemeContext) +// ✅ 抽取为语义化 Hook +function useTheme() { + const ctx = useContext(ThemeContext); + if (!ctx) throw new Error("useTheme must be used within a ThemeProvider"); + return ctx; +} +``` + +### Context 的性能陷阱 + +> [!warning] Provider 的 value 一旦变化,**所有**消费该 Context 的组件都会重渲染 +> +> 因为每次 `{ value: count }` 都是一个新对象,浅比较不等。 + +**解决方案:** + +```tsx +// 方案1:拆分成多个小 Context +const UserContext = createContext(user); +const ThemeContext = createContext(theme); + +// 方案2:用 useReducer 保持 dispatch 引用稳定 +function App() { + const [state, dispatch] = useReducer(reducer, initial); + return + + ; +} +``` + +> [!tip] 何时用 Context? +> - 适合"全局配置":主题、语言、用户信息 +> - 不适合高频更新的值(频繁变化会导致大量重渲染) +> - 复杂状态管理考虑 Zustand / Jotai / Redux Toolkit + +--- + +## useRef + +### 两个用途 + +```tsx +// 用途1:持有可变值,修改不触发重渲染 +const timerRef = useRef(null); +const prevCountRef = useRef(""); + +// 用途2:访问 DOM 元素 +function FocusInput() { + const inputRef = useRef(null); + + useEffect(() => { inputRef.current?.focus(); }, []); + + return ; +} +``` + +### ref vs state 对比 + +| 维度 | state | ref | +|------|-------|-----| +| 修改触发重渲染 | ✅ | ❌ | +| 跨渲染持久化 | ✅ | ✅ | +| 写入方式 | `setState(val)` | `ref.current = val` | +| 典型用途 | UI 驱动数据 | DOM 句柄 / 定时器 / 临时计数器 | + +> [!note] 本质区别 +> ref 的 `.current` 是一个普通的 JavaScript 属性,修改它就像改任何对象的属性一样,React 完全不知道。State 是 React 管理的响应式数据,修改后会告诉 React "请重新渲染"。 + +### 实用技巧:保存上一次的值 + +```tsx +function usePrevious(value: T): T | undefined { + const ref = useRef(); + useEffect(() => { ref.current = value; }); + return ref.current; +} + +// 用法 +function Example({ value }) { + const prev = usePrevious(value); + // prev !== undefined && prev !== value → 值变更了 +} +``` + +--- + +## 自定义 Hook 实战 + +自定义 Hook 是 React 中最强大的抽象手段之一 —— 它是一个普通函数,名字以 `use` 开头,内部可以调用其他 Hook,从而实现**状态逻辑的跨组件复用**。 + +### 示例:自动失焦 Hook + +```tsx +function useAutoFocus(delayMs: number = 300) { + const ref = useRef(null); + + useEffect(() => { + const timer = setTimeout(() => ref.current?.focus(), delayMs); + return () => clearTimeout(timer); + }, [delayMs]); + + return ref; // 暴露给组件绑定到 input +} + +// 使用 +function LoginForm() { + const usernameRef = useAutoFocus(); + return ; +} +``` + +### 示例:在线状态检测 + +```tsx +function useIsOnline() { + const [online, setOnline] = useState(navigator.onLine); + + useEffect(() => { + setOnline(navigator.onLine); + const onOnline = () => setOnline(true); + const onOffline = () => setOnline(false); + window.addEventListener("online", onOnline); + window.addEventListener("offline", onOffline); + return () => { + window.removeEventListener("online", onOnline); + window.removeEventListener("offline", onOffline); + }; + }, []); + + return online; +} +``` + +### 自定义 Hook 设计原则 + +> [!summary] 好的自定义 Hook 应遵循以下原则 +> 1. **单一职责** —— 每个 Hook 解决一个问题(如上述两个例子各自独立) +> 2. **命名清晰** —— `useXxx` 格式,读起来像动作:"监听网络"、"记住上次值" +> 3. **返回值明确** —— 返回需要的引用或状态,不要暴露过多内部细节 +> 4. **可以组合** —— `useFetch` 内部可用 `useEffect` + `useRef`,外层可再用 `useAutoFocus` + +--- + +## Hook 调试技巧 + +> [!tip] React DevTools 浏览器扩展 +> 安装后可以: +> - 查看每个组件的 Hook 值和更新次数 +> - 定位"为什么这个组件在重渲染" +> - 记录渲染时间,发现性能瓶颈 + +--- + +## 关联笔记 + +- [[3. 组件篇/01-组件基础.md]] +- [[3. 组件篇/03-性能优化.md]] +- [[config/REACT/react-best-practices.md]] diff --git a/hhs/REACT/2. Hooks 篇/06-性能优化 Hooks.md b/hhs/REACT/2. Hooks 篇/06-性能优化 Hooks.md new file mode 100644 index 0000000..b41f5af --- /dev/null +++ b/hhs/REACT/2. Hooks 篇/06-性能优化 Hooks.md @@ -0,0 +1,421 @@ +--- +tags: [React, Hooks, Performance, useMemo, useCallback, Frontend] +create time: 2026-04-29 22:05 +--- + +# 性能优化 Hooks + +## 概述 + +当 React 应用出现不必要的重渲染时,开发者最先想到的是 `useMemo` 和 `useCallback`。但它们也有认知成本——**不恰当的使用反而会让代码更慢更乱**。理解何时该用、何时不该用是关键。 + +> [!question] 思考:为什么大部分 React 应用根本不需要 useMemo? +> 现代浏览器处理一个普通 JavaScript 函数只需几微秒,而 `useMemo` 自身就有缓存对比 + 闭包引用的开销。在没有测量(measure)之前就加记忆化,本质上是猜。 + +```mermaid +flowchart TD + A["发现卡顿"] --> B{"是否在 DevTools
    Profiler 中定位?"} + B -->|否| C["先安装 React DevTools
    定位瓶颈组件"] + B -->|是| D{"子组件是否因
    props 引用变化重渲染?"} + D -->|是| E["给子组件加 React.memo"] + D -->|否| F{"计算是否真的昂贵?"} + F -->|是| G["用 useMemo 缓存结果"] + F -->|否| H["不要优化 — 保持简单"] + E --> I{"回调引用是否
    每次都被替换?"} + I -->|是| J["用 useCallback 稳定引用"] + I -->|否| K["不要优化"] + + style C fill:#F5A87D,color:#000 + style H fill:#F5A87D,color:#000 + style K fill:#F5A87D,color:#000 + style G fill:#4FC08D,color:#fff + style J fill:#4FC08D,color:#fff +``` + +本文聚焦以下 Hooks,按实用场景排序: + +| Hook | 作用 | React 版本 | +|------|------|------------| +| `useMemo` | 缓存计算结果 | 16.8+ | +| `useCallback` | 缓存函数引用 | 16.8+ | +| `useTransition` | 标记低优先级更新 | 18+ | +| `useDeferredValue` | 产生延迟值副本 | 18+ | +| `useDebugValue` | 自定义 Hook 调试标记 | 16.8+ | +| `useId` | 服务端一致的唯一 ID | 18.3+ | + +> [!note] 前置知识 +> 这些 Hooks 建立在 `useState` / `useEffect` 的基础上。如果你还不熟悉核心 Hooks,请先阅读 [[05-核心 Hooks]]。 + +--- + +## useMemo + +```tsx +const memoizedValue = useMemo(() => computeExpensive(a, b), [a, b]); +``` + +### 执行时机 + +| 时机 | 行为 | +|------|------| +| 依赖未变 | 返回上次缓存的值(不调用计算函数) | +| 任一依赖变化 | 重新执行计算函数并缓存新结果 | +| 组件首次渲染 | 执行计算(无缓存可用) | + +### 适用场景 + +```tsx +// ✅ 适用:昂贵计算 + 频繁 re-render +const sortedUsers = useMemo(() => { + return users.sort((a, b) => a.name.localeCompare(b.name)); +}, [users]); + +// ✅ 适用:复杂对象/数组衍生值 +const userStats = useMemo(() => ({ + total: users.length, + active: users.filter(u => u.active).length, + avgAge: users.reduce((sum, u) => sum + u.age, 0) / users.length, +}), [users]); + +// ❌ 不适用:简单运算(开销比 useMemo 本身还大) +const double = useMemo(() => count * 2, [count]); +``` + +> [!tip] 判断标准 +> - 如果表达式只包含 `+ - * /` 或简单的 `.filter()` / `.map()` → 不需要 useMemo +> - 如果涉及 API 调用、深层遍历、DOM 测量、大量排序 → 考虑 useMemo +> - 如果传给了 `React.memo` 包裹的子组件作为 prop → 优先考虑 useMemo + +### 常见陷阱 + +> [!warning] 依赖陷阱:useMemo 的第二个参数决定了一切 +> ```tsx +> // ❌ 忘记放入依赖 —— 闭包捕获旧值 +> const memo = useMemo(() => items.filter(i => i.price > minPrice), [items]); +> // minPrice 变了但 memo 没刷新! +> +> // ✅ 列出所有外部引用 +> const memo = useMemo(() => items.filter(i => i.price > minPrice), [items, minPrice]); +> ``` + +--- + +## useCallback + +```tsx +const memoizedCallback = useCallback( + (arg1: string, arg2: number) => doSomething(arg1, arg2), + [dep1, dep2], +); +``` + +### 本质 + +```mermaid +flowchart LR + A["每次渲染创建新函数"] --> B["子组件收到新引用"] + B --> C["子组件 re-render
    即使 props 内容没变"] + + D["useCallback 包装"] --> E["保持同一函数引用"] + E --> F["memo / React.memo
    拦截重渲染"] + + style C fill:#F5A87D,color:#000 + style F fill:#4FC08D,color:#fff +``` + +### 典型使用模式 + +```tsx +// 父组件:稳定 callback 引用传给子组件 +function Parent() { + const [query, setQuery] = useState(""); + + // 不包装:每次 render 都是新函数 → Child 永远 re-render + // const handleChange = (e) => setQuery(e.target.value); + + // 包装:引用稳定,Child 在 query 不变时不重渲染 + const handleChange = useCallback((e: React.ChangeEvent) => { + setQuery(e.target.value); + }, []); // setQuery 引用稳定(setState),可不列入依赖 + + return ; +} + +// 子组件:配合 React.memo 生效 +const SearchInput = React.memo(({ value, onChange }: { value: string; onChange: (e: React.ChangeEvent) => void }) => { + return ; +}); +``` + +> [!example] useCallback + React.memo 联合效果 +> 见 [[05-核心 Hooks]] 中"**三者联合:防止过度重渲染**"章节,有完整示例。 + +### 依赖数组设计原则 + +> [!summary] 不要把所有局部变量都塞进依赖数组 +> ```tsx +> // ❌ 错误思路:把所有变量都列上 +> const handler = useCallback(() => { +> doSomething(a, b, c, d, e, f); +> }, [a, b, c, d, e, f]); +> // 几乎所有变量都会变 → 几乎每次都生成新函数 → 失去意义 +> +> // ✅ 正确思路:思考"哪些真正影响这个回调的行为" +> const handler = useCallback(() => { +> fetchData(keyword); // keyword 是唯一外部依赖 +> }, [keyword]); +> ``` +> +> **关键经验**:`setState` setter 函数和 `useRef.current` 引用是稳定的,不需要放入依赖数组。 + +--- + +## useTransition(React 18) + +```tsx +const [isPending, startTransition] = useTransition(); + +function SearchPage() { + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + + const handleChange = (q: string) => { + setQuery(q); // 同步更新(立即渲染,高优先级) + + // 标记为 transition:低优先级更新 + startTransition(() => { + setResults(performSearch(q)); // 延迟渲染,不阻塞 UI + }); + }; + + return ( + <> + handleChange(e.target.value)} /> + {isPending && } + + + ); +} +``` + +### 语义解读 + +`useTransition` 的核心思想是:**将一批状态更新分为"马上显示"和"稍后显示"**。`startTransition` 内部的 setState 会被降级为后台优先级,React 可以随时中断它去响应用户交互。 + +### 使用场景区别 + +```mermaid +quadrantChart + title "更新优先级划分" + x-axis "高优先级" --> "低优先级" + y-axis "数据驱动" --> "UI 展示" + "onClick → setState": [0.8, 0.9] + "路由切换": [0.7, 0.7] + "search → results": [0.3, 0.8] + "scroll animation": [0.2, 0.3] + "typing in input": [0.9, 0.5] +``` + +### useTransition vs useDeferredValue + +| 维度 | useTransition | useDeferredValue | +|------|---------------|-------------------| +| 控制粒度 | 包裹特定 setState | 包裹整个值 | +| 语义 | 启动一个低优先级事务 | 产生一个延迟副本 | +| 适合场景 | 表单提交后加载详情 | 搜索框前后分屏 | +| 使用复杂度 | 需手动选择哪些更新降级 | 一行封装,自动推导 | + +> [!tip] 如何选择? +> - 你想让 UI 看起来流畅(输入立刻响应,结果稍后出来)→ `useDeferredValue` +> - 你有一组相关更新想让它们一起降级 → `useTransition` +> - 不确定?先用 `useTransition`,它在大多数场景下都能工作 + +--- + +## useDeferredValue(React 18) + +```tsx +function SearchPage() { + const [query, setQuery] = useState(""); + const deferredQuery = useDeferredValue(query); // query 即时更新,deferredQuery 延迟更新 + + return ( + <> + setQuery(e.target.value)} /> + {/* 快速响应用户输入 */} + + {/* 耗时操作延迟渲染 */} + + + ); +} +``` + +### 内部工作原理 + +```mermaid +sequenceDiagram + participant U as 用户输入 + participant S as useState (query) + participant D as useDeferredValue + participant R as React Scheduler + + U->>S: setQuery("abc") + S-->>R: 高优先级更新(立即渲染) + Note over R: 输入框立刻显示 "abc" + R->>D: query 已更新 + D->>R: 请求延迟值(可被抢占) + Note over R: 如果有更高优先级事件
    会暂停 deferred 渲染 + R->>R: 空闲时渲染 HeavyResultsList +``` + +> [!question] 思考:deferred 值延迟多久? +> React 没有固定的延迟时间。它的策略是:"如果有更高优先级的工作要处理(比如用户正在打字),就暂缓 deferred 渲染;等浏览器空闲了再补上。"这使得它既能保证流畅性,又能最终呈现结果。 + +--- + +## useDebugValue(自定义 Hook 调试) + +> [!abstract] 当你编写自定义 Hook 时,如何在 React DevTools 中看到有意义的值? +> 这就是 `useDebugValue` 的用武之地 —— 它为自定义 Hook 添加自定义显示标签。 + +```tsx +function useOnlineStatus() { + const [online, setOnline] = useState(navigator.onLine); + + // 👇 在 DevTools 中显示可读的 "🟢 Online" 或 "🔴 Offline" + useDebugValue(online ? "🟢 Online" : "🔴 Offline"); + + useEffect(() => { + const onOnline = () => setOnline(true); + const onOffline = () => setOnline(false); + window.addEventListener("online", onOnline); + window.addEventListener("offline", onOffline); + return () => { + window.removeEventListener("online", onOnline); + window.removeEventListener("offline", onOffline); + }; + }, []); + + return online; +} +``` + +### 延迟格式化(高性能场景) + +```tsx +function useFetch(url: string) { + const [data, setData] = useState(null); + + // 延迟格式化:只在 DevTools 展开时才执行 format 函数 + // 避免在开发环境中对大型数据集造成额外开销 + useDebugValue(data, d => + d ? `${d.items?.length || 0} items loaded` : "loading..." + ); + + // ... fetch logic + return data; +} +``` + +> [!tip] 生产环境安全 +> `useDebugValue` 在生产构建中会被自动忽略,不会产生任何运行时开销。放心在自定义 Hook 中使用。 + +--- + +## useId(React 18.3+) + +> [!important] SSR 兼容的唯一 ID +> `useId` 专为 accessibility 属性设计(如 `aria-labelledby`、`htmlFor`)。它保证了服务端和客户端生成的 ID 完全一致,解决了 hydrate mismatch 问题。 + +```tsx +function FormField() { + // 每次渲染生成唯一且稳定的 ID + const labelId = useId(); + const inputId = useId(); + + return ( + <> + + + + ); +} +``` + +### useId vs Math.random() vs 手动字符串 + +| 方式 | SSR 一致 | Hydrate 匹配 | 确定性 | +|------|----------|-------------|--------| +| `useId()` | ✅ | ✅ | ✅ 基于树结构 | +| `Math.random()` | ❌ | ❌ | ❌ 每次随机 | +| 手动 `"field-1"` | ✅ | ⚠️ 需人工维护 | ⚠️ 易重复 | + +> [!warning] useId 不适合用于 key +> useId 生成的 ID 是稳定的但不一定是唯一的(同一组件多次渲染可能返回相同 ID)。列表的 `key` 仍应使用业务标识符。 + +--- + +## 性能调优 Checklist + +> [!summary] 优化顺序 +> 不要一开始就用 useMemo/useCallback!按以下顺序排查: + +1. **减少不必要的 state** — 能派生的从 state 中移除(derived state) +2. **拆分巨型组件** — 子组件独立维护自己的 state +3. **React DevTools Profiler** — 定位哪个组件在多余 re-render +4. **给高频子组件加 React.memo** +5. **最后才考虑 useMemo / useCallback** + +```tsx +// Step 1: 先移除所有 useMemo/useCallback 做基准测试 +// Step 2: 加上 React.memo 包裹频繁更新的子组件 +// Step 3: 仅针对仍存在的瓶颈点添加 useMemo/useCallback +``` + +### React DevTools Profiler 实操 + +```tsx +import { Profiler } from "react"; + +function onRenderCallback( + id: string, // "MyComponent" + phase: "mount" | "update", + actualDuration: number // 本次渲染耗时(ms) +) { + console.log(`${id} ${phase}: ${actualDuration.toFixed(2)}ms`); +} + +// 用法:包裹需要监控的 subtree + + + +``` + +> [!tip] Profiler API vs DevTools 界面 +> - **Profiler API**:适合持续记录生产环境中的性能数据 +> - **DevTools 界面**:适合开发阶段交互式点击分析哪个组件导致了重渲染 +> 两者可以结合使用。 + +--- + +## 何时绝对不要用这些 Hook? + +> [!question] 思考:哪些场景加了优化反而适得其反? + +| 场景 | 原因 | +|------|------| +| 简单算术运算 | `count * 2` 的开销远小于闭包比较 | +| 低频渲染组件 | 一年才重渲染一次的东西不需要优化 | +| 顶层容器组件 | 容器本身很少子组件,优化收益为零 | +| 嵌套过深的依赖 | `useCallback(() => fn(a,b,c,d,e), [a,b,c,d,e])` → 等价于不用 | + +> [!note] Google 的前端性能研究结论 +> 在他们的实际项目度量中,超过 90% 的重渲染问题通过合理的 `React.memo` 就能解决,真正需要 `useMemo` / `useCallback` 的场景不足 5%。 + +--- + +## 关联笔记 + +- [[05-核心 Hooks]] — useState / useEffect / useContext / useRef 详解 +- [[07-自定义 Hooks]] — 如何设计和组合可复用的自定义 Hook diff --git a/hhs/REACT/2. Hooks 篇/07-自定义 Hooks.md b/hhs/REACT/2. Hooks 篇/07-自定义 Hooks.md new file mode 100644 index 0000000..6042210 --- /dev/null +++ b/hhs/REACT/2. Hooks 篇/07-自定义 Hooks.md @@ -0,0 +1,463 @@ +--- +tags: [React, Custom Hooks, Frontend] +create time: 2026-04-29 22:30 +--- + +# 自定义 Hooks + +## 概述 + +自定义 Hook 是 React 中最强大的逻辑复用机制。它以函数形式封装可复用的副作用逻辑和状态,通过 Hook 组合实现"代码即组件"的哲学。理解如何设计良好的自定义 Hook,是从中级迈向高级 React 开发者的分水岭。 + +## 命名规范与设计原则 + +> [!question] 思考 +> 为什么普通函数不能直接持有 React state?Hook 和普通函数的根本区别是什么? +> (提示:从 React 的内部执行上下文和 Fiber 架构角度理解) + +### 必须以 `use` 开头 + +```tsx +// ✅ 正确 +function useFetch(url: string) { ... } +function useLocalStorage(key: string) { ... } +function useWindowSize() { ... } + +// ❌ 错误:不遵循 use 前缀约定,React 无法识别为 Hook +function fetchWithRetry(url: string) { ... } +``` + +> [!warning] 前置依赖 +> 所有自定义 Hook 示例默认已导入: +> ```tsx +> import { useState, useEffect, useCallback, useMemo, useRef, RefObject } from "react"; +> ``` + +### 规则与常规函数不同 + +```mermaid +graph LR + A["自定义 Hook"] --> B["可以在任何条件/循环内调用"] + A --> C["可以嵌套调用(Hook 中再调其他 Hook)"] + A --> D["⚠️ 但必须在组件顶层或 Hook 中调用"] + + D1["不能在回调/普通函数内调用"] --> D + + E["普通函数"] --> F["无执行顺序约束"] + F --> G["不能直接持有 React state/effect"] + + style A fill:#61DAFB,color:#000 +``` + +## 常见 Hook 模式 + +### 1. useFetch — 数据获取 + +```tsx +interface UseFetchResult { + data: T | null; + loading: boolean; + error: Error | null; + refetch: () => void; +} + +function useFetch(url: string, options?: RequestInit): UseFetchResult { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // ✅ 使用 useRef 跟踪组件挂载状态,避免卸载后 setState + const mountedRef = useRef(true); + + const execute = useCallback(async () => { + if (!mountedRef.current) return; + setLoading(true); + setError(null); + try { + const res = await fetch(url, options ?? {}); + if (!res.ok) throw new Error(res.statusText); + const json: T = await res.json(); + if (mountedRef.current) setData(json); + } catch (err) { + if (mountedRef.current) setError(err as Error); + } finally { + if (mountedRef.current) setLoading(false); + } + }, [url, JSON.stringify(options)]); + + useEffect(() => { + execute(); + return () => { mountedRef.current = false; }; + }, [execute]); + + return { data, loading, error, refetch: execute }; +} +``` + +### 2. useDebounce — 防抖 + +```tsx +interface DebounceResult { + debouncedValue: T; + cancel: () => void; +} + +function useDebounce(value: T, delay: number): DebounceResult { + const [debouncedValue, setDebouncedValue] = useState(value); + const timerRef = useRef>(); + + useEffect(() => { + timerRef.current = setTimeout(() => setDebouncedValue(value), delay); + return () => clearTimeout(timerRef.current); + }, [value, delay]); + + // ✅ 提供 cancel,外部可在需要时主动取消待触发的回调 + const cancel = useCallback(() => clearTimeout(timerRef.current), []); + + return { debouncedValue, cancel }; +} + +// 使用 +function SearchInput() { + const [query, setQuery] = useState(""); + const { debouncedValue } = useDebounce(query, 300); + + // 用 debouncedValue 发起 API 请求 —— 只在用户停止输入 300ms 后触发 + useEffect(() => { fetchData(debouncedValue); }, [debouncedValue]); + + return setQuery(e.target.value)} />; +} +``` + +### 3. useLocalStorage — 持久化状态 + +```tsx +function useLocalStorage(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void] { + const [storedValue, setStoredValue] = useState(() => { + // ✅ SSR 安全:检查 window 是否存在 + if (typeof window === "undefined") return initialValue; + try { + const item = window.localStorage.getItem(key); + return item ? JSON.parse(item) : initialValue; + } catch { + return initialValue; + } + }); + + const setValue = (value: T | ((prev: T) => T)) => { + const valueToStore = value instanceof Function ? value(storedValue) : value; + setStoredValue(valueToStore); + // ✅ SSR 安全 + if (typeof window !== "undefined") { + window.localStorage.setItem(key, JSON.stringify(valueToStore)); + } + }; + + useEffect(() => { + const handleStorage = (e: StorageEvent) => { + if (e.key === key && e.newValue !== null) { + setStoredValue(JSON.parse(e.newValue)); + } + }; + // ✅ SSR 安全 + if (typeof window !== "undefined") { + window.addEventListener("storage", handleStorage); + return () => window.removeEventListener("storage", handleStorage); + } + }, [key]); + + return [storedValue, setValue]; +} +``` + +### 4. useIntersectionObserver — 视口检测 + +```tsx +function useIntersectionObserver( + options?: IntersectionObserverInit +): [RefObject, boolean] { + const [isVisible, setIsVisible] = useState(false); + const ref = useRef(null); + + // ✅ 用 useMemo 缓存 options,避免每次渲染新建对象导致 observer 重建 + const memoizedOptions = useMemo(() => ({ + root: null, + rootMargin: "0px", + threshold: 0.1, + ...options, + }), [JSON.stringify(options)]); + + useEffect(() => { + const el = ref.current; + if (!el) return; + + const observer = new IntersectionObserver(([entry]) => { + setIsVisible(entry.isIntersecting); + }, memoizedOptions); + + observer.observe(el); + return () => observer.disconnect(); + }, [memoizedOptions]); + + return [ref, isVisible]; +} +``` + +### 5. useMediaQuery — 媒体查询 + +```tsx +function useMediaQuery(query: string): boolean { + const [matches, setMatches] = useState(false); + + useEffect(() => { + const media = window.matchMedia(query); + setMatches(media.matches); + + const handler = (e: MediaQueryListEvent) => setMatches(e.matches); + media.addEventListener("change", handler); + return () => media.removeEventListener("change", handler); + }, [query]); + + return matches; +} + +// 使用 +const isMobile = useMediaQuery("(max-width: 768px)"); +``` + +### 6. usePrevious — 记录上一次值 + +```tsx +function usePrevious(value: T): T | undefined { + const ref = useRef(); + + // ✅ useEffect 在渲染完成后才执行,恰好获取"上一轮"的值 + useEffect(() => { + ref.current = value; + }, [value]); + + return ref.current; +} + +// 使用:判断值是否发生变化 +function Component({ count }) { + const prevCount = usePrevious(count); + return

    {count === prevCount ? "不变" : "已变化!"}

    ; +} +``` + +### 7. useBoolean — 布尔状态简化器 + +```tsx +function useBoolean(initialValue = false) { + const [value, setValue] = useState(initialValue); + + const toggle = useCallback(() => setValue(v => !v), []); + const setTrue = useCallback(() => setValue(true), []); + const setFalse = useCallback(() => setValue(false), []); + + return { value, toggle, setTrue, setFalse }; +} + +// 使用:替代手动写 { checked, setChecked } +function ToggleButton() { + const { value: on, toggle } = useBoolean(); + return ; +} +``` + +## 常见陷阱与避坑 + +> [!danger] Hook 设计的 5 个经典陷阱 +> 以下是在生产环境中高频踩中的坑,务必警惕。 + +### 陷阱 1:闭包陷阱(Stale Closure) + +```tsx +// ❌ 问题:count 在 useCallback 创建时被捕获,永远是初始值 +function useCounter() { + const [count, setCount] = useState(0); + const double = useCallback(() => { + console.log(count); // 始终是 0! + setCount(count * 2); + }, []); // 空依赖数组 → 捕获了初始状态 +} + +// ✅ 解法 1:使用函数式 setState +const double = useCallback(() => { + setCount(c => c * 2); // 读取最新值 +}, []); + +// ✅ 解法 2:将 count 加入依赖 +// const double = useCallback(() => {...}, [count]); +``` + +### 陷阱 2:无限循环 + +```tsx +// ❌ 问题:每次渲染都创建新对象,导致 useEffect 无限触发 +function BadComponent({ data }) { + const [state, setState] = useState([]); + + useEffect(() => { + setState([{ items: data }]); // 新数组 = 新引用 + }, [{ items: data }]); // ← 每次都是新对象,永远不等价 +} + +// ✅ 解法:正确声明依赖项 +useEffect(() => { + setState(prev => (prev[0]?.items === data ? prev : [{ items: data }])); +}, [data]); +``` + +### 陷阱 3:异步操作缺少清理 + +```tsx +// ❌ 组件卸载后仍执行 setState → React 警告 +useEffect(() => { + fetch("/api").then(res => setData(res.data)); // 卸载后回调仍会执行 +}, []); + +// ✅ 使用 AbortController 取消请求 +useEffect(() => { + const controller = new AbortController(); + fetch("/api", { signal: controller.signal }) + .then(res => res.json()) + .then(setData) + .catch(err => { if (err.name !== "AbortError") throw err; }); + + return () => controller.abort(); +}, []); +``` + +### 陷阱 4:过度封装 + +```tsx +// ❌ 为简单逻辑造 Hook,反而增加复杂度 +function useToggle(initial = false) { + const [val, setVal] = useState(initial); + return [val, () => setVal(v => !v), () => setVal(true), () => setVal(false)]; + // 返回值太长,调用方难以理解每个位置的含义 +} + +// ✅ 直接内联或提取有意义的语义化 Hook +const [open, setOpen] = useState(false); + +``` + +### 陷阱 5:副作用竞态(Race Condition) + +```tsx +// ❌ 快速切换搜索词时,旧请求可能晚于新请求返回 +function SearchResults({ query }) { + const [results, setResults] = useState([]); + + useEffect(() => { + searchAPI(query).then(setResults); + }, [query]); + +// ✅ 用 ref + 版本号追踪"当前请求" +function useDebouncedSearch(query: string, delay = 300): T | null { + const [result, setResult] = useState(null); + const requestIdRef = useRef(0); + + useEffect(() => { + const timer = setTimeout(async () => { + const thisId = ++requestIdRef.current; + const data = await searchAPI(query); + if (thisId === requestIdRef.current) { + setResult(data); + } + }, delay); + return () => clearTimeout(timer); + }, [query, delay]); + + return result; +} +``` + +> [!tip] 自检清单 +> - Hook 的依赖数组是否包含了所有引用的外部变量? +> - 异步操作中是否有清理机制防止"僵尸回调"? +> - Hook 是否在条件语句中嵌套了?(违反 Rules of Hooks) +> - Hook 的返回值类型是否清晰?优先用 interface/type 标注 + +## 组合模式与 HOC 的对比 + +```mermaid +flowchart LR + subgraph Composition["Hook 组合(推荐)"] + C1["useBoolean"] --> C3["useModal"] + C2["useClickOutside"] --> C3 + C3 --> C4["useForm"] + C4 --> C5["DashboardPage"] + style C3 fill:#61DAFB,color:#000 + style C5 fill:#4FC08D,color:#fff + end + + subgraph HOC["HOC 包装(不推荐)"] + H1[Component] --> H2[HOC1] + H2 --> H3[HOC2] + H3 --> H4[HOC3] + style H2 fill:#F5A87D,color:#000 + style H3 fill:#F5A87D,color:#000 + style H4 fill:#F5A87D,color:#000 + end +``` + +### 实战:Hook 层层组合 + +```tsx +// Layer 1: 基础 Hook +function useBoolean(initial = false) { ... } +function useClickOutside(ref, handler) { ... } + +// Layer 2: 组合基础 Hook → 业务 Hook +function useModal() { + const { value: isOpen, toggle, setFalse: close } = useBoolean(); + const overlayRef = useRef(null); + + // Hook 中可以安全调用其他 Hook + useClickOutside(overlayRef, close); + + return { isOpen, open: toggle, close, overlayRef }; +} + +// Layer 3: 业务页面 +function SettingsPanel() { + const { isOpen, open, close, overlayRef } = useModal(); + return ( +
    + + {isOpen && } +
    + ); +} +``` + +> [!tip] Hook 设计的 S.O.L.I.D 原则 +> - **Single Responsibility**:一个 Hook 只做一件事(如 useFetch 只负责 fetch) +> - **Open/Closed**:新需求加新 Hook,而非修改已有 Hook +> - **Interface Segregation**:返回值尽量精确,不过度暴露内部细节 +> - **Dependency Inversion**:Hook 应依赖抽象(接口),而非具体实现 + +## 抽象层级参考 + +```tsx +// Level 1: 基础 Hook(操作层面) +function useClickOutside(ref: RefObject, handler: () => void) {} +function useEventListener(target: any, event: string, fn: Function) {} + +// Level 2: 业务 Hook(场景层面) +function useModal() { return { isOpen, open, close, overlayRef: useClickOutside(...) } } +function useForm(initialValues: FormValues) { return { values, errors, submit, reset } } + +// Level 3: 领域 Hook(领域层面) +function usePermission(role: Role) {} // 权限检查 +function usePagination(state: PaginationState) {} // 分页 +``` + +## 关联笔记 + +- [[05-核心 Hooks]] — useState、useEffect 等基础 Hook,是理解自定义 Hook 的前置知识 +- [[06-性能优化 Hooks]] — useMemo、useCallback 在自定义 Hook 中的依赖优化实践 diff --git a/hhs/REACT/3. 生态工具篇/08-路由管理.md b/hhs/REACT/3. 生态工具篇/08-路由管理.md new file mode 100644 index 0000000..b90927f --- /dev/null +++ b/hhs/REACT/3. 生态工具篇/08-路由管理.md @@ -0,0 +1,192 @@ +--- +tags: [React, Router, Navigation, Frontend] +create time: 2026-04-29 22:07 +--- + +# 路由管理 + +## 概述 + +前端路由是单页应用(SPA)的核心基础设施。本文档以 React Router v7 为主介绍路由配置、嵌套路由、懒加载、动态路由和守卫拦截等实战模式。 + +## React Router v7 核心概念 + +```mermaid +graph TB + A[BrowserRouter] --> B["哈希历史 vs HTML5 历史"] + B --> C[RouterProvider] + C --> D[Route 树] + D --> E["Element / Component / ComponentFunction"] + D --> F["Layout Route"] + F --> G["Child Routes"] + + style A fill:#F4DBD6,color:#000 + style E fill:#61DAFB,color:#000 +``` + +### 基本结构 + +```tsx +// app.tsx(v7 推荐入口) +import { createRootRouteWithContext, createRouter, RouterProvider } from "@tanstack/react-router"; +// 或传统 react-router-dom v6/v7 +import { createBrowserRouter, RouterProvider } from "react-router-dom"; + +const router = createBrowserRouter([ + { + path: "/", + element: , + children: [ + { index: true, element: }, + { path: "about", element: }, + ], + }, +]); + +function App() { + return ; +} +``` + +## Layout Route 与嵌套路由 + +```tsx +{ + // /dashboard 及其子路径共用此布局 + path: "dashboard", + element: , // 侧边栏 + Header 常驻 + children: [ + { index: true, element: }, // /dashboard → DashboardHome + { path: "stats", element: }, // /dashboard/stats + { path: "settings", element: }, // /dashboard/settings + { path: "settings/:tab", element: }, // /dashboard/settings/profile + ], +} +``` + +```jsx +// DashboardLayout.tsx +function DashboardLayout() { + return ( +
    + +
    +
    + {/* v7 Outlet 替代了 v6的children渲染 */} +
    +
    + ); +} +``` + +> [!note] Outlet vs Children +> - v6 用 `` 内部定义子路由 +> - v7 用 `` 显式标注嵌套出口,更清晰 + +## 路由参数获取 + +```tsx +import { useSearchParams, useParams, useNavigate, useLocation } from "react-router-dom"; + +function UserPage() { + const { id } = useParams(); // /user/:id → { id: "123" } + const [searchParams] = useSearchParams(); // ?q=hello&page=1 + const location = useLocation(); // { pathname: "/user/123", search: "?q=hello" } + const navigate = useNavigate(); // navigate("/home") / navigate(-1) + + const query = searchParams.get("q"); + + return

    User #{id}, searching for "{query}"

    ; +} +``` + +### v7 新增:useRouteContext + Data APIs + +```tsx +// v7 data routers 支持 loader/action +{ + path: "posts/:postId", + loader: async ({ params }) => { + const res = await fetch(`/api/posts/${params.postId}`); + return res.json(); + }, + // loader 数据自动注入 component props +} +``` + +## 懒加载 Code Splitting + +```tsx +import { lazy, Suspense } from "react"; + +// 路由级代码分割 +const AdminPage = lazy(() => import("./pages/AdminPage")); +const SettingsPage = lazy(() => import("./pages/SettingsPage")); + +{ + path: "admin", + element: ( + }> + + + ), +} +``` + +> [!tip] 懒加载时机判断 +> - 首屏路由(首页、登录页)**不要**懒加载 +> - 低频访问页面(设置、管理员面板)适合懒加载 +> - 每个 chunk 建议不超过 100KB gzipped + +## 动态路由与 Splat 路由 + +```tsx +// :param —— 单个段匹配 +{ path: "users/:userId", element: } // /users/42 + +// * splat —— 贪婪匹配剩余所有 +{ path: "docs/*", element: } // /docs/a/b/c +{ path: "*", element: } // 404 fallback +``` + +## 路由守卫与权限控制 + +```tsx +// 方案1:条件渲染(简单场景) +function PrivateRoute({ children }: { children: React.ReactNode }) { + const { user } = useAuth(); + if (!user) return ; + return <>{children}; +} + +// 方案2:高阶包装 +const withAuth = (Component: React.FC) => { + return (props: any) => { + const { loading, authenticated } = useAuth(); + if (loading) return ; + if (!authenticated) return ; + return ; + }; +}; + +// 方案3:v7 Loader 守卫(服务端前置检查) +{ + path: "admin", + loader: () => { + if (!isAuthenticated()) throw new Response("", { status: 401 }); + if (!isAdmin()) throw new Response("", { status: 403 }); + }, + element: , +} +``` + +## 导航 API 对比 + +| 方法 | 适用场景 | 是否保留历史记录 | +|------|----------|------------------| +| `navigate(path)` | 程序化跳转 | ✅ 有 history entry | +| `Home` | 声明式导航 | ✅ 预加载 prefetch | +| `navigate(-1)` | 返回上一页 | ✅ | +| `` | 替换当前 entry | ❌ 不增加 history | + +## 关联笔记 diff --git a/hhs/REACT/3. 生态工具篇/09-状态管理.md b/hhs/REACT/3. 生态工具篇/09-状态管理.md new file mode 100644 index 0000000..70b71c7 --- /dev/null +++ b/hhs/REACT/3. 生态工具篇/09-状态管理.md @@ -0,0 +1,213 @@ +--- +tags: [React, State Management, Context, Zustand, Redux, Frontend] +create time: 2026-04-29 22:08 +--- + +# 状态管理 + +## 概述 + +当组件树变得复杂,跨层级共享状态、服务器数据同步和状态变更追踪成为挑战。本文档对比 Context API、Zustand 和 Redux Toolkit 三种主流方案,帮助你在不同场景下做出正确选择。 + +## 选型决策图 + +```mermaid +graph TD + A["需要全局状态吗?"] -->|"否"| B["useState / useReducer ✅"] + A -->|"是"| C["状态类型?"] + + C --> D["纯 UI 状态
    (主题、菜单展开、模态框)"] + C --> E["服务器数据 / 异步缓存"] + C --> F["应用级业务状态
    (用户信息、购物车、权限)"] + + D --> G["Context API ✅"] + E --> H["TanStack Query / SWR ✅"] + F --> I["状态规模?"] + + I --> J["小型 (< 5 store)"] + I --> K["中大型"] + + J --> L["Zustand ✅"] + K --> M["Redux Toolkit + RTK Query ✅"] + + style G fill:#4FC08D,color:#fff + style H fill:#F5A87D,color:#000 + style L fill:#61DAFB,color:#000 + style M fill:#764abc,color:#fff +``` + +## Context API —— 轻量传递 + +```tsx +const AuthContext = createContext<{ user: User | null; login: (u: User) => void }>({ + user: null, + login: () => {}, +}); + +// Provider +function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(null); + + const login = (u: User) => setUser(u); + + return {children}; +} + +// Consumer +function Profile() { + const { user, login } = useContext(AuthContext); + return
    Hello, {user?.name}
    ; +} +``` + +### Context 的性能局限 + +| 问题 | 原因 | 解决方案 | +|------|------|----------| +| value 变化时所有消费组件重渲染 | Context Value 引用每次都是新的 | 拆分多个 Context / 用 reducer 保持 dispatch 引用稳定 | +| 不支持 selector | 没有 "只取子字段" 的机制 | 手动封装或使用第三方库 | +| SSR hydration mismatch | 客户端与初始值不一致 | 延迟消费或用 useEffect 包裹 | + +## Zustand —— 轻量级现代方案 + +```ts +import { create } from "zustand"; + +interface StoreState { + count: number; + users: User[]; + increment: () => void; + fetchUsers: () => Promise; +} + +const useStore = create((set, get) => ({ + count: 0, + users: [], + + increment: () => set(state => ({ count: state.count + 1 })), + + fetchUsers: async () => { + const res = await fetch("/api/users"); + const data = await res.json(); + set({ users: data }); + }, +})); + +// 组件中使用 +function Counter() { + // ✅ 只订阅 count —— 其他状态变化不会触发此组件重渲染 + const count = useStore(s => s.count); + const increment = useStore(s => s.increment); + + return ; +} + +// 批量更新 +useStore.setState(({ count }) => ({ count: count + 1, flag: true })); +``` + +### Zustand 的优势 + +| 特性 | Zustand | Context | +|------|---------|---------| +| Bundle size | ~1KB | 内置 React(~0) | +| Selector 支持 | ✅ 精确订阅 | ❌ 全部消费者都重渲染 | +| TypeScript 推断 | 完善 | 需手动标注 | +| DevTools | 原生支持 | 无 | +| 中间件扩展 | persist、immer、devtools | 需额外封装 | + +## Redux Toolkit —— 企业级方案 + +```ts +import { createSlice, configureStore, useDispatch, useSelector } from "@reduxjs/toolkit"; + +interface CounterSlice { + value: number; + status: "idle" | "loading" | "succeeded" | "failed"; +} + +const counterSlice = createSlice({ + name: "counter", + initialState: { value: 0, status: "idle" } as CounterSlice, + reducers: { + incremented: state => { state.value += 1; }, // ✅ Immer:直接 mutate! + fetchedAsync: { + pending: state => { state.status = "loading"; }, + fulfilled: (state, action) => { + state.status = "succeeded"; + state.value = action.payload.value; + }, + rejected: state => { state.status = "failed"; }, + }, + }, +}); + +export const { incremented, fetchedAsync } = counterSlice.actions; + +const store = configureStore({ + reducer: { counter: counterSlice.reducer }, +}); +``` + +```tsx +function CounterComponent() { + const dispatch = useDispatch(); + const count = useSelector((s: AppState) => s.counter.value); + + return ; +} +``` + +### RTK Query —— 内置数据获取 + +```ts +import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; + +const api = createApi({ + reducerPath: "api", + baseQuery: fetchBaseQuery({ baseUrl: "/api" }), + endpoints: build => ({ + getUsers: build.query({ query: () => "/users" }), + updateUser: build.mutation>({ + query: ({ id, ...patch }) => ({ url: `/users/${id}`, method: "PATCH", body: patch }), + }), + }), +}); + +export const { useGetUsersQuery, useUpdateUserMutation } = api; +``` + +## 三框架横向对比 + +| 维度 | Context API | Zustand | Redux Toolkit | +|------|-------------|---------|---------------| +| Bundle 大小 | ~0KB(内置) | ~1KB | ~15KB | +| 学习曲线 | 低 | 低 | 中 | +| Selector | ❌ | ✅ | ✅(useSelector) | +| Immutable | ❌ | ❌(但可用 immer middleware) | ✅(Immer 内置) | +| 调试工具 | ❌ | ✅ | ✅ Redux DevTools | +| 服务端渲染 | ⚠️ 需手动处理 | ✅ | ✅ | +| 适用规模 | 小型项目 | 中小/中型 | 大型企业级 | + +## 常见反模式 + +> [!warning] 以下做法应避免 + +```tsx +// ❌ 把所有东西塞进一个 store +const useBigStore = create(() => ({ + user: ..., theme: ..., sidebarOpen: ..., notifications: ..., cart: ..., preferences: ..., // 20+ 个状态 +})); + +// ✅ 按功能拆分为多个 store +const useUserStore = create(...) +const useThemeStore = create(...) + +// ❌ 在 state 中存储服务器返回的数据却不做缓存 +const [data, setData] = useState(fetch(...)) // 页面切换丢失,重复请求 + +// ✅ 使用 TanStack Query 等专用数据获取库处理缓存和失效 +const { data } = useQuery({ queryKey: ["users"], queryFn: fetchUsers }); +``` + +## 关联笔记 diff --git a/hhs/REACT/3. 生态工具篇/10-TS + React.md b/hhs/REACT/3. 生态工具篇/10-TS + React.md new file mode 100644 index 0000000..678419a --- /dev/null +++ b/hhs/REACT/3. 生态工具篇/10-TS + React.md @@ -0,0 +1,213 @@ +--- +tags: [React, TypeScript, Frontend] +create time: 2026-04-29 22:09 +--- + +# TS + React + +## 概述 + +TypeScript 为 React 提供编译时类型检查和智能提示。本文档系统梳理 Props、State、Hook、Ref 等核心场景的类型定义方式,以及进阶的泛型推导模式。 + +## Props 类型定义 + +### 基础方式 + +```tsx +// 方式1:interface(推荐,可 extend) +interface ButtonProps { + label: string; + onClick?: () => void; +} +const Button = ({ label, onClick }: ButtonProps) => ; + +// 方式2:type alias +type ButtonProps = { label: string; onClick?: () => void }; + +// ⚠️ 避免:函数参数解构后不再标注 +// 这样会导致每个参数无法被单独推断 +function Component({ a, b }) { ... } // any! +``` + +### 合成事件类型 + +```tsx +// ❌ 不要用 HTML 原生的 Event +const handleChange = (e: Event) => {}; + +// ✅ 用 React 的合成事件类型 +const handleChange = (e: React.ChangeEvent) => { + e.target.value; // string | number | string[] +}; + +const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); +}; + +const handleClick = (e: React.MouseEvent) => { + console.log(e.button); // 鼠标按键 +}; + +const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") submit(); +}; +``` + +### Children 类型 + +```tsx +// 通用 children 类型 +function Card({ children }: { children: React.ReactNode }) {} + +// 严格类型 children(限制允许的子节点类型) +interface TabsProps { + children: React.ReactElement; // 只能是 Tab 组件 +} + +// 数组形式 +interface ListProps { + children: React.ReactElement<{ item: T }> []; +} +``` + +## State 类型推导 + +```tsx +interface User { name: string; role: "admin" | "user"; age: number } + +// 完整泛型 +const [user, setUser] = useState({ name: "", role: "user", age: 0 }); + +// 可选初始值 +const [user, setUser] = useState(null); +// 使用时需判空 +user?.name; // 安全 +(user as User).name; // or +user!?.name; // non-null assertion + +// useReducer 类型推导 +interface State { items: Item[]; filter: string } +type Action = { type: "SET_FILTER"; payload: string } | { type: "ADD_ITEM"; payload: Item }; + +function reducer(state: State, action: Action): State { + switch (action.type) { + case "SET_FILTER": return { ...state, filter: action.payload }; + case "ADD_ITEM": return { ...state, items: [...state.items, action.payload] }; + } +} +const [state, dispatch] = useReducer(reducer, initialState); +``` + +## Ref 类型定义 + +```tsx +// 元素 ref +const inputRef = useRef(null); + +// mutable value ref(不触发 re-render) +const timerIdRef = useRef>(undefined); +timerIdRef.current = setInterval(() => {}, 1000); + +// class component style ref object(用于挂载子组件引用) +const childRef = useRef(null); +// 需要 useImperativeHandle 暴露方法给父级 + +// forwardRef 中 ref 的正确用法 +const FancyInput = forwardRef(function FancyInput(props, ref) { + const innerRef = useRef(null); + + useImperativeHandle(ref, () => ({ + focus: () => innerRef.current?.focus(), + blur: () => innerRef.current?.blur(), + })); + + return ; +}); +``` + +## Hook 类型推导 + +```tsx +// 自定义 Hook 返回值类型 +interface UseCountReturn { + count: number; + increment: () => void; + decrement: () => void; +} + +function useCount(initial = 0): UseCountReturn { + const [count, setCount] = useState(initial); + return { + count, + increment: () => setCount(c => c + 1), + decrement: () => setCount(c => c - 1), + }; +} + +// generic hook —— 最强大的类型推导场景 +function useAsync( + asyncFn: (...args: Args) => Promise, + deps: DependencyList +): { data: T | null; loading: boolean; error: Error | null; invoke: (...args: Args) => void } { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const invoke = useCallback(async (...args: Args) => { + setLoading(true); + setError(null); + try { + const result = await asyncFn(...args); + setData(result); + } catch (err) { + setError(err as Error); + } finally { + setLoading(false); + } + }, [asyncFn, ...deps]); + + useEffect(() => { invoke(); }, [invoke]); + + return { data, loading, error, invoke }; +} + +// 使用:T 自动推导! +const { data: users } = useAsync(fetchUsers, []); // data: User[] | null +const { data: user } = useAsync(fetchUserById, [id]); // data: User | null +``` + +## discriminated Union(区分联合类型) + +```tsx +// 类型守卫 —— React 状态管理中最常用的类型模式 +interface SuccessAction { type: "success"; data: User[] } +interface ErrorAction { type: "error"; error: string } +interface LoadingAction { type: "loading" } + +type Action = SuccessAction | ErrorAction | LoadingAction; + +function reducer(state: State, action: Action): State { + switch (action.type) { + case "success": + return { ...state, data: action.data, status: "loaded" }; + // ✅ TS 自动推断 action.data 是 User[] + case "error": + return { ...state, error: action.error }; + // ✅ TS 自动推断 action.error 是 string + case "loading": + return { ...state, status: "loading" }; + } +} +``` + +```mermaid +graph LR + A["Union Type"] -->|"switch / if"| B["Type Narrowing"] + B --> C["discriminated union: type field"] + B --> D["typeof check"] + B --> E["in operator"] + + style C fill:#61DAFB,color:#000 +``` + +## 关联笔记 diff --git a/hhs/REACT/4. 进阶篇/11-组件通信模式.md b/hhs/REACT/4. 进阶篇/11-组件通信模式.md new file mode 100644 index 0000000..1a8c6f2 --- /dev/null +++ b/hhs/REACT/4. 进阶篇/11-组件通信模式.md @@ -0,0 +1,187 @@ +--- +tags: [React, Component Communication, Props, Context, Frontend] +create time: 2026-04-29 22:10 +--- + +# 组件通信模式 + +## 概述 + +React 中父子组件之间的数据流动有多种方式。理解每种模式的适用场景和性能影响,能够避免过度设计或通信瓶颈。本文档从简单到复杂,梳理完整的通信方案谱系。 + +## 通信方向全景图 + +```mermaid +graph TD + A["父 → 子"] --> B["Props(最基础)"] + A --> C["Context Provider(跨层级)"] + A --> D["状态管理库(全局共享)"] + + E["子 → 父"] --> F["回调 Prop(事件驱动)"] + E --> G["refs(imperative)"] + + H["兄弟组件"] --> I["状态提升到共同父组件"] + H --> J["通过共同祖先通信"] + H --> K["状态管理库 / 发布订阅"] + + style B fill:#4FC08D,color:#fff + style C fill:#F5A87D,color:#000 + style F fill:#61DAFB,color:#000 +``` + +## Props Drill(属性逐层透传) + +### 问题场景 + +```tsx +function App() { + const user = getUser(); + return
    ; // Header 不需要 user,但深层的 Avatar 需要! +} + +function Header({ user }) { + return ( + + ); +} +``` + +### 解决方案对比 + +| 方法 | 适合场景 | 额外依赖 | +|------|----------|---------| +| **Context** | 少量深层传递(主题、用户信息) | 无 | +| **自定义 Hook + Props 重组织** | 中等深度(3-4 层) | 无 | +| **状态管理库** | 频繁变化、多组件共享 | Zustand/Redux | +| **Render Prop / HOC** | 复用逻辑而非传值 | 无 | + +## Callback Prop(子 → 父) + +```tsx +interface FormProps { + onSubmit: (data: FormValues) => Promise; +} + +function LoginForm({ onSubmit }: FormProps) { + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const data = collectFormData(); + onSubmit(data); // 通知父组件 + }; + + return ; +} + +// 父组件 +function App() { + const handleLogin = async (data: FormValues) => { + await api.login(data); + navigate("/dashboard"); + }; + + return ; +} +``` + +> [!note] 为什么不用 props.children 做通信? +> children 是 UI 内容插槽,不是通信机制。通信用 callback prop 保持关注点分离。 + +## Context 跨层级通信 + +```tsx +const UserContext = createContext(null); + +function UserProfile({ children }: { children: React.ReactNode }) { + const user = useDatabaseUser(userId); + + return ( + + {/* Avatar、Settings、OrderHistory 任意深度都可消费 */} + {children} + + ); +} + +function OrderHistory() { + const user = useContext(UserContext); // 直接获取,无需中间层透传 + return
    {user?.orders?.map(...)}
    ; +} +``` + +> [!warning] Context 的性能陷阱 +> - Provider value 对象每次渲染都是新引用 → 所有消费者重渲染 +> - 解决:拆分 Context、或使用 `useReducer` 返回稳定的 `{state, dispatch}` 对象 + +## 状态管理库方案 + +```tsx +// Zustand 方案:任意两个组件共享 state,无需祖先后代关系 +import { create } from "zustand"; + +const useCartStore = create((set) => ({ + items: [], + addItem: (item) => set(state => ({ items: [...state.items, item] })), +})); + +function ProductCard() { + const addItem = useCartStore(s => s.addItem); + return ; +} + +function CartIcon() { + const items = useCartStore(s => s.items); + return {items.length}; +} +// 两者毫无关联,但共享同一份状态 +``` + +## Ref Imperative API(命令式通信) + +```tsx +const formRef = useRef(null); + +function Parent() { + const handleSubmit = () => { + formRef.current?.validate(); // 命令子组件执行方法 + formRef.current?.reset(); + }; + + return <>; +} + +const ChildForm = forwardRef((props, ref) => { + useImperativeHandle(ref, () => ({ + validate: () => validator.validate(), + reset: () => setFields(initialValues), + })); + + return
    ...
    ; +}); +``` + +## 决策树 + +```mermaid +graph TD + A["需要通信?"] -->|"是"| B["通信范围?"] + + B --> C["仅父子两层"] + B --> D["多层级穿透"] + B --> E["跨层级 + 兄弟之间"] + B --> F["需命令式调用子组件方法"] + + C --> G["Props + Callback ✅"] + D --> H["Context ✅"] + E --> I["Zustand / Redux ✅"] + F --> J["forwardRef + useImperativeHandle ✅"] + + style G fill:#4FC08D,color:#fff + style H fill:#61DAFB,color:#000 + style I fill:#F5A87D,color:#000 + style J fill:#A0AEC0,color:#000 +``` + +## 关联笔记 diff --git a/hhs/REACT/4. 进阶篇/12-HOC 与 Render Props.md b/hhs/REACT/4. 进阶篇/12-HOC 与 Render Props.md new file mode 100644 index 0000000..ed38a19 --- /dev/null +++ b/hhs/REACT/4. 进阶篇/12-HOC 与 Render Props.md @@ -0,0 +1,201 @@ +--- +tags: [React, HOC, Render Props, Pattern, Frontend] +create time: 2026-04-29 22:11 +--- + +# HOC 与 Render Props + +## 概述 + +Hooks 出现之前,HOC(高阶组件)和 Render Props 是 React 中复用它逻辑的两种主要模式。理解它们的原理、适用场景和局限,有助于阅读遗留代码并理解为什么 Hooks 成为更优解。 + +## HOC(高阶组件) + +### 概念 + +```mermaid +graph LR + A["原始组件 Component"] -->|"注入 props"| B["HOC 函数"] + B --> C["增强组件 EnhancedComponent"] + C --> D["获得额外能力:日志/权限/数据"] + + style B fill:#F5A87D,color:#000 +``` + +HOC 是一个**函数**,接收组件作为参数,返回增强后的新组件: + +```tsx +// 基础模式 +function withAuth

    ( + WrappedComponent: React.ComponentType

    +) { + return function WithAuth(props: P) { + const user = useAuth(); + if (!user) return ; + return ; + }; +} + +// 使用:装饰器风格 +const ProtectedPage = withAuth(function Dashboard() { + return

    Dashboard

    ; +}); + +// TS 类型推导:保留原始 props +interface Props { title: string } +const StyledTitle = withTheme(function Title({ title }: Props) { + return

    {title}

    ; +}); // ✅ TS 推断 Props 不变 +``` + +### 常见 HOC 模式 + +```tsx +// 1. 日志/HUD +function withLogger

    (Comp: React.FC

    ): React.FC

    { + return function LoggedComponent(props: P) { + useEffect(() => console.log(`${Comp.name} mounted`), []); + return ; + }; +} + +// 2. 加载态封装 +function withLoading

    ( + Comp: React.FC

    , + fetchData: () => Promise +): React.FC

    { + return function LoadingWrapper(props: P) { + const [loading, setLoading] = useState(true); + useEffect(() => { fetchData().then(() => setLoading(false)); }, []); + return loading ? : ; + }; +} + +// 3. Props 转换(驼峰→kebab) +function withPropsTransformer

    ( + Comp: React.FC

    , + transform: (p: P) => Record +): React.FC { + return function Transformed(props: P) { + const extra = transform(props); + return ; + }; +} +``` + +### HOC 的局限性 + +| 问题 | 说明 | +|------|------| +| **Static 属性丢失** | `withAuth(Page)` 返回的新组件没有 Page.getInitialProps | +| **Wrapper Hell** | `withAuth(withLogging(withData(Page)))`——嵌套过深调试困难 | +| **Props 冲突** | 多个 HOC 都注入 `data` prop,后一个覆盖前一个 | +| **ref 丢失** | 直接传 ref 给增强组件会报错(需用 forwardRef 包装) | + +> [!tip] HOC 兼容 ref +> ```tsx +> export function withAuth

    (Wrapped: React.ComponentType

    ) { +> return React.forwardRef((props, ref) => { +> const { authenticated } = useAuth(); +> if (!authenticated) return ; +> return ; +> }); +> } +> ``` + +## Render Props + +### 核心思想 + +通过 prop 传递一个**函数**,该函数返回 JSX——将 UI 渲染逻辑委托给调用方。 + +```tsx +interface MouseTrackerProps { + render: (position: { x: number; y: number }) => React.ReactNode; +} + +function MouseTracker({ render }: MouseTrackerProps) { + const [pos, setPos] = useState({ x: 0, y: 0 }); + + useEffect(() => { + const handler = (e: MouseEvent) => setPos({ x: e.clientX, y: e.clientY }); + window.addEventListener("mousemove", handler); + return () => window.removeEventListener("mousemove", handler); + }, []); + + // 🎯 关键:render 函数返回什么就渲染什么 + return

    {render(pos)}
    ; +} + +// 使用 + ( +
    Cursor at ({x}, {y})
    +)} />; +``` + +### 等价于 children 的情况 + +```tsx +// 当 render prop 只是把数据传给 children 时,可以用 children 替代 +function DataProvider({ children }: { children: (data: Data) => React.ReactNode }) { + const data = useDatabase(); + return <>{children(data)}; +} + + + {(data) => } +; +``` + +## HOC vs Render Props vs Custom Hook + +```mermaid +graph TB + A["逻辑复用需求"] --> B["方案对比"] + + B --> C["HOC"] + B --> D["Render Props"] + B --> E["Custom Hook"] + + C --> F["⚠️ Wrapper 嵌套深"] + C --> G["⚠️ 静态方法丢失"] + C --> H["✅ 不修改原组件结构"] + + D --> I["⚠️ 回调地狱"] + D --> J["⚠️ Prop 命名冲突风险"] + D --> K["✅ 灵活的 UI 控制"] + + E --> L["✅ 简洁直观"] + E --> M["✅ 可直接操作 state / effect"] + E --> N["✅ 无 wrapper 嵌套"] + E --> O["❌ 只能用于组件内部"] + + style L fill:#4FC08D,color:#fff + style M fill:#4FC08D,color:#fff + style N fill:#4FC08D,color:#fff +``` + +## 为什么 Hooks 取代了它们? + +```tsx +// ❌ HOC 方式 +const ConnectedUserList = withAuth(withCache(withPagination(UserList))); +// 三层嵌套 → 调试困难、性能不可见、type 推导混乱 + +// ✅ Hook 方式 +function UserList() { + useAuth(); // 身份验证 + const cache = useCache(); // 数据缓存 + const pagination = usePagination(); // 分页管理 + + return
    {/* ... */}
    ; +} +// 扁平可读、天然共享 state、TS 完美推断 +``` + +> [!note] HOC 和 Render Props 真的被淘汰了吗? +> - HOC:在需要**包裹**组件但不修改其内部的场景仍有价值(如第三方库封装) +> - Render Props:当父组件需要**完全控制子组件的渲染内容**时仍然有用 +> - 但 90%+ 的场景,Custom Hook 是更好的选择 + +## 关联笔记 diff --git a/hhs/REACT/4. 进阶篇/13-并发特性.md b/hhs/REACT/4. 进阶篇/13-并发特性.md new file mode 100644 index 0000000..63f73c9 --- /dev/null +++ b/hhs/REACT/4. 进阶篇/13-并发特性.md @@ -0,0 +1,177 @@ +--- +tags: [React, Concurrent, Suspense, Transitions, Frontend] +create time: 2026-04-29 22:12 +--- + +# 并发特性 + +## 概述 + +React 18 引入了并发渲染(Concurrent Rendering)架构,将 UI 更新划分为可中断、可恢复、可优先级调度的任务。理解并发的核心概念——Suspend、Transition、时间切片——能帮助你写出更流畅的用户体验。 + +## React 渲染架构演进 + +```mermaid +graph LR + A["React 17 同步渲染"] -->|"全部一次性完成"| B["长时间阻塞主线程 ❌"] + + C["React 18 并发渲染"] -->|"可打断/可恢复"| D["Fiber Scheduler"] + D --> E["高优先级任务:用户输入 ⏩"] + D --> F["低优先级任务:数据加载 🐢"] + F -->|"被高优先级打断"| G["暂停 → 之后恢复 ✅"] + + style B fill:#F5A87D,color:#000 + style G fill:#4FC08D,color:#fff +``` + +## Suspense —— 声明式等待 + +### 基本用法 + +```tsx +// LazyComponent 在首次挂载时自动 code-split +const SettingsPage = lazy(() => import("./pages/SettingsPage")); + +}> + + +``` + +### Suspense + Data Fetching(实验性 API) + +```tsx +import { use } from "react"; + +// 资源标记为 Suspense-compatible +const promise = fetchData("/api/profile"); + +function Profile() { + // use() 会 suspend 直到 promise resolve + const profile = use(promise); + + return
    {profile.name}
    ; +} + +// 外层用 Suspense 包裹 +}> + + +``` + +## useTransition —— 标记低优先级更新 + +```tsx +function SearchPage() { + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [isPending, startTransition] = useTransition(); + + const handleInputChange = (e: React.ChangeEvent) => { + const value = e.target.value; + + // 立即响应输入 + setQuery(value); + + // 低优先级的结果更新(可被输入打断) + startTransition(() => { + setResults(heavySearch(value)); + }); + }; + + return ( + <> + + {isPending && } + + + ); +} +``` + +### Transition 与直接 setState 对比 + +```mermaid +timeline + title "输入 "hello" 的渲染行为" + + 直接 setState : 每次按键 → re-render\n(h/h/e/l/o 共 5 次) + useTransition : h,e,l,l → 跳过中间\no → 最终渲染一次 +``` + +## useDeferredValue —— 延迟副本 + +```tsx +function TodoApp() { + const [filter, setFilter] = useState(""); + const deferredFilter = useDeferredValue(filter); // 延迟 ~16ms + + return ( + <> + {/* 快速响应用户输入 */} + setFilter(e.target.value)} placeholder="过滤..." /> + + {/* 耗时操作使用延迟值 */} + + + ); +} +``` + +> [!tip] Transition vs DeferredValue 选择指南 +> +> | 场景 | 推荐 | +> |------|------| +> | 表单提交后展示新视图 | `useTransition` | +> | 输入框即时搜索 + 延迟加载 | `useDeferredValue` | +> | 多个状态联动更新 | `useTransition`(更明确控制粒度) | +> | 简单延迟一个值 | `useDeferredValue`(一行搞定) | + +## 时间切片原理 + +```mermaid +sequenceDiagram + participant Browser as 浏览器主线程 + participant Fiber as Fiber Scheduler + + Browser->>Fiber: 构建整棵组件树(工作单元) + Fiber->>Browser: 渲染帧 1(约 16ms)✅ + Note over Fiber,Browser: 把任务切分为 ~5ms 的工作单元 + Browser->>Fiber: 下一帧开始 + Fiber->>Browser: 渲染帧 2 ✅ + Note over Fiber: 如果用户点击了按钮
    高优先级任务插入队列 + Fiber-->>Browser: 暂停当前帧 + Browser->>Fiber: 处理用户输入 ✅ + Fiber->>Browser: 继续未完成的渲染帧 + + style Browser fill:#F5A87D,color:#000 + style Fiber fill:#4FC08D,color:#fff +``` + +### 关键概念 + +- **Work Breakdown**:React 将渲染工作拆成多个小单元(work unit),每个单元约 1ms +- **Yield to Browser**:每个单元完成后让出控制权给浏览器,确保 UI 响应性 +- **Priority Scheduling**:用户输入 > 网络响应 > 后台数据更新 > 动画 + +## 并发模式下的陷阱 + +> [!warning] 需要注意的行为变化 + +```tsx +// 1. useEffect 的执行时机变了 +useEffect(() => { + console.log("effect"); +}, [dep]); +// 效果不再与渲染同步,可能在异步 commit 阶段执行 + +// 2. 浏览器生命周期方法(如 getSnapshotBeforeUpdate)已过时 +// 并发模式下无法保证同步 commit + +// 3. 旧版第三方库可能与并发不兼容 +// 遇到 "Cannot update a component during rendering" → 检查是否有不规范的 side effect + +// 4. StrictMode 双重渲染(开发环境) +// 用于发现不纯的 render 函数和清理逻辑缺失 +``` + +## 关联笔记 diff --git a/hhs/REACT/4. 进阶篇/14-Serverside Rendering.md b/hhs/REACT/4. 进阶篇/14-Serverside Rendering.md new file mode 100644 index 0000000..5e10b9e --- /dev/null +++ b/hhs/REACT/4. 进阶篇/14-Serverside Rendering.md @@ -0,0 +1,190 @@ +--- +tags: [React, Next.js, SSR, SSG, ISR, Frontend] +create time: 2026-04-29 22:13 +--- + +# Server-Side Rendering + +## 概述 + +服务端渲染(SSR)让 React 组件在服务器端预渲染为 HTML,显著改善首屏加载速度和 SEO。本文档以 Next.js App Router 为核心,介绍 SSR/SSG/ISR 的渲染策略与最佳实践。 + +## 渲染模式对比 + +```mermaid +graph TB + subgraph "客户端渲染 CSR" + A[HTML空白页] --> B["下载 JS Bundle"] + B --> C["执行 React hydration"] + C --> D["显示内容"] + end + + subgraph "服务端渲染 SSR" + E[请求页面] --> F["服务器渲染 React → HTML"] + F --> G["发送含内容的 HTML"] + G --> H["客户端 hydration"] + H --> I["交互可用"] + end + + subgraph "静态生成 SSG" + J["构建时渲染"] --> K["生成纯 HTML 文件"] + K --> L["CDN 分发"] + end + + style F fill:#4FC08D,color:#fff + style H fill:#F5A87D,color:#000 + style J fill:#61DAFB,color:#000 +``` + +### 三种策略决策表 + +| 策略 | 适用场景 | 数据时效性 | 构建参与 | +|------|----------|-----------|---------| +| **SSR**(Server Render) | 个性化页面、实时数据 | ✅ 每次请求实时生成 | ❌ | +| **SSG**(Static Site Generation) | 博客、文档、营销页 | ⏱️ 构建时生成 | ✅ | +| **ISR**(Incremental Static Regeneration) | 新闻列表、商品目录 | 🔄 定时后台更新 | ✅(增量) | + +## Next.js App Router 架构 + +```tsx +// app/layout.tsx —— 根布局(所有页面共享) +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + + ); +} + +// app/page.tsx —— 首页(SSR by default) +async function HomePage() { + // ✅ 直接在组件中 await API + const posts = await fetchPosts(); + + return ( +
    + {posts.map(post => )} +
    + ); +} + +// app/blog/[slug]/page.tsx —— 动态路由页 +async function PostPage({ params }: { params: { slug: string } }) { + const post = await getPostBySlug(params.slug); + return
    {post.content}
    ; +} +``` + +## Streaming SSR + Suspense + +```mermaid +sequenceDiagram + participant Client as 浏览器 + participant Server as 服务器 + + Client->>Server: GET /dashboard + Server->>Server: 并行请求 user/profile/orders + Note over Server: 每个请求可有自己的 Suspense boundary + + Server-->>Client: HTML: Navbar + Sidebar
    (立即显示,~200ms) + + Server-->>Client: Stream: Profile card
    (中等优先级,~800ms) + + Server-->>Client: Stream: Order history
    (低优先级,~1500ms) + + Note over Client: 用户体验:渐进式展示,而非等全部完成 +``` + +```tsx +// Dashboard layout(流式渲染的关键) +function DashboardLayout({ children }: { children: React.ReactNode }) { + return ( + <> + {/* 非关键 UI 用 Suspense 包裹 */} + }> + + + + {children} + + {/* 各区域独立 Suspense */} + }> + + + }> + + + + ); +} +``` + +## Data Fetching 策略 + +```tsx +// 方案1:直接 await(默认缓存 + 共享缓存) +async function Page() { + const data = await fetchData(); // 自动缓存,同路径请求去重 + return
    {data}
    ; +} + +// 方案2:带 revalidate 的 ISR +async function Page() { + const data = await fetchData({ next: { revalidate: 60 } }); // 60秒后后台重新验证 + return
    {data}
    ; +} + +// 方案3:no-store(强制 SSR,不走缓存) +async function Page() { + const data = await fetchData({ cache: "no-store" }); // 每次请求都获取最新数据 + return
    {data}
    ; +} + +// 方案4:client component 中的 fetch(使用 TanStack Query) +"use client"; +function Page() { + const { data } = useQuery({ queryKey: ["data"], queryFn: () => fetch("/api/data").then(r => r.json()) }); + return
    {data}
    ; +} +``` + +## SSR vs Client Component 边界 + +```tsx +// server component(默认,无需声明) +async function ServerComponent() { + // ✅ 可以直接访问数据库、API密钥、文件系统 + const db = await dbConnection.query("SELECT * FROM users"); + return ; +} + +// client component(需显式声明) +"use client"; + +function InteractiveChart() { + // ✅ 可以使用 useState/useEffect/DOM API + const [zoom, setZoom] = useState(1); + useEffect(() => { ... }, []); + + return ; +} + +// Parent +function Dashboard() { + return ( + <> + {/* 在服务端渲染 */} + {/* 在客户端渲染 */} + + ); +} +``` + +> [!warning] Client → Server 通信限制 +> - Client Component 无法直接调用 Server Component 的方法或 props +> - 解决方案:通过 URL 参数、cookies、或后端 API 传递数据 + +## 关联笔记 diff --git a/hhs/REACT/5. 工程实践篇/15-性能优化.md b/hhs/REACT/5. 工程实践篇/15-性能优化.md new file mode 100644 index 0000000..780f32a --- /dev/null +++ b/hhs/REACT/5. 工程实践篇/15-性能优化.md @@ -0,0 +1,157 @@ +--- +tags: [React, Performance, Optimization, Frontend] +create time: 2026-04-29 22:14 +--- + +# 性能优化 + +## 概述 + +React 性能优化的核心原则是 **"减少不必要的渲染"**。本文档从诊断工具到具体手段,提供完整的调优方法论。 + +## 性能诊断工具箱 + +```mermaid +graph TB + A[发现性能问题] --> B["选择诊断工具"] + + B --> C["React DevTools Profiler"] + B --> D["Chrome Performance Tab"] + B --> E["Lighthouse"] + B --> F["Web Vitals 监控"] + + C --> G["定位重渲染的组件和原因"] + D --> H["分析主线程阻塞时段"] + E --> I["整体 LCP/FID/CLS 评分"] + F --> J["生产环境真实用户数据"] + + style C fill:#61DAFB,color:#000 + style H fill:#F5A87D,color:#000 +``` + +### React DevTools Profiler 使用要点 + +1. 录制期间进行关键交互(点击、输入) +2. 观察 **Commit 颜色** —— 红色越深表示重渲染越多 +3. 展开组件树,关注 "why did this render" 原因 +4. 对比优化前后的 Commit 时间变化 + +## React.memo —— 阻止子组件重渲染 + +```tsx +const ExpensiveList = React.memo(({ items }: { items: Item[] }) => { + // props.items 引用不变时,跳过整个子树的 re-render + return ( +
      + {items.map(item =>
    • {item.name}
    • )} +
    + ); +}, (prevProps, nextProps) => prevProps.items === nextProps.items); // 自定义比较函数 +``` + +> [!warning] React.memo 的适用边界 +> - 只对**纯组件**有用——相同 props 必须产生相同的输出 +> - 父组件每次传新对象/函数作 prop → memo 无效 +> - 简单列表(几十项以内)不需要 memo + +## 虚拟列表(Virtual Scrolling) + +当列表项超过数百条时,虚拟滚动通过只渲染可视区域内的 DOM 元素来大幅降低内存占用: + +```tsx +// 方案1:tanstack/virtual(推荐) +import { useVirtualizer } from "@tanstack/react-virtual"; + +function VirtualList({ items }: { items: string[] }) { + const parentRef = useRef(null); + + const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 50, // 预估每项高度 + overscan: 5, // 视口上下各多渲染 5 项 + }); + + return ( +
    +
    + {virtualizer.getVirtualItems().map(virtualRow => ( +
    + {items[virtualRow.index]} +
    + ))} +
    +
    + ); +} +``` + +> [!tip] 何时需要虚拟列表? +> - 列表项 > ~50 且每帧渲染耗时可感知 +> - 固定高度的项目比可变高度更容易实现 +> - React Window / React Virtualized 是老牌的成熟方案 + +## Code Splitting + +### 路由级拆分 + +```tsx +// Next.js App Router(内置) +const AdminPage = lazy(() => import("./pages/Admin")); + +// vite + React Router +const Settings = lazy(() => import(/* vite: preload */ "./pages/Settings")); +``` + +### 组件级拆分 + +```tsx +import dynamic from "next/dynamic"; + +// 不加载图表库直到真正需要 +const Chart = dynamic(() => import("recharts"), { ssr: false }); +// 或带 loading fallback +const HeavyEditor = dynamic(() => import("@monaco-editor/react"), { + loading: () =>

    Loading editor...

    , + ssr: false, +}); +``` + +### Bundle 分析与优化 + +```bash +# 安装插件 +npm install --save-dev rollup-plugin-visualizer +# 或在 Next.js 中使用 next-bundle-analyzer + +# 生成可视化报告 +npx run build && npx visualizer +``` + +> [!tip] Bundle 大小目标 +> | 层级 | 目标大小(gzipped)| +> |------|---------------------| +> | 首屏 chunk | < 150KB | +> | 单个 chunk | < 300KB | +> | JS Total | < 500KB(SPA)/ < 200KB(PWA) | + +## Lighthouse 关键指标调优 + +| 指标 | 含义 | 优化方向 | +|------|------|----------| +| **FCP**(First Contentful Paint) | 首次内容绘制 | 减小首屏 HTML/JS 体积 | +| **LCP**(Largest Contentful Paint) | 最大内容绘制 | 图片懒加载、预加载关键资源 | +| **INP**(Interaction to Next Paint) | 交互响应延迟 | useTransition、删除同步 heavy work | +| **CLS**(Cumulative Layout Shift) | 布局偏移 | 预留图片宽高、避免字体闪烁 | + +## 关联笔记 diff --git a/hhs/REACT/5. 工程实践篇/16-测试.md b/hhs/REACT/5. 工程实践篇/16-测试.md new file mode 100644 index 0000000..547b10a --- /dev/null +++ b/hhs/REACT/5. 工程实践篇/16-测试.md @@ -0,0 +1,225 @@ +--- +tags: [React, Testing, Vitest, RTL, Frontend] +create time: 2026-04-29 22:15 +--- + +# 测试 + +## 概述 + +可靠的测试是大型 React 项目长期维护的基石。本文档以 Vitest + React Testing Library (RTL) 为主,介绍组件单元测试、Hook 测试和集成测试的最佳实践。 + +## 测试金字塔 + +```mermaid +graph TB + A["测试金字塔"] + + A --> B["单元测试 ~70%"] + A --> C["集成测试 ~20%"] + A --> D["E2E 测试 ~10%"] + + B --> B1["纯函数 / util"] + B --> B2["自定义 Hook"] + B --> B3["原子组件(Button)"] + + C --> C1["多组件交互流程"] + C --> C2["表单提交 → API → 状态更新"] + + D --> D1["用户旅程:登录→搜索→下单"] + + style B fill:#4FC08D,color:#fff + style C fill:#F5A87D,color:#000 + style D fill:#61DAFB,color:#000 +``` + +## 环境配置 + +```jsonc +// vitest.config.ts +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + test: { + environment: "jsdom", // 模拟浏览器 DOM + setupFiles: "./src/test/setup.ts", + globals: true, + }, +}); +``` + +```ts +// src/test/setup.ts +import "@testing-library/jest-dom/vitest"; // 扩展 expect 匹配器 +import { vi } from "vitest"; + +// Mock window.matchMedia(解决媒体查询测试报错) +Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation(query => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), // deprecated + removeListener: vi.fn(), // deprecated + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}); +``` + +## RTL 核心哲学 + +> [!tip] RTL 设计原则 +> - **测试行为,不测试实现** — 关注用户能感知到的东西(文本、按钮、网络请求) +> - **像用户一样思考** — 用 `screen.getByRole("button", { name: "Submit" })` 而非 `.querySelector(".btn-primary"` +> - **断言明确期望的结果** — 不要测试 state 的值,测试渲染输出 + +```tsx +// ❌ 反例:耦合于内部实现 +expect(component.state.count).toBe(2); +expect(wrapper.find(Button).length).toBe(1); + +// ✅ 正例:基于用户感知 +const button = screen.getByRole("button", { name: /add/i }); +userEvent.click(button); +await screen.findByText(/added!/i); +``` + +## 组件单元测试 + +### 基础模式 + +```tsx +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Counter } from "./Counter"; + +describe("", () => { + it("初始显示 0", () => { + render(); + expect(screen.getByText("0")).toBeInTheDocument(); + }); + + it("点击按钮后计数增加", async () => { + render(); + const button = screen.getByRole("button"); + + await userEvent.click(button); + expect(screen.getByText("1")).toBeInTheDocument(); + + await userEvent.click(button); + await userEvent.click(button); + expect(screen.getByText("3")).toBeInTheDocument(); + }); + + it("禁用态不可点击", () => { + render(); + const button = screen.getByRole("button"); + expect(button).toBeDisabled(); + }); +}); +``` + +### Props 驱动 UI + +```tsx +describe("", () => { + it("显示用户基本信息", () => { + render(); + expect(screen.getByText("Alice")).toBeInTheDocument(); + expect(screen.getByRole("img", { name: /avatar/i })).toHaveAttribute("alt", "Alice avatar"); + }); + + it("显示操作菜单当 admin 时", () => { + render(); + expect(screen.getByRole("button", { name: /edit/i })).toBeInTheDocument(); + }); + + it("普通用户不显示操作菜单", () => { + render(); + expect(screen.queryByRole("button", { name: /edit/i })).not.toBeInTheDocument(); + }); +}); +``` + +## Mock 异步操作 + +```tsx +it("loading 态在请求完成后消失", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => [{ id: 1, name: "Test" }], + } as Response); + + render(); + + // 等待 loading 态出现再消失 + const spinner = await screen.findByRole("status"); + expect(spinner).toHaveTextContent("Loading..."); + + // 数据渲染完成 + const item = await screen.findByText("Test"); + expect(item).toBeInTheDocument(); +}); + +it("网络错误显示错误提示", async () => { + vi.mocked(fetch).mockRejectedValueOnce(new Error("Network error")); + + render(); + + await waitFor(() => { + expect(screen.getByText("Failed to load")).toBeInTheDocument(); + }); +}); +``` + +## Hook 测试 + +```tsx +import { renderHook, act } from "@testing-library/react"; +import { useDebounce } from "../hooks/useDebounce"; + +describe("useDebounce", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("值不变时返回原始值", () => { + const { result } = renderHook(({ value }) => useDebounce(value, 300), { + initialProps: { value: "hello", delay: 300 }, + }); + + expect(result.current).toBe("hello"); + }); + + it("延迟后返回新值", async () => { + const { result, rerender } = renderHook( + ({ value }) => useDebounce(value, 300), + { initialProps: { value: "a" } } + ); + + rerender({ value: "b" }); + expect(result.current).toBe("a"); // 尚未变化 + + act(() => vi.advanceTimersByTime(300)); + expect(result.current).toBe("b"); // 防抖完成 + }); +}); +``` + +## E2E 测试选择 + +| 工具 | 适用场景 | 特点 | +|------|----------|------| +| **Playwright** | 全功能 E2E(推荐) | 跨浏览器、内置 trace、重试机制 | +| **Cypress** | 可视化调试友好 | DevTools 体验好、社区活跃 | +| **Puppeteer** | Google 官方、精细控制 | 底层 API、灵活性高 | + +> [!note] E2E 边界 +> - E2E 只应覆盖**关键用户旅程**(登录、下单、支付) +> - 不要为每个页面的每个字段写 E2E——那属于集成测试的范畴 + +## 关联笔记 diff --git a/hhs/REACT/5. 工程实践篇/17-可访问性.md b/hhs/REACT/5. 工程实践篇/17-可访问性.md new file mode 100644 index 0000000..14f5f36 --- /dev/null +++ b/hhs/REACT/5. 工程实践篇/17-可访问性.md @@ -0,0 +1,238 @@ +--- +tags: [React, A11y, Accessibility, Frontend] +create time: 2026-04-29 22:16 +--- + +# 可访问性 + +## 概述 + +可访问性(Accessibility,简称 a11y)确保残障用户也能正常使用应用。这不仅是道德责任,在许多国家和地区也是法律要求。本文档梳理 React 中实现无障碍的关键实践。 + +## WCAG 核心原则 + +```mermaid +graph TB + A["WCAG 2.1 四大原则"] --> B["Perceivable
    可感知"] + A --> C["Operable
    可操作"] + A --> D["Understandable
    可理解"] + A --> E["Robust
    鲁棒性"] + + B --> B1["文本替代"] + B --> B2["颜色对比度 ≥ 4.5:1"] + + C --> C1["键盘可达"] + C --> C2["足够的时间"] + + D --> D1["可读的文本"] + D --> D2["一致导航"] + + E --> E1["兼容辅助技术"] + + style A fill:#F5A87D,color:#000 + style B fill:#4FC08D,color:#fff + style C fill:#61DAFB,color:#000 + style D fill:#A0AEC0,color:#000 + style E fill:#ED8936,color:#000 +``` + +## 语义化 HTML(最重要的一条) + +```tsx +// ❌ 滥用 div + onClick +
    navigate("/home")}>Home
    +
    goTo("/about")}>About
    + +// ✅ 使用原生元素——天生支持键盘、屏幕阅读器、SEO + +
    首页 +关于 +``` + +### 常用语义标签对照表 + +| 功能 | 错误写法 | 正确写法 | +|------|---------|---------| +| 按钮行为 | `
    ` | `