三千字講清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: ''          }      }  }

細心的人會問,這個時候需不需要給PropsState加上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組件,其實此組件也是一個受控組件,當我們改變inputvalue的時候需要調用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絕大部分問題了.