在前面的几篇里,我们把TypeScript的基础类型、接口、泛型这些核心概念都过了一遍,说实话,光看语法还是挺枯燥的。这篇咱们直接来点实际的,把TS和React结合起来,看看在真正的组件开发里,TS到底怎么帮我们兜底、提效。这篇文章的核心关键词很简单:TypeScript、React。我会从项目搭建讲到组件类型设计,再到一个完整的加减法计算器实战,最后把我在实际开发里踩过的高频报错和坑都翻出来晒一晒。适合刚学完TS基础、想在React项目里试试水的同学,也适合已经写了几天React组件但总觉得类型没写明白的人。
先说个我自己的体会:早期我用React写项目,JavaScript状态下代码确实能跑,但一旦项目过万行,改个接口字段就要全局搜,漏一个地方就白屏。切到TS之后,编辑器直接帮我圈出了所有报错点,这种“被工具兜底”的感觉,真的是用过就回不去。所以这篇不是纯理论,我会尽量按实际开发顺序来,你跟着走一遍就能上手。
1. 为什么要用TypeScript写React
1.1 从JavaScript到React+TS,开发体验到底变了什么
很多人喜欢用“多了类型标注”来解释TypeScript,这其实只说对了一半。真正用过之后你会发现,TS给React开发带来的最大变化是:编辑器变成了一个“懂业务的编译器”。
举个例子,在纯JavaScript的React里,你定义了一个Props对象:
javascript复制function UserCard(props) {
return (
<div>
<h3>{props.name}</h3>
<p>{props.age}</p>
</div>
);
}
调用的时候如果不小心少传了age,或者传成了字符串,运行时页面就会渲染成undefined或者直接报错。这种错误写的时候完全看不到,只能等页面跑起来才知道。而一旦项目里有几十个组件,互相嵌套,调用关系深了之后,排查这种问题的成本极高。
用TypeScript重写一下:
typescript复制interface UserCardProps {
name: string;
age: number;
}
function UserCard(props: UserCardProps) {
return (
<div>
<h3>{props.name}</h3>
<p>{props.age}</p>
</div>
);
}
现在你在别的地方调用<UserCard name="张三" />时,编辑器立刻会在age下面画一条红色波浪线,提示你缺少属性。不需要运行代码,问题在键入的瞬间就被发现了。这就是TS在React项目里最直观的价值——类型即文档,而且是一份永远不会过时的、被编译器实时检查的文档。
除了Props校验,函数的返回值、事件处理器的参数、useState的状态类型,TS全部都能帮你盯住。你可以在编码阶段就把一类常见的“低级错误”扼杀掉,而不是靠运行时去碰运气。
1.2 什么样的React项目值得引入TypeScript
这几乎是新项目默认要回答的问题。我的建议很简单:只要是打算长期维护、不止一个人参与、或者数据模型有点复杂的React项目,都值得用TS。反过来,如果你只是写一个两页的展示型Demo,跑起来就行,那JS确实也能用。
但这里有一个非常重要的点:不要等项目写了一半才引入TS。如果项目已经积累了上千个JS文件,再补TS类型是一件非常痛苦的事情,因为你需要一个一个文件去补齐接口定义。新项目最好直接选React+TS模板。我们下面就说怎么搭建。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 搭建React+TypeScript项目
2.1 用Vite快速创建React+TS模板
目前创建React+TS项目,我最推荐的是Vite。相比Webpack,Vite的开发服务器启动速度快,配置更少,TS支持也是开箱即用的。你只需要执行一行命令:
bash复制npm create vite@latest my-react-ts-app -- --template react-ts
命令执行完,Vite会生成一个带TypeScript配置的React项目。用编辑器打开之后,你会看到src目录下有.tsx文件——这个后缀表示文件内既包含TypeScript代码又包含JSX语法。
有一点要注意:.ts文件里不能写JSX,.tsx文件里可以写JSX。这是新人最容易踩的第一个坑——把组件写在了.ts文件里,然后报错说找不到React或语法解析失败。
2.2 tsconfig.json配置与React 18的类型支持
Vite生成的模板里自带了一个tsconfig.json,这里有几个配置项值得大家关注:
json复制{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
}
}
其中"jsx": "react-jsx"是React 17之后推荐的模式。它允许你在组件文件里不显式import React,直接写JSX语法。如果你用的是React 18,这个配置能减少不少样板代码。
另外,随着TypeScript版本迭代,baseUrl这个配置项开始出现弃用警告。让我明确说明一下:在较新的TypeScript版本(5.x后期到7.0方向)中,官方建议尽可能使用paths替代依赖baseUrl来做路径别名。如果你在项目里看到了类似提示,不用紧张,把baseUrl删掉,只保留paths就可以了:
json复制{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}
上面这种写法在旧项目里很常见,新项目我更建议:
json复制{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
}
}
这样@/components/Button就可以指向src/components/Button,同时不会触发baseUrl的弃用警告。
3. 组件开发中的TypeScript核心实践
3.1 函数组件的Props类型设计
React现在的开发方式以函数组件为主,TS在函数组件里的核心工作就是给Props定义类型。除了最基础的interface写法,我建议把type和interface的选型思路也捋一下。两者大部分场景下可以互换,但interface更适合描述对象结构,也更容易做声明合并;type则可以配合联合类型、交叉类型做更灵活的组合。
定义一个常见的用户卡片组件:
typescript复制interface UserCardProps {
name: string;
age?: number; // 可选属性
role: 'admin' | 'user' | 'guest'; // 字面量联合类型
onAction?: (id: string) => void; // 函数类型
children?: React.ReactNode; // 嵌套内容
}
这里的React.ReactNode是一个比较宽泛的类型,表示任何React可以渲染的内容,包括字符串、数字、JSX元素、数组等等。如果你要写一个类似Layout的容器组件,children基本都是这个类型。
当你需要复用某个组件并对Props做扩展时,type的交叉类型很实用:
typescript复制type BaseButtonProps = {
size: 'small' | 'medium' | 'large';
label: string;
};
type PrimaryButtonProps = BaseButtonProps & {
variant: 'primary';
backgroundColor?: string;
};
这种写法在封装UI组件库时特别方便。先定义基础属性,再通过交叉类型派生不同风格的组件。
3.2 useState与useRef的类型推导
React 18的useState配合TS以后,类型推导会比你想的更智能。当你传入一个初始值时,TS会尝试自动推导状态类型:
typescript复制const [count, setCount] = useState(0); // count 类型为 number
const [name, setName] = useState(''); // name 类型为 string
但有一种情况必须显式声明泛型,就是初始值为null或者空数组的时候。比如下面这个场景:
typescript复制interface User {
id: number;
name: string;
}
const [user, setUser] = useState<User | null>(null);
如果不写<User | null>,TS会推断user的类型为null,后面你想setUser({ id: 1, name: '张三' })会直接报类型错误。同理,空数组也应该声明泛型:
typescript复制const [list, setList] = useState<User[]>([]);
这里还有个React 18相关的细节要说一下。React 18引入了自动批处理机制,也就是说在定时器、Promise回调、原生事件等场景下,多个setState也会被合并到同一次渲染中。这个行为的类型层面其实没有特殊影响,但如果你在代码里依赖了更新后的状态值,要注意拿到的可能不是最新值。
useRef在TS里有一个地方特别容易踩坑。初始值为null时,需要显式指定泛型:
typescript复制const inputRef = useRef<HTMLInputElement>(null);
这个类型的含义是:inputRef.current要么是HTMLInputElement,要么是null。在使用时你会看到TS要求先做空值判断:
typescript复制useEffect(() => {
inputRef.current?.focus();
}, []);
如果你非常确定这个DOM元素挂载后一定存在,也可以用非空断言inputRef.current!.focus(),但要小心:如果元素还没渲染完就调用,这句代码就会在运行时崩溃。我的建议是优先用可选链?.,不要滥用非空断言。
3.3 事件处理与表单元素的类型
React中的事件类型和原生DOM事件名很像,但实际类型名不一样,这是新人最容易搞混的地方。原生DOM事件是MouseEvent,React合成事件则是React.MouseEvent。举个例子:
typescript复制const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
console.log(event.clientX);
};
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
console.log(event.target.value);
};
这里尖括号里传入的是事件绑定的DOM元素类型。<HTMLButtonElement>表示按钮,<HTMLInputElement>表示输入框,<HTMLFormElement>表示表单。你可能会想,这些类型难道不能自动推断吗?在一些内联写法里可以:
typescript复制<button onClick={(e) => console.log(e.clientX)}>点击</button>
但当你把事件处理函数单独提取出来,或者封装成自定义Hook时,就必须显式写明类型了。不然TS会提示e隐式类型为any。
表单场景还有一个细节需要留意。受控组件里,input输入框的值类型是string,但checkbox的类型是boolean。所以如果你封装一个支持多种表单控件的通用组件,应该设计成联合类型:
typescript复制interface FormFieldProps {
type: 'input' | 'checkbox';
value: string | boolean;
onChange: (value: string | boolean) => void;
}
然后在组件内部根据type做类型守卫收窄,这样既保证了类型安全,又不会在运行时出现意外。
4. 实战:从零写一个加减法计算器
4.1 设计状态结构
聊了这么多基础,不如直接拿一个真实的例子串一遍。我们就做一个加减法计算器,支持两个数字的加减,并且能记录每次计算的历史。这个需求虽然简单,但足以覆盖Props类型、事件处理、状态管理和列表渲染这些核心场景。
先定义好计算器里会用到的类型:
typescript复制interface HistoryItem {
id: number;
expression: string;
result: number;
}
type Operator = 'add' | 'subtract';
组件内部的状态设计是这样的:
typescript复制const [num1, setNum1] = useState('');
const [num2, setNum2] = useState('');
const [operator, setOperator] = useState<Operator>('add');
const [result, setResult] = useState<number | null>(null);
const [history, setHistory] = useState<HistoryItem[]>([]);
这里把num1和num2的初始值设置为空字符串而不是数字,是因为输入框的值天然是字符串。等到真正计算时再转型。
4.2 运算逻辑与类型守卫
计算按钮的点击事件类型是React.MouseEvent<HTMLButtonElement>:
typescript复制const handleCalculate = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
const a = parseFloat(num1);
const b = parseFloat(num2);
if (isNaN(a) || isNaN(b)) {
alert('请输入有效的数字');
return;
}
const total = operator === 'add' ? a + b : a - b;
setResult(total);
const newItem: HistoryItem = {
id: Date.now(),
expression: `${a} ${operator === 'add' ? '+' : '-'} ${b} = ${total}`,
result: total,
};
setHistory([...history, newItem]);
};
这里有几个值得注意的点。parseFloat的结果是number,但可能是NaN,所以要用isNaN做运行时校验。虽然TS不能替我们判断用户的输入是否合法,但它保证了我们对operator的判断只能是'add'或'subtract'两种,写错了或者新增了一种没有处理的类型,编译器就会提醒你。
这里setHistory([...history, newItem])用到了数组展开。在React 18的自动批处理机制下,连续多次点击会把状态更新合并,但因为你每次点击都会从当前的history派生新数组,所以结果一般是正确的。不过更严谨的写法是用函数式更新:
typescript复制setHistory((prev) => [...prev, newItem]);
使用函数式更新的好处是:状态更新始终基于最新值,避免某些异步场景下读到过期状态。这也是我在项目里对setState的一个小纪律——只要新状态依赖旧状态,就优先使用函数式写法。
4.3 运算符切换与按钮的类型
运算符切换按钮组,这里用到了map渲染两个按钮:
typescript复制const operators: { value: Operator; label: string }[] = [
{ value: 'add', label: '+' },
{ value: 'subtract', label: '-' },
];
<div>
{operators.map((op) => (
<button
key={op.value}
type="button"
onClick={() => setOperator(op.value)}
style={{
fontWeight: operator === op.value ? 'bold' : 'normal',
}}
>
{op.label}
</button>
))}
</div>
op.value的类型在这个场景下是Operator,不会被TS推断成string。这让我在setOperator(op.value)时不需要做额外断言。如果你在别的地方看到类似下面这种报错:
Type 'string' is not assignable to type 'Operator'.
大概率是因为你在某个接口里把value定义成了string,而不是字面量联合类型。解决办法就是给value加上更精确的类型,而不是用as Operator强行断言。
5. 高频报错与排查技巧
5.1 常见TypeScript报错速查表
我在实际项目中积累了一些出现频率很高的TS报错,这里整理成一张速查表,方便大家遇到问题直接对照。
| 报错信息 | 常见原因 | 解决方法 |
|---|---|---|
Property 'xxx' does not exist on type 'yyy' |
访问了接口中未定义的属性 | 在接口中补全该属性,或使用可选属性xxx? |
Type 'undefined' is not assignable to type 'string' |
可选属性可能为undefined |
加空值判断,或给属性设置默认值 |
Argument of type 'string' is not assignable to parameter of type 'number' |
给数字类型的参数传了字符串 | 提前用Number()或parseFloat()转换 |
Element implicitly has an 'any' type |
数组或对象没有定义索引类型 | 给数组加泛型,如const list: User[] = [] |
Cannot find module 'xxx' or its corresponding type declarations |
第三方库缺少类型声明 | 安装@types/xxx,或用declare module 'xxx' |
Type 'MouseEvent' is not generic |
用了原生MouseEvent但没有指定元素类型 |
改为React.MouseEvent<HTMLButtonElement> |
这张表里的报错几乎每个React+TS项目都会碰上,至少遇到其中两三个。我特意把类型声明相关的报错也放进去了,因为现在很多npm包自带类型,但有一些老包没有,这时候你需要去安装@types/对应的包,或者自己写一个declaration.d.ts文件:
typescript复制declare module 'some-untyped-library';
5.2 几个容易忽略的坑
第一个坑是泛型箭头函数在.tsx文件里的写法。如果你在.tsx文件里写:
typescript复制const getValue = <T,>(key: string): T => {
// ...
};
注意泛型参数后面的逗号<T,>。这是因为JSX语法解析器会把<T>当成标签开头,加一个逗号就能明确区分这是泛型而不是JSX。这个细节特别容易让新人在第一次写泛型工具函数时卡住。
第二个坑是React 18中React.FC的使用争议。以前很多教程推荐用React.FC<T>来标注函数组件,但这个类型自带一个隐含的children属性,并且对泛型组件的支持不太友好。现在社区越来越倾向于不声明React.FC,而是直接给props标注类型。我觉得这个趋势是合理的,因为直接写function UserCard(props: UserCardProps)更直观,也没那么多隐含行为。
第三个坑和React Native有关。如果你用的是React Native而不是Web端React,有些DOM类型是不存在的。比如HTMLInputElement在React Native里就不适用,你要用TextInput类型的引用:
typescript复制const inputRef = useRef<TextInput>(null);
很多从Web转React Native的同学最容易在这里报一堆类型错误,因为RN环境根本没有DOM的全局类型。
6. 关于React与Taro的额外提醒
如果你的项目用到Taro(基于React语法的小程序多端框架),上面的很多类型经验依然适用,但有几个特殊注意点。Taro中事件对象的类型通常继承自小程序的事件类型,比如ITouchEvent,而不是Web端的React.MouseEvent。这意味着你写点击事件的参数类型时要留意:
typescript复制const handleTap = (event: ITouchEvent) => {
console.log(event.detail);
};
另外,Taro的组件属性类型很多也需要从@tarojs/components导入,而不是React的默认HTML元素类型。比如你要给View组件加点击事件,ViewProps里可能没有Web端所有的原生事件。我的经验是:做跨端项目时,少依赖具体的DOM类型,多用event.currentTarget.dataset这类端上通用的取值方式。
如果你是从React Native转到Taro,还要特别注意:RN的很多组件类型和Taro不通用,TextInput在Taro里需要映射成Input组件。类型层面的问题大多是因为两端组件体系不一致引起的,建议一开始就为业务侧的数据模型建立独立的TS接口,不要让组件的Props直接依赖端上的组件类型。
7. 实操总结与个人体会
我用React+TypeScript的时间大概有两年多,从最开始在项目里少量引入类型,到后来全面用TS重写业务模块,最直观的感受是:代码的可读性提高了,重构的胆子变大了。以前改一个组件Props的字段,要手动检查所有调用处,现在只要把接口一改,所有报错点都自动标红了。这种体验真的会改变写代码的节奏。
写这篇的时候我特意把React 18的批处理机制提了一下,因为很多人在新版本里遇到了状态更新不如预期的问题。批处理本意是减少渲染次数、提升性能,但它也会让多个setState在同一事件循环里合并。类型层面这不会报错,反而是业务逻辑层面更需要关注。
如果你刚开始接触React+TS,我建议从今天讲的小例子开始:试着给一个已有的React组件加上Props类型,再写一个自己常用的自定义Hook,把它的入参和返回值类型定义清楚。坚持几周之后再看原来的JavaScript项目,你可能会有一种“裸奔”的感觉。
最后分享一个小技巧:平时多注意看TS的报错信息,不要看到红波浪线就烦。大部分报错信息其实已经把解决方案写在里面了,比如“Property 'xxx' does not exist on type 'yyy'”,它告诉你是哪个属性不存在、在哪个类型上不存在。跟着信息一步步修,慢慢就会形成自己的排查套路。祝大家在React+TS的路上少踩坑,多享受类型带来的安全感。
