📚 TypeScript 教程系列
- 入门与配置
- 基础类型与变量声明
- 函数
- 流程控制与运算符
- 集合类型
- 异步编程与错误处理
- 接口与类
- 泛型与类型组合
- 高级类型
- 模块、装饰器与工程化
- TypeScript 与 Bun 实战
- TypeScript 与 React 实战(本文)
前面的章节我们系统掌握了 TypeScript 的类型系统,并在 Bun 服务端场景里落地。本篇把 TypeScript 带入 React 前端工程:从项目初始化、组件与 Props 的类型设计,到 Hooks、Context、事件处理、泛型组件、状态管理库的类型对接,最后落地一个类型完备的可运行示例。目标只有一个——让类型系统在 UI 工程中真正兑现价值:少写文档、少踩 undefined、重构有底气。
为什么在 React 中使用 TypeScript
React 本身是"动态类型友好"的——props 是普通对象,state 可以是任意值。但正因为灵活,组件之间的契约很容易被破坏。在 React 项目里认真对待 TypeScript,核心收益有三点:
- 组件契约显式化:Props 的形状、哪些必填哪些可选、回调的参数类型,全部写进类型定义。调用方在 JSX 里写错一个 prop 名立刻红线,而不是等到运行时白屏。
- Hooks 类型安全:
useState 的初值类型决定后续 setter 的参数类型,useReducer 的 action 联合类型让 dispatch 调用点享受穷举校验,useRef 区分"可变 ref"与"DOM ref"。 - 重构有底气:改一个 props 字段名或一个 Context 值结构,编译器会列出所有受影响的组件文件,比"全局搜索 + 手动核对"可靠得多。
💡 成本提示:React 19 的类型由 @types/react 提供,Vite 的 react-ts 模板会自动安装,开箱即用。你不需要额外的转译层,tsc --noEmit 配合 IDE 就能拿到完整的类型检查与跳转。
项目初始化
用 Vite 创建项目
1 2 3 4 5 6
| bun create vite my-app --template react-ts
cd my-app bun install
|
💡 用 Bun 跑 Vite:安装完成后用 bun dev 启动开发服务器、bun run build 构建产物。Vite 模板生成的 package.json scripts 在 Bun 下直接可用,无需改动。类型检查用 bunx tsc -b(project references 需构建模式,模板的 build 脚本同样依赖 tsc -b)。
生成的关键文件结构:
1 2 3 4 5 6 7 8 9
| my-app/ ├── src/ │ ├── App.tsx # 根组件 │ ├── main.tsx # 入口 │ └── vite-env.d.ts # Vite 环境类型声明 ├── tsconfig.json # 根配置:用 references 指向 app/node,自身不含编译选项 ├── tsconfig.app.json # 应用代码(src)的 TS 配置 ├── tsconfig.node.json # 给 vite.config.ts 等用的 TS 配置 └── vite.config.ts # Vite 配置
|
tsconfig 关键配置
Vite 官方 react-ts 模板用 project references 把配置拆成三份:tsconfig.json 仅用 references 指向 tsconfig.app.json 与 tsconfig.node.json,自身不含编译选项(files: []);应用代码的配置集中在 tsconfig.app.json,关键几项如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| { "compilerOptions": { "target": "ES2022", "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", "moduleResolution": "Bundler", "jsx": "react-jsx", "verbatimModuleSyntax": true, "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "noUncheckedIndexedAccess": true, "baseUrl": ".", "paths": { "@/*": ["src/*"] } }, "include": ["src"] }
|
tsconfig.node.json 则把 vite.config.ts 等构建期脚本单独编译(include: ["vite.config.ts"],module/target 走 Node 侧设置)。
💡 jsx: "react-jsx":React 17+ 的新版 JSX 转换由编译器自动注入 react/jsx-runtime,组件文件顶部不再需要 import React from 'react'。但使用 React.FC、React.ReactNode 等类型时仍需按需导入类型。
组件与 Props 类型
函数组件的两种写法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| import { type ReactNode } from 'react';
function Button({ label, onClick }: { label: string; onClick: () => void }) { return <button onClick={onClick}>{label}</button>; }
interface CardProps { title: string; children: ReactNode; }
function Card({ title, children }: CardProps) { return ( <section> <h2>{title}</h2> {children} </section> ); }
|
⚠️ 关于 React.FC:早期教程常用 const Button: React.FC<Props> = (props) => ...。它的好处是显式约束返回值为 ReactNode,但不再隐式添加 children(@types/react 18 起已移除该行为,children 需显式声明),且对泛型组件支持不友好。现代 React 项目更推荐直接标注参数类型,React.FC 只在需要约束返回值时使用。
可选、默认值与联合类型
1 2 3 4 5 6 7 8 9 10 11
| interface AvatarProps { src: string; size?: number; shape?: 'circle' | 'square'; alt?: string; }
function Avatar({ src, size = 48, shape = 'circle', alt = '' }: AvatarProps) { const style = { width: size, height: size, borderRadius: shape === 'circle' ? '50%' : 0 }; return <img src={src} alt={alt} style={style} />; }
|
size? 表示可选;解构里给的 = 48 是运行时默认值,它会让函数体内 size 的类型收窄为 number(不再是 number | undefined)。shape 用字面量联合 'circle' | 'square',调用方传错值(如 'round')会在编译期报错。
事件处理类型
React 的事件类型都来自 React 命名空间,常用的有 React.ChangeEvent、React.MouseEvent、React.KeyboardEvent 等,泛型参数指向触发事件的元素类型。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| import { useState, type ChangeEvent, type FormEvent } from 'react';
function SearchForm() { const [keyword, setKeyword] = useState('');
const handleChange = (e: ChangeEvent<HTMLInputElement>) => { setKeyword(e.target.value); };
const handleSubmit = (e: FormEvent<HTMLFormElement>) => { e.preventDefault(); console.log('搜索:', keyword); };
return ( <form onSubmit={handleSubmit}> <input value={keyword} onChange={handleChange} placeholder="输入关键词" /> <button type="submit">搜索</button> </form> ); }
|
💡 小技巧:懒得查事件类型时,可以先写 onChange={(e) => ...},把光标悬停在 e 上让 IDE 推断出类型,再决定要不要显式标注。但提取成独立函数时,显式标注事件类型能让调用点更清晰。
children 与 render props
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| import { type ReactNode } from 'react';
interface LayoutProps { header?: ReactNode; children: ReactNode; }
function Layout({ header, children }: LayoutProps) { return ( <div> {header && <header>{header}</header>} <main>{children}</main> </div> ); }
interface ListProps<T> { items: T[]; renderItem: (item: T, index: number) => ReactNode; }
function List<T>({ items, renderItem }: ListProps<T>) { return <ul>{items.map((item, i) => <li key={i}>{renderItem(item, i)}</li>)}</ul>; }
|
ReactNode 与 ReactElement 的区别:
| 类型 | 含义 | 典型场景 |
|---|
ReactElement | 一个具体的 JSX 元素(有 type、props) | 需要对返回的元素做克隆/检查 |
ReactNode | ReactElement | string | number | boolean | null | ReactNode[] | 组件的 children、任意可渲染内容 |
JSX.Element | 等价于 ReactElement(全局命名空间;React 19 类型起已弃用,改用 React.JSX.Element) | 老代码常见,新代码用 ReactElement |
绝大多数情况用 ReactNode 即可,它最宽松也最符合"能渲染就行"的语义。
Hooks 类型
useState
useState 是泛型函数,初值的类型决定 state 的类型;当初值可能为 undefined 或需要更精确的联合类型时,显式传入泛型参数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| import { useState } from 'react';
function Counter() { const [count, setCount] = useState(0);
const [user, setUser] = useState<{ name: string; age: number } | null>(null);
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
return ( <div> <button onClick={() => setCount((c) => c + 1)}>{count}</button> {user && <p>{user.name}</p>} </div> ); }
|
⚠️ useState(null) 的坑:不写泛型时类型会被推断为 null,后续 setUser({ name: 'Tom' }) 直接报错。只要初值是 null 或空数组等"占位值",就应显式传入泛型参数。
useReducer:用联合类型约束 Action
useReducer 配合 判别联合(discriminated union) 是 React 里类型安全度最高的状态方案,dispatch 调用点享受穷举校验,switch 分支里能自动收窄。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| import { useReducer } from 'react';
interface State { count: number; loading: boolean; error: string | null; }
type Action = | { type: 'increment' } | { type: 'decrement' } | { type: 'set'; payload: number } | { type: 'loading' } | { type: 'success'; payload: number } | { type: 'error'; payload: string };
const initialState: State = { count: 0, loading: false, error: null };
function reducer(state: State, action: Action): State { switch (action.type) { case 'increment': return { ...state, count: state.count + 1 }; case 'decrement': return { ...state, count: state.count - 1 }; case 'set': return { ...state, count: action.payload }; case 'loading': return { ...state, loading: true, error: null }; case 'success': return { ...state, loading: false, count: action.payload }; case 'error': return { ...state, loading: false, error: action.payload }; default: const _exhaustive: never = action; return _exhaustive; } }
function useCounter() { const [state, dispatch] = useReducer(reducer, initialState); return { state, dispatch }; }
|
default 分支里的 const _exhaustive: never = action 是经典的穷举检查技巧:当你将来给 Action 新增一个分支却忘了在 switch 里处理,这一行就会编译报错,把遗漏挡在编译期。
useRef
useRef 有两种用法,对应两种类型语义,初学者常混淆:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| import { useRef, useEffect } from 'react';
function Demo() { const inputRef = useRef<HTMLInputElement>(null); useEffect(() => { inputRef.current?.focus(); }, []);
const timerRef = useRef<number | null>(null); const countRef = useRef(0);
useEffect(() => { timerRef.current = window.setInterval(() => { countRef.current += 1; }, 1000); return () => { if (timerRef.current) window.clearInterval(timerRef.current); }; }, []);
return <input ref={inputRef} />; }
|
| 用法 | 初值 | 泛型 | current 类型 |
|---|
| DOM ref | null | HTMLInputElement 等 | T | null |
| 可变值 ref | 默认值或 null | 值类型(或含 null) | T(或 T | null) |
useMemo/useCallback
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| import { useMemo, useCallback, useState } from 'react';
function PriceList({ items }: { items: { name: string; price: number }[] }) { const [taxRate, setTaxRate] = useState(0.1);
const total = useMemo(() => items.reduce((s, i) => s + i.price, 0), [items]);
const format = useCallback( (price: number) => `¥${(price * (1 + taxRate)).toFixed(2)}`, [taxRate], );
return ( <ul> {items.map((i) => ( <li key={i.name}> {i.name}: {format(i.price)} </li> ))} <li>合计:{format(total)}</li> </ul> ); }
|
Context 的类型安全
Context 是 React 里最容易"类型逃逸"的地方——默认值给 null 后,所有消费者都得做非空判断。推荐用 undefined 哨兵 + 自定义 hook 断言的模式,把"忘记包 Provider"的运行时错误转成编译期错误。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| import { createContext, useContext, useState, type ReactNode } from 'react';
interface ThemeContextValue { theme: 'light' | 'dark'; toggle: () => void; }
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
function ThemeProvider({ children }: { children: ReactNode }) { const [theme, setTheme] = useState<'light' | 'dark'>('light'); const value: ThemeContextValue = { theme, toggle: () => setTheme((t) => (t === 'light' ? 'dark' : 'light')), }; return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>; }
function useTheme(): ThemeContextValue { const ctx = useContext(ThemeContext); if (!ctx) { throw new Error('useTheme 必须在 <ThemeProvider> 内部使用'); } return ctx; }
function ThemeButton() { const { theme, toggle } = useTheme(); return <button onClick={toggle}>当前主题:{theme}</button>; }
|
这种模式的三个关键点:
createContext 的泛型写 T | undefined,默认值给 undefined,逼迫消费者走自定义 hook。- 自定义 hook 内做非空断言并抛错,返回值类型是
T(非空)。 - 调用方解构出来的字段类型准确,IDE 自动补全与重构跳转都正常工作。
💡 React 19 的 use:除了 useContext,React 19 还提供 use(Context),可以在条件分支与循环中读取 Context(useContext 必须在顶层调用)。类型行为与 useContext 一致,上面的“undefined 哨兵 + hook 断言”模式同样适用。
泛型组件
当组件需要"保持输入与输出类型一致"时(如 Table、Select、List),就需要泛型组件。函数声明形式天然支持泛型,箭头函数需要显式写泛型参数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
| import { useState, type ReactNode } from 'react';
interface SelectProps<T> { options: T[]; value: T | undefined; onChange: (value: T) => void; getKey: (item: T) => string | number; getLabel: (item: T) => ReactNode; }
function Select<T>({ options, value, onChange, getKey, getLabel }: SelectProps<T>) { return ( <select value={value === undefined ? '' : String(getKey(value))} onChange={(e) => { const matched = options.find((o) => String(getKey(o)) === e.target.value); if (matched) onChange(matched); // 类型为 T,与 options 一致 }} > {options.map((o) => ( <option key={getKey(o)} value={String(getKey(o))}> {getLabel(o)} </option> ))} </select> ); }
interface User { id: number; name: string; } function UserPicker({ users }: { users: User[] }) { const [selected, setSelected] = useState<User | undefined>(undefined); return ( <Select options={users} value={selected} onChange={setSelected} getKey={(u) => u.id} getLabel={(u) => u.name} /> ); }
|
⚠️ 箭头函数泛型组件:const Select = <T,>(props: SelectProps<T>) => ... 里的 <T,> 必须加逗号,否则 .tsx 里会被解析成 JSX 标签。这是 React + TS 的经典坑。
ref 与命令式句柄
React 19 起,ref 可以作为普通 prop 直接传给函数组件,forwardRef 已不再需要(仍可用,仅用于维护 React 18 及更早的遗留代码)。子组件在 props 中声明 ref,配合 useImperativeHandle 暴露命令式方法即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| import { useRef, useImperativeHandle, type Ref } from 'react';
interface InputHandle { focus: () => void; clear: () => void; }
interface FancyInputProps { defaultValue?: string; ref?: Ref<InputHandle>; }
function FancyInput({ defaultValue = '', ref }: FancyInputProps) { const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => ({ focus: () => inputRef.current?.focus(), clear: () => { if (inputRef.current) inputRef.current.value = ''; }, }));
return <input ref={inputRef} defaultValue={defaultValue} />; }
function App() { const handleRef = useRef<InputHandle>(null); return ( <> <FancyInput ref={handleRef} /> <button onClick={() => handleRef.current?.focus()}>聚焦</button> <button onClick={() => handleRef.current?.clear()}>清空</button> </> ); }
|
💡 forwardRef 的去向:React 19 之前,要让函数组件接收 ref 必须用 forwardRef 包裹,并写成 forwardRef<THandle, TProps>((props, ref) => ...)。React 19 把 ref 提升为普通 prop 后,这套写法成为遗留——新代码直接在 props 里声明 ref?: Ref<T>,更直观。若只是把原生 DOM ref 透传给内部元素,连 useImperativeHandle 都不需要,直接把 ref 传下去即可。
与状态管理库对接
Zustand
Zustand 的 store 用 create 定义,类型由你的 state 推断;用 create<T>()(...) 的"双调用"写法能在闭包内拿到正确的 set/get 类型。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| import { create } from 'zustand';
interface CounterState { count: number; increment: () => void; reset: () => void; }
const useCounterStore = create<CounterState>()((set) => ({ count: 0, increment: () => set((s) => ({ count: s.count + 1 })), reset: () => set({ count: 0 }), }));
function Counter() { const count = useCounterStore((s) => s.count); const increment = useCounterStore((s) => s.increment); return <button onClick={increment}>{count}</button>; }
|
TanStack Query
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| import { useQuery, useMutation, queryOptions } from '@tanstack/react-query';
interface Todo { id: number; title: string; done: boolean; }
const todoOptions = queryOptions({ queryKey: ['todos'], queryFn: async (): Promise<Todo[]> => { const res = await fetch('/api/todos'); return res.json(); }, });
function TodoList() { const { data, isLoading } = useQuery(todoOptions);
const toggle = useMutation({ mutationFn: async (id: number) => { const res = await fetch(`/api/todos/${id}/toggle`, { method: 'POST' }); return res.json() as Promise<Todo>; }, });
if (isLoading) return <p>加载中…</p>; return ( <ul> {data?.map((t) => ( <li key={t.id} onClick={() => toggle.mutate(t.id)}> {t.done ? '✅' : '⬜'} {t.title} </li> ))} </ul> ); }
|
💡 queryFn 显式标注返回类型:让 useQuery 的 data 类型准确(Todo[] | undefined)。如果不标注,res.json() 会被推断成 any,整个链路的类型安全就破了。
综合实战:可搜索的用户列表
把前面所有要点串起来——Props 类型、事件、useReducer、Context、泛型组件——落地一个类型完备的小功能。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130
| import { createContext, useContext, useReducer, useState, type ChangeEvent, type Dispatch, type ReactNode, } from 'react';
interface User { id: number; name: string; role: 'admin' | 'editor' | 'viewer'; }
interface UserState { users: User[]; selectedId: number | null; }
type UserAction = | { type: 'select'; id: number } | { type: 'clear' } | { type: 'setUsers'; users: User[] };
const initialUserState: UserState = { users: [], selectedId: null };
function userReducer(state: UserState, action: UserAction): UserState { switch (action.type) { case 'select': return { ...state, selectedId: action.id }; case 'clear': return { ...state, selectedId: null }; case 'setUsers': return { ...state, users: action.users }; default: { const _exhaustive: never = action; return _exhaustive; } } }
interface UserContextValue { state: UserState; dispatch: Dispatch<UserAction>; selectedUser: User | null; }
const UserContext = createContext<UserContextValue | undefined>(undefined);
function UserProvider({ children }: { children: ReactNode }) { const [state, dispatch] = useReducer(userReducer, initialUserState); const selectedUser = state.users.find((u) => u.id === state.selectedId) ?? null; return ( <UserContext.Provider value={{ state, dispatch, selectedUser }}> {children} </UserContext.Provider> ); }
function useUsers(): UserContextValue { const ctx = useContext(UserContext); if (!ctx) throw new Error('useUsers 必须在 <UserProvider> 内使用'); return ctx; }
interface SearchInputProps<T> { items: T[]; onFilter: (keyword: string) => void; placeholder?: string; }
function SearchInput<T>({ onFilter, placeholder }: SearchInputProps<T>) { const handleChange = (e: ChangeEvent<HTMLInputElement>) => onFilter(e.target.value); return <input onChange={handleChange} placeholder={placeholder} />; }
function UserSearchPanel() { const { state, dispatch, selectedUser } = useUsers(); const [keyword, setKeyword] = useState('');
const filtered = state.users.filter((u) => u.name.toLowerCase().includes(keyword.toLowerCase()), );
return ( <div style={{ display: 'flex', gap: 16 }}> <div> <SearchInput<User> items={state.users} onFilter={setKeyword} placeholder="搜索用户名" /> <ul> {filtered.map((u) => ( <li key={u.id} onClick={() => dispatch({ type: 'select', id: u.id })}> {u.name}({u.role}) </li> ))} </ul> </div> <div> {selectedUser ? ( <> <h3>{selectedUser.name}</h3> <p>角色:{selectedUser.role}</p> <button onClick={() => dispatch({ type: 'clear' })}>取消选择</button> </> ) : ( <p>请选择左侧用户</p> )} </div> </div> ); }
function App() { return ( <UserProvider> <UserSearchPanel /> </UserProvider> ); }
|
⚠️ 上例为了把所有知识点塞进一个文件做了简化。真实项目里建议按职责拆分文件,UserProvider 挂在应用根部(如本例的 App),让 useUsers 在整棵子树可用。
常见坑与最佳实践
| 场景 | 错误做法 | 正确做法 |
|---|
useState 初值为 null | useState(null),后续 setter 报错 | useState<T | null>(null) 显式标注泛型 |
| Context 默认值 | createContext<T>({} as T) 强转绕过 | createContext<T | undefined>(undefined) + hook 断言 |
| 泛型箭头函数组件 | const C = <T>(...) => 在 tsx 报错 | const C = <T,>(...) => 加逗号 |
| 事件处理 | (e: any) => ... | (e: ChangeEvent<HTMLInputElement>) => ... |
queryFn 返回值 | 不标注,data 推断成 any | queryFn: async (): Promise<T[]> => ... |
useReducer 漏 case | 只写几个分支,新增 action 静默遗漏 | default 分支用 never 做穷举检查 |
ref 作为命令式句柄 | React 19 仍用 forwardRef 包裹 | 在 props 声明 ref?: Ref<T>,配合 useImperativeHandle |
通用原则
- Props 用
interface,状态联合用 type:interface 便于声明合并与扩展,type 更适合联合与映射类型。 - 能用推断就别手写:
useState(0) 已经能推断出 number,再写 useState<number>(0) 是噪声。只有推断不准(null、空数组、联合类型)时才显式标注。 - 把运行时错误前置到编译期:Context 的"忘记包 Provider"、reducer 的"漏写 case"、props 的"字段拼错"——这三类用类型都能挡住,是 TS 在 React 里最大的价值。
any 是债:尤其在 queryFn、JSON.parse、第三方回调里,一个 any 会让类型链路断裂。用 unknown + 类型守卫或显式标注替代。
小结
本篇把 TypeScript 的类型能力映射到 React 工程的每个角落:组件 Props 与事件、Hooks(含 useReducer 的判别联合与穷举检查)、Context 的 undefined 哨兵模式、泛型组件、ref 作为普通 prop 与命令式句柄,以及与 Zustand / TanStack Query 的对接。核心心法只有一条——让编译器替你守住组件之间的契约,把“运行时白屏”尽可能压到“编辑器红线”。
掌握这些之后,你已经具备在一个真实 React 项目里写出类型完备、可重构、可维护代码的能力。TypeScript 教程系列到此完结,希望你把类型系统当成日常工程的底层基础设施,而不是额外的负担。