原文: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 和嚴格模式
磨刀不誤砍柴工。首先咱們要安裝好 typescript
和 tslib
,使用 tslib
可讓咱們生成的代碼更加緊湊。json
yarn add -D typescript # tslib 彌補編譯目標不支持的功能,如 yarn add tslib
而後,就可使用 tsc
命令來初始化項目的 TypeScript 配置了。設計模式
# 爲項目建立 tsconfig.json ,使用默認編譯設置 yarn tsc --init
接着,安裝 react
,react-dom
和它們的類型文件。數組
yarn add react react-dom yarn add -D @types/{react,react-dom}
很是棒!如今咱們就能夠開始研究組件模式了,你準備好了麼?安全
無狀態組件(Stateless Component)就是沒有狀態(state)的組件。大多數時候,它們就是純函數。
下面讓咱們來使用 TypeScript 隨便編寫一個無狀態的按鈕組件。bash
就像使用純 JavaScript 同樣,咱們須要引入 react
以支持 JSX 。
(譯註:TypeScript 中,要支持 JSX,文件拓展名必須爲 .tsx
)app
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>
的類型別名,而且它預約義了 children
、displayName
和 defaultProps
等屬性。因此,咱們用不着本身寫,能夠直接拿來用。
因而,最終的代碼長這樣:
讓咱們來建立一個有狀態的計數組件,並在其中使用咱們上面建立的 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 能夠實時地把錯誤用法反饋給開發者,從而避免錯誤。
好比:
因爲容器組件 ButtonCounter
尚未任何屬性,因此咱們把 Component
的第一個泛型參數組件屬性類型設置爲 object
,由於 props
屬性在 React 中老是 {}
。第二個泛型參數是組件狀態類型,因此這裏使用咱們前面定義的 State
類型。
你可能已經注意到,在上面的代碼中,咱們把組件更新函數獨立成了組件類外部的純函數。這是一種經常使用的模式,這樣的話咱們就能夠在不須要了解任何組件內部細節的狀況下,單獨對這些更新函數進行測試。此外,因爲咱們使用了 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 = {...}
實現。這樣的話,就須要把類型 Props
的 color
標記爲可選屬性。像下面這樣(多了一個問號):
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
了。
爲了告訴 TypeScript 編譯器 color
已經被定義了,有如下 3 種辦法:
!
操做符(Bang Operator)顯式地告訴編譯器它的值不爲空,像這樣 <button onClick={handleClick!}>{children}</button>
<button onClick={handleClick ? handleClick: undefined}>{children}</button>
withDefaultProps
,該函數會更新咱們的屬性類型定義而且設置默認屬性。是我見過的最純粹的解決辦法。多虧了 TypeScript 2.8 新增的預約義條件類型,withDefaultProps
實現起來很是簡單。
注意:Omit
並無成爲 TypeScript 2.8 預約義的條件映射類型,所以須要自行實現:declare type Omit<T, K> = Pick<T, Exclude<keyof T, K>>;
下面咱們用它來解決上面的問題:
或者更簡單的:
如今,Button
的組件屬性已經定義好,能夠被使用了。在類型定義上,默認屬性也被標記爲可選屬性,可是在是現實上仍然是必選的。
{ onClick(e: MouseEvent<HTMLElement>): void color?: string }
在使用方式上也是如出一轍:
render(){ return ( <ButtonWithDefaultProps onClick={this.handleIncrement} > Increment </ButtonWithDefaultProps> ) }
withDefaultProps
也能用在直接使用 class
定義的組件上,以下圖所示:
這裏多虧了 TS 的類結構源,咱們不須要顯式定義
Props
泛型類型
ButtonViaClass
組件的用法也仍是保持一致:
render(){ return ( <ButtonViaClass onClick={this.handleIncrement} > Increment </ButtonViaClass> ) }
接下來咱們會編寫一個可展開的菜單組件,當點擊組件時,它會顯示子組件內容。咱們會用多種不一樣的組件模式來實現它。
要想讓一個組件變得可複用,最簡單的辦法是把組件子元素變成一個函數或者新增一個 render
屬性。這也是渲染回調(Render Callback)又被稱爲子組件函數(Function as Child Component)的緣由。
首先,讓咱們來實現一個擁有 render
屬性的 Toggleable
組件:
存在很多疑惑?
讓咱們來一步一步看各個重要部分的實現:
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
組件了:
或者給 render
屬性傳遞渲染函數:
得益於強大的 TS ,咱們在編碼的時候還能夠有代碼提示和正確的類型檢查:
若是咱們想複用它,能夠簡單的建立一個新組件來使用它:
這個全新的 ToggleableMenu
組件如今就能夠用在菜單組件中了:
並且效果也正如咱們所預期:
這種方式很是適合用在須要改變渲染內容自己,而又不想使用狀態的場景。由於咱們把渲染邏輯移到了 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
組件:
其使用方式以下:
這裏要注意:咱們自定義的 props
屬性並無安全的類型檢查,由於它被定義爲索引類型 { [name: string]: any }
。
在菜單組件的渲染中,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>> } }
完整的實現版本以下:
有了 static ofType
靜態方法以後,咱們就能夠建立正確的類型檢查泛型組件了:
一切都跟以前同樣,可是此次咱們的 props
有了類型檢查!
既然咱們的 Toggleable
組件已經實現了 render
屬性,那麼實現高階組件(High Order Component, HOC)就很容易了。渲染回調模式的最大好處之一就是,它能夠直接用於實現 HOC。
下面讓咱們來實現這個 HOC。
咱們須要新增如下內容:
displayName
(用於調試工具展現,便於閱讀)WrappedComponent
(用於訪問原組件,便於測試)hoist-non-react-statics
包的 hoistNonReactStatics
方法這樣咱們就能夠以 HOC 的方式來建立 Toggleable
菜單項了, 並且仍然保持了對屬性的類型檢查。
const ToggleableMenuViaHOC = withToggleable(MenuItem)
壓軸大戲來了!
咱們來實現一個能夠經過父組件進行高度配置的 Toggleable
,這種是一種很是強大的模式。
可能有人會問,受控組件(Controlled Component)是什麼?在這裏意味着,我想要同時控制 Menu
組件中全部 ToggleableMenu
的內容是否顯示,看看下面的動態你應該就知道是什麼了。
爲了實現該目標,咱們須要修改下 ToggleableMenu
組件,修改後的內容以下:
而後,咱們還須要在 Menu
中新增一個狀態,而且把它傳遞給 ToggleableMenu
。
最後,還須要修改 Toggleable
最後一次,讓它變得更加無敵和靈活。
修改內容以下:
show
屬性到 Props
show
是可選的)show
的值來初始化狀態 show
,由於咱們但願該值只能來自於其父組件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
組件誕生了:
同時,使用 Toggleable
的 withToggleable
也還要作些輕微調整,以便傳遞 show
屬性和類型檢查。
使用 TS 來實現對 React 組件進行正確的類型檢查實際上是至關難的。可是隨着 TS 2.8 新功能的發佈,咱們幾乎能夠隨意使用通用的 React 組件模式來實現類型安全的組件。
在本篇超長文中,多虧了 TS,咱們學習瞭如何實現具備多種模式且類型安全的組件。
綜合來看,其實最強大的模式非屬性渲染(Render Prop)莫屬,有了它,咱們能夠不費吹灰之力就能夠實現組件注入和高階組件。
文中全部的示範代碼託管於做者的 GitHub 倉庫。
最後,還有一點要強調的是,本文中涉及的類型安全模板可能只適用於使用 VDOM/JSX 的庫:
ngFor
中