三千字講清TypeScript與React的實戰技巧

三旬老漢發表於2019-07-26

很多時候雖然我們瞭解了TypeScript相關的基礎知識,但是這不足以保證我們在實際專案中可以靈活運用,比如現在絕大部分前端開發者的專案都是依賴於框架的,因此我們需要來講一下React與TypeScript應該如何結合運用。

如果你僅僅瞭解了一下TypeScript的基礎知識就上手框架會碰到非常多的坑(比如筆者自己),如果你是React開發者一定要看過本文之後再進行實踐。

快速啟動TypeScript版react

使用TypeScript編寫react程式碼,除了需要typescript這個庫之外,還至少需要額外的兩個庫:

yarn add -D @types/{react,react-dom}
</pre>

可能有人好奇@types開頭的這種庫是什麼?

由於非常多的JavaScript庫並沒有提供自己關於TypeScript的宣告檔案,導致TypeScript的使用者無法享受這種庫帶來的型別,因此社群中就出現了一個專案DefinitelyTyped,他定義了目前市面上絕大多數的JavaScript庫的宣告,當人們下載JavaScript庫相關的@types宣告時,就可以享受此庫相關的型別定義了。

當然,為了方便我們選擇直接用TypeScript官方提供的react啟動模板。

create-react-app react-ts-app --scripts-version=react-scripts-ts
</pre>

無狀態元件

我們用初始化好了上述模板之後就需要進行正式編寫程式碼了。

無狀態元件是一種非常常見的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} />
 )
}
</pre>

但是在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} />
 )
}
</pre>

這樣做在這個例子中看似沒問題,但是當我們要用到children的時候是不是又要去定於children型別?

比如這樣:

interface IProps {
 logo?: string
 className?: string
 alt?: string
 children?: ReactNode
}
</pre>

其實有一種更規範更簡單的辦法,type SFC<P>其中已經定義了children型別。

我們只需要這樣使用:

export const Logo: React.SFC<IProps> = props => {
 const { logo, className, alt } = props
 return (
 <img src={logo} className={className} alt={alt} />
 )
}
</pre>

我們現在就可以替換App.tsx中的logo元件,可以看到相關的props都會有程式碼提示:

如果我們這個元件是業務中的通用元件的話,甚至可以加上註釋:

interface IProps {
 /**
 * logo的地址
 */
 logo?: string
 className?: string
 alt?: string
}
</pre>

這樣在其他同事呼叫此元件的時候,除了程式碼提示外甚至會有註釋的說明:

有狀態元件

現在假設我們開始編寫一個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: ''
 }
 }
}
</pre>

細心的人會問,這個時候需不需要給Props和State加上Readonly,因為我們的資料都是不可變的,這樣會不會更嚴謹?

其實是不用的,因為React的宣告檔案已經自動幫我們包裝過上述型別了,已經標記為readonly。

如下:

接下來我們需要新增元件方法,大多數情況下這個方法是本元件的私有方法,這個時候需要加入訪問控制符private。

private updateValue(value: string) {
 this.setState({ itemText: value })
 }
</pre>

接下來也是大家經常會碰到的一個不太好處理的型別,如果我們想取某個元件的ref,那麼應該如何操作?

比如我們需要在元件更新完畢之後,使得input元件focus。

首先,我們需要用React.createRef建立一個ref,然後在對應的元件上引入即可。

private inputRef = React.createRef<HTMLInputElement>()
...
<input
 ref={this.inputRef}
 className="edit"
 value={this.state.itemText}
/>
</pre>

需要注意的是,在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 })
}
</pre>

當我們需要提交表單的時候,需要這樣定義事件型別:

private handleSubmit(e: React.FormEvent<HTMLFormElement>) {
 e.preventDefault()
 if (!this.state.itemText.trim()) {
 return
 }
 this.props.handleSubmit(this.state.itemText)
 this.setState({itemText: ''})
 }
</pre>

那麼這麼多型別的定義,我們怎麼記得住呢?遇到其它沒見過的事件,難道要去各種搜尋才能定義型別嗎?其實這裡有一個小技巧,當我們在元件中輸入事件對應的名稱時,會有相關的定義提示,我們只要用這個提示中的型別就可以了。

預設屬性

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',
 }
}
</pre>

再回到TodoInput元件中,我們直接用class作為型別傳入元件,同時例項化類,作為預設屬性。

用class作為props型別以及生產預設屬性例項有以下好處:

  • 程式碼量少:一次編寫,既可以作為型別也可以例項化作為值使用
  • 避免錯誤:分開編寫一旦有一方造成書寫錯誤不易察覺

這種方法雖然不錯,但是之後我們會發現問題了,雖然我們已經宣告瞭預設屬性,但是在使用的時候,依然顯示inputSetting可能未定義。

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

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

 
如果你還覺得這個方法有點繁瑣,因為如果這種情況過多,我們需要額外寫非常多的條件判斷,而更重要的是,我們明明已經宣告瞭值,就不應該再做條件判斷了,應該有一種方法讓編譯器自己推匯出這裡的型別不是undefined,這就涉及到一些高階型別了。

利用高階型別解決預設屬性報錯

我們現在需要先宣告defaultProps的值:

const todoInputDefaultProps = {
 inputSetting: {
 maxlength: 20,
 placeholder: '請輸入todo',
 }
}
</pre>

接著定義元件的props型別

type Props = {
 handleSubmit: (value: string) => void
 children: React.ReactNode
} & Partial<typeof todoInputDefaultProps>
</pre>

Partial的作用就是將型別的屬性全部變成可選的,也就是下面這種情況:

{
 inputSetting?: {
 maxlength: number;
 placeholder: string;
 } | undefined;
}
</pre>

那麼現在我們使用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>
 )
 }
...
}
</pre>

我們看到依舊會報錯:



其實這個時候我們需要一個函式,將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
 }
}
</pre>

這個函式接受一個defaultProps物件,<DP extends object>這裡是泛型約束,代表DP這個泛型是個物件,然後返回一個匿名函式。

再看這個匿名函式,此函式也有一個泛型P,這個泛型P也被約束過,即<P extends Partial<DP>>,意思就是這個泛型必須包含可選的DP型別(實際上這個泛型P就是元件傳入的Props型別)。

接著我們看型別別名PropsExcludingDefaults,看這個名字你也能猜出來,它的作用其實是剔除Props型別中關於defaultProps的部分,很多人可能不清楚Omit這個高階型別的用法,其實就是一個語法糖:

ype Omit<P, keyof DP> = Pick<P, Exclude<keyof P, keyof DP>>
</pre>

而型別別名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: ''})
 }
}
</pre>

高階元件

關於在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)
}
</pre>

如果你搞懂了上一小節的內容,這裡應該沒有什麼難度。

這裡我們的P表示傳遞到HOC的元件的props,React.ComponentType<P> 是React.FunctionComponent<P> | React.ClassComponent<P>的別名,表示傳遞到HOC的元件可以是類元件或者是函式元件。

其餘的地方Omit as P等都是講過的內容,讀者可以自行理解,我們不再像上一小節那樣一行行解釋了。

只需要這樣使用:

const HOC = withTodoInput<Props>(TodoInput)
</pre>

小結

我們總結了最常見的幾種元件在TypeScript下的編寫方式,通過這篇文章你可以解決在React使用TypeScript絕大部分問題了.

大家可以點個關注哦~評論,留下自己的腳步!之後還會給大家帶來一系列技術乾貨,包括最新面試題也會不斷和大家分享,大家可以去我的主頁【點選進入】免費領取哈!

相關文章