With TypeScript 2.8+ :更好的 React 組件開發模式

近兩年來一直在關注 React 開發,最近也開始全面應用 TypeScript 。國內有不少講解 React 和 TypeScript 的教程,但如何將 TypeScript 更好地應用到 React 組件開發模式的文章卻幾乎沒有(也多是我沒找到),特別是 TS 的一些新特性,如:條件類型、條件類型中的類型引用等。這些新特性如何應用到 React 組件開發?沒辦法只能去翻一些國外的文章,結合 TS 的官方文檔慢慢摸索... 因而就有了想法把這個過程整理成文檔。html

本文內容很長,但願你有個舒服的椅子,咱們立刻開始。react

全部代碼均使用 React 16.三、TypeScript 2.9 + strict mode 編寫
所有示例代碼都在這裏

開始

本文假設你已經對 React、TypeScript 有必定的瞭解。我不會講到例如:webpack 打包、Babel 轉碼、TypeScript 編譯選項這一類的問題,而將一切焦點放在如何將 TS 2.8+ 更好地應用到 React 組件設計模式中。webpack

首先,咱們從無狀態組件開始。git

無狀態組件

無狀態組件就是沒有 state 的,一般咱們也叫作純函數組件。用原生 JS 咱們能夠這樣寫一個按鈕組件:github

import React from 'react';

const Button = ({onClick: handleClick, children}) => (
  <button onClick={handleClick}>{children}</button>
);

若是你把代碼直接放到 .tsx 文件中,tsc 編譯器立刻會提示錯誤:有隱含的 any 類型,由於用了嚴格模式。咱們必須明確的定義組件屬性,修改一下:web

import React, { MouseEvent, ReactNode } from 'react';

interface Props { 
 onClick(e: MouseEvent<HTMLElement>): void;
 children?: ReactNode;
};

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

OK,錯誤沒有了!好像已經完事了?其實再花點心思能夠作的更好。typescript

React 中有個預約義的類型,SFC設計模式

type SFC<P = {}> = StatelessComponent<P>;

他是 StatelessComponent 的一個別名,而 StatelessComponent 聲明瞭純函數組件的一些預約義示例屬性和靜態屬性,如:childrendefaultPropsdisplayName 等,因此咱們不須要本身寫全部的東西!數組

最後咱們的代碼是這樣的:安全

圖片描述

有狀態的類組件

接着咱們來建立一個計數器按鈕組件。首先咱們定義初始狀態:

const initialState = {count: 0};

而後,定義一個別名 State 並用 TS 推斷出類型:

type State = Readonly<typeof initialState>;
知識點:這樣作不用分開維護接口聲明和實現代碼,比較實用的技巧

同時應該注意到,咱們將全部的狀態屬性聲明爲 readonly 。而後咱們須要明肯定義 state 爲組件的實例屬性:

readonly state: State = initialState;

爲何要這樣作?咱們知道在 React 中咱們不能直接改變 State 的屬性值或者 State 自己:

this.state.count = 1; 
this.state = {count: 1};

若是這樣作在運行時將會拋出錯誤,但在編寫代碼時卻不會。因此咱們須要明確的聲明 readonly ,這樣 TS 會讓咱們知道若是執行了這種操做就會出錯了:

圖片描述

下面是完整的代碼:

這個組件不須要外部傳遞任何 Props ,因此泛型的第一個參數給的是不帶任何屬性的對象

圖片描述

屬性默認值

讓咱們來擴展一下純函數按鈕組件,加上一個顏色屬性:

interface Props {
    onClick(e: MouseEvent<HTMLElement>): void;
    color: string;
}

若是想要定義屬性默認值的話,咱們知道能夠經過 Button.defaultProps = {...} 作到。而且咱們須要把這個屬性聲明爲可選屬性:(注意屬性名後的 ?

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

那麼組件如今看起來是這樣的:

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

一切看起來好像都很簡單,可是這裏有一個「痛點」。注意咱們使用了 TS 的嚴格模式,color?: string 這個可選屬性的類型如今是聯合類型 -- string | undefined

這意味着什麼?若是你要對這種屬性進行一些操做,好比:substr() ,TS 編譯器會直接報錯,由於類型有多是 undefined ,TS 並不知道屬性默認值會由 Component.defaultProps 來建立。

碰到這種狀況咱們通常用兩種方式來解決:

  • 使用類型斷言手動去除,添加 ! 後綴,像這樣:color!.substr(...)
  • 使用條件判斷或者三元操做符讓 TS 編譯器知道這個屬性不是 undefined,好比: if (color) ...

以上的方式雖然能夠工做但有種畫蛇添足的感受,畢竟默認值已經有了只是 TS 編譯器「不知道」而已。下面來講一種可重用的方案:咱們寫一個 withDefaultProps 函數,利用 TS 2.8 的條件類型映射,能夠很簡單的完成:

圖片描述

這裏涉及到兩個 type 定義,寫在 src/types/global.d.ts 文件裏面:

declare type DiffPropertyNames<T extends string | number | symbol, U> =
    { [P in T]: P extends U ? never: P }[T];

declare type Omit<T, K> = Pick<T, DiffPropertyNames<keyof T, K>>;

看一下 TS 2.8 的新特性說明 關於 Conditional Types 的說明,就知道這兩個 type 的原理了。

注意 TS 2.9 的新變化: keyof T 的類型是 string | number | symbol 的結構子類型。

如今咱們能夠利用 withDefaultProps 函數來寫一個有屬性默認值的組件了:

圖片描述

如今使用這個組件時默認值屬性已經發生做用,是可選的;而且在組件內部使用這些默認值屬性不用再手動斷言了,這些默認值屬性就是必填屬性!感受還不錯對吧

withDefautProps 函數一樣能夠應用在 stateful 有狀態的類組件上。

渲染回調模式

有一種重用組件邏輯的設計方式是:把組件的 children 寫成渲染回調函數或者暴露一個 render 函數屬性出來。咱們將用這種思路來作一個摺疊面板的場景應用。

首先咱們先寫一個 Toggleable 組件,完整的代碼以下:

圖片描述

下面咱們來逐段解釋下這段代碼,首先先看到組件的屬性聲明相關部分:

type Props = Partial<{
    children: RenderCallback;
    render: RenderCallback;
}>;

type RenderCallback = (args: ToggleableRenderArgs) => React.ReactNode;

type ToggleableRenderArgs = {
    show: boolean;
    toggle: Toggleable['toggle'];
}

咱們須要同時支持 childrenrender 函數屬性,因此這兩個要聲明爲可選的屬性。注意這裏用了 Partial 映射類型,這樣就不須要每一個手動 ? 操做符來聲明可選了。

爲了保持 DRY 原則(Don't repeat yourself ),咱們還聲明瞭 RenderCallback 類型。

最後,咱們將這個回調函數的參數聲明爲一個獨立的類型:ToggleableRenderArgs

注意咱們使用了 TS 的查找類型lookup types ),這樣 toggle 的類型將和類中定義的同名方法類型保持一致:

private toggle = (event: MouseEvent<HTMLElement>) => {
    this.setState(prevState => ({show: !prevState.show}));
};
一樣是爲了 DRY ,TS 很是給力!

接下來是 State 相關的:

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

這個沒什麼特別的,跟前面的例子同樣。

剩下的部分就是 渲染回調 設計模式了,代碼很好理解:

class Toggleable extends Component<Props, State> {

    // ...

    render() {
        const {children, render} = this.props;
        const {show} = this.state;
        const renderArgs = {show, toggle: this.toggle};

        if (render) {
            return render(renderArgs);
        } else if (isFunction(children)) {
            return children(renderArgs);
        } else {
            return null;
        }
    }

    // ...
}

如今咱們能夠將 children 做爲一個渲染函數傳遞給 Toggleable 組件:

圖片描述

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

圖片描述

下面咱們來完成摺疊面板剩下的工做,先寫一個 Panel 組件來重用 Toggleable 的邏輯:

圖片描述

最後寫一個 Collapse 組件來完成這個應用:

圖片描述

這裏咱們不談樣式的事情,運行起來看看,跟期待的效果是否一致?

圖片描述

這種方式對於須要擴展渲染內容時很是有用:Toggleable 組件並不知道也不關心具體的渲染內容,但他控制着顯示狀態邏輯!

組件注入模式

爲了使組件邏輯更具伸縮性,下面咱們來講說組件注入模式。

那麼什麼是組件注入模式呢?若是你用過 React-Router ,你已經使用過這種模式來定義路由了:

<Route path="/example" component={Example}/>

不一樣於渲染回調模式,咱們使用 component 屬性「注入」一個組件。爲了演示這個模式是如何工做的,咱們將重構摺疊面板這個場景,首先寫一個可重用的 PanelItem 組件:

import { ToggleableComponentProps } from './Toggleable';

type PanelItemProps = { title: string };

const PanelItem: SFC<PanelItemProps & ToggleableComponentProps> = props => {
    const {title, children, show, toggle} = props;

    return (
        <div onClick={toggle}>
            <h1>{title}</h1>
            {show ? children : null}
        </div>
    );
};

而後重構 Toggleable 組件:加入新的 component 屬性。對比先頭的代碼,咱們須要作出以下變化:

  • children 屬性類型更改成 function 或者 ReactNode(當使用 component 屬性時)
  • component 屬性將傳遞一個組件注入進去,這個注入組件的屬性定義上須要有 ToggleableComponentProps (實際上是原來的 ToggleableRenderArgs ,還記得嗎?)
  • 還須要定義一個 props 屬性,這個屬性將用來傳遞注入組件須要的屬性值。咱們會設置 props 能夠擁有任意的屬性,由於咱們並不知道注入組件會有哪些屬性,固然這樣咱們會丟失 TS 的嚴格類型檢查...
const defaultInjectedProps = {props: {} as { [propName: string]: any }};
type DefaultInjectedProps = typeof defaultInjectedProps;
type Props = Partial<{
    children: RenderCallback | ReactNode;
    render: RenderCallback;
    component: ComponentType<ToggleableComponentProps<any>>
} & DefaultInjectedProps>;

下一步咱們把原來的 ToggleableRenderArgs 修改成 ToggleableComponentProps ,容許將注入組件須要的屬性經過 <Toggleable props={...}/> 這樣來傳遞:

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

如今咱們還須要重構一下 render 方法:

render() {
    const {component: InjectedComponent, children, render, props} = this.props;
    const {show} = this.state;
    const renderProps = {show, toggle: this.toggle};

    if (InjectedComponent) {
        return (
            <InjectedComponent {...props} {...renderProps}>
                {children}
            </InjectedComponent>
        );
    }

    if (render) {
        return render(renderProps);
    } else if (isFunction(children)) {
        return children(renderProps);
    } else {
        return null;
    }
}

咱們已經完成了整個 Toggleable 組件的修改,下面是完整的代碼:

圖片描述

最後咱們寫一個 PanelViaInjection 組件來應用組件注入模式:

import React, { SFC } from 'react';
import { Toggleable } from './Toggleable';
import { PanelItemProps, PanelItem } from './PanelItem';

const PanelViaInjection: SFC<PanelItemProps> = ({title, children}) => (
    <Toggleable component={PanelItem} props={{title}}>
        {children}
    </Toggleable>
);
注意: props 屬性沒有類型安全檢查,由於他被定義爲了包含任意屬性的可索引類型:
{ [propName: string]: any }

如今咱們能夠利用這種方式來重現摺疊面板場景了:

class Collapse extends Component {

    render() {
        return (
            <div>
                <PanelViaInjection title="標題一"><p>內容1</p></PanelViaInjection>
                <PanelViaInjection title="標題二"><p>內容2</p></PanelViaInjection>
                <PanelViaInjection title="標題三"><p>內容3</p></PanelViaInjection>
            </div>
        );
    }
}

泛型組件

在組件注入模式的例子中,props 屬性丟失了類型安全檢查,咱們如何去修復這個問題呢?估計你已經猜出來了,咱們能夠把 Toggleable 組件重構爲泛型組件!

下來咱們開始重構 Toggleable 組件。首先咱們須要讓 props 支持泛型:

type DefaultInjectedProps<P extends object = object> = { props: P };
const defaultInjectedProps: DefaultInjectedProps = {props: {}};
                          
type Props<P extends object = object> = Partial<{
    children: RenderCallback | ReactNode;
    render: RenderCallback;
    component: ComponentType<ToggleableComponentProps<P>>
} & DefaultInjectedProps<P>>;

而後讓 Toggleable 的 class 也支持泛型:

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

看起來好像已經搞定了!若是你是用的 TS 2.9,能夠直接這樣用:

const PanelViaInjection: SFC<PanelItemProps> = ({title, children}) => (
     <Toggleable<PanelItemProps> component={PanelItem} props={{title}}>
         {children}
     </Toggleable>
);

可是若是 <= TS 2.8 ... JSX 裏面不能直接應用泛型參數 那麼咱們還有一步工做要作,加入一個靜態方法 ofType ,用來進行構造函數的類型轉換:

static ofType<T extends object>() {
    return Toggleable as Constructor<Toggleable<T>>;
}

這裏用到一個 type:Constructor,依然定義在 src/types/global.d.ts 裏面:

declare type Constructor<T = {}> = { new(...args: any[]): T };

好了,咱們完成了全部的工做,下面是 Toggleable 重構後的完整代碼:

如今咱們來看看怎麼使用這個泛型組件,重構下原來的 PanelViaInjection 組件:

import React, { SFC } from 'react';
import { Toggleable } from './Toggleable';
import { PanelItemProps, PanelItem } from './PanelItem';

const ToggleableOfPanelItem = Toggleable.ofType<PanelItemProps>();

const PanelViaInjection: SFC<PanelItemProps> = ({title, children}) => (
    <ToggleableOfPanelItem component={PanelItem} props={{title}}>
        {children}
    </ToggleableOfPanelItem>
);

全部的功能都能像原來的代碼同樣工做,而且如今 props 屬性也支持 TS 類型檢查了,很棒有木有!

圖片描述

高階組件

最後咱們來看下 HOC 。前面咱們已經實現了 Toggleable 的渲染回調模式,那麼很天然的咱們能夠衍生出一個 HOC 組件。

若是對 HOC 不熟悉的話,能夠先看下 React 官方文檔對於 HOC 的說明。

先來看看定義 HOC 咱們須要作哪些工做:

  • displayName (方便在 devtools 裏面進行調試)
  • WrappedComponent (能夠訪問原始的組件 -- 有利於調試)
  • 引入 hoist-non-react-statics 包,將原始組件的靜態方法所有「複製」到 HOC 組件上

下面直接上代碼 -- withToggleable 高階組件:

圖片描述

如今咱們來用 HOC 重寫一個 Panel :

import { PanelItem } from './PanelItem';
import withToggleable from './withToggleable';

const PanelViaHOC = withToggleable(PanelItem);

而後,又能夠實現摺疊面板了

class Collapse extends Component {

    render() {
        return (
            <div>
                <PanelViaHOC title="標題一"><p>內容1</p></PanelViaHOC>
                <PanelViaHOC title="標題二"><p>內容2</p></PanelViaHOC>
            </div>
        );
    }
}

尾聲

感謝能堅持看完的朋友,大家真的很棒!
全部的示例代碼都在 這裏 ,若是以爲還不錯幫忙給個 star

最後,感謝 Anders Hejlsberg 和全部的 TS 貢獻者

相關文章
相關標籤/搜索