Skip to content
This page has been auto-translated and may contain errors.View in English

React 中的 TypeScript

父组件把一个 prop 从 word 改名为 currentWord。三个子组件仍然在读 word,但没有任何警告:应用能编译,页面能加载,第一个点击渲染其中某个子组件的按钮的用户看到的是空白屏幕。TypeScript 在 JavaScript 的基础上加了一层类型:每个值都有声明或推断的类型,编译器在代码运行之前就能标出不匹配的地方。

在 React 中,这正好能解决这种隐藏在组件之间数据流处的 bug。一个有类型的 prop 就是一份契约:父组件必须发送正确的形状,子组件可以信任收到的东西,你的编辑器可以在两端自动补全。

这一章讲 React 特定的部分:给状态、Props、子元素、函数 Props 和组件返回值添加类型。它假设你已经了解 TypeScript 的基础、联合类型、自定义类型和泛型;本课程在这一节开始时会有复习课程,然后用这些知识来用 TypeScript 重写 Assembly: Endgame 游戏。

设置:Vite 的 react-ts 模板

Vite 提供了 React 模板的 TypeScript 版本。在初始化时加一个标志,你就能得到一个完全配置好的项目:

bash
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 从你传的初始值推断类型,所以用真实初始值创建的状态根本不需要任何注解:

tsx
const [currentWord, setCurrentWord] = useState(getRandomWord())

如果 getRandomWord 返回字符串,那么 currentWord 就是 stringsetCurrentWord 也只接受字符串。调用 setCurrentWord(true) 的话 TypeScript 会立刻标出来:布尔值不能赋给期望字符串的地方。这就是类型推断在起作用,大多数状态你可以放着不管。

当初始值是空的或太宽泛而不足以描述状态将来的形态时,推断就失效了。空数组没有说明它会装什么,所以 useState([]) 推断出来的是 never[],一个永远装不了任何东西的数组,这样每次 push 都会出错。这时候你就需要显式形式:useState 是个泛型函数,你用尖括号传入类型参数。

tsx
const [guessedLetters, setGuessedLetters] = useState<string[]>([])

现在即使初始值是空数组,状态也被认为是字符串数组,值和 setter 都会强制这一点。同样的方法也处理可为 null 的状态,值一开始是 null 然后才变成真实的东西:

tsx
type Word = { text: string; difficulty: number }

const [selectedWord, setSelectedWord] = useState<Word | null>(null)

联合类型 Word | null 告诉 TypeScript 这个状态能合法地装这两种形态。现在每次读 selectedWord 都必须先处理 null 情况才能碰 .text,这把经典的运行时崩溃变成了编译时的提示。状态本身的运作方式没有变;TypeScript 只是锁定了状态能装什么。

给组件 Props 添加类型

Props 作为一个对象到达,所以给它们添加类型就是给这个对象添加注解。对于只有一两个 prop 的组件,行内注解就能用:

tsx
function ConfettiContainer({ isGameWon }: { isGameWon: boolean }) {
  // ...
}

随着 prop 增多,行内类型会变得混乱。标准模式是一个有名字的**类型别名**,按惯例叫 ComponentNameProps,声明在组件上面:

tsx
type GameStatusProps = {
  isGameWon: boolean
  wrongGuessCount: number
  message?: string
}

function GameStatus({ isGameWon, wrongGuessCount, message }: GameStatusProps) {
  // ...
}

message 上的 ? 标记它是可选的:父组件可以省略它,在组件内部它的类型是 string | undefined。用 interface GameStatusProps { ... } 也能在完全一样的位置工作;对于给 props 添加类型,两者是可互换的,本课程为了和其他地方的自定义类型保持一致而坚持用类型别名。

如果组件接受 children,把它的类型标为 ReactNode,这是个宽泛的类型,覆盖 React 能渲染的一切:元素、字符串、数字、片段、这些东西的数组。

tsx
import { type ReactNode } from 'react'

type CardProps = {
  title: string
  children: ReactNode
}

子元素如何流过组件在子元素和组合里讲;ReactNode 就是描述它们的类型。关于 props 的一切运行时表现都保持不变。类型注解只是在上面加了契约。

给函数 Props 添加类型

组件经常作为 props 接收函数:点击处理器、一个向上回报值的回调。函数 prop 的类型用箭头语法:参数列表带上它的类型、箭头和返回类型。

tsx
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 注解会拒绝所有这些直到你把它扩宽。

tsx
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 的状态,还有其他组件依赖其签名的函数。

导入共享类型

类型像其他绑定一样被导出和导入,这让一个定义为整个应用服务。数据模块通常会和数据一起导出它们的形态:

tsx
// 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 这样的类型活在一个地方,每个碰这个数据的组件都导入同样的契约,改变某个文件中的形态就会在每个需要更新的调用点浮现。

事件对象是类型第一次咬人的地方。行内处理器免费获得它的事件类型,因为 React 知道 <input>onChange 传什么,所以 onChange={event => setGuess(event.target.value)} 中的 event 已经有类型了。把那个处理器提到一个有名函数里,上下文就消失了:参数变成隐式的 any,模板的严格类型检查把它变成错误。用匹配那个元素的 React 事件类型注解它。

tsx
function GuessInput() {
  const [guess, setGuess] = useState('')

  function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
    setGuess(event.target.value)
  }

  function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault()
    setGuess('')
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={guess} onChange={handleChange} />
    </form>
  )
}

尖括号里的元素类型是让 event.target.value 成为 string 的东西;如果你放 HTMLElement 在那儿,target 根本没有 value。同样的形态覆盖其他的:React.MouseEvent<HTMLButtonElement> 是点击,React.KeyboardEvent<HTMLInputElement> 是按键。每一个也都能按名字导入,比如 import { type ChangeEvent } from 'react'。处理器在运行时做什么是事件表单的地盘;注解只是给 React 交给它们的东西命名。

更老的 React + TypeScript 代码把组件的类型写为 const Header: React.FC = () => ...React.FC 注解整个函数而不是它的返回值,它失宠是有实际原因的:历史上它悄悄给每个组件的 props 加了 children,不管组件是否接受任何,还有它让泛型组件复杂化了。现在的惯例是这一章用的那个,一个有类型 props 和推断或注解的 JSX.Element 返回的普通函数。

偏好派生类型而不是重新声明它们。当一个组件需要现有形态的一部分时,工具类型保持单一事实来源完好:一个拿着颜色字段的芯片的行内样式对象是 Pick<Language, 'backgroundColor' | 'color'>Pick 是白名单,所以后来加到 Language 的字段不能落进你交给 style 的对象。相反工作上用 Omit,从 props 类型去掉一个已知的键,在这里继承源类型成长出什么是你想要的行为。

同样的直觉用到 DOM 重度包装器:ComponentProps<'button'> 从 React 拉进一个本地 button 接受的每一个 prop,所以设计系统的按钮能扩展它而不是手列 onClickdisabled 之类的。

每种注解策略都以它自己的方式失败,这是值得规划的部分。什么都注解,每次重构都会在只重述推断已经知道的文件中拖一个 diff,所以那些注解掉队了,开始描述代码不再有的形态。只注解边界,一个坏推断,一个从无类型依赖泄出的 any,悄悄蔓延过本地值直到它遇到一个拒绝它的边界。

第二种失败更便宜,因为边界就是注解已经坐的地方,所以把"给边界添加类型"当作默认,派生值推断的类型让你惊讶的那一刻加个本地注解。

Juno给边界添加类型,推断其他 TypeScript 就像给你在组件之间传的盒子贴标签。

用真实初始值创建的状态自己贴标签,初始值是空的或 null 时你自己写标签,比如 useState<string[]>([])。Props 得到一个小的有名类型比如 GameStatusProps,列出每个 prop 和它的类型。

一旦标签贴上,你的编辑器在任何东西形态不对的时候警告你,甚至在你运行应用之前。

Juno给边界添加类型,推断其他useState 从真实初始值推断,对空的和可为 null 的情况传入泛型,比如 useState<Word | null>(null)

给每个组件一个 ComponentNameProps 别名,用 ? 标记可选项,把 children 标为 ReactNode,把函数 props 写成签名比如 (value: string) => void。一个提取出来的事件处理器需要它的参数被注解,比如 React.ChangeEvent<HTMLInputElement>,因为只有行内处理器免费获得那个类型。

从拥有数据的模块导出类型,用 import { type Language } 导入共享它们。

Juno给边界添加类型,推断其他 注解公开表面,props 和导出签名,让推断处理私有派生值。

跳过 React.FC 而偏好有类型 props 的普通函数和返回 JSX.ElementJSX.Element | null,如果你想要保证的话。派生而不是复制:PickComponentProps<'button'> 保持一个事实来源,所以形态变化在每个受影响的调用点浮现为编译错误。

那就是手册。曾经是一个返回点标记的函数,现在是一个完整的工具包:组件和状态、effect 和数据、可重用的组件模式、路由、一个推理性能的渲染模型,还有一个类型层在别人遇到前就在边界处捕捉错误。选一个你真的想存在的项目,用 npm create vite@latest 搭建它,当项目开始需要时把这些章节打开。