引言
如果你刚接触 React 和 TypeScript 的组合,可能会有这样的感受:JavaScript 还没写利索,怎么又多了一套类型语法?别急,今天我们从一个非常具体的场景出发------在 React 中管理一个"用户名" ,一步一步把 TypeScript 在 React 项目中的用法搞清楚。
这篇文章的目标读者是:对 React 有基本了解(知道组件、props、state 是什么),但对 TypeScript 还很陌生的人。我们会从最基础的组件类型定义讲起,逐步深入到父子组件的数据流设计。全程用代码说话,不堆砌概念。
一、从一个简单的 Hello 组件开始
假设我们要写一个 React 组件,它的任务很简单:接收一个用户名,然后显示一句问候语。
在纯 JavaScript 中,我们可能会这样写:
tsx
javascript
const HelloComponent = (props) => {
return <h2>Hello {props.userName}</h2>;
};
这段代码能跑,但有一个问题:谁也不知道 props 里面到底有什么 。如果调用者传错了属性名,比如传了 username 而不是 userName,程序不会报错,只是页面显示"Hello undefined"------然后你得花时间去排查。
TypeScript 解决的就是这个问题。我们给 props 加上一份"说明书",明确告诉调用者:这个组件需要一个叫 userName 的属性,而且它的值必须是字符串。
tsx
typescript
import * as React from 'react';
// 用 interface 定义 Props 的"形状"
interface Props {
userName: string; // 冒号后面是类型,表示这个属性必须是字符串
}
// React.FC 是"Function Component"的缩写,是一个泛型类型
// 把 Props 传进去,就等于告诉 TypeScript:这个组件的 props 必须符合 Props 接口
const HelloComponent: React.FC<Props> = (props) => {
return <h2>Hello {props.userName}</h2>;
};
export default HelloComponent;
这里有两个关键点需要展开说一下。
1.1 interface 是什么?
interface 是 TypeScript 中用来描述对象结构的工具。你可以把它理解成一份"合同"或"蓝图"------它规定了某个对象必须具备哪些属性,以及这些属性分别是什么类型。
在上面的例子里,Props 接口规定:任何被当作 HelloComponent 的 props 使用的对象,必须有一个名为 userName 的属性,且该属性的值必须是字符串。
如果调用者这样使用:
tsx
ini
<HelloComponent userName="张三" /> // ✅ 正确
<HelloComponent user="张三" /> // ❌ 报错:缺少 userName
<HelloComponent userName={123} /> // ❌ 报错:userName 应该是字符串
TypeScript 会在你编写代码时就给出错误提示,而不是等到页面运行时才发现问题。
1.2 React.FC 是什么?
React.FC 是 TypeScript 为 React 函数组件提供的类型定义 。它是一个泛型(generic),可以接收一个类型参数------也就是你的 Props 接口。
使用 React.FC<Props> 的好处是:
- 它会自动为你的组件加上
children属性的类型定义(虽然我们这里没用上) - 它明确了返回值类型是
React.ReactElement,让类型检查更完整
当然,你也可以不用 React.FC,直接这样写:
tsx
javascript
const HelloComponent = (props: Props) => {
return <h2>Hello {props.userName}</h2>;
};
两种写法都可行,但 React.FC 是更规范的做法,在团队项目中推荐使用。
二、让用户修改名字:引入表单交互
光显示名字还不够,我们想让用户自己输入并修改名字 。这就需要引入表单元素(<input>)和事件处理。
先看一个最基础的版本:
tsx
typescript
import * as React from 'react';
interface Props {
editingName: string; // 当前正在编辑的名字
onEditingNameUpdated: (newName: string) => void; // 名字变化时的回调
onNameUpdated: () => 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>更新名字:</label>
<input value={editingName} onChange={onChange} />
<button disabled={disabled} onClick={onNameSubmit}>
确认修改
</button>
</>
);
};
export default NameEditingComponent;
这段代码的核心是把"数据"和"修改数据的操作"都通过 props 交给父组件管理 。子组件只负责展示和触发事件,不自己保存任何状态。这种模式叫作受控组件(controlled component)------输入框的值完全由父组件控制。
2.1 事件类型为什么要写那么长?
你可能注意到了 onChange 的参数类型写得很长:
tsx
css
(e: React.ChangeEvent<HTMLInputElement>) => { ... }
React.ChangeEvent<HTMLInputElement> 是 React 提供的事件类型 ,它描述了"一个发生在 HTML 输入框上的变化事件"。这个类型包含了事件对象的所有信息,比如 e.target.value(输入框当前的内容)、e.target.name(输入框的名字)等等。
写这个类型的好处是:
- 编辑器会自动提示 :当你输入
e.的时候,编辑器会列出所有可用属性和方法 - 防止拼写错误 :如果你把
e.target.value写成e.target.valule,TypeScript 会报错 - 代码即文档:任何人看到这个类型,都知道这个函数是用来处理输入框变化事件的
React.MouseEvent<HTMLButtonElement>------ 按钮点击事件React.FormEvent<HTMLFormElement>------ 表单提交事件React.KeyboardEvent<HTMLInputElement>------ 键盘事件
2.2 这个组件的设计思路
这个 NameEditingComponent 的设计遵循了一个重要原则:数据向下流动,事件向上传递。
- 数据向下 :
editingName从父组件传入,子组件只负责展示 - 事件向上 :用户输入时,子组件通过
onEditingNameUpdated把新值"汇报"给父组件;用户点击按钮时,通过onNameUpdated通知父组件"可以正式更新了"
这样做的好处是数据流向清晰 。所有关于名字的数据都存放在父组件中,子组件只是一个"展示+交互"的壳。当项目变大、组件变多时,这种清晰的流向能极大降低调试难度。
三、让组件自己管状态:另一种设计思路
上面的例子中,所有数据都由父组件管理。但有时候,让子组件自己管理一部分状态会让代码更简洁。来看另一种写法:
tsx
typescript
import * as React from 'react';
interface Props {
initialUserName: string; // 初始名字
onNameUpdated: (newName: string) => void; // 确认更新时通知父组件
}
const NameEditComponent: React.FC<Props> = (props) => {
// 用 useState 在组件内部维护一个"编辑中的名字"
const [editingName, setEditingName] = React.useState(props.initialUserName);
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setEditingName(e.target.value); // 更新内部状态
};
const onNameSubmit = () => {
props.onNameUpdated(editingName); // 把最终结果告诉父组件
};
return (
<>
<label>更新名字:</label>
<input value={editingName} onChange={onChange} />
<button onClick={onNameSubmit}>确认修改</button>
</>
);
};
export default NameEditComponent;
3.1 useState 的类型
useState 是 React 提供的 Hook,用来在函数组件中创建和管理状态。
tsx
ini
const [editingName, setEditingName] = React.useState(props.initialUserName);
这行代码做了三件事:
- 创建一个名叫
editingName的状态变量,初始值为props.initialUserName - 创建一个名叫
setEditingName的函数,用来修改editingName - 当
setEditingName被调用时,React 会重新渲染组件,并更新界面
TypeScript 在这里发挥了作用:它会根据初始值自动推断类型 。因为 props.initialUserName 是字符串,TypeScript 就知道 editingName 也应该是字符串,setEditingName 只能接收字符串参数。
你也可以显式指定类型:
tsx
c
const [editingName, setEditingName] = React.useState<string>(props.initialUserName);
两种写法都可以,但在初始值足够明确的情况下,让 TypeScript 自动推断更简洁。
3.2 两种设计模式对比
现在我们有两种设计思路:
| 第一种(受控组件) | 第二种(内部状态) | |
|---|---|---|
| 数据存在哪 | 父组件 | 子组件内部 |
| 谁负责更新数据 | 父组件 | 子组件自己 |
| 父组件需要传什么 | editingName + 两个回调 | initialUserName + 一个回调 |
| 适用场景 | 需要父组件实时感知变化 | 只需最终结果,中间过程不重要 |
第一种方式更"重",父组件需要管理更多状态,但控制力更强------父组件可以随时知道用户输入了什么。
第二种方式更"轻",父组件只需要传初始值和最终回调,中间过程完全由子组件自己打理。适合那些"只需要最终结果"的场景。
没有哪种更好,只有哪种更合适。根据实际需求选择就行。
四、把它们组合起来:一个完整的 App
现在我们把所有组件组合成一个完整的应用。这个应用的功能是:
- 页面加载后,从"服务器"(这里用
setTimeout模拟)获取用户名 - 展示当前用户名
- 用户可以通过输入框修改名字,点击确认后正式更新
tsx
typescript
import * as React from 'react';
import HelloComponent from './components/Hello';
import NameEditingComponent from './components/NameEditingComponent';
const App = () => {
// 正式的名字
const [name, setName] = React.useState<string>('defaultUserName');
// 编辑中的名字(还没正式确认)
const [editingName, setEditingName] = React.useState('defaultUserName');
// 模拟从服务器加载用户名
const loadUsername = () => {
setTimeout(() => {
setName('name from async call');
setEditingName('name from async call');
}, 2000);
};
// 组件首次渲染后执行
React.useEffect(() => {
loadUsername();
}, []);
// 用户点击"确认修改"时调用
const setUserNameState = () => {
setName(editingName);
};
return (
<>
当前名字:{name}
<HelloComponent userName={editingName} />
<NameEditingComponent
editingName={editingName}
onNameUpdated={setUserNameState}
onEditingNameUpdated={setEditingName}
disabled={editingName === '' || editingName === name}
/>
</>
);
};
export default App;
4.1 useEffect 是做什么的?
useEffect 是 React 的另一个 Hook,用来处理副作用 (side effect)。什么是副作用?简单说,就是不属于"根据数据渲染 UI"这个主流程的操作,比如:
- 从服务器获取数据
- 操作 DOM(比如修改页面标题)
- 设置定时器
tsx
scss
React.useEffect(() => {
loadUsername();
}, []);
这个写法表示:在组件第一次渲染完成后执行 loadUsername 。第二个参数 [] 是一个空数组,表示这个效果不依赖任何外部变量,所以只执行一次。
如果不传第二个参数,useEffect 会在每次渲染后都执行,这通常不是我们想要的。
4.2 状态更新的流程
整个应用的数据流是这样的:
- 初始化 :
name和editingName都被设为'defaultUserName' - 加载数据 :组件渲染后,
useEffect触发loadUsername,2 秒后把两个状态都更新为'name from async call' - 用户编辑 :用户在输入框中打字,触发
onChange,调用setEditingName更新编辑中的名字(此时正式名字name不变) - 确认修改 :用户点击按钮,触发
onNameSubmit,调用setUserNameState,把name更新为editingName的值 - 按钮禁用 :当
editingName为空字符串,或者editingName和name相同时,按钮不可用------避免无效操作
这个流程中,TypeScript 确保了每一步的数据类型都是正确的:setName 只能接收字符串,setEditingName 也只能接收字符串,任何类型错误都会在编译时被捕获。
五、总结
通过这个"用户名管理"的小例子,我们覆盖了 React + TypeScript 开发中的几个核心知识点:
- 用 interface 定义 Props 类型:给组件参数加上"说明书",让调用者明确知道需要传什么
- React.FC 泛型类型 :为函数组件提供完整的类型约束
- 事件类型 :
React.ChangeEvent、React.MouseEvent等,让事件处理函数类型安全 - useState 的类型推断 :根据初始值自动推导状态类型,也可以显式指定
- useEffect 处理副作用:在组件渲染后执行异步操作
- 受控组件 vs 内部状态:两种数据管理模式的取舍
- 单向数据流 :数据从父组件流向子组件,事件从子组件流回父组件
TypeScript 给 React 带来的最大价值,不是让你多写几行类型代码,而是让你在写代码的时候就能发现潜在的问题 ,而不是等到页面报错再去排查。对于初学者来说,这可能会多花一些学习成本,但一旦习惯了这种"先定义类型再写逻辑"的方式,你会发现自己写的代码更自信、更少出错。
希望这篇文章能帮你迈出 React + TypeScript 的第一步。