摘要
以NameEdit三次重构拆解React+TS类型体系:React.FC泛型、interface Props、React.ChangeEvent合成事件,及从传事件到无状态纯展示的进化路径。
一、React + TypeScript:类型约束让组件"自文档化"
JavaScript 写 React 有一个隐痛:你永远不知道子组件期望什么 props,除非打开文件看源码,或者依赖注释和文档。三个月后回头看自己写的组件,onChange 接收什么参数?是 string 还是 Event?------只能靠猜。
TypeScript 解决了这个问题。React 本身就是用 TypeScript 写的,React.FC、ReactNode、React.ChangeEvent 这些类型都是 React 源码中内置的类型声明。当你在 .tsx 文件中写一个组件,它的 props 接口就是一份活文档------IDE 会提示每个属性的类型,编译阶段就能拦截类型不匹配的错误。
本文以一个 NameEdit 组件为样本,展示它如何经历三次重构,每一步都体现 React + TypeScript 组件设计的核心原则。
二、React.FC:函数组件的类型签名
先看最简单的 Hello 组件:
typescript
import * as React from 'react';
interface Props {
userName: string;
}
const HelloComponent: React.FC<Props> = (props) => {
return <h1>Hello {props.userName}</h1>
}
React.FC<Props> 是函数组件的类型注解。它的源码定义是:
typescript
type FC<P = {}> = FunctionComponent<P>;
FC 是 FunctionComponent 的类型别名,<P = {}> 表示泛型参数 P 默认值为空对象 {}------如果你不传泛型参数,props 就是空对象。当你传了 Props,TypeScript 就用 Props 接口来约束 props 的形状。
interface 和 type 都可以声明类型,为什么组件 props 推荐用 interface?
typescript
// 两种方式都可以
interface Props {
userName: string;
}
// type Props = {
// userName: string;
// }
interface 是"接口"------定义对象需要满足的属性和方法。type 是"类型别名"------给一个类型起别称。在组件 props 的场景中,两者功能等价,但 interface 更符合语义:组件 props 本质上是一个"对象需要满足的约束",用接口描述更自然。此外,interface 支持声明合并(同名接口自动合并),在大型项目中扩展第三方类型时非常有用。
三、第一次进化:子组件传事件 → 类型污染
最初的 NameEdit 组件设计是这样的:
typescript
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>
)
}
父组件这样使用:
typescript
const App = () => {
const [username, setUsername] = useState('initialName');
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setUsername(e.target.value);
}
return <NameEditComponent username={username} onChange={onChange} />
}
这个版本的问题在于:事件对象穿透了组件边界 。React.ChangeEvent<HTMLInputElement> 这种底层 DOM 事件的类型从子组件一路传到父组件,导致父组件必须知道 HTMLInputElement 这个实现细节。父组件的可读性被拖累------它原本的使命是"持有状态和修改状态,让子组件共享",但现在被迫处理 DOM 事件对象。
React.ChangeEvent 是 React 的合成事件类型。React.ChangeEvent<HTMLInputElement> 表示"一个来自 <input> 元素的变更事件"。React 的合成事件是对原生 DOM 事件的跨浏览器封装,类型签名精确到元素级别------ChangeEvent<HTMLInputElement> 和 ChangeEvent<HTMLTextAreaElement> 是不同的类型。
四、第二次进化:子组件私有状态 → 值传递
改进方案:把事件处理封装在子组件内部,只把值传给父组件。
typescript
interface Props {
initialUserName: string;
onNameUpdated: (name: 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);
}
return (
<>
<label>Update name:</label>
<input value={editingName} onChange={onChange} />
<button onClick={onNameSubmit}>Change</button>
</>
)
}
关键变化:子组件自己维护 editingName 私有状态,onChange 不再暴露给父组件,onNameUpdated 接收的是 (name: string) => void------一个干净的字符串回调,而不是 React.ChangeEvent。
父组件变得简洁:
typescript
const App: React.FC = () => {
const [username, setUsername] = useState('initialName');
const setUsernameState = (name: string) => {
setUsername(name);
}
return (
<div>
<Hello userName={username} />
<NameEditComponent
initialUserName={username}
onNameUpdated={setUsernameState}
/>
</div>
)
}
父组件不再关心 input 事件,只关心"用户最终提交了什么名字"。组件边界清晰了,类型也干净了。
五、第三次进化:状态提升 → 无状态纯展示
第二个版本还有一个问题:子组件持有 editingName 状态,关闭组件后状态丢失。如果需要在父组件中展示编辑中的内容(比如实时预览),或者需要在多个地方同步编辑状态,子组件私有状态就不够用了。
最终方案:把 editingName 提升到父组件,子组件变成纯展示组件。
typescript
// NameEditingComponent.tsx
interface Props {
editingName: string;
onNameUpdated: () => void;
onEditingNameUpdated: (newEditingName: string) => void;
disabled: boolean;
}
const NameEditingComponent: 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} />
<button disabled={disabled} onClick={onNameUpdated}>Change</button>
</>
)
}
父组件持有所有状态:
typescript
const App = () => {
const [name, setName] = React.useState<string>('defaultUserName');
const [editingName, setEditingName] = React.useState('defaultEditingName');
const loadUserName = () => {
setTimeout(() => {
setName('name from async call');
setEditingName('name from async call');
}, 2000)
}
React.useEffect(() => {
loadUserName(); // 组件挂载后异步加载用户名
}, [])
const setUsernameState = () => {
setName(editingName);
}
return (
<div>
名字:{name}
<HelloComponent userName={editingName} />
<NameEditingComponent
editingName={editingName}
onNameUpdated={setUsernameState}
onEditingNameUpdated={setEditingName}
disabled={editingName === "" || editingName === name}
/>
</div>
)
}
这个版本达到了组件设计的理想状态:UI = fn(props) 。子组件没有任何状态,它的输出完全由 props 决定。测试时不需要 mock 任何内部状态,只需传入不同的 props 就能覆盖所有场景。disabled 属性也是一个亮点------子组件不判断"按钮是否该禁用",父组件计算好传下来,子组件只管渲染。
useState<string> 的泛型参数显式声明了状态类型。TypeScript 能从初始值 'defaultUserName' 推断出 string 类型,但显式声明可以让意图更明确,特别是在初始值可能为 null 或 undefined 的场景中。
六、useEffect 与异步数据加载
useEffect 在本项目中用于异步加载用户名:
typescript
React.useEffect(() => {
loadUserName(); // 2秒后异步返回用户名
}, [])
[] 空依赖数组意味着只在组件挂载后执行一次。组件的第一要素是赶快显示出来------先渲染初始状态,让用户看到界面,然后异步请求数据,数据返回后更新状态触发重新渲染。这种"先渲染再加载"的模式让用户感知到的速度更快,避免了"白屏等待数据"的体验。
useEffect 的三种生命周期对应:
| 依赖数组 | 时机 | 用途 |
|---|---|---|
[] |
挂载后执行一次 | 初始化:首次加载数据、订阅事件 |
[dep] |
挂载后 + 依赖变化后 | 响应式:依赖变化时重新执行 |
| 不传 | 每次渲染后 | 极少数场景:每次更新都需要同步 |
七、TypeScript 配置:tsconfig 的关键选项
tsconfig.app.json 中几个与 React 密切相关的选项:
json
{
"compilerOptions": {
"jsx": "react-jsx",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"noUnusedLocals": true,
"noUnusedParameters": true
}
}
"jsx": "react-jsx":使用 React 17+ 的新 JSX 转换,不需要在每个文件中import React from 'react',但本项目使用import * as React更显式"moduleResolution": "bundler":告诉 TypeScript 用打包器(Vite)的方式解析模块,支持.tsx扩展名的直接导入"allowImportingTsExtensions": true:允许import App from './App.tsx'带扩展名导入"noUnusedLocals"和"noUnusedParameters":编译时检查未使用的变量和参数,保持代码整洁
typescript-eslint 插件(版本 8.59.2)配合 ESLint Flat Config,在 lint 层面提供 TypeScript 特有的规则校验,与 tsc 的类型检查形成双保险。
八、三次进化的对比
| 维度 | 版本一:传事件 | 版本二:私有状态 | 版本三:状态提升 |
|---|---|---|---|
| 子组件状态 | 无 | 有(editingName) | 无 |
| 父组件类型负担 | 重(需要 React.ChangeEvent) | 轻(string) | 轻(string) |
| 子组件 props 接口 | onChange: (e: ChangeEvent) => void | onNameUpdated: (name: string) => void | 四个属性,各司其职 |
| 可测试性 | 一般 | 一般 | 高(纯函数) |
| 状态跨组件共享 | 不可 | 不可 | 可 |
| 设计原则 | 事件透传 | 值封装 | UI = fn(props) |
版本三达到了组件设计的终极形态:子组件职责单一,只负责展示;父组件持有所有状态,通过 props 控制子组件的每一个行为。这种模式下,组件复用性最高、测试最简单、重构最安全。
九、总结
React + TypeScript 的组合,让组件开发从"约定"走向"约束"。interface 定义 props 形状,React.FC 约束函数签名,React.ChangeEvent 精确到元素级别的合成事件类型------这些类型系统不是负担,而是让组件在不同开发者之间传递时,不需要任何口头沟通就能理解其接口。
组件设计的进化路径清晰可见:从"子组件传事件污染父组件"到"子组件封装状态"再到"状态提升、子组件纯展示" 。每一步都让组件更接近 UI = fn(props) 的理想模型------输入 props,输出界面,不持有状态,不产生副作用。
这个模型不是 React 的规则,而是 React 设计哲学的自然结果。当你理解了它,写出的组件自然就是可测试、可复用、可维护的。