在 React 当中使用 TypeScript

在 React 当中使用 TypeScript

本文的主要目的是梳理一下在 React 当中使用 TypeScript,也就是 ReactTypeScript 的结合使用,主要参考的是 React+TypeScript Cheatsheets,在原文基础之上有所调整,主要是方便自己理解,想要了解更为详细的内容可以参考原文

前半部分会梳理一下在 React 当中经常用到的一些 TypeScript 类型定义,后半部分会梳理一些在实际应用过程当中遇到的问题

组件 Props

我们先从几种定义 Props 经常用到的类型开始看起

基础类型

1
2
3
4
5
6
7
type BasicProps = {
message: string
count: number
disabled: boolean
names: string[] // 数组类型
status: 'waiting' | 'success' // 用『联合类型』限制为下面两种『字符串字面量』类型
}

对象类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type ObjectOrArrayProps = {
obj: object // ❌ 不推荐,除非不太需要用到具体的属性
obj2: {} // ❌ 同上
obj3: { // ✅ 拥有具体属性的对象类型
id: string
title: string
}
objArr: { // ✅ 比较常用的对象数组
id: string
title: string
}[]
dict1: { // ✅ key 可以为任意 string,值限制为 MyType 类型
[key: string]: MyType
}
dict2: Record<string, MyType> // ✅ 基本上和 dict1 相同,使用了 TypeScript 内置的 Record 类型
}

函数类型

1
2
3
4
5
6
7
type FunctionProps = {
onSomething: Function // ❌ 因为不能设定参数以及返回值类型
onClick: () => void // ✅ 对于没有参数的函数比较常用
onChange: (id: number) => void // ✅ 带函数的参数
onClick(event: React.MouseEvent<HTMLButtonElement>): void // ✅ 参数为 React 的按钮事件
optional?: OptionalType // ✅ 可选参数类型
}

React 相关类型

1
2
3
4
5
6
7
8
9
10
export declare interface AppProps {
children1: JSX.Element; // ❌ 因为没有考虑数组
children2: JSX.Element | JSX.Element[]; // ❌ 因为没有考虑字符串 children
children4: React.ReactChild[]; // 勉强可用,但是没考虑 null
children: React.ReactNode; // ✅ 包含所有 children 情况
functionChildren: (name: string) => React.ReactNode; // ✅ 返回 React 节点的函数
style?: React.CSSProperties; // ✅ 在内联 style 时使用
props: React.ComponentProps<'button'>; // ✅ 原生 button 标签自带的所有 props 类型,也可以在泛型的位置传入组件 提取组件的 Props 类型
onClickButton:React.ComponentProps<'button'>['onClick']; // ✅ 在上一步的基础之上提取出原生的 onClick 函数类型,此时函数的第一个参数会自动推断为 React 的点击事件类型
}

函数式组件

比较常见方式

1
2
3
interface AppProps = { message: string }

const App = ({ message }: AppProps) => <div>{message}</div>

另外还有一种包含 children 的函数式组件,我们可以直接使用内置类型 React.FC,这样不光会包含我们定义的 AppProps 还会自动加上一个 children 类型,以及其他组件上会出现的类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 等同于
AppProps & {
children: React.ReactNode
propTypes?: WeakValidationMap<P>
contextTypes?: ValidationMap<any>
defaultProps?: Partial<P>
displayName?: string
}

// 使用
interface AppProps = { message: string }

const App: React.FC<AppProps> = ({ message, children }) => {
return (
<>
{children}
<div>{message}</div>
</>
)
}

不过针对于简单的函数式组件,还是建议使用下面的第二种方式

1
2
3
4
5
6
7
8
9
interface Greeting {
name: string
age: number
}

const Hello: React.FC<Greeting> = (props) => <h1>Hello {props.name}</h1>

// 推荐使用第二种
const Hello2 = (props: Greeting) => <h1>Hello {props.name}</h1>

Hooks

@types/react 包在 16.8 以上的版本开始对 Hooks 的支持

useState

这里分为两种情况,如果我们的默认值已经可以说明类型,那么不用手动声明类型,交给 TypeScript 自动推断即可

1
2
3
4
const [val, toggle] = React.useState(false)

toggle(false)
toggle(true)

但是如果初始值是 nullundefined,那就需要通过泛型手动传入我们所期望的类型

1
2
3
const [user, setUser] = React.useState<IUser | null>(null)

setUser(newUser)

这样也可以保证在我们直接访问 user 上的属性时,提示你它有可能是 null,可以通过 optional-chaining 语法(TypeScript 3.7 以上支持)来避免这个错误

1
2
// ✅
const name = user?.name

useReducer

通常会使用 Discriminated Unions 来标注 action 的类型

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
const initialState = { count: 0 }

type ACTIONTYPE =
| { type: 'increment'; payload: number }
| { type: 'decrement'; payload: string }

function reducer(state: typeof initialState, action: ACTIONTYPE) {
switch (action.type) {
case 'increment':
return { count: state.count + action.payload }
case 'decrement':
return { count: state.count - Number(action.payload) }
default:
throw new Error()
}
}

function Counter() {
const [state, dispatch] = React.useReducer(reducer, initialState)
return (
<>
Count: {state.count}
<button onClick={() => dispatch({ type: 'decrement', payload: '5' })}>
-
</button>
<button onClick={() => dispatch({ type: 'increment', payload: 5 })}>
+
</button>
</>
)
}

Discriminated Unions 一般是一个联合类型,其中每一个类型都需要通过类似 type 这种特定的字段来区分,当你传入特定的 type 时,剩下的类型 payload 就会自动匹配推断,类似于下面这样

  • 当我们写入的 type 匹配到 decrement 的时候,TypeScript 会自动推断出相应的 payload 应该是 string 类型
  • 当我们写入的 type 匹配到 increment 的时候,则 payload 应该是 number 类型

这样一来,在我们使用 dispatch 的时候,输入对应的 type,编辑器就会自动提示我们剩余的参数类型

useEffect

useEffect 有些特殊,因为 useEffect 传入的函数,它的返回值要么是一个方法(清理函数),要么就是 undefined,其他情况都会报错,比较常见的一个情况是我们的 useEffect 需要执行一个 async 函数,比如

1
2
3
4
5
// Type 'Promise<void>' provides no match for the signature '(): void | undefined'
useEffect(async () => {
const user = await getUser()
setUser(user)
}, [])

上面的写法在编辑器当中会有报错提示,因为我们虽然没有在 async 函数里显式的返回值,但是我们都知道 async 函数默认会返回一个 Promise,这就导致了 TypeScript 的报错,所以我们来稍微调整一下上面的示例

1
2
3
4
5
6
7
useEffect(() => {
const getUser = async () => {
const user = await getUser()
setUser(user)
}
getUser()
}, [])

或者也可以采用下面这种自执行函数的方式,不过可读性不太好,不推荐

1
2
3
4
5
6
useEffect(() => {
(async () => {
const user = await getUser()
setUser(user)
})()
}, [])

useRef

这个 Hook 在很多时候是没有初始值的,这样可以声明返回对象中 current 属性的类型

1
const ref = useRef<HTMLElement>(null)

以一个按钮场景为例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function TextInputWithFocusButton() {
const inputEl = React.useRef<HTMLInputElement>(null)
const onButtonClick = () => {
if (inputEl && inputEl.current) {
inputEl.current.focus()
}
}
return (
<>
<input ref={inputEl} type="text" />
<button onClick={onButtonClick}>Focus the input</button>
</>
)
}

onButtonClick 事件触发时,可以肯定 inputEl 也是有值的,因为组件是同级别渲染的,但是还是依然要做冗余的非空判断,针对于这种情况可以使用『非空断言』的方式

1
const ref = useRef<HTMLElement>(null!)

null! 这种语法称为非空断言,跟在一个值后面表示我们断定它是有值的,所以在我们使用 inputEl.current.focus() 的时候,TypeScript 不会给出报错,不过需要注意的是,但是这种语法比较危险,建议『尽量少的去使用它』

一种更为好的解决方式就是使用『可选链』,如下

1
2
3
if (inputEl && inputEl.current) {
inputEl.current?.focus()
}

在绝大部分情况下,『可选链』的方式是个更为安全的选择,除非这个值真的不可能为空(比如在使用之前就赋值了)

useImperativeHandle

在此之前,我们先来简单的了解一下 forwardRef 这个 API,因为函数式组件默认不可以加 ref,所以它不像类组件那样有自己的实例,这个 API 一般是函数式组件用来接收父组件传来的 ref,所以需要标注好实例类型,也就是父组件通过 ref 可以拿到什么样类型的值

1
2
3
4
5
6
7
8
9
type Props = {}

export type Ref = HTMLButtonElement

export const FancyButton = React.forwardRef<Ref, Props>((props, ref) => (
<button ref={ref} className="MyClassName">
{props.children}
</button>
))

由于这个例子里直接把 ref 转发给 button 了,所以直接把类型标注为 HTMLButtonElement 即可,这样一来,父组件向下面这样调用,就可以拿到正确类型

1
2
3
4
5
6
export const App = () => {
const ref = useRef<HTMLButtonElement>()
return (
<FancyButton ref={ref} />
)
}

下面在回到 useImperativeHandle 上,useImperativeHandle 的作用是可以让我们在使用 ref 时自定义暴露给父组件的实例值,通常来说这在开发一些通用组件的情况下比较适用,但是在和 TypeScript 结合使用的时候就会遇到不小的问题,比如我们有一个通用的列表组件,它的样子可能会是下面这样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type ListRef<ItemType> = {
scrollToItem: (item: ItemType) => void
}

type ListProps<ItemType> = {
items: ItemType[]
}

const List = forwardRef(function List<ItemType>(props: ListProps<ItemType>) {
useImperativeHandle<ListRef<ItemType>, ListRef<ItemType>>(ref, () => ({
scrollToItem: (item: ItemType) => undefined
}))
return null
}) as <ItemType>(
p: ListProps<ItemType> & { ref: Ref<ListRef<ItemType>> }
) => ReactElement<any> | null

let ref = useRef<ListRef<number>>(null)

<List items={[1, 2, 3]} ref={ref} />

不幸的是 TypeScript 在执行高阶函数编程时无法保留自由类型参数,这确实是最好的方法,因为 forwardRef 原则上返回类型只是一个 普通函数,针对于这种情况,我们更为推荐使用一个自定义的 innerRef 来代替原生的 ref

1
2
3
4
5
6
7
8
9
10
type ListProps = {
innerRef?: React.Ref<{ scrollToTop(): void }>
}

function List(props: ListProps) {
useImperativeHandle(props.innerRef, () => ({
scrollToTop() { }
}))
return null
}

结合我们之前提到的 useRef,使用是这样的

1
2
3
4
5
6
7
8
9
10
11
function Use() {
const listRef = useRef<{ scrollToTop(): void }>(null!)

useEffect(() => {
listRef.current.scrollToTop()
}, [])

return (
<List innerRef={listRef} />
)
}

看上去是不是清晰许多,但是在这里我们也只是简单的提及一二,因为平时遇到的实在是有限(除非专门开发一些通用的组件库等),关于 React.forwardRef 更为复杂的用法和示例可以参考下面几个链接

自定义 Hook

如果我们想仿照 useState 的形式,返回一个数组给用户使用,一定要记得在适当的时候使用 as const 来标记这个返回值是个常量,告诉 TypeScript 数组里的值不会删除,改变顺序等,否则返回的每一项都会被 TypeScript 推断成是『所有类型可能性的联合类型』,这会影响正常使用

1
2
3
4
5
6
7
8
export function useLoading() {
const [isLoading, setState] = React.useState(false)
const load = (aPromise: Promise<any>) => {
setState(true)
return aPromise.finally(() => setState(false))
}
return [isLoading, load] as const
}

如上,只有我们添加了 as const 才会推断出 [boolean, typeof load],否则会是 (boolean | typeof load)[]

React + TypeScript

下面我们再来看一些在实际当中结合使用 ReactTypeScript 过程当中会遇到的一些问题

模块导入相关问题

通常我们在使用 import 引入非 JavaScript 模块的时候,TypeScript 会提示我们找不到相关模块,而此时使用 require 却是可以的,如下

1
2
3
4
5
6
import styles from './login.less'
import logo from '@assets/images/logo.svg'

const logo2 = require('@assets/images/logo.svg')

console.log(logo2)

针对于这种情况,我们需要给非 JavaScript 模块添加申明

1
2
3
4
5
6
7
8
9
10
11
12
/* style */
declare module '*.css'
declare module '*.less'
declare module '*.scss'

/* 图片 */
declare module '*.svg'
declare module '*.png'
declare module '*.jpg'
declare module '*.jpeg'
declare module '*.gif'
declare module '*.bmp'

另外我们可能见到过 import * as React from 'react' 这样的引入方式,那么它与 import React from 'react' 有什么区别呢?简单来说有两点

  • 第一种写法是将所有用 export 导出的成员赋值给 React,导入后用 React.xx 来进行访问
  • 第二种写法仅是将默认导出(export default)的内容赋值给 React

我们也可以通过配置 tsconfig.json 来解决 import * as xx from 'xx' 这样的引入方式,如下

1
2
3
4
{
// 允许默认导入没有设置默认导出(export default xxx)的模块可以以 import xx from 'xx' 的形式来引入模块
"allowSyntheticDefaultImports": true
}

而配置前后的对比如下

1
2
3
4
5
6
7
// 配置前
import * as React from 'react'
import * as ReactDOM from 'react-dom'

// 配置后
import React from 'react'
import ReactDOM from 'react-dom'

antd 的按需加载

方案有很多种,我们这里采用的是 ts-loader 转译 TypeScript 的方案,更多方案可以参考 Webpack 转译 Typescript 现有方案 这篇文章

  • .babelrc
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"presets": [
"@babel/preset-react",
"@babel/preset-env"
],
"plugins": [
[
"import",
{
"libraryName": "antd",
"libraryDirectory": "es",
"style": "css"
/* `style: true` 会加载 less 文件*/
}
]
]
}
  • tsconfig.json
1
2
3
4
5
6
7
{
"compilerOptions": {
"target": "es5",
"jsx": "preserve", // 保留 jsx
// ...
}
}
  • webpack.config.js
1
2
3
4
5
6
7
{
test: /\.tsx?$/,
use: [
'babel-loader',
'ts-loader'
]
}

使用 React.createRef()

定义如下

1
2
3
4
// 源码
interface RefObject<T> {
readonly current: T | null
}

使用

1
2
3
4
5
6
7
8
9
10
const ref1: React.RefObject<HTMLDivElement> = React.createRef()

const inputRef = React.createRef<Comp>()
class EditScene extends React.Component<Props> {
inputRef: React.RefObject<Comp>
constructor(props) {
super(props)
this.inputRef = React.createRef<Comp>()
}
}

@connect 装饰器相关问题

TypeScript 3.0 版本之前,我们在使用 React 配合 Redux 一类 HOC 库的时候,经常会用到诸如 connect(TodoList)withRouter(TodoList) 之类的封装,而这些函数其实都可以用装饰器的方式来调用,如下

1
2
3
4
5
6
7
8
9
10
11
export interface TodoListProps extends RouteComponentProps<{}> {
todos: Todo[]
}

@withRouter
@connect(mapStateToProps)
export class TodoList extends PureComponent<TodoListProps, {}> {
render() {
return null
}
}

其中的 @connect 装饰器在平常正常使用的过程中是没有问题的,但是一旦和 TypeScript 结合使用的时候就会报错,这是因为我们在使用装饰器的过程当中会自动注入一些 props 给组件,这一部分属性不需要外部传入,因此是可选的,但是在 strictNullChecks 属性开启的时候(它的作用是不允许把 nullundefined 赋值给其他类型变量)就会出现属性冲突,因为 TypeScript 不允许装饰器修改被装饰的对象的类型,因此在 props 定义中为 required 的属性依然为 required

比如对于上面的示例,在实例化 TodoList 这个组件的时候,必需要传入所有的 TodoListProps 所定义的属性,否则会提示我们有错误存在

而在 TypeScript 3.0 以后,我们就可以声明 defaultProps 属性用来表明某些属性对外部组件而言是可选的(具体可见 Support for defaultProps in JSX),如下

1
2
3
4
5
6
7
8
@withRouter
@connect((state) => ({ todos: state.todos })
export class TodoList extends PureComponent<TodoListProps, {}> {
static defaultProps: TodoListProps
render() {
return null
}
}

这里的 static defaultProps: TodoListProps 表明所有的 TodoListprops TodoListProps 对外部组件都是可选的,这就意味着外部组件可以什么属性都不用传也不会有错误,同时对于内部而言所有的属性都是 NotNullable

综上,通常情况下我们的一个组件会有一部分属性由装饰器注入,而另一部分则需要外部实例化时传入,因此可以将一个组件的 props 接口声明成两层结构,第一层为由装饰器注入的部分,第二层则为完整的属性接口,然后将 defaultProps 设置成为第一层接口即可,比如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export interface TodoListInnerProps extends RouteComponentProps<{}> {
todos: Todo[]
}

export interface TodoListProps extends TodoListInnerProps {
className?: string
onLoad?(): void
}

@withRouter
@connect((state) => ({ todos: state.todos })
export class TodoList extends PureComponent<TodoListProps, {}> {
static defaultProps: TodoListInnerProps
render() {
return null
}
}

最后我们再来简单的总结一下其中需要注意的地方

  1. 首先 TypeScript 要要 3.0.1 版本以上
  2. 其次 @types/react 需要是最新版
  3. 最后 withRouterconnect 等函数的 @types 中的签名需要手动修改一下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { ComponentClass } from 'react'
import {
connect as nativeConnect,
MapDispatchToPropsParam,
MapStateToPropsParam
} from 'react-redux'
import { withRouter as nativeWithRouter } from 'react-router'

export type ComponentDecorator<P = any> = <T extends ComponentClass<P>>(WrappedComponent: T) => T

export const connect: <P, S = Todo>(
mapState: MapStateToPropsParam<Partial<P>, P, S>,
mapDispatch?: MapDispatchToPropsParam<Partial<P>, P>
) => ComponentDecorator = nativeConnect as any

export const withRouter: ComponentDecorator = nativeWithRouter as any

HOC 的类型定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import React, { Component } from 'react'
import HelloClass from './HelloClass'

interface Loading {
loading: boolean
}

// HOC 可以接收一个类组件,也可以接收一个函数组件,所以参数的类型是 React.ComponentType
// 源码当中的定义为 type ComponentType<P = {}> = ComponentClass<P> | FunctionComponent<P>
function HelloHOC<P>(WrappedComponent: React.ComponentType<P>) {
return class extends Component<P & Loading> {
render() {
const { loading, ...props } = this.props
return loading ? <div>Loading...</div> : <WrappedComponent {...props as P} />
}
}
}

export default HelloHOC(HelloClass)

参考

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×