vault backup: 2026-04-29 22:19:51

This commit is contained in:
hhs
2026-04-29 22:19:51 +08:00
parent 0421a3c801
commit 94bc44ab8f
19 changed files with 4877 additions and 0 deletions
@@ -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-测试]]
+296
View File
@@ -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 = <h1 className="title">Hello, World!</h1>;
// 编译后的产物
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 (
<>
<h1>Hello!</h1>
<h2>今天 {date.toLocaleDateString()}.</h2>
{/* 条件:JSX 不能用 if-else,用三元运算符或逻辑与 */}
{date.getTime() < Date.now() && <p>日期已过期</p>}
{/* 数组渲染:每个元素需要 key prop */}
{[1, 2, 3].map(n => (
<span key={n}>{n}</span>
))}
</>
);
}
```
### 允许与不允许的表达式
| ✅ 允许 | ❌ 不允许 |
|---------|----------|
| `{variable}` | `{if (x) ...}` — JSX 中不支持语句 |
| `{arr.map(...)}` | `{for (let i...)}` — 同上 |
| `{cond && <Comp />}` | `{func()}` — 函数无返回值时渲染 `undefined`(不报错但不生效)|
| `{null}`, `{false}` | `{""}`, `{0}` — 会渲染空字符串或数字 `0`,常见陷阱 |
> [!question] 为什么 `{cond ? <A /> : func()}` 能跑?
> - 这是合法的 JS 表达式——只要 `func()` 返回一个值即可
> - 但建议写成 `{cond ? <A /> : <B />}`,保持视觉一致性,避免混用 JSX 和函数调用
> [!note] null / undefined / boolean vs "" / 0
> - `null`、`undefined`、`true`、`false` 渲染结果为空——这是有意设计
> - `0` 和 `""` 会输出到 DOM,常见陷阱
## 条件渲染模式
```jsx
// 模式1:&& 短路(推荐用于简单场景)
{isLoggedIn && <AdminDashboard />}
// 模式2:三元运算符(适合两种视图切换)
{isLoading ? <Spinner /> : <Content />}
// 模式3:提取为组件(复杂条件逻辑)
return <UserCard user={user} showActions={isAdmin} />;
// 模式4:IIFE(极少使用,仅临时调试)
{(function () {
if (!data) return <EmptyState />;
return <DataDisplay data={data} />;
})()}
```
## 列表渲染与 Key
```tsx
interface TodoItem {
id: number;
text: string;
completed: boolean;
}
function TodoList({ items }: { items: TodoItem[] }) {
return (
<ul>
{items.map((item) => (
<li key={item.id}>
<input type="checkbox" checked={item.completed} readOnly />
{item.text}
</li>
))}
</ul>
);
}
```
> [!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 (
<div>
<button onClick={handleClick}>Count: {count}</button>
<form onSubmit={handleSubmit}>
<input type="text" name="q" />
</form>
</div>
);
}
```
### 事件传参
当需要向事件处理函数传递参数时,有两种写法:
```tsx
interface TodoItemProps {
id: number;
onDelete: (id: number) => void;
}
function TodoItem({ id, onDelete }: TodoItemProps) {
return (
<button onClick={() => onDelete(id)}>
{/* ⚡ 箭头函数包裹:创建闭包捕获当前 id */}
删除
</button>
);
}
```
> [!warning] 常见陷阱
> - ~~`onClick={onDelete(id)}`~~ ❌ — 会在渲染时立即执行 `onDelete`,而非点击时执行
> - ~~`onClick={onDelete}`~~ — 缺少参数,不知道要删除哪个
> - ✅ **正确姿势**:`() => onDelete(id)` — 用箭头函数延迟执行并捕获参数
> - 💡 如果不需要参数,直接传引用即可:`onClick={handleClick}`(不要加括号)
### 常用合成事件类型
```ts
type MouseEvents = React.MouseEvent<HTMLButtonElement>;
type FormEvents = React.FormEvent<HTMLFormElement>;
type ChangeEvents = React.ChangeEvent<HTMLInputElement>;
type KeyboardEvents = React.KeyboardEvent<HTMLDivElement>;
```
> [!tip] TypeScript 事件类型的推导技巧
> - 不传泛型时:`event.target` 类型为 `EventTarget`(信息最少)
> - 传入具体元素后:`event.currentTarget` 可以精确定位到对应 DOM 类型
> - 💡 **最佳实践**:优先使用 `currentTarget` 而非 `target`,避免类型收窄问题
## JSX 基本规则
写 JSX 时有几条硬性规则需要遵守:
```tsx
// 规则1:自闭合标签 —— 无子元素的元素必须加 /
<img src={avatar} alt="头像" />
<Input placeholder="搜索..." />
// 规则2:className 替代 class
<div className="card">内容</div>
// <div class="card"> ❌ — "class"是JS保留字
// 规则3:style 接收对象而非字符串
<div style={{ color: "red", fontSize: 16 }}>样式</div>
// 驼峰命名:fontSize, backgroundColor, gridColumn 等
// 规则4:单根节点或 Fragment
const Card = ({ title, children }) => (
<> {/* ✅ Fragment:不产生额外 DOM 节点 */}
<h2>{title}</h2>
<div>{children}</div>
</>
);
```
> [!note] key 属性的唯一要求
> - `key` 只在数组上下文中有效——它是 React 的元数据,不会被传递给组件
> - 在自定义组件上使用 key 会报错:**key 只能在列表子元素上使用**
## Props 传递方式
```tsx
interface CardProps {
title: string;
className?: string;
children: React.ReactNode;
}
function Card({ title, className = "", children }: CardProps) {
return (
<div className={`card ${className}`}>
<h3>{title}</h3>
{children}
</div>
);
}
// 展开 props
const attrs = { className: "card", title: "详情" };
<Card {...attrs} />;
// 动态属性名
<div style={{ color: isActive ? "red" : "black" }}>
// 受控 props(父组件控制子组件内部 state)
<Select value={selectedValue} onChange={setSelectedValue} />
// 函数作为 prop(常见于表单回调)
<SearchBar onSearch={(q) => setQuery(q)} />
```
> [!question] 为什么 children 的类型是 React.ReactNode 而不是 JSX.Element?
> - `JSX.Element` 只代表 React 元素(如 `<div />`、`<Comp />`)
> - `React.ReactNode` 包含更多可能:字符串、数字、Fragment、甚至 null/undefined
> - 💡 使用 `JSX.Element` 会报错当子元素是纯文本或数组时
## JSX 与模板引擎对比
```mermaid
graph LR
A[JSX] --> B["编译期检查"]
A --> C["完整的 JavaScript 能力"]
A --> D["类型安全(TS)"]
E["模板引擎 v-html/v-if"] --> F["运行时解析"]
E --> G["字符串插值 {{}}"]
E --> H["有限的 JS 表达式"]
style A fill:#61DAFB,color:#000
style E fill:#42B883,color:#fff
```
> [!tip] JSX 的核心优势
> 1. **强类型** — 配合 TypeScript 编译期拦截错误
> 2. **零额外语法** — 没有 {{}} / v-if / v-for 等新规则,一切在 JS 中
> 3. **JS 全能力** — 解构、spread、闭包、高阶函数随时可用
## JSX 渲染流程
```mermaid
sequenceDiagram
participant Dev as 开发者
participant Babel as Babel/TSX
participant React as React Runtime
participant VDOM as Fiber (虚拟 DOM)
participant Diff as Reconciler Diff
participant BM as Browser API
Dev->>Babel: 编写 JSX:<h1>Hello</h1>
Note over Dev,Babel: "编译期转换,非运行时"
Babel->>Babel: 转译成 createElement()
Babel->>React: React.createElement("h1", null, "Hello")
Note over React: 返回 VDOM Plain Object
React->>VDOM: 创建/更新 Fiber 树
VDOM->>Diff: Diff 算法比较新旧树
Diff->>Diff: 找出最小变更集
Diff->>BM: patch 真实 DOM
Note over BM: "批量更新,一次 Reflow/Repaint"
```
> [!note] 一句话总结渲染流程
> JSX → `createElement()` → VDOM → **Diff** → patch 真实 DOM
>
> JSX 的每次重新渲染都经历完整的 Diff 过程——这也是为什么理解 key 的作用至关重要。
@@ -0,0 +1,418 @@
---
tags: [React, Components, Props, Frontend]
create time: 2026-04-29 22:02
---
# 组件与 Props
## 概述
React 应用由一组可复用的组件构成。理解组件的拆分粒度、Props 的类型安全传递,以及组合优于继承的设计哲学,是写出高质量 React 代码的核心。本章从组件的基本形态讲起,深入到 Props 的校验与传递模式,最后介绍组件组合的高级技巧。
> [!tip] 本节学习路线
> 1. 函数组件 vs 类组件 → 为什么函数组件成为主流
> 2. 组件拆分原则 → 如何找到合理的职责边界
> 3. Props 定义与校验 → TypeScript 方式(推荐)和 PropTypes 方式
> 4. Props 传递模式 → 基础透传、回调通信、children 插槽
> 5. Composition 高级模式 → React.memo、forwardRef、受控组件
> 6. Context 初探 → 轻量级跨层级通信
## 函数组件 vs 类组件
```tsx
// ✅ 函数组件(当前推荐写法)
// 输入 props,输出 JSX —— 纯函数的直观映射
function Greeting({ name }: { name: string }) {
return <h1>Hello, {name}</h1>;
}
// ⚠️ 类组件(遗留项目常见,新项目中应避免)
// this.props + render() —— 需要额外处理 this 绑定
class Greeting extends React.Component<{ name: string }> {
render() {
return <h1>Hello, {this.props.name}</h1>;
}
}
```
### 为什么推荐函数组件?
| 对比维度 | 函数组件 + Hooks | 类组件 |
|----------|------------------|--------|
| 心智模型 | 纯函数:输入 props → 输出 JSX | 实例方法 + this 绑定 |
| 逻辑复用 | Custom Hooks(声明式组合) | HOC / Render Props(嵌套地狱)|
| 类型推导 | TS 自动推断完善 | 需要手动泛型参数 |
| 性能开销 | 无实例创建成本 | new 实例 + bind overhead |
| Suspense | 完全支持 | 不支持 |
| Hooks 绑定 | 基于调用顺序 | 依赖 this 上下文 |
> [!note] Hooks 绑定的本质
> useState、useEffect 等 Hook 按调用顺序内部维护一个链表。这就是为什么 Hook **不能写在条件语句或循环中**——顺序一旦改变,状态就会错配。
## 组件拆分原则
> [!question] 思考:一个按钮算一个独立组件,还是把按钮和其外层容器一起写?
>
> 如果按钮只在这一处使用,合并更简洁;如果多个地方共用同一个按钮样式或交互逻辑,就应该拆出来。核心判断标准:**这个组件是否有独立的业务语义?**
好的拆分遵循 **"单一职责" + "合理抽象"**。下面用流程图展示不同层级的职责划分:
```mermaid
graph LR
subgraph P ["Page Level — 页面组装层"]
A["AboutPage<br/>拼接各功能模块"]
end
subgraph F ["Feature Level — 功能模块层"]
B["UserProfile<br/>头像 + 昵称 + 设置入口"]
C["Dashboard<br/>图表 + 数据表格"]
end
subgraph C2 ["Component Level — 通用 UI 层"]
D["Button<br/>按钮变体 & 尺寸"]
E["Modal<br/>弹窗外壳"]
end
subgraph PL ["Primitive Level — 原子层"]
F2["IconButton<br/>图标按钮"]
G["Avatar<br/>头像图片"]
end
A --> B
A --> C
B --> D
B --> G
C --> E
D --> F2
```
### 判断是否该拆分的标准
1. **可复用性** — 同一组 JSX 出现两次以上,考虑抽取为独立组件
2. **可读性** — 单个文件超过 300 行、单个组件超过 80 行,应考虑拆分
3. **测试粒度** — 难以单独测试的巨型组件应拆为小组件
4. **业务语义** — 每个组件应对应一个清晰的业务概念
```tsx
// ❌ 反例:巨型组件,混合了多个不相关的职责
function Dashboard() {
// 200+ 行,混合了 Header、Sidebar、DataTables、Chart...
// 改 Header 要遍历整文件,测试也要 mock 所有子模块
}
// ✅ 正例:拆分为功能块,每个组件职责单一
function Dashboard() {
return (
<>
<DashboardHeader /> {/* 专注头部信息 */}
<aside><Sidebar /></aside> {/* 专注侧边导航 */}
<main>
<DataTable data={tasks} /> {/* 专注数据表格 */}
<Chart data={stats} /> {/* 专注图表展示 */}
</main>
</>
);
}
```
## Props 定义与类型校验
### TypeScript 接口方式(推荐)
```tsx
interface ButtonProps {
label: string; // 必填:按钮文字
variant?: "primary" | "secondary" | "danger"; // 可选:视觉变体(联合类型约束)
size?: "sm" | "md" | "lg"; // 可选:尺寸档位
disabled?: boolean; // 可选:禁用态
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void; // 可选:点击回调
children?: React.ReactNode; // 可选:内容插槽
}
// 方式1:解构赋值设默认值(适合简单默认值)
function Button({ label, variant = "primary", onClick }: ButtonProps) {
return <button className={`btn btn-${variant}`} onClick={onClick}>{label}</button>;
}
// 方式2:全部字段显式声明默认值(适合需要覆盖全部可选字段时)
function Button({ label, variant = "primary", size = "md", disabled = false, children }: ButtonProps) {
return <button className={`btn btn-${variant} btn-${size}`} disabled={disabled}>{children}</button>;
}
```
> [!tip] 类型定义的位置建议
> - 小型组件:interface 与组件放在同一文件中
> - 大型组件库:type 导出到独立的 `types.ts`,便于跨文件引用和复用
### PropTypes 方式(JS 项目或过渡期)
```js
import PropTypes from 'prop-types';
Button.propTypes = {
label: PropTypes.string.isRequired, // 必填字符串
variant: PropTypes.oneOf(["primary", "secondary"]), // 枚举限制
onClick: PropTypes.func, // 可选函数
};
```
> [!warning] 不要混用两种校验方式
> TS 提供**编译时检查**,PropTypes 是**运行时兜底**。选其一即可,新项目统一用 TS。混用会导致维护和调试成本翻倍。
## Props 传递模式
### 基础传递
```tsx
// 1. 直接传递字面量值
<Button label="提交" disabled />
// 2. 传递变量或表达式
<Button label={submitText} onClick={handleSubmit} />
```
### 展开透传(Spread Props)
```tsx
// 当一层组件需要将自身 props 原样传给子组件时,避免逐个手写
const inputProps = { placeholder: "请输入", onChange: handleChange };
<Input {...inputProps} /> // ⚡ Spread Operator 将对象解包为独立的 props
// 局部覆盖:父 prop 优先级低于子 prop(后者覆盖前者)
<Input {...inputProps} autoFocus /> // autoFocus 额外传入,其余来自 inputProps
```
### 回调 Prop(子 → 父通信)
```tsx
// 子组件通过回调将数据或事件通知父组件
<Form onSubmit={(data) => console.log("收到表单数据:", data)} />
// 💡 父组件将处理函数作为 prop 传入,子组件在合适时机调用它
```
### Render Item Prop(列表渲染定制)
```tsx
// 父组件告诉子组件:"每个 item 长什么样"
<List
items={items}
renderItem={(item) => <ListItem key={item.id} {...item} />}
/>
// 📌 比完整的 Render Props 模式更轻量的写法
```
## Children 与 Composition
```tsx
// Card 接收 children(任意 JSX 内容),灵活度远超固定 string prop
function Card({ children, header }: { children: React.ReactNode; header: string }) {
return (
<div className="card">
<h3>{header}</h3>
<div className="card-body">{children}</div>
</div>
);
}
// 使用示例:children 可以包含任意合法的 JSX
<Card header="用户信息">
<p>姓名:张三</p>
<p>年龄:25</p>
</Card>
// Fragment —— 不产生额外 DOM 节点,用于包裹兄弟元素
function ListItem({ text, icon }: { text: string; icon: React.ReactNode }) {
return (
<li>
<> {/* ⚡ Fragment 缩写语法 */}
<span className="icon">{icon}</span>
<span>{text}</span>
</>
</li>
);
}
```
> [!question] children 的类型为什么是 React.ReactNode 而不是 JSX.Element?
> - `JSX.Element` 只代表 React 元素(如 `<div />`、`<Comp />`)
> - `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 <li>{name},{age} 岁</li>;
});
// 自定义比较函数(仅在需要深比较时使用,一般不需要)
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<HTMLInputElement, { placeholder: string }>(({ placeholder }, ref) => {
return <input ref={ref} placeholder={placeholder} />;
});
// 使用方可以拿到原生 input 的 ref
function Form() {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus(); // 💡 挂载后自动聚焦
}, []);
return <FancyInput ref={inputRef} placeholder="请输入..." />;
}
```
> [!important] forwardRef 的使用场景
> 不是所有组件都需要 forwardRef。**优先通过 props 解决问题**(可控组件模式)。只在以下场景才需要:
> - 需要暴露 DOM API(聚焦、测量、滚动等)
> - 封装底层 UI 库组件时需要透传 ref
## 受控组件模式(Controlled Components)
```tsx
// 组件的内部 state 由外部 props 控制 —— 单一数据源原则
function ControlledInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
return (
<input
value={value} // 🔑 value 由外部决定
onChange={(e) => onChange(e.target.value)} // 🔑 变化时通知外部更新
placeholder="输入内容"
/>
);
}
// 父组件持有唯一真实状态
function SearchPage() {
const [query, setQuery] = useState("");
return (
<>
<ControlledInput value={query} onChange={setQuery} />
<p>搜索关键词:{query || "(空)"}</p>
</>
);
}
```
> [!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 (
<ThemeContext.Provider value={theme}>
{/* 其下所有子组件(不限层级)都可以通过 useContext 消费 */}
<Toggle />
<Content />
</ThemeContext.Provider>
);
}
function Toggle() {
const theme = useContext(ThemeContext); // 无需中间层透传
return <button onClick={() => setTheme(t => t === "light" ? "dark" : "light")}>切换主题</button>;
}
```
> [!warning] Context 的性能陷阱
> - Provider value 如果是**新对象**,每次渲染都是不同的引用 → 所有消费者全部重渲染
> - ✅ 解决:用 `useReducer` 返回稳定的 `{state, dispatch}` 对象,保持引用一致
> - ✅ 解决:拆分 Context,避免一个大 Context 塞入过多数据
## 组件组合的进阶模式
### Compound Components(复合组件)
当一个组件的子项之间存在**隐式共享状态**时,使用复合组件模式:
```tsx
// 手风琴组件:Tab 之间共享 activeIndex,但父组件无需关心
function Accordion({ children }: { children: React.ReactNode }) {
const [activeIndex, setActiveIndex] = useState<number | null>(null);
return (
<AccordionContext.Provider value={{ activeIndex, setActiveIndex }}>
<div className="accordion">{children}</div>
</AccordionContext.Provider>
);
}
function AccordionTab({ title, children }: { title: string; children: React.ReactNode }) {
const ctx = useContext(AccordionContext);
// 💡 每个 Tab 自己从 Context 里拿状态,父组件不用逐一传递
const isActive = ctx.activeIndex !== null;
return (
<div onClick={() => ctx.setActiveIndex(ctx.activeIndex)}>
<h3>{title}</h3>
{isActive && <div>{children}</div>}
</div>
);
}
// 对外暴露子组件属性,方便用户使用
Accordion.Tab = AccordionTab;
// 使用:状态在 Tabs 间共享,父组件只需包裹
<Accordion>
<Accordion.Tab title="第一章">...</Accordion.Tab>
<Accordion.Tab title="第二章">...</Accordion.Tab>
</Accordion>
```
> [!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
@@ -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<string[]>([]);
// 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 (
<>
<p>总和: {countA + countB}</p>
<Counter value={countA} onChange={setCountA} />
<Counter value={countB} onChange={setCountB} />
</>
);
}
```
> [!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 (
<div>
{state.status === "loading" && <Spinner />}
{state.status === "error" && <Alert>{state.error}</Alert>}
{state.items.map(item => (
<Item key={item.id} {...item} onRemove={() => dispatch({ type: "REMOVE", payload: item.id })} />
))}
</div>
);
}
```
> [!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<NodeJS.Timeout>(); // 不能在渲染期间赋值
// ✅ 用 useRef 存储非渲染相关的可变值
const timerRef = useRef<NodeJS.Timeout>();
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<Map<string, any>>(() => 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-性能优化]]