React + TS 2.8:終極組件設計模式指南

原文:Ultimate React Component Patterns with Typescript 2.8, Martin Hochelreact

本文的寫做靈感來自於 《React Component Patterns》,線上演示地址 >>點我>>

熟悉個人朋友都知道,我不喜歡寫無類型支持的 JavaScript,因此從 TypeScript 0.9 開始我就深深地愛上它了。
除了類型化的 JavaScript,我也很是喜歡 React,React 和 TypeScript 的結合讓我感受置身天堂:D。
在整個應用中,類型安全和 VDOM 的無縫銜接,讓開發體驗變得妙趣橫生!git

因此本文想要分享什麼信息呢?
儘管網上有不少關於 React 組件設計模式的文章,可是沒有一篇介紹如何使用 TypeScript 來實現。
與此同時,最新版的 TypeScript 2.8 也帶來了使人激動人心的功能,好比支持條件類型(Conditional Types)、標準庫中預約義的條件類型以及同態映射類型修飾符等等,這些功能使咱們可以更簡便地寫出類型安全的通用組件模式。github

本文很是長,可是請不要被嚇到了,由於我會手把手教你掌握終極 React 組件設計模式!typescript

文中全部的設計模式和例子都使用 TypeScript 2.8 和嚴格模式

準備

磨刀不誤砍柴工。首先咱們要安裝好 typescripttslib,使用 tslib 可讓咱們生成的代碼更加緊湊。json

yarn add -D typescript
# tslib 彌補編譯目標不支持的功能,如
yarn add tslib

而後,就可使用 tsc 命令來初始化項目的 TypeScript 配置了。設計模式

# 爲項目建立 tsconfig.json ,使用默認編譯設置
yarn tsc --init

接着,安裝 reactreact-dom 和它們的類型文件。數組

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

很是棒!如今咱們就能夠開始研究組件模式了,你準備好了麼?安全

無狀態組件

無狀態組件(Stateless Component)就是沒有狀態(state)的組件。大多數時候,它們就是純函數
下面讓咱們來使用 TypeScript 隨便編寫一個無狀態的按鈕組件。bash

就像使用純 JavaScript 同樣,咱們須要引入 react 以支持 JSX 。
(譯註:TypeScript 中,要支持 JSX,文件拓展名必須爲 .tsxapp

import React from 'react'
const Button = ({ onClick: handleClick, children }) => (
  <button onClick={handleClick}>{children}</button>
)

不過 tsc 編譯器報錯了:(。咱們須要明確地告訴組件它的屬性是什麼類型。因此,讓咱們來定義組件屬性:

import React, { MouseEvent, ReactNode } from 'react'
type Props = { 
 onClick(e: MouseEvent<HTMLElement>): void
 children?: ReactNode 
}
const Button = ({ onClick: handleClick, children }: Props) => (
  <button onClick={handleClick}>{children}</button>
)

很好!這下終於沒有報錯了!可是咱們還能夠作得更好!

@types/react 類型模塊中預約了 type SFC<P>,它是 interface StatelessComponent<P> 的類型別名,而且它預約義了 childrendisplayNamedefaultProps 等屬性。因此,咱們用不着本身寫,能夠直接拿來用。

因而,最終的代碼長這樣:

Stateless Component

狀態組件

讓咱們來建立一個有狀態的計數組件,並在其中使用咱們上面建立的 Button 組件。

首先,定義好初始狀態 initialState

const initialState = { clicksCount: 0 }

這樣咱們就可使用 TypeScript 來對它進行類型推斷了。

這種作法可讓咱們不用分別獨立維護類型和實現,若是實現變動了類型也會隨之自動改變,妙!

type State = Readonly<typeof initialState>

同時,這裏也明確地把全部屬性都標記爲只讀。在使用的時候,咱們還須要顯式地把狀態定義爲只讀,並聲明爲 State 類型。

readonly state: State = initialState

爲何聲明爲只讀呢?
這是由於 React 不容許直接更新 state 及其屬性。相似下面的作法是錯誤的

this.state.clicksCount = 2
this.state = { clicksCount: 2 }

該作法在編譯時不會出錯,可是會致使運行時錯誤。經過使用 Readonly 顯式地把類型 type State 的屬性都標記爲只讀屬性,以及聲明 state 爲只讀對象,TypeScript 能夠實時地把錯誤用法反饋給開發者,從而避免錯誤。

好比:

Compile time State type safety

因爲容器組件 ButtonCounter 尚未任何屬性,因此咱們把 Component 的第一個泛型參數組件屬性類型設置爲 object,由於 props 屬性在 React 中老是 {}。第二個泛型參數是組件狀態類型,因此這裏使用咱們前面定義的 State 類型。

Stateful Component

你可能已經注意到,在上面的代碼中,咱們把組件更新函數獨立成了組件類外部的純函數。這是一種經常使用的模式,這樣的話咱們就能夠在不須要了解任何組件內部細節的狀況下,單獨對這些更新函數進行測試。此外,因爲咱們使用了 TypeScript ,並且已經把組件狀態設置爲只讀,因此在這種純函數中對狀態的修改也會被及時發現。

const decrementClicksCount = (prevState: State) 
                      => ({ clicksCount: prevState.clicksCount-- })

// Will throw following complile error:
//
// [ts]
// Cannot assign to 'clicksCount' because it is a constant or a read-only property.

是否是很酷呢?;)

默認屬性

如今讓咱們來拓展一下 Button 組件,給它添加一個 string 類型的 color 屬性。

type Props = { 
    onClick(e: MouseEvent<HTMLElement>): void
    color: string 
}

若是想給組件設置默認屬性,咱們可使用 Button.defaultProps = {...} 實現。這樣的話,就須要把類型 Propscolor 標記爲可選屬性。像下面這樣(多了一個問號):

type Props = { 
    onClick(e: MouseEvent<HTMLElement>): void
    color?: string 
}

此時,Button 組件就變成了下面的模樣:

const Button: SFC<Props> = ({ onClick: handleClick, color, children }) => (
    <button style={{ color }} onClick={handleClick}>
        {children}
    </button>
)

這種實現方式工做起來是沒毛病的,可是卻存在隱患。由於咱們是在嚴格模式下,因此可選屬性 color 的類型實際上是聯合類型 undefined | string

假如後續咱們須要用到 color,那麼 TypeScript 就會拋出錯誤,由於編譯器並不知道 color 已經被定義在 Component.defaultProps 了。

Default Props issue

爲了告訴 TypeScript 編譯器 color 已經被定義了,有如下 3 種辦法:

  • 使用! 操做符(Bang Operator)顯式地告訴編譯器它的值不爲空,像這樣 <button onClick={handleClick!}>{children}</button>
  • 使用三元操做符(Ternary Operator)告訴編譯器值它的值不爲空:<button onClick={handleClick ? handleClick: undefined}>{children}</button>
  • 建立一個可複用的高階函數(High Order Function)withDefaultProps,該函數會更新咱們的屬性類型定義而且設置默認屬性。是我見過的最純粹的解決辦法。

多虧了 TypeScript 2.8 新增的預約義條件類型,withDefaultProps 實現起來很是簡單。

withDefaultProps High order function generic helper

注意: Omit 並無成爲 TypeScript 2.8 預約義的條件映射類型,所以須要自行實現: declare type Omit<T, K> = Pick<T, Exclude<keyof T, K>>;

下面咱們用它來解決上面的問題:

Define default props on our Button component

或者更簡單的:

Define default props on inline with Component implementation

如今,Button 的組件屬性已經定義好,能夠被使用了。在類型定義上,默認屬性也被標記爲可選屬性,可是在是現實上仍然是必選的。

{
  onClick(e: MouseEvent<HTMLElement>): void
  color?: string
}

button with default props

在使用方式上也是如出一轍:

render(){
  return (
    <ButtonWithDefaultProps 
      onClick={this.handleIncrement}
    >
      Increment
    </ButtonWithDefaultProps>
  )
}

withDefaultProps 也能用在直接使用 class 定義的組件上,以下圖所示:

inline class

這裏多虧了 TS 的類結構源,咱們不須要顯式定義 Props 泛型類型

ButtonViaClass 組件的用法也仍是保持一致:

render(){
  return (
    <ButtonViaClass
      onClick={this.handleIncrement}
    >
      Increment
    </ButtonViaClass>
  )
}

接下來咱們會編寫一個可展開的菜單組件,當點擊組件時,它會顯示子組件內容。咱們會用多種不一樣的組件模式來實現它。

渲染回調/渲染屬性模式

要想讓一個組件變得可複用,最簡單的辦法是把組件子元素變成一個函數或者新增一個 render 屬性。這也是渲染回調(Render Callback)又被稱爲子組件函數(Function as Child Component)的緣由。

首先,讓咱們來實現一個擁有 render 屬性的 Toggleable 組件:

toggleable component

存在很多疑惑?

讓咱們來一步一步看各個重要部分的實現:

const initialState = { show: false }
type State = Readonly<typeof initialState>

這個沒什麼新內容,就跟咱們前文的例子同樣,只是聲明狀態類型。

接下來咱們須要定義組件屬性。注意:這裏咱們使用映射類型 Partial 來把屬性標記爲可選,而不是使用 ? 操做符。

type Props = Partial<{
  children: RenderCallback
  render: RenderCallback
}>
type RenderCallback = (args: ToggleableComponentProps) => JSX.Element
type ToggleableComponentProps = { 
  show: State['show']
  toggle: Toggleable['toggle'] 
}

咱們但願同時支持子組件函數和渲染回調函數,因此這裏把它們都標記爲可選的。爲了不重複造輪子,這裏爲渲染函數建立了 RenderCallback 類型:

type RenderCallback = (args: ToggleableComponentProps) => JSX.Element

其中,看起來可能使人疑惑的是類型 type ToggleableComponentProps

type ToggleableComponentProps = { 
  show: State['show']
  toggle: Toggleable['toggle'] 
}

這個實際上是用到了 TS 的類型查詢功能,這樣的話咱們就不須要重複定義類型了:

  • show: State['show']:使用在狀態中已經定義的類型來爲 show 聲明類型
  • toggle: Toggleable['toggle']:經過類型推斷和類結構獲取方法類型。優雅而強大!

其餘部分的實現是很直觀的,標準的渲染屬性/子組件函數模式:

export class Toggleable extends Component<Props, State> {
  // ...
  render() {
    const { children, render } = this.props
    const renderProps = { show: this.state.show, toggle: this.toggle }
    if (render) {
      return render(renderProps)
    }
    return isFunction(children) ? children(renderProps) : null
  }
  // ...
}

至此,咱們就能夠經過子組件函數來使用 Toggleable 組件了:

children as a function

或者給 render 屬性傳遞渲染函數:

render prop

得益於強大的 TS ,咱們在編碼的時候還能夠有代碼提示和正確的類型檢查:

soundness

若是咱們想複用它,能夠簡單的建立一個新組件來使用它:

ToggleableMenu

這個全新的 ToggleableMenu 組件如今就能夠用在菜單組件中了:

Menu Component

並且效果也正如咱們所預期:

menu demo

這種方式很是適合用在須要改變渲染內容自己,而又不想使用狀態的場景。由於咱們把渲染邏輯移到了 ToggleableMenu 的子組件函數中,同時又把狀態邏輯留在 Toggleable 組件中。

組件注入

爲了讓咱們的組件更加靈活,咱們還能夠引入組件注入(Component Injection)模式。

何爲組件注入模式?若是你熟悉 React-Router 的話,那麼在定義路由的時候就是在使用這個模式:

<Route path="/foo" component={MyView} />

因此,除了傳遞 render/children 屬性,咱們還能夠經過 component 屬性來注入組件。爲此,咱們須要把行內渲染回調函數重構成可複用的無狀態組件:

import { ToggleableComponentProps } from './toggleable'
type MenuItemProps = { title: string }
const MenuItem: SFC<MenuItemProps & ToggleableComponentProps> = ({
  title,
  toggle,
  show,
  children,
}) => (
  <>
    <div onClick={toggle}>
      <h1>{title}</h1>
    </div>
    {show ? children : null}
  </>
)

這樣的話,ToggleableMenu 也須要重構下:

type Props = { title: string }
const ToggleableMenu: SFC<Props> = ({ title, children }) => (
  <Toggleable
    render={({ show, toggle }) => (
      <MenuItem show={show} toggle={toggle} title={title}>
        {children}
      </MenuItem>
    )}
  />
)

接下來,讓咱們來定義新的 component 屬性。
首先,咱們須要更新下屬性成員:

  • children 能夠是函數或者是 ReactNode
  • component 是新成員,它的值爲組件,該組件的屬性須要實現 ToggleableComponentProps,同時它又必須支持默認爲 any 的泛型類型,這樣它不會僅僅用於實現了 ToggleableComponentProps 屬性的組件。
  • props 是新成員,用來往下傳遞任意屬性,這也是一種通用模式。它被定義爲類型是 any 的索引類型,因此這裏咱們其實丟失了嚴格的安全檢查。
// 使用任意屬性類型來聲明默認屬性,props 默認爲空對象
const defaultProps = { props: {} as { [name: string]: any } }
type Props = Partial<
  {
    children: RenderCallback | ReactNode
    render: RenderCallback
    component: ComponentType<ToggleableComponentProps<any>>
  } & DefaultProps
>
type DefaultProps = typeof defaultProps

接着,須要把新的 props 同步到 ToggleableComponentProps,這樣才能使用 props 屬性 <Toggleable props={...}/>

export type ToggleableComponentProps<P extends object = object> = {
  show: State['show']
  toggle: Toggleable['toggle']
} & P

最後還須要修改下 render 方法:

render() {
    const { 
     component: InjectedComponent, 
     children, 
     render, 
     props 
    } = this.props
    const renderProps = { 
     show: this.state.show, toggle: this.toggle 
    }
    // 當使用 component 屬性時,children 不是一個函數而是 ReactNode
    if (InjectedComponent) {
      return (
        <InjectedComponent {...props} {...renderProps}>
          {children}
        </InjectedComponent>
      )
    }
    if (render) {
      return render(renderProps)
    }
    // children as a function comes last
    return isFunction(children) ? children(renderProps) : null
  }

把前面的內容都綜合起來,就實現了一個支持 render 屬性、函數子組件和組件注入的 Toggleable 組件:

full toggleable component

其使用方式以下:

ToggleableMenu with component injection pattern

這裏要注意:咱們自定義的 props 屬性並無安全的類型檢查,由於它被定義爲索引類型 { [name: string]: any }

We can pass anything to our props prop :(

在菜單組件的渲染中,ToggleableMenuViaComponentInjection 組件的使用方式跟原來一致:

export class Menu extends Component {
  render() {
    return (
      <>
        <ToggleableMenuViaComponentInjection title="First Menu">
          Some content
        </ToggleableMenuViaComponentInjection>
        <ToggleableMenuViaComponentInjection title="Second Menu">
          Another content
        </ToggleableMenuViaComponentInjection>
        <ToggleableMenuViaComponentInjection title="Third Menu">
          More content
        </ToggleableMenuViaComponentInjection>
      </>
    )
  }
}

泛型組件

在前面咱們實現組件注入模式時,有一個大問題是 props 屬性失去了嚴格的類型檢查。如何解決這個問題?你可能已經猜到了!咱們能夠把 Toggleable 實現爲泛型組件。

首先,咱們須要把屬性泛型化。咱們可使用默認泛型參數,這樣的話,當咱們不須要傳 props 時就能夠不用顯式傳遞該參數了。

type Props<P extends object = object> = Partial<
  {
    children: RenderCallback | ReactNode
    render: RenderCallback
    component: ComponentType<ToggleableComponentProps<P>>
  } & DefaultProps<P>
>

此外,還須要使 ToggleableComponentProps 泛型化,不過它如今其實已是了,因此這塊不須要重寫。

惟一須要改動的是 type DefaultProps ,由於目前的實現方式中,它是沒有辦法獲取泛型類型的,因此咱們須要把它改成另外一種方式:

type DefaultProps<P extends object = object> = { props: P }
const defaultProps: DefaultProps = { props: {} }

立刻就要完成了!

最後把 Toggleable 組件變成泛型組件。一樣地,咱們使用了默認參數,由於只有在使用組件注入時才須要傳參,其餘狀況時則不須要。

export class Toggleable<T = {}> extends Component<Props<T>, State> {}

大功告成!不過,真的麼?咱們如何才能在 JSX 中使用泛型類型?

很遺憾,並不能。

因此,咱們還須要引入 ofType 泛型組件工廠模式:

export class Toggleable<T extends object = object> extends Component<Props<T>, State> {
  static ofType<T extends object>() {
    return Toggleable as Constructor<Toggleable<T>>
  }
}

完整的實現版本以下:

generic props

有了 static ofType 靜態方法以後,咱們就能夠建立正確的類型檢查泛型組件了:

ofType

一切都跟以前同樣,可是此次咱們的 props 有了類型檢查!

type safe

高階組件

既然咱們的 Toggleable 組件已經實現了 render 屬性,那麼實現高階組件(High Order Component, HOC)就很容易了。渲染回調模式的最大好處之一就是,它能夠直接用於實現 HOC。

下面讓咱們來實現這個 HOC。

咱們須要新增如下內容:

  • displayName(用於調試工具展現,便於閱讀)
  • WrappedComponent (用於訪問原組件,便於測試)
  • 使用 hoist-non-react-statics 包的 hoistNonReactStatics 方法

hoc implemention

這樣咱們就能夠以 HOC 的方式來建立 Toggleable 菜單項了, 並且仍然保持了對屬性的類型檢查。

const ToggleableMenuViaHOC = withToggleable(MenuItem)

Proper type annotation

受控組件

壓軸大戲來了!
咱們來實現一個能夠經過父組件進行高度配置的 Toggleable ,這種是一種很是強大的模式。

可能有人會問,受控組件(Controlled Component)是什麼?在這裏意味着,我想要同時控制 Menu 組件中全部 ToggleableMenu 的內容是否顯示,看看下面的動態你應該就知道是什麼了。

controlled component

爲了實現該目標,咱們須要修改下 ToggleableMenu 組件,修改後的內容以下:

ToggleableMenu

而後,咱們還須要在 Menu 中新增一個狀態,而且把它傳遞給 ToggleableMenu

Stateful Menu component

最後,還須要修改 Toggleable 最後一次,讓它變得更加無敵和靈活。
修改內容以下:

  1. 新增 show 屬性到 Props
  2. 更新默認屬性(由於 show 是可選的)
  3. 更新默認狀態,使用屬性 show 的值來初始化狀態 show,由於咱們但願該值只能來自於其父組件
  4. 使用 componentWillReceiveProps 來利用公開屬性更新狀態

1 & 2 對應的修改:

const initialState = { show: false }
const defaultProps: DefaultProps = { ...initialState, props: {} }

type State = Readonly<typeof initialState>
type DefaultProps<P extends object = object> = { props: P } & Pick<State, 'show'>

3 & 4 對應的修改:

export class Toggleable<T = {}> extends Component<Props<T>, State> {
  static readonly defaultProps: Props = defaultProps
  // Bang operator used, I know I know ...
  state: State = { show: this.props.show! }

  componentWillReceiveProps(nextProps: Props<T>) {
    const currentProps = this.props
    
    if (nextProps.show !== currentProps.show) {
      this.setState({ show: Boolean(nextProps.show) })
    }
  }
}

至此,終極 Toggleable 組件誕生了:

final Toggleable

同時,使用 ToggleablewithToggleable 也還要作些輕微調整,以便傳遞 show 屬性和類型檢查。

withToggleable Hoc with controllable functionality

總結

使用 TS 來實現對 React 組件進行正確的類型檢查實際上是至關難的。可是隨着 TS 2.8 新功能的發佈,咱們幾乎能夠隨意使用通用的 React 組件模式來實現類型安全的組件。

在本篇超長文中,多虧了 TS,咱們學習瞭如何實現具備多種模式且類型安全的組件。

綜合來看,其實最強大的模式非屬性渲染(Render Prop)莫屬,有了它,咱們能夠不費吹灰之力就能夠實現組件注入和高階組件。

文中全部的示範代碼託管於做者的 GitHub 倉庫

最後,還有一點要強調的是,本文中涉及的類型安全模板可能只適用於使用 VDOM/JSX 的庫:

  • 使用語言服務的 Angular 模板也具有類型檢查,可是在有些地方也仍是會失效,好比 ngFor
  • Vue 模板目前也尚未相似 Angular ,因此它的模板和數據綁定其實是魔術字符串。不過這可能在將來會改變。雖然也能夠對模板字符串使用 VDOM,不過用起來應該會很笨重,由於有太多屬性類型定義。(snabdom 表示:怪我咯)。
相關文章
相關標籤/搜索