本文的主要目的是梳理一下在 React
当中使用 TypeScript
,也就是 React
和 TypeScript
的结合使用,主要参考的是 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 } dict2: Record<string , MyType> }
函数类型 1 2 3 4 5 6 7 type FunctionProps = { onSomething: Function onClick: () => void onChange: (id: number ) => void onClick(event: React.MouseEvent<HTMLButtonElement>): void 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[]; children4: React.ReactChild[]; children: React.ReactNode; function Children : (name: string ) => React .ReactNode ; style?: React.CSSProperties; props: React.ComponentProps<'button' >; onClickButton:React.ComponentProps<'button' >['onClick' ]; }
函数式组件 比较常见方式
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 )
但是如果初始值是 null
或 undefined
,那就需要通过泛型手动传入我们所期望的类型
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 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 = HTMLButtonElementexport 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 下面我们再来看一些在实际当中结合使用 React
和 TypeScript
过程当中会遇到的一些问题
模块导入相关问题 通常我们在使用 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 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 { "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 现有方案 这篇文章
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" } ] ] }
1 2 3 4 5 6 7 { "compilerOptions" : { "target" : "es5" , "jsx" : "preserve" , } }
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
属性开启的时候(它的作用是不允许把 null
、undefined
赋值给其他类型变量)就会出现属性冲突,因为 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
表明所有的 TodoList
的 props 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 } }
最后我们再来简单的总结一下其中需要注意的地方
首先 TypeScript
要要 3.0.1
版本以上
其次 @types/react
需要是最新版
最后 withRouter
,connect
等函数的 @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 ) => Texport 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 } 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)
参考