第 10 章 · TypeScript + React 18 实战
本章目标:掌握 React.FC vs 直接函数、props interface 写法;为 useState 泛型、事件类型 建立习惯;配置 React Router 与 自定义 Hook 类型;按步骤将 academy-demo-react 从 JS 迁移到 TS。
学时建议:4~5 小时(含 2 小时迁移跟练)
前置:本模块 ch04~ch05、ch08;academy-demo-react 已能 npm run build。
10.1 项目 TS 配置
cd academy-demo-react
npm install -D typescript @types/react @types/react-dom
tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"baseUrl": ".",
"paths": { "@/*": ["src/*"] }
},
"include": ["src"]
}
package.json:
{
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"typecheck": "tsc --noEmit"
}
}
src/vite-env.d.ts:
/// <reference types="vite/client" />
vite.config.ts 与 ch09 相同结构,插件换 @vitejs/plugin-react。
行业案例 · 贤紫优选商城:数据运营大屏 React 子应用从 CRA 迁至 Vite + TS;共享@shared/types包与 Vue 后台同源 OpenAPI。Hooks 单测覆盖useDebounce、useLocalStorage,类型回归在 PR 阶段拦截 props 变更。
10.2 React.FC vs 直接函数组件
10.2.1 现代推荐:直接函数 + Props 类型
import type { Course } from '@/types/course'
type CourseCardProps = {
course: Course
compact?: boolean
onSelect?: (id: string) => void
onFavorite?: (id: string, value: boolean) => void
}
export function CourseCard({
course,
compact = false,
onSelect,
onFavorite
}: CourseCardProps) {
return (
<article
className={compact ? 'course-card compact' : 'course-card'}
onClick={() => onSelect?.(course.id)}
>
<h3>{course.title}</h3>
<button
type="button"
onClick={e => {
e.stopPropagation()
onFavorite?.(course.id, true)
}}
>
收藏
</button>
</article>
)
}
10.2.2 React.FC(了解即可)
import type { FC } from 'react'
export const CourseCard: FC<CourseCardProps> = ({ course, compact = false }) => {
return <article className="course-card">{course.title}</article>
}
| 对比 | 直接函数 | React.FC |
|---|---|---|
children | 显式写在 Props | 历史版本隐式注入 |
| 泛型组件 | 自然支持 | 较别扭 |
| 社区趋势 | React 19 文档推荐 | 逐渐减少 |
| 本模块 | 默认采用 | 仅老代码保留 |
结论:用 function Component(props: Props) 或箭头函数 + 类型注解;不必强行 FC。
10.3 Props:interface vs type
// types/course.ts — 与 ch08 共享
export interface Course {
id: string
title: string
desc: string
hours: number
tags: readonly string[]
level: 'beginner' | 'intermediate' | 'advanced'
cover?: string
}
// 扩展 Props
export interface CourseListProps {
courses: Course[]
keyword: string
onKeywordChange: (value: string) => void
favorites: ReadonlySet<string>
onToggleFavorite: (id: string) => void
}
组件文件:
import type { CourseListProps } from '@/types/course'
export function CourseList({
courses,
keyword,
onKeywordChange,
favorites,
onToggleFavorite
}: CourseListProps) {
return (
<>
<input
value={keyword}
onChange={e => onKeywordChange(e.target.value)}
placeholder="搜索课程"
aria-label="搜索课程"
/>
{courses.map(c => (
<CourseCard
key={c.id}
course={c}
onFavorite={id => onToggleFavorite(id)}
/>
))}
</>
)
}
ReadonlySet / readonly 数组表达「父组件传入不可变」契约。
10.4 useState 泛型与事件类型
10.4.1 useState
const [keyword, setKeyword] = useState<string>('')
const [courses, setCourses] = useState<Course[]>([])
const [favorites, setFavorites] = useState<Set<string>>(() => new Set())
const [status, setStatus] = useState<'idle' | 'loading' | 'error'>('idle')
// 可空
const [detail, setDetail] = useState<CourseDetail | null>(null)
初始值为 null 时必须 useState<CourseDetail | null>(null),否则推断为 null 无法赋值。
10.4.2 事件类型
function SearchBar({ onChange }: { onChange: (v: string) => void }) {
const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value)
}
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
}
const handleKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') e.currentTarget.blur()
}
return (
<form onSubmit={handleSubmit}>
<input onChange={handleInput} onKeyDown={handleKey} />
</form>
)
}
| 事件 | 类型 |
|---|---|
| input change | ChangeEvent<HTMLInputElement> |
| button click | MouseEvent<HTMLButtonElement> |
| form submit | FormEvent<HTMLFormElement> |
| ul scroll | UIEvent<HTMLUListElement> |
技巧:用 e.currentTarget 而非 e.target,类型更精确。
10.4.3 useRef
const inputRef = useRef<HTMLInputElement>(null)
function focus() {
inputRef.current?.focus()
}
// 保存可变值、不触发重渲染
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
10.5 自定义 Hook 类型
// hooks/useDebounce.ts
import { useEffect, useState } from 'react'
export function useDebounce<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delayMs)
return () => clearTimeout(id)
}, [value, delayMs])
return debounced
}
// hooks/useLocalStorage.ts
import { useCallback, useState } from 'react'
export function useLocalStorage<T>(
key: string,
initial: T
): [T, (value: T | ((prev: T) => T)) => void] {
const [stored, setStored] = useState<T>(() => {
const raw = localStorage.getItem(key)
if (raw === null) return initial
return JSON.parse(raw) as T
})
const setValue = useCallback(
(value: T | ((prev: T) => T)) => {
setStored(prev => {
const next = typeof value === 'function'
? (value as (prev: T) => T)(prev)
: value
localStorage.setItem(key, JSON.stringify(next))
return next
})
},
[key]
)
return [stored, setValue]
}
// hooks/useCourses.ts
import { useEffect, useState } from 'react'
import type { Course } from '@/types/course'
import { fetchJson } from '@/utils'
export interface UseCoursesResult {
courses: Course[]