三千字講清TypeScript與React的實戰技巧
- 2019 年 10 月 4 日
- 筆記

很多時候雖然我們了解了TypeScript相關的基礎知識,但是這不足以保證我們在實際項目中可以靈活運用,比如現在絕大部分前端開發者的項目都是依賴於框架的,因此我們需要來講一下React與TypeScript應該如何結合運用。
如果你僅僅了解了一下TypeScript的基礎知識就上手框架會碰到非常多的坑(比如筆者自己),如果你是React開發者一定要看過本文之後再進行實踐。
快速啟動TypeScript版react
使用TypeScript編寫react程式碼,除了需要typescript
這個庫之外,還至少需要額外的兩個庫:
yarn add -D @types/{react,react-dom}
可能有人好奇@types
開頭的這種庫是什麼?
由於非常多的JavaScript庫並沒有提供自己關於TypeScript的聲明文件,導致TypeScript的使用者無法享受這種庫帶來的類型,因此社區中就出現了一個項目DefinitelyTyped,他定義了目前市面上絕大多數的JavaScript庫的聲明,當人們下載JavaScript庫相關的@types
聲明時,就可以享受此庫相關的類型定義了。
當然,為了方便我們選擇直接用TypeScript官方提供的react啟動模板。
create-react-app react-ts-app --scripts-version=react-scripts-ts
無狀態組件
我們用初始化好了上述模板之後就需要進行正式編寫程式碼了。
無狀態組件是一種非常常見的react組件,主要用於展示UI,初始的模板中就有一個logo圖,我們就可以把它封裝成一個Logo
組件。
在JavaScript中我們往往是這樣封裝組件的:
import * as React from 'react' export const Logo = props => { const { logo, className, alt } = props return ( <img src={logo} className={className} alt={alt} /> ) }
但是在TypeScript中會報錯:

原因就是我們沒有定義props
的類型,我們用interface
定義一下props的類型,那麼是不是這樣就行了:
import * as React from 'react' interface IProps { logo?: string className?: string alt?: string } export const Logo = (props: IProps) => { const { logo, className, alt } = props return ( <img src={logo} className={className} alt={alt} /> ) }
這樣做在這個例子中看似沒問題,但是當我們要用到children
的時候是不是又要去定於children
類型?
比如這樣:
interface IProps { logo?: string className?: string alt?: string children?: ReactNode }
其實有一種更規範更簡單的辦法,type SFC<P>
其中已經定義了children
類型。
我們只需要這樣使用:
export const Logo: React.SFC<IProps> = props => { const { logo, className, alt } = props return ( <img src={logo} className={className} alt={alt} /> ) }
我們現在就可以替換App.tsx
中的logo
組件,可以看到相關的props都會有程式碼提示:

如果我們這個組件是業務中的通用組件的話,甚至可以加上注釋:
interface IProps { /** * logo的地址 */ logo?: string className?: string alt?: string }
這樣在其他同事調用此組件的時候,除了程式碼提示外甚至會有注釋的說明:

有狀態組件
現在假設我們開始編寫一個Todo應用:

首先需要編寫一個todoInput
組件:

如果我們按照JavaScript的寫法,只要寫一個開頭就會碰到一堆報錯

有狀態組件除了props之外還需要state,對於class寫法的組件要泛型的支援,即Component<P, S>
,因此需要傳入傳入state和props的類型,這樣我們就可以正常使用props和state了。
import * as React from 'react' interface Props { handleSubmit: (value: string) => void } interface State { itemText: string } export class TodoInput extends React.Component<Props, State> { constructor(props: Props) { super(props) this.state = { itemText: '' } } }
細心的人會問,這個時候需不需要給Props
和State
加上Readonly
,因為我們的數據都是不可變的,這樣會不會更嚴謹?
其實是不用的,因為React的聲明文件已經自動幫我們包裝過上述類型了,已經標記為readonly
。
如下:

接下來我們需要添加組件方法,大多數情況下這個方法是本組件的私有方法,這個時候需要加入訪問控制符private
。
private updateValue(value: string) { this.setState({ itemText: value }) }
接下來也是大家經常會碰到的一個不太好處理的類型,如果我們想取某個組件的ref
,那麼應該如何操作?
比如我們需要在組件更新完畢之後,使得input
組件focus
。
首先,我們需要用React.createRef
創建一個ref,然後在對應的組件上引入即可。
private inputRef = React.createRef<HTMLInputElement>() ... <input ref={this.inputRef} className="edit" value={this.state.itemText} />
需要注意的是,在createRef
這裡需要一個泛型,這個泛型就是需要ref
組件的類型,因為這個是input組件,所以類型是HTMLInputElement
,當然如果是div
組件的話那麼這個類型就是HTMLDivElement
。
受控組件
再接著講TodoInput
組件,其實此組件也是一個受控組件,當我們改變input
的value
的時候需要調用this.setState
來不斷更新狀態,這個時候就會用到『事件』類型。
由於React內部的事件其實都是合成事件,也就是說都是經過React處理過的,所以並不原生事件,因此通常情況下我們這個時候需要定義React中的事件類型。
對於input
組件onChange
中的事件,我們一般是這樣聲明的:
private updateValue(e: React.ChangeEvent<HTMLInputElement>) { this.setState({ itemText: e.target.value }) }
當我們需要提交表單的時候,需要這樣定義事件類型:
private handleSubmit(e: React.FormEvent<HTMLFormElement>) { e.preventDefault() if (!this.state.itemText.trim()) { return } this.props.handleSubmit(this.state.itemText) this.setState({itemText: ''}) }
那麼這麼多類型的定義,我們怎麼記得住呢?遇到其它沒見過的事件,難道要去各種搜索才能定義類型嗎?其實這裡有一個小技巧,當我們在組件中輸入事件對應的名稱時,會有相關的定義提示,我們只要用這個提示中的類型就可以了。

默認屬性
React中有時候會運用很多默認屬性,尤其是在我們編寫通用組件的時候,之前我們介紹過一個關於默認屬性的小技巧,就是利用class來同時聲明類型和創建初始值。
再回到我們這個項目中,假設我們需要通過props來給input
組件傳遞屬性,而且需要初始值,我們這個時候完全可以通過class來進行程式碼簡化。
// props.type.ts interface InputSetting { placeholder?: string maxlength?: number } export class TodoInputProps { public handleSubmit: (value: string) => void public inputSetting?: InputSetting = { maxlength: 20, placeholder: '請輸入todo', } }
再回到TodoInput
組件中,我們直接用class作為類型傳入組件,同時實例化類,作為默認屬性。

用class作為props類型以及生產默認屬性實例有以下好處:
- 程式碼量少:一次編寫,既可以作為類型也可以實例化作為值使用
- 避免錯誤:分開編寫一旦有一方造成書寫錯誤不易察覺
這種方法雖然不錯,但是之後我們會發現問題了,雖然我們已經聲明了默認屬性,但是在使用的時候,依然顯示inputSetting
可能未定義。

在這種情況下有一種最快速的解決辦法,就是加!
,它的作用就是告訴編譯器這裡不是undefined,從而避免報錯。

如果你覺得這個方法過於粗暴,那麼可以選擇三目運算符做一個簡單的判斷:

如果你還覺得這個方法有點繁瑣,因為如果這種情況過多,我們需要額外寫非常多的條件判斷,而更重要的是,我們明明已經聲明了值,就不應該再做條件判斷了,應該有一種方法讓編譯器自己推導出這裡的類型不是undefined,這就涉及到一些高級類型了。
利用高級類型解決默認屬性報錯
我們現在需要先聲明defaultProps的值:
const todoInputDefaultProps = { inputSetting: { maxlength: 20, placeholder: '請輸入todo', } }
接著定義組件的props類型
type Props = { handleSubmit: (value: string) => void children: React.ReactNode } & Partial<typeof todoInputDefaultProps>
Partial
的作用就是將類型的屬性全部變成可選的,也就是下面這種情況:
{ inputSetting?: { maxlength: number; placeholder: string; } | undefined; }
那麼現在我們使用Props是不是就沒有問題了?
export class TodoInput extends React.Component<Props, State> { public static defaultProps = todoInputDefaultProps ... public render() { const { itemText } = this.state const { updateValue, handleSubmit } = this const { inputSetting } = this.props return ( <form onSubmit={handleSubmit} > <input maxLength={inputSetting.maxlength} type='text' value={itemText} onChange={updateValue} /> <button type='submit' >添加todo</button> </form> ) } ... }
我們看到依舊會報錯:

其實這個時候我們需要一個函數,將defaultProps
中已經聲明值的屬性從『可選類型』轉化為『非可選類型』。
我們先看這麼一個函數:
const createPropsGetter = <DP extends object>(defaultProps: DP) => { return <P extends Partial<DP>>(props: P) => { type PropsExcludingDefaults = Omit<P, keyof DP> type RecomposedProps = DP & PropsExcludingDefaults return (props as any) as RecomposedProps } }
這個函數接受一個defaultProps
對象,<DP extends object>
這裡是泛型約束,代表DP
這個泛型是個對象,然後返回一個匿名函數。
再看這個匿名函數,此函數也有一個泛型P
,這個泛型P
也被約束過,即<P extends Partial<DP>>
,意思就是這個泛型必須包含可選的DP
類型(實際上這個泛型P就是組件傳入的Props類型)。
接著我們看類型別名PropsExcludingDefaults
,看這個名字你也能猜出來,它的作用其實是剔除Props
類型中關於defaultProps
的部分,很多人可能不清楚Omit
這個高級類型的用法,其實就是一個語法糖:
type Omit<P, keyof DP> = Pick<P, Exclude<keyof P, keyof DP>>
而類型別名RecomposedProps
則是將默認屬性的類型DP
與剔除了默認屬性的Props
類型結合在一起。
其實這個函數只做了一件事,把可選的defaultProps
的類型剔除後,加入必選的defaultProps
的類型,從而形成一個新的Props
類型,這個Props
類型中的defaultProps
相關屬性就變成了必選的。
這個函數可能對於初學者理解上有一定難度,涉及到TypeScript文檔中的高級類型,這算是一次綜合應用。
完整程式碼如下:
import * as React from 'react' interface State { itemText: string } type Props = { handleSubmit: (value: string) => void children: React.ReactNode } & Partial<typeof todoInputDefaultProps> const todoInputDefaultProps = { inputSetting: { maxlength: 20, placeholder: '請輸入todo', } } export const createPropsGetter = <DP extends object>(defaultProps: DP) => { return <P extends Partial<DP>>(props: P) => { type PropsExcludingDefaults = Omit<P, keyof DP> type RecomposedProps = DP & PropsExcludingDefaults return (props as any) as RecomposedProps } } const getProps = createPropsGetter(todoInputDefaultProps) export class TodoInput extends React.Component<Props, State> { public static defaultProps = todoInputDefaultProps constructor(props: Props) { super(props) this.state = { itemText: '' } } public render() { const { itemText } = this.state const { updateValue, handleSubmit } = this const { inputSetting } = getProps(this.props) return ( <form onSubmit={handleSubmit} > <input maxLength={inputSetting.maxlength} type='text' value={itemText} onChange={updateValue} /> <button type='submit' >添加todo</button> </form> ) } private updateValue(e: React.ChangeEvent<HTMLInputElement>) { this.setState({ itemText: e.target.value }) } private handleSubmit(e: React.FormEvent<HTMLFormElement>) { e.preventDefault() if (!this.state.itemText.trim()) { return } this.props.handleSubmit(this.state.itemText) this.setState({itemText: ''}) } }
高階組件
關於在TypeScript如何使用HOC一直是一個難點,我們在這裡就介紹一種比較常規的方法。
我們繼續來看TodoInput
這個組件,其中我們一直在用inputSetting
來自定義input
的屬性,現在我們需要用一個HOC來包裝TodoInput
,其作用就是用高階組件向TodoInput
注入props。
我們的高階函數如下:
import * as hoistNonReactStatics from 'hoist-non-react-statics' import * as React from 'react' type InjectedProps = Partial<typeof hocProps> const hocProps = { inputSetting: { maxlength: 30, placeholder: '請輸入待辦事項', } } export const withTodoInput = <P extends InjectedProps>( UnwrappedComponent: React.ComponentType<P>, ) => { type Props = Omit<P, keyof InjectedProps> class WithToggleable extends React.Component<Props> { public static readonly UnwrappedComponent = UnwrappedComponent public render() { return ( <UnwrappedComponent inputSetting={hocProps} {...this.props as P} /> ); } } return hoistNonReactStatics(WithToggleable, UnwrappedComponent) }
如果你搞懂了上一小節的內容,這裡應該沒有什麼難度。
這裡我們的P表示傳遞到HOC的組件的props,React.ComponentType<P>
是 React.FunctionComponent<P> | React.ClassComponent<P>
的別名,表示傳遞到HOC的組件可以是類組件或者是函數組件。
其餘的地方Omit
as P
等都是講過的內容,讀者可以自行理解,我們不再像上一小節那樣一行行解釋了。
只需要這樣使用:
const HOC = withTodoInput<Props>(TodoInput)
小結
我們總結了最常見的幾種組件在TypeScript下的編寫方式,通過這篇文章你可以解決在React使用TypeScript絕大部分問題了.