React 搭配 TypeScript,就是在 .tsx 文件中编写组件,并给 props、state、ref 和事件处理函数标注类型,这样编译器能在代码运行之前发现缺失的 prop 或写错的事件处理函数。类型在构建时会被去掉:在浏览器中运行的,和你不写类型时写出的 JavaScript 完全一样。本页的在线编辑器运行的就是这种 JavaScript,每个编辑器旁边的静态代码块中都附有带类型的版本。
下面是带类型的同一个 Badge。count 是可选的(?),所以第二个徽章会使用默认值 0,children 既能接受元素,也能接受普通字符串。
type BadgeProps = {
label: string;
count?: number;
children: React.ReactNode;
};
function Badge({ label, count = 0, children }: BadgeProps) {
return (
<div>
<strong>{label}</strong> ({count})
<div>{children}</div>
</div>
);
}
<Badge count={3}>Hi</Badge>;
// Error: Property 'label' is missing in type '{ children: string; count: number; }'
创建 TypeScript 项目
Vite 提供了 React 加 TypeScript 的模板:
npm create vite@latest my-app -- --template react-ts
cd my-app
npm install
npm run dev
你会得到 .tsx 文件、开启了 strict 的 tsconfig.json,以及已经安装好的 React 类型定义(@types/react、@types/react-dom)。Vite 在开发服务和构建时会去掉类型,但不会检查类型;模板的 build 脚本会先运行 tsc,编辑器也会在你输入时进行检查。Next.js 等框架在你用它们的 CLI 创建项目时,也会以同样的方式配置好 TypeScript。
为 props 标注类型
把 props 描述为一个对象类型,并标注在解构后的参数上。下面几种模式覆盖了大多数组件:
interface UserCardProps {
name: string; // required
age?: number; // optional, may be undefined
role: 'admin' | 'member'; // a union of allowed strings
tags: string[];
onSelect: (id: string) => void; // a callback prop
children?: React.ReactNode; // anything React can render
}
function UserCard({ name, age, role, tags, onSelect, children }: UserCardProps) {
// ...
}
type 还是 interface。两者都能用于 props。interface 可以用 extends 扩展,重复声明时会合并;type 还能表达联合类型、交叉类型和映射类型。大多数团队选定其中一种并在所有地方使用。
children。标签之间的任何内容都用 React.ReactNode:元素、字符串、数字、数组、null。props 页面讲解了 children 最初是如何传进来的。
为什么 React.FC 可有可无。const Badge: React.FC<BadgeProps> = (...) => ... 为整个函数而不是参数标注类型。从 React 18 的类型定义开始,它不再替你添加 children,它也无法为泛型组件接收类型参数,而且它能做的,带类型的参数都能做到。旧代码中大量使用它;新代码的常见选择是使用为 props 标注类型的普通函数。
为 useState 标注类型
初始值简单时,类型推断就够了:useState(0) 是 number,useState('') 是 string。当 state 可能存放的值比初始值所暗示的更多时,就要传入类型参数:比如一开始是 null 的值,或者几个固定字符串之一。
点击“Load user”,状态会从 idle 变为 loading,再变为 done。带类型的 state:
type User = { id: number; name: string };
type Status = 'idle' | 'loading' | 'done';
const [user, setUser] = useState<User | null>(null);
const [status, setStatus] = useState<Status>('idle');
user.name; // Error: 'user' is possibly 'null'
user?.name; // OK
setStatus('ready'); // Error: Argument of type '"ready"' is not assignable to parameter of type 'SetStateAction<Status>'
没有 <User | null> 时,useState(null) 推断出的类型是 null,setUser(data) 就无法通过编译。联合类型迫使你处理值为空的情况,这正是运行示例中 user ? ... : ... 的那个判断。
用于 DOM 元素的 useRef
指向 DOM 元素的 ref 使用该元素的类型,初始值为 null,因为在第一次渲染后 React 挂上它之前,这个元素还不存在。
输入一些内容,然后点击“Log the value”:控制台会打印你输入的内容,它是直接从 DOM 元素中读取的。带类型的版本:
const inputRef = useRef<HTMLInputElement>(null);
// inputRef.current is HTMLInputElement | null
inputRef.current?.focus(); // OK
inputRef.current.focus(); // Error: 'inputRef.current' is possibly 'null'
const timerId = useRef<number | null>(null); // a mutable value, not a DOM node
使用与标签对应的元素接口:HTMLInputElement、HTMLDivElement、HTMLCanvasElement、HTMLButtonElement。在 React 19 的类型定义中,useRef 总是需要一个参数,并返回一个 current 可以赋值的 RefObject,所以同一种写法既适用于 DOM ref,也适用于定时器 id 这样的值。运行时的行为参见 useRef。
事件处理函数的类型
内联的处理函数不需要标注:在 onChange={(e) => setName(e.target.value)} 中,TypeScript 能从它所传给的 prop 推断出 e 的类型。把处理函数移到单独的函数中时才需要标注。
提交后控制台会打印地址;preventDefault 阻止了页面重新加载。带类型的版本:
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setEmail(e.target.value); // e.target is HTMLInputElement, value is string
}
function handleSubmit(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault();
}
function handleClick(e: React.MouseEvent<HTMLButtonElement>) {}
function handleKey(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter') { /* ... */ }
}
从 @types/react 19.2.10 开始,onSubmit 的类型是 React.SubmitEvent,旧的 React.FormEvent 被标记为已弃用;如果你用的是较旧版本的类型定义,就写 React.FormEvent<HTMLFormElement>。类型参数是处理函数所绑定的元素。如果不确定类型,可以在编辑器中把鼠标悬停在 onChange prop 上,或者一次性为整个处理函数标注类型:const handleChange: React.ChangeEventHandler<HTMLInputElement> = (e) => { ... }。
为 context 标注类型
把值的类型传给 createContext。如果有合理的默认值就传进去,这样每个使用者拿到的都是非 null 的值:
type Theme = 'light' | 'dark';
const ThemeContext = createContext<Theme>('light');
const theme = useContext(ThemeContext); // Theme
如果没有合适的默认值(已登录的用户、store),就从 null 开始,并把 useContext 包在一个 hook 中,当组件在 provider 之外使用时抛出错误。这样每个调用者都能拿到非 null 的类型,出错时得到清晰的错误信息,而不是在深处崩溃。
type Auth = { user: string; logout: () => void };
const AuthContext = createContext<Auth | null>(null);
export function useAuth(): Auth {
const value = useContext(AuthContext);
if (!value) throw new Error('useAuth must be used inside <AuthProvider>');
return value;
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState('Ada');
return (
<AuthContext value={{ user, logout: () => setUser('') }}>
{children}
</AuthContext>
);
}
<AuthContext value={...}> 是 React 19 的 provider 语法;<AuthContext.Provider value={...}> 的类型标注方式相同。
泛型组件
适用于任意数据项类型的列表、表格或下拉选择框就是泛型组件:一个类型参数把 items prop 和 renderItem 回调联系起来。
带类型的 List:
type ListProps<T> = {
items: T[];
renderItem: (item: T) => React.ReactNode;
getKey: (item: T) => string | number;
};
function List<T>({ items, renderItem, getKey }: ListProps<T>) {
return <ul>{items.map((item) => <li key={getKey(item)}>{renderItem(item)}</li>)}</ul>;
}
<List items={books} getKey={(b) => b.isbn} renderItem={(b) => b.title} />;
// b is inferred as the book type, so b.titel would be an error
TypeScript 会从 items 推断出 T,所以调用者永远不需要写类型参数。在 .tsx 文件中,箭头函数要写 <T,> 而不是 <T>,因为单独的 <T> 会被当成 JSX 标签。
扩展原生元素的 props
包装 button 或 input 的组件应该接受原生元素支持的所有属性。React.ComponentProps<'button'> 就是这整套属性;在它的基础上加上你自己的 props,再把其余的展开传下去。
type ButtonProps = React.ComponentProps<'button'> & {
variant?: 'primary' | 'ghost';
};
function Button({ variant = 'primary', style, ...rest }: ButtonProps) {
return (
<button
{...rest}
style={{ fontWeight: variant === 'primary' ? 600 : 400, ...style }}
/>
);
}
<Button type="submit" disabled onClick={(e) => console.log(e.currentTarget)}>
Save
</Button>;
onClick、disabled、type 和 aria-* 都有类型,无需逐个列出。在 React 19 中,ref 是普通的 prop,ComponentProps<'button'> 也包含它,所以 <Button ref={buttonRef}> 会通过展开运算到达 DOM 节点,不需要 forwardRef。想排除 ref 时用 ComponentPropsWithoutRef<'button'>,想读取你自己某个组件的 props 时用 ComponentProps<typeof UserCard>。
常见的类型错误
“'ref.current' is possibly 'null'”。第一次渲染时元素还不存在。使用 ref.current?.focus(),或者在使用前先检查 if (ref.current)。
联合类型的 state 报“Type 'string' is not assignable”。const [status, setStatus] = useState('idle') 推断出的是 string,比你想要的类型更宽,把它传给类型为 Status 的 prop 就会报错。应写成 useState<Status>('idle')。
事件标注成了错误的元素。在 select 上使用 React.ChangeEvent<HTMLInputElement>,会让 e.target 的类型不对。类型参数必须与处理函数所绑定的元素一致。
用 any 消除报错。它会让这个值涉及的所有地方都不再检查类型。优先使用 unknown 再收窄类型,或者从源头修正类型。
常见问题
如何用 TypeScript 创建 React 应用?
运行 npm create vite@latest my-app -- --template react-ts,然后运行 npm install 和 npm run dev。组件写在 .tsx 文件中,Vite 构建时会去掉类型;要检查类型需运行 tsc(模板的 build 脚本会运行它)。
React 中 children 的类型是什么?
React.ReactNode。它接受 React 能渲染的任何东西:元素、字符串、数字、数组、null 和 undefined。只有在需要恰好一个元素时才用 React.ReactElement。
useState 的值可能为 null 时怎么写类型?
显式传入联合类型:useState<User | null>(null)。不传类型参数时,TypeScript 会推断出 null 是唯一允许的值。
React 中 onChange 事件的类型是什么?
input 用 React.ChangeEvent<HTMLInputElement>(textarea 和 select 分别用 HTMLTextAreaElement 或 HTMLSelectElement)。这样 e.target.value 的类型就是 string。
应该使用 React.FC 吗?
可用可不用。直接为 props 参数标注类型(function Card({ title }: CardProps))效果相同,读起来更简单,还能配合泛型使用。React.FC 已不再隐式添加 children,所以它带来的额外好处很少。
props 应该用 type 还是 interface?
两者都可以。interface 可以扩展和合并;type 可以表达联合类型和映射类型。为代码库选定一种约定并坚持使用即可。