React 中的 TypeScript
父组件把一个 prop 从 word 改名为 currentWord。三个子组件仍然在读 word,但没有任何警告:应用能编译,页面能加载,第一个点击渲染其中某个子组件的按钮的用户看到的是空白屏幕。TypeScript 在 JavaScript 的基础上加了一层类型:每个值都有声明或推断的类型,编译器在代码运行之前就能标出不匹配的地方。
在 React 中,这正好能解决这种隐藏在组件之间数据流处的 bug。一个有类型的 prop 就是一份契约:父组件必须发送正确的形状,子组件可以信任收到的东西,你的编辑器可以在两端自动补全。
这一章讲 React 特定的部分:给状态、Props、子元素、函数 Props 和组件返回值添加类型。它假设你已经了解 TypeScript 的基础、联合类型、自定义类型和泛型;本课程在这一节开始时会有复习课程,然后用这些知识来用 TypeScript 重写 Assembly: Endgame 游戏。
设置:Vite 的 react-ts 模板
Vite 提供了 React 模板的 TypeScript 版本。在初始化时加一个标志,你就能得到一个完全配置好的项目:
npm create vite@latest my-react-app -- --template react-ts组件文件使用 .tsx 扩展名而不是 .jsx(TypeScript 加 JSX),没有 JSX 的普通模块使用 .ts 而不是 .js。关于设置的其他方面都和往常一样;Vite 会把类型去掉然后把纯 JavaScript 发送到浏览器。
这样做有个陷阱:类型错误会在你的编辑器里出现,npm run build 也会失败(它在打包前运行 tsc -b),但开发服务器在检查类型之前就把注解去掉了。npm run dev 会在充满类型错误的情况下正常运行,所以一个能运行的应用并不能证明类型检查通过了。
给状态添加类型
useState 通常能自动推断类型。这个 hook 从你传的初始值推断类型,所以用真实初始值创建的状态根本不需要任何注解:
const [currentWord, setCurrentWord] = useState(getRandomWord())如果 getRandomWord 返回字符串,那么 currentWord 就是 string,setCurrentWord 也只接受字符串。调用 setCurrentWord(true) 的话 TypeScript 会立刻标出来:布尔值不能赋给期望字符串的地方。这就是类型推断在起作用,大多数状态你可以放着不管。
当初始值是空的或太宽泛而不足以描述状态将来的形态时,推断就失效了。空数组没有说明它会装什么,所以 useState([]) 推断出来的是 never[],一个永远装不了任何东西的数组,这样每次 push 都会出错。这时候你就需要显式形式:useState 是个泛型函数,你用尖括号传入类型参数。
const [guessedLetters, setGuessedLetters] = useState<string[]>([])现在即使初始值是空数组,状态也被认为是字符串数组,值和 setter 都会强制这一点。同样的方法也处理可为 null 的状态,值一开始是 null 然后才变成真实的东西:
type Word = { text: string; difficulty: number }
const [selectedWord, setSelectedWord] = useState<Word | null>(null)联合类型 Word | null 告诉 TypeScript 这个状态能合法地装这两种形态。现在每次读 selectedWord 都必须先处理 null 情况才能碰 .text,这把经典的运行时崩溃变成了编译时的提示。状态本身的运作方式没有变;TypeScript 只是锁定了状态能装什么。
给组件 Props 添加类型
Props 作为一个对象到达,所以给它们添加类型就是给这个对象添加注解。对于只有一两个 prop 的组件,行内注解就能用:
function ConfettiContainer({ isGameWon }: { isGameWon: boolean }) {
// ...
}随着 prop 增多,行内类型会变得混乱。标准模式是一个有名字的**类型别名**,按惯例叫 ComponentNameProps,声明在组件上面:
type GameStatusProps = {
isGameWon: boolean
wrongGuessCount: number
message?: string
}
function GameStatus({ isGameWon, wrongGuessCount, message }: GameStatusProps) {
// ...
}message 上的 ? 标记它是可选的:父组件可以省略它,在组件内部它的类型是 string | undefined。用 interface GameStatusProps { ... } 也能在完全一样的位置工作;对于给 props 添加类型,两者是可互换的,本课程为了和其他地方的自定义类型保持一致而坚持用类型别名。
如果组件接受 children,把它的类型标为 ReactNode,这是个宽泛的类型,覆盖 React 能渲染的一切:元素、字符串、数字、片段、这些东西的数组。
import { type ReactNode } from 'react'
type CardProps = {
title: string
children: ReactNode
}子元素如何流过组件在子元素和组合里讲;ReactNode 就是描述它们的类型。关于 props 的一切运行时表现都保持不变。类型注解只是在上面加了契约。
给函数 Props 添加类型
组件经常作为 props 接收函数:点击处理器、一个向上回报值的回调。函数 prop 的类型用箭头语法:参数列表带上它的类型、箭头和返回类型。
type NewGameButtonProps = {
startNewGame: () => void
}
type LetterButtonProps = {
letter: string
onGuess: (value: string) => void
}() => void 描述一个不接受参数也不返回东西的函数,大多数事件风格处理器的形态。(value: string) => void 说明子组件会用字符串调用这个函数,所以父组件的实现必须接受一个。传一个参数类型不匹配的处理器,错误出现在 JSX 调用点,在父组件中,编译时就知道。
不过检查做了两个让步,两个都看起来像类型检查器在放过什么。处理器可以声明比类型列出的更少的参数,所以 onGuess={() => setOpen(true)} 满足 (value: string) => void。还有 () => void 接受返回值的函数,所以 startNewGame={async () => { await saveScore() }} 类型检查通过,即使它交回一个没人等待的 promise。编译器捕捉的是参数类型错误而不是形态的每一个差异。
返回类型和派生值
把鼠标悬停在一个函数组件上,TypeScript 已经知道它返回 React.JSX.Element,从返回语句中的 JSX 推断出来。(JSX 命名空间现在活在 react 模块里面而不是全局的,所以手写注解意味着要导入它。)
你可以放着这个推断不管,很多代码库都这么做。注解返回类型是个关于严格程度的选择:它保证组件总是返回一个单独的元素,有些团队在更大的代码库中看重这一点。这是比 React 承诺的更严格的承诺,因为组件合法地可能返回字符串、数字、数组或 null,而一个光秃秃的 JSX.Element 注解会拒绝所有这些直到你把它扩宽。
import { type JSX } from 'react'
function Header(): JSX.Element {
return <h1>Assembly: Endgame</h1>
}
function ConfettiContainer({ isGameWon }: { isGameWon: boolean }): JSX.Element | null {
if (!isGameWon) return null
return <Confetti />
}ConfettiContainer 有时通过返回 null 不渲染任何东西,这和在条件渲染中用的一样,所以它的注解返回类型是联合 JSX.Element | null。同样的习惯延伸到组件内派生的值:箭头函数的返回类型在参数括号后面跟上,而对产生 JSX 的数据的 map 生成 JSX.Element[]。这些注解中的大多数只是重述推断已经得出的,所以把它们当作可选的文档。一直挣得的那些坐在边界上:props、空的或可为 null 的状态,还有其他组件依赖其签名的函数。
导入共享类型
类型像其他绑定一样被导出和导入,这让一个定义为整个应用服务。数据模块通常会和数据一起导出它们的形态:
// languages.ts
export type Language = {
name: string
backgroundColor: string
color: string
}
// LanguageChips.tsx
import { type Language } from './languages'
type LanguageChipsProps = {
languages: Language[]
}导入中的 type 关键字标记它为仅类型,所以它在编译的 JavaScript 中完全消失。一旦像 Language 这样的类型活在一个地方,每个碰这个数据的组件都导入同样的契约,改变某个文件中的形态就会在每个需要更新的调用点浮现。
用真实初始值创建的状态自己贴标签,初始值是空的或 null 时你自己写标签,比如 useState<string[]>([])。Props 得到一个小的有名类型比如 GameStatusProps,列出每个 prop 和它的类型。
一旦标签贴上,你的编辑器在任何东西形态不对的时候警告你,甚至在你运行应用之前。
那就是手册。曾经是一个返回点标记的函数,现在是一个完整的工具包:组件和状态、effect 和数据、可重用的组件模式、路由、一个推理性能的渲染模型,还有一个类型层在别人遇到前就在边界处捕捉错误。选一个你真的想存在的项目,用 npm create vite@latest 搭建它,当项目开始需要时把这些章节打开。

