如果你写过的 React 组件里到处都是
any,或者在useEffect里踩过"无限循环"的坑,那么这篇文章就是为你准备的。我们会从最基础的类型约束一路推到状态管理、本地存储,用真实代码和踩坑经验,帮你建立起一套牢不可破的 React + TS 心智模型。
前端圈子有个有趣的规律:当你的代码开始在企业级项目里流转时,TypeScript 就不再是"可选项",而是"安全带"。 React 本身就是用 TS 写的,二者配合有着天然的契合度。类型约束可以帮你在编译阶段提前揪出 80% 的低级错误,剩下的 20% 交给 React 的单向数据流来兜底------这就是今天我们要一起搭建的"安全开发流水线"。
我会从一个简单的"打招呼"组件开始,逐步升级到状态管理、父子通信、副作用处理和本地存储,把散落在你日常开发里的知识点串成一条线。
一、给你的组件穿上"类型铠甲":React.FC 与泛型,到底怎么"泛"?
很多同学从 JS 切到 TS 写的第一个函数组件长这样:
javascript
const Hello = (props) => {
return <h2>Hello {props.userName}</h2>
}
这样的代码在 TS 环境下会直接报错:props 隐式含有 any 类型。这时候你需要告诉 TypeScript:这个组件接收的 props 到底长什么样。最优雅的方式就是用 React 内置的 React.FC。
1. 泛型:类型的"函数"
React.FC 其实是一个泛型类型。我们先看看 React 源码里它的定义:
swift
type FC<P = {}> = FunctionComponent<P>;
interface FunctionComponent<P = {}> {
(props: P, context?: any): ReactElement<any, any> | null;
// ... 其他静态属性
}
这里的 <P = {}> 就是泛型参数 。你可以把泛型理解成类型的函数 :它接收一个类型参数 P,然后返回一个新的具体类型。默认参数是 {},这意味着如果你不传类型,props 就是一个空对象。当我们在使用时传入了 Props 接口:
typescript
interface Props {
userName: string;
}
const Hello: React.FC<Props> = (props) => { ... }
这时候 P 被赋值为了 Props,整个 React.FC<Props> 就变成了:一个函数组件,它接收的 props 必须满足 { userName: string } 这个形状。当你写 <Hello userName="张三" /> 时一切安好,一旦写成 <Hello userName={123} />,编辑器立刻标红------这就是编译时的类型安全。
2. interface 还是 type?该纠结吗?
很多初学者会问:声明 Props 到底用 interface 还是 type?答案很简单:在对象类型的定义上,两者几乎可以互换。上面的 Props 用 type 写也完全没问题:
ini
type Props = {
userName: string;
}
但两者还是有一些区别,我帮你简单梳理一下:
- interface 可以继承(extends),type 可以交叉(&) :
interface A extends B {}和type A = B & {}能达到类似效果。 - interface 可以声明合并:同一个名字的 interface 可以多次声明,最终会合并成一个。type 不行,重复声明会报错。
- type 能表达联合类型、元组等,interface 不行 :比如
type Status = 'loading' | 'success' | 'error',这种场景必须用 type。
对于组件 Props 这种需要描述对象形状的场景,我个人习惯用 interface,因为它像一份"契约",语义上更符合"你必须满足这个接口才能使用组件"。不过团队里统一就好,不必过度纠结。
小结一句:泛型让类型变得动态可复用,React.FC<Props> 就是用泛型把"组件类型"和"Props 类型"粘在一起的胶水。
二、事件处理:React.ChangeEvent<HTMLInputElement> 到底深到哪里去?
组件里总少不了表单控件,onChange 是最常见的。为了避免到处写 (e: any),我们必须掌握 React 合成事件的正确类型。
React 的事件系统是合成事件 ,它将浏览器的原生事件做了跨浏览器封装,让我们写的事件处理函数拿到的 e 其实不是原生 Event,而是 SyntheticEvent。React.ChangeEvent 就是专门给表单 onChange 准备的类型。
1. 为什么是泛型?
我们再看一个简化版的类型定义:
csharp
interface ChangeEvent<T = Element> extends SyntheticEvent<T> {
target: EventTarget & T;
}
ChangeEvent 也是一个泛型,T 就是发生事件的元素类型。当你写 React.ChangeEvent<HTMLInputElement> 时,T 被指定为 HTMLInputElement,于是 e.target 的类型就变成了 EventTarget & HTMLInputElement------既保留了标准 EventTarget 的通用属性,又额外拥有了 HTMLInputElement 特有的 value、checked 等。所以你在回调里可以直接 e.target.value 而不会报错。
换句话说:泛型元素类型决定了你能从 e.target 上拿到哪些专属属性。 如果你把 T 换成 HTMLSelectElement,那你就能安全地访问 selectedIndex 等属性。
2. 常用事件类型一览
掌握了规律,你就可以举一反三:
- 鼠标事件:
React.MouseEvent<HTMLButtonElement> - 键盘事件:
React.KeyboardEvent<HTMLInputElement> - 表单提交:
React.FormEvent<HTMLFormElement> - 焦点事件:
React.FocusEvent<HTMLInputElement>
在项目中善用这些类型,不仅能让编辑器自动补全,更能在重构时避免因为改动了元素标签而忘记更新事件处理逻辑的尴尬。
三、单向数据流:两个版本还不够,我们要"三轮对比"
React 的灵魂就是单向数据流 :状态永远在父组件中持有,通过 props 向下传递,子组件想要更新状态必须通过回调上报。这就像公司财务审批------只有财务总监(父组件)能改账本,各部门(子组件)只能提交申请。我们用一个"编辑名字"的场景来层层递进,看看不同设计带来的差异。
需求很简单:展示一个名字,旁边有一个输入框和一个按钮,输入新名字后点按钮更新展示的名字。
版本1:事件对象"透传"到父组件(反面教材)
typescript
// App.tsx
const App = () => {
const [username, setUsername] = useState("initialName");
return (
<>
<Hello userName={username} />
<NameEditComponent
username={username}
onChange={(e) => setUsername(e.target.value)}
/>
</>
)
}
// NameEditComponent.tsx
interface Props {
username: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
const NameEditComponent: React.FC<Props> = (props) => (
<input value={props.username} onChange={props.onChange} />
)
问题出在哪?
- 父组件被迫处理
onChange的事件对象,代码里直接出现了e.target.value,让父组件的可读性变差------它本应只关心"用户名变了",而不应关心"怎么变出来的"。 - 每次按键都会触发父组件状态更新,导致整个组件树重新渲染,而实际上在用户按下"确认"按钮之前,我们根本就不需要更新最终的
username。 - 子组件像个"传声筒",除了转发事件啥也没干,没有封装任何业务逻辑。
版本2:编辑态留在子组件内部(可用,但状态分散)
typescript
// App.tsx
const App = () => {
const [name, setName] = useState("defaultUserName");
const changeName = (newName: string) => setName(newName);
return (
<>
<Hello userName={name} />
<NameEditComponent onNameUpdated={changeName} />
</>
)
}
// NameEditComponent.tsx
const NameEditComponent: React.FC<{ onNameUpdated: (newName: string) => void }> = (props) => {
const [editingName, setEditingName] = useState("");
return (
<>
<input
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
/>
<button onClick={() => props.onNameUpdated(editingName)}>确认</button>
</>
)
}
进步明显:
- 父组件终于清爽了,只接收最终的值,完全不碰事件对象。
- 子组件自己管理编辑态,只在提交时才通知父组件,避免了频繁的无谓渲染。
但它有一个小问题:编辑状态分散在子组件内,如果将来需要从外部重置编辑框(比如异步数据回填),会稍微麻烦一点。另外,子组件有了自己的状态,就不再是一个纯粹的"无状态展示组件"。
版本3:"状态提升 + 展示组件"(最佳实践)
更推崇的做法是:将编辑态也提升到父组件,子组件退化成一个纯展示组件,所有状态都通过 props 传入,所有行为都通过回调上报。 这就是我们最终落地的代码:
typescript
// App.tsx
const App = () => {
const [name, setName] = useState("defaultUserName");
const [editingName, setEditingName] = useState(name);
const setUserNameState = () => {
setName(editingName);
};
return (
<>
<Hello userName={name} />
<NameEditComponent
editingName={editingName}
onEditingNameUpdated={setEditingName}
onNameUpdated={setUserNameState}
disabled={editingName === '' || editingName === name}
/>
</>
)
}
// NameEditComponent.tsx
interface Props {
editingName: string;
onNameUpdated: () => void;
onEditingNameUpdated: (newEditingName: string) => void;
disabled: boolean;
}
const NameEditComponent: React.FC<Props> = (props) => {
const { editingName, onNameUpdated, onEditingNameUpdated, disabled } = props;
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onEditingNameUpdated(e.target.value);
}
return (
<>
<label>Update name:</label>
<input value={editingName} onChange={onChange} disabled={disabled} />
<button onClick={onNameUpdated} disabled={disabled}>Change</button>
</>
)
}
为什么这个版本更好?
- 父组件依然是唯一的状态持有者 ,甚至
editingName也由它管理,应用的状态流动完全可预测。 - 子组件彻底变成了"无状态组件" ,只负责渲染和触发回调,符合
UI = fn(props)的函数式理念。这样组件更容易测试、复用,性能也更好(无内部状态,不用额外的内存)。 - 禁用的逻辑放在父组件,避免了子组件关心业务规则(比如"不能提交空名字或与原名相同的名字")。
通过三轮演进的对比,你会发现:单向数据流的本质不是"限制",而是让状态变更的路径变得唯一且可追踪,这才是大型应用不出 bug 的基石。
四、useEffect:为什么叫副作用?没有它会怎样?
React 函数组件的主体应该是一个纯函数 :给定 props 和 state,返回 JSX。任何与渲染 UI 无关的操作------比如网络请求、操作 localStorage、订阅事件、修改全局变量------都叫副作用 。React 为我们提供了 useEffect 钩子,专门用来把这些操作"推迟"到渲染完成之后执行,从而保证渲染过程的纯净和高效。
1. 如果没有 useEffect,会发生什么?
假设我们想在组件显示后立刻去请求一个用户名,很多新手可能会这样写:
javascript
const App = () => {
const [name, setName] = useState("");
fetch("/api/user").then(res => res.json()).then(data => setName(data.name));
return <div>Hello {name}</div>;
}
这段代码会直接报错或出现诡异行为,因为:
- 渲染函数本身是同步 的,而
fetch是异步的,React 没法保证在渲染期间进行异步操作的安全。 - 更致命的是,每次组件渲染(比如父组件更新导致重新渲染)都会再执行一次
fetch,造成无限循环和请求爆炸。 - 即便用条件包裹,渲染函数也不应该包含副作用,这违背了 React 的设计原则,可能触发"渲染中修改 state"的警告。
没有 useEffect,我们就无法在函数组件里安全地访问 DOM、调用 API、操作外部存储。 useEffect 的意义就是将组件渲染与副作用分离,让 React 先完成渲染(保证页面快速出现),然后再执行你指定的副作用(数据获取、订阅等)。
2. 依赖数组的三种行为模式
很多同学拿着 Class 组件的生命周期来类比,但更准确的理解是:useEffect 就是用来"同步"外部数据(如服务器数据、localStorage)与组件状态的。
-
只在挂载后执行一次:
[]scssuseEffect(() => { loadUsername(); }, []); // 空数组 → 无依赖 → 只在 mount 后执行一次 -
挂载后 + 指定依赖变化时执行:
[dep]javascriptuseEffect(() => { localStorage.setItem('todos', JSON.stringify(todos)); }, [todos]); // todos 变化时,自动将最新数据写入本地 -
每次渲染后都执行:不传第二个参数
javascriptuseEffect(() => { console.log('组件每次更新都会执行这个副作用'); }); // 谨慎使用,容易造成死循环 -
清理副作用:返回一个函数
scssuseEffect(() => { const timer = setInterval(() => { /* ... */ }, 1000); return () => clearInterval(timer); // 组件卸载时清除定时器 }, []);这个返回的函数会在组件卸载前调用,避免内存泄漏。如果依赖数组不为空,它还会在下一次 effect 执行前被调用,以清理上一次的副作用。
3. 为什么要有副作用?
现实的应用不可能只做渲染。你需要从服务器拿数据,需要持久化用户操作,需要监听窗口大小变化......这些都是"副作用"。React 用 useEffect 让我们可以优雅地管理这些操作,同时保持渲染函数的纯净。这也符合"组件第一使命是先渲染出来,让用户觉得快,然后再慢慢处理数据"的优化哲学。
五、实战串联:Todo List + localStorage 持久化
把上面的知识点串起来,我们来实现一个完整的 Todo List,数据自动存到 localStorage,刷新不丢失。
1. 惰性初始化 state,从本地读取
ini
const [todos, setTodos] = useState(() => {
const saved = localStorage.getItem('todos');
return saved ? JSON.parse(saved) : [];
});
useState 可以接受一个函数,该函数只在组件初始化时执行一次,完美避免了每次渲染都去读 localStorage 的性能浪费。
2. 用 useEffect 同步 todos 到本地
javascript
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
[todos] 告诉 React:每次 todos 的引用发生变化(比如你调用了 setTodos),就把最新的列表存到 localStorage。不要忘记 JSON.stringify,因为 localStorage 只接受字符串。
3. 核心操作:新增、切换、删除
这些操作都必须通过 setTodos 返回一个全新的数组,遵守不可变数据的原则:
ini
const addTodo = (text: string) => {
if (!text.trim()) return;
setTodos([
{ id: Date.now(), text, completed: false },
...todos,
]);
};
const toggleTodo = (id: number) => {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
};
const deleteTodo = (id: number) => {
setTodos(todos.filter(todo => todo.id !== id));
};
接着把方法和数据通过 props 分发给子组件:
ini
<TodoInput onAdd={addTodo} />
<TodoList todos={todos} onToggle={toggleTodo} onDelete={deleteTodo} />
<TodoState
total={todos.length}
active={todos.filter(t => !t.completed).length}
completed={todos.filter(t => t.completed).length}
onClearCompleted={() => setTodos(todos.filter(t => !t.completed))}
/>
TodoInput 负责收集用户输入并调用 onAdd;TodoList 渲染列表并触发切换和删除;TodoState 显示统计并清空已完成项。所有数据流都一目了然,出了 bug 只需检查父组件里的几行核心逻辑。
六、总结:类型、数据流与副作用的三位一体
回顾我们搭建的整个过程,React + TypeScript 的最佳实践可以凝练成三句话:
- 类型约束是导航仪,不是枷锁。 善用
React.FC<Props>、React.ChangeEvent<HTMLXxxElement>等泛型,让错误提前暴露在编译期,而不是生产环境的控制台。 - 单向数据流是法律。 状态提升,子组件保持无状态,永远通过 props 读取数据,通过回调上报更新。这样数据路径唯一,心智负担最小。
- 副作用交给
useEffect管。 一切与渲染无关的操作都推迟到 commit 阶段,正确声明依赖,让组件渲染保持纯净且可预测。
当你下次接到需求时,先在心里画一条数据流:状态在哪定义?谁要读?谁要改?修改是否需要同步到本地?这条链路理清了,代码自然就出来了。
"前端工程化的本质,就是让正确的事情容易做,让错误的事情早发现。" 用好 React 和 TypeScript 的组合,你写的就不仅是功能,更是信心。