318 lines
9.8 KiB
Markdown
318 lines
9.8 KiB
Markdown
|
|
---
|
|||
|
|
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-性能优化]]
|