React 组件进化论:从状态混乱到 UI = fn(props) 的三次重构
同一个"修改用户名"功能,我写了三个版本。从把 event 对象丢给父组件,到子组件持有私有状态,再到状态完全提升------这三次重构的背后,是 React 单向数据流思想的层层递进。本文结合 TypeScript 类型约束、useEffect 生命周期、localStorage 持久化,带你彻底搞懂 React 组件设计的核心法则。
一、React + TypeScript:为什么企业级开发离不开类型约束?
React 本身就是用 TypeScript 写的,源码中内置了大量类型声明。在企业级开发中,TypeScript 提供的类型约束 、静态编译检查 和丰富的语言特性,让组件间的协作像签合同一样可靠。
1.1 React.FC:函数组件的"身份证"
typescript
// React 源码中的类型声明
type FC<P = {}> = FunctionComponent<P>;
React.FC 是 React 函数组件的类型别名,P 是泛型参数,用来约束 props 的类型。不传时默认为 {},传了就用你定义的类型来约束。
1.2 interface vs type:组件 Props 该用哪个?
typescript
// 方式一:interface
interface Props {
userName: string;
}
// 方式二:type
type Props = {
userName: string;
};
// 两者都可以,但 interface 更适合定义"对象需要满足的属性和方法"
// 组件的 props 本质上是一个接口契约,推荐使用 interface
const HelloComponent: React.FC<Props> = (props) => {
return <h2>Hello {props.userName}</h2>;
};
选择建议:
| 特性 | interface | type |
|---|---|---|
| 对象形状描述 | ✅ 推荐 | ✅ 可以 |
| 联合类型/交叉类型 | ❌ 不支持 | ✅ 支持 |
| 声明合并 | ✅ 自动合并 | ❌ 覆盖 |
| 组件 Props | ✅ 推荐 | ✅ 可以 |
| 自定义事件类型 | ✅ 推荐 | ✅ 可以 |
二、三次重构:一个"修改用户名"功能的进化史
我们要实现的功能很简单:一个 Hello 组件显示用户名,一个 NameEdit 组件修改用户名。但"状态放在哪里"这个问题,经历了三次演进。
2.1 V1 版本:把 event 对象传给父组件
csharp
┌─────────────────────────────────────────┐
│ App(父组件) │
│ ┌─────────────┐ ┌─────────────────┐ │
│ │ Hello │ │ NameEdit │ │
│ │ 显示用户名 │ │ input + event │ │
│ └─────────────┘ └────────┬────────┘ │
│ │ event │
│ state: username ◀──────────┘ │
└─────────────────────────────────────────┘
V1 的代码:
tsx
// App.tsx (V1)
import * as React from 'react';
import Hello from './components/Hello';
import NameEditComponent from './components/NameEditComponent';
const App: React.FC = () => {
const [username, setUserName] = React.useState("initialName");
// 父组件直接接收 event 对象
const setUsernameState = (event: React.ChangeEvent<HTMLInputElement>) => {
setUserName(event.target.value);
};
return (
<div>
<Hello userName={username} />
<NameEditComponent
username={username}
onChange={setUsernameState}
/>
</div>
);
};
export default App;
tsx
// NameEditComponent.tsx (V1)
import * as React from 'react';
interface Props {
username: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
const NameEditComponent: React.FC<Props> = (props) => {
return (
<div>
<label>Update name:</label>
<input value={props.username} onChange={props.onChange} />
</div>
);
};
export default NameEditComponent;
V1 的问题:
- 子组件把
event对象直接传给父组件,父组件必须关心ChangeEvent<HTMLInputElement>类型 - 父组件的职责被污染------本应只负责"持有状态和修改状态",现在还要处理 DOM 事件细节
- 每输入一个字符,
username立即变化,Hello组件也立即更新------没有"提交"概念
2.2 V2 版本:子组件持有私有状态
lua
┌──────────────────────────────────────────────┐
│ App(父组件) │
│ state: username │
│ ┌─────────────┐ ┌─────────────────────┐ │
│ │ Hello │ │ NameEdit │ │
│ │ 显示用户名 │ │ 私有状态: editingName│ │
│ └─────────────┘ │ input 自己管 onChange │ │
│ │ 点击按钮才提交给父组件 │ │
│ └──────────┬──────────┘ │
│ │ newName:string │
│ setUserName(newName) ◀────────┘ │
└──────────────────────────────────────────────┘
V2 的代码:
tsx
// App.tsx (V2)
import * as React from 'react';
import Hello from './components/Hello';
import NameEditComponent from './components/NameEditComponent';
const App: React.FC = () => {
const [username, setUserName] = React.useState("initialName");
return (
<div>
<Hello userName={username} />
<NameEditComponent
initialUserName={username}
onNameUpdated={setUserName}
/>
</div>
);
};
export default App;
tsx
// NameEditComponent.tsx (V2)
import * as React from 'react';
interface Props {
initialUserName: string;
onNameUpdated: (newName: string) => void;
}
const NameEditComponent: React.FC<Props> = (props) => {
// 子组件持有私有状态
const [editingName, setEditingName] = React.useState(props.initialUserName);
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setEditingName(e.target.value); // 自己修改自己的状态
};
const onNameSubmit = () => {
props.onNameUpdated(editingName); // 提交时只传值,不传 event
};
return (
<>
<label>Update name:</label>
<input value={editingName} onChange={onChange} />
<button onClick={onNameSubmit}>Change</button>
</>
);
};
export default NameEditComponent;
V2 的改进:
- 父组件不再需要处理
ChangeEvent,只接收string类型的值------接口更干净 - 子组件引入"编辑中"概念:输入时不影响
Hello组件,点击按钮才提交 ChangeEvent的复杂性被封装在子组件内部
V2 的问题:
editingName是子组件的私有状态,父组件无法感知和控制- 如果父组件的
username变了(比如异步请求返回),子组件的editingName不会同步更新 - 按钮没有禁用逻辑,用户可以提交空名字或未修改的名字
2.3 V3 版本:状态提升到父组件(最终版)
yaml
┌──────────────────────────────────────────────────┐
│ App(父组件) │
│ state: name state: editingName │
│ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ Hello │ │ NameEdit (无状态) │ │
│ │ 显示 name │ │ props: editingName │ │
│ └─────────────┘ │ props: onEditingName.. │ │
│ │ props: onNameUpdated │ │
│ │ props: disabled │ │
│ └─────────────────────────┘ │
│ UI = fn(props) 子组件纯展示,零状态 │
└──────────────────────────────────────────────────┘
V3 的代码:
tsx
// App.tsx (V3 - 最终版)
import * as React from 'react';
import HelloComponent from './components/Hello';
import NameEditComponent from './components/NameEditingComponent';
const App = () => {
const [name, setName] = React.useState<string>("defaultUserName");
// 编辑中的名字也由父组件持有
const [editingName, setEditingName] = React.useState("defaultUserName");
const loadUsername = () => {
setTimeout(() => {
const newName = "name from async call";
setName(newName);
setEditingName(newName);
}, 2000);
};
// 副作用:组件挂载后异步加载数据
React.useEffect(() => {
loadUsername();
}, []);
const setUserNameState = () => {
setName(editingName);
};
return (
<>
名字: {name}
<HelloComponent userName={editingName} />
<NameEditComponent
editingName={editingName}
onNameUpdated={setUserNameState}
onEditingNameUpdated={setEditingName}
disabled={editingName === "" || editingName === name}
/>
</>
);
};
export default App;
tsx
// NameEditingComponent.tsx (V3 - 无状态子组件)
import * as React from 'react';
interface Props {
editingName: string;
onNameUpdated: () => void;
onEditingNameUpdated: (newEditingName: string) => void;
disabled: boolean;
}
const NameEditingComponent: React.FC<Props> = (props) => {
const {
editingName,
onEditingNameUpdated,
onNameUpdated,
disabled
} = props;
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onEditingNameUpdated(e.target.value);
};
const onNameSubmit = () => {
onNameUpdated();
};
return (
<>
<label>Update name:</label>
<input
value={editingName}
onChange={onChange}
/>
<button
disabled={disabled}
onClick={onNameSubmit}
>Change</button>
</>
);
};
export default NameEditingComponent;
V3 的核心改进:
| 改进点 | 说明 |
|---|---|
| 状态完全提升 | editingName 由父组件持有,子组件零状态 |
| 子组件纯展示 | UI = fn(props),子组件职责单一,只负责渲染 |
| disabled 逻辑 | 名字为空或未修改时禁用按钮,防无效提交 |
| 异步同步 | loadUsername 同时更新 name 和 editingName,子组件自动同步 |
三、三次重构的对比总结
3.1 架构对比
vbnet
V1: event 穿透 V2: 子组件私有状态 V3: 状态完全提升
┌──────────┐ ┌──────────┐ ┌──────────┐
│ App │ │ App │ │ App │
│ username│ │ username│ │ name │
│ │ │ │ │ editing │
│ ┌─────┐ │ │ ┌─────┐ │ │ ┌─────┐ │
│ │Edit │ │ │ │Edit │ │ │ │Edit │ │
│ │ │ │ │ │editing│ │ │ │ │ │
│ │event│ │ │ │Name │ │ │ │纯展示│ │
│ └──┬──┘ │ │ └──┬──┘ │ │ └─────┘ │
│ │event │ │value │ UI=fn() │
│ ◀──┘ │ ◀──┘ │ │
└──────────┘ └──────────┘ └──────────┘
父组件处理DOM事件 子组件自管状态 父组件统管状态
3.2 核心差异对比
| 维度 | V1 | V2 | V3 |
|---|---|---|---|
| 状态归属 | 父组件 | 父+子各有 | 全在父组件 |
| 事件传递 | 传 event 对象 | 传 string 值 | 传 string 值 |
| 子组件职责 | 透传 event | 编辑+提交 | 纯展示 |
| 父组件复杂度 | 高(处理DOM) | 中 | 中(多一个state) |
| 异步同步 | ✅ | ❌ 不同步 | ✅ |
| 按钮禁用 | ❌ | ❌ | ✅ |
| 可测试性 | 低 | 中 | 高 |
3.3 为什么 V3 最好?
React 的核心公式:UI = fn(props)
diff
组件函数 = 纯函数
输入: props(属性)
输出: ReactNode(UI)
纯函数特性:
- 相同输入,相同输出
- 无副作用
- 不依赖外部状态
V3 中子组件 NameEditingComponent 完全符合这个公式------给定相同的 props,渲染出相同的 UI,没有任何私有状态。这带来了三个好处:
- 可预测性:UI 完全由 props 决定,调试时只需检查 props
- 性能优化 :纯展示组件可以用
React.memo轻松优化 - 可复用性:不依赖任何外部状态,可以在任何地方使用
四、useEffect:副作用与生命周期管理
4.1 useEffect 的四种用法
tsx
// 用法一:只挂载后执行(依赖项为空数组)
useEffect(() => {
console.log('只会在挂载后执行一次');
}, []);
// 用法二:挂载后 + 特定依赖更新后执行
useEffect(() => {
console.log('挂载后执行,todos 更新后也会执行');
localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
// 用法三:挂载后 + 每次更新都执行(不传依赖项)
useEffect(() => {
console.log('挂载后执行,每次更新都执行');
});
// 用法四:卸载前执行清理(返回一个函数)
useEffect(() => {
const interval = setInterval(() => {
console.log('interval is here');
}, 1000);
// 返回的函数在组件卸载前执行
return () => {
console.log('组件卸载前执行,清理定时器');
clearInterval(interval);
};
}, []);
4.2 依赖项与执行时机对照表
| 依赖项 | 挂载后 | 依赖更新后 | 每次更新 | 卸载前 |
|---|---|---|---|---|
[] |
✅ | ❌ | ❌ | ✅(如有return) |
[state] |
✅ | ✅(state变化时) | ❌ | ✅(如有return) |
| 不传 | ✅ | ❌ | ✅ | ✅(如有return) |
4.3 清理函数:防止内存泄漏
tsx
const Demo = () => {
useEffect(() => {
const interval = setInterval(() => {
console.log('interval is here');
}, 1000);
// 如果不清理,组件卸载后定时器仍在运行
// 这就是内存泄漏------这块内存永远无法回收
return () => {
console.log('组件卸载前执行,做内存清理工作');
clearInterval(interval);
};
}, []);
return (
<>
Demo
</>
);
};
// 父组件中条件渲染 Demo
const App = () => {
const [count, setCount] = useState(0);
return (
<div>
Count: {count}
<button onClick={() => setCount(count + 1)}>count++</button>
{count % 2 === 0 && <Demo />} {/* 偶数显示,奇数卸载 */}
</div>
);
};
为什么必须清理?
javascript
组件挂载 ──▶ setInterval 启动 ──▶ 每秒执行
│
▼
组件卸载 ──▶ 如果不 clearInterval:
│
▼
定时器仍在运行,但组件已不存在
╳ 内存泄漏:这块内存永远无法回收
╳ 性能下降:无用的定时器持续消耗资源
五、localStorage:前端本地持久化
5.1 三种前端存储方案
| 方案 | 容量 | 类型 | 适用场景 |
|---|---|---|---|
| localStorage | ~5MB | key-value 字符串 | 配置信息、关键数据 |
| sessionStorage | ~5MB | key-value 字符串 | 会话级临时数据 |
| IndexedDB | 远大于5MB | 类数据库结构化存储 | 大量数据、离线应用 |
5.2 在 React 中使用 localStorage
tsx
const App = () => {
// 惰性初始化:从 localStorage 读取已有数据
const [todos, setTodos] = useState(() => {
try {
const stored = localStorage.getItem('todos');
return stored ? JSON.parse(stored) : [];
} catch (err) {
// JSON.parse 可能失败(数据损坏),需兜底处理
return [];
}
});
// todos 变化时自动同步到 localStorage
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
const addTodo = (text) => {
if (text.trim() === '') return;
setTodos([
{ id: Date.now(), text, completed: false },
...todos
]);
// 注意:不需要在这里手动 setItem
// useEffect 会自动同步
};
// ...其他操作方法
};
关键设计:
scss
用户操作 ──▶ setTodos(新状态) ──▶ 组件重新渲染
│
▼
useEffect([todos]) 触发
│
▼
localStorage.setItem(...)
│
▼
数据持久化到本地
为什么不在 addTodo 里手动 setItem?
tsx
// ❌ 错误做法:手动同步
const addTodo = (text) => {
setTodos([...todos, { id: Date.now(), text, completed: false }]);
localStorage.setItem('todos', JSON.stringify(todos)); // 这里 todos 还是旧值!
};
// ✅ 正确做法:用 useEffect 自动同步
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
setTodos 是异步的,调用后 todos 变量在当前执行上下文中仍是旧值。手动 setItem 会存入旧数据。而 useEffect 在渲染完成后执行,此时 todos 已经是最新的。
六、TypeScript 在组件通信中的类型约束
6.1 自定义事件的类型声明
tsx
interface Props {
// 普通属性
editingName: string;
disabled: boolean;
// 自定义事件:只传值
onNameUpdated: () => void;
onEditingNameUpdated: (newEditingName: string) => void;
}
6.2 React 合成事件类型
tsx
interface Props {
// 如果必须传 event,用 React 的合成事件类型
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
// React.ChangeEvent 是泛型,T 指定事件发生的元素类型
// <HTMLInputElement> → input 元素的 change 事件
// <HTMLSelectElement> → select 元素的 change 事件
// <HTMLTextAreaElement> → textarea 元素的 change 事件
6.3 设计原则:事件接口应该传什么?
tsx
// ❌ V1 的做法:传 event 对象
interface Props {
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
// 问题:父组件被迫关心 DOM 事件细节
// ✅ V2/V3 的做法:只传值
interface Props {
onNameUpdated: (newName: string) => void;
}
// 优势:父组件只需处理业务逻辑,不耦合 DOM
核心原则:子组件负责处理 DOM 事件,父组件只负责处理业务数据。
七、完整实战:TodoList + useEffect + localStorage
将前面所有知识点融合,实现一个带本地持久化的 TodoList:
tsx
import { useState, useEffect } from 'react';
import TodoInput from "./components/TodoInput";
import TodoList from "./components/TodoList";
import TodoStats from "./components/TodoStats";
import './App.css';
// 演示 useEffect 卸载清理
const Demo = () => {
useEffect(() => {
const interval = setInterval(() => {
console.log('interval is here');
}, 1000);
return () => {
console.log('组件卸载前执行,做内存清理工作');
clearInterval(interval);
};
}, []);
return <><Demo /></>;
};
const App = () => {
const [count, setCount] = useState(0);
// 惰性初始化:从 localStorage 读取
const [todos, setTodos] = useState(() => {
try {
const stored = localStorage.getItem('todos');
return stored ? JSON.parse(stored) : [];
} catch (err) {
return [];
}
});
// 只在 count 变化时执行
useEffect(() => {
console.log('count 改变也会执行');
}, [count]);
// 每次更新都执行(不推荐,仅演示)
useEffect(() => {
console.log('每次更新都执行');
});
// 只在挂载后执行一次
useEffect(() => {
console.log('只会在挂载后执行');
}, []);
// todos 变化时同步到 localStorage
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
const countBy = () => {
setCount(count + 1);
};
const addTodo = (text) => {
if (text.trim() === '') return;
setTodos([
{ id: Date.now(), text, completed: false },
...todos
]);
};
const toggleTodo = (id) => {
setTodos(todos.map(todo =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
));
};
const deleteTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id));
};
const clearCompleted = () => {
setTodos(todos.filter(todo => !todo.completed));
};
const activeCount = todos.filter(t => !t.completed).length;
const completedCount = todos.length - activeCount;
return (
<div>
Count: {count}
<button onClick={countBy}>count++</button>
{count % 2 === 0 && <Demo />}
<h1>My Todo List</h1>
<TodoInput onAdd={addTodo} />
<TodoList
todos={todos}
onToggle={toggleTodo}
onDelete={deleteTodo}
/>
<TodoStats
total={todos.length}
active={activeCount}
completed={completedCount}
onClearCompleted={clearCompleted}
/>
</div>
);
};
export default App;
7.1 组件通信全景图
scss
┌─────────────┐
│ App │
│ (状态中心) │
│ todos state │
└──────┬──────┘
│
┌───────────────┼───────────────┐
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ TodoInput │ │ TodoList │ │ TodoStats │
│ │ │ │ │ │
│ props: │ │ props: │ │ props: │
│ onAdd() │ │ todos │ │ total │
│ │ │ onToggle() │ │ active │
│ 子组件私有 │ │ onDelete() │ │ completed │
│ inputValue │ │ │ │ onClear() │
│ state │ │ 纯展示 │ │ │
└─────────────┘ └─────────────┘ └─────────────┘
通信法则:
- 父组件通过
props向下传递数据 - 子组件通过自定义事件向上通知
- 共享状态放在父组件,私有状态放子组件
- 数据流向始终单向:父 → 子
八、总结
8.1 知识体系图
kotlin
React + TypeScript 企业级开发
├── 类型约束
│ ├── React.FC<P> 函数组件类型
│ ├── interface Props 组件接口契约
│ └── ChangeEvent<T> 合成事件类型
├── 组件通信
│ ├── 单向数据流 父→子 传 props
│ ├── 自定义事件 子→父 传值
│ └── 状态提升 共享状态放父组件
├── useEffect
│ ├── [] 挂载后执行 异步请求、初始化
│ ├── [dep] 依赖更新执行 数据同步
│ ├── 不传 每次更新执行 谨慎使用
│ └── return 卸载前清理 防内存泄漏
└── 本地存储
├── useState 惰性初始化 从 localStorage 读取
├── useEffect 自动同步 状态变化时写入
└── try-catch 兜底 JSON.parse 防错
8.2 组件设计三原则
| 原则 | 说明 | 对应版本 |
|---|---|---|
| 单一职责 | 每个组件只做一件事 | V3 子组件纯展示 |
| 状态提升 | 共享状态放在最近的共同父组件 | V3 父组件持有 |
| UI = fn(props) | 纯函数组件,相同 props 相同 UI | V3 最终形态 |
8.3 三次重构的本质
makefile
V1 → V2 → V3 的本质是:状态归属的不断优化
V1: 状态在父,但事件处理耦合到父 → 职责不清
V2: 状态分散在父子两处 → 同步困难
V3: 状态全部在父,子组件纯展示 → UI = fn(props)
React 的设计哲学就藏在这三次重构里:把状态放到合适的位置,让数据单向流动,让组件成为纯函数。
如果这篇文章对你有帮助,欢迎点赞 和收藏!