TypeScript 2.8下的終極React元件模式

螞蟻金服資料體驗技術發表於2018-05-25

譯者簡介 zqlu 螞蟻金服·資料體驗技術團隊

翻譯自Ultimate React Component Patterns with Typescript 2.8,作者Martin Hochel

這篇部落格受React Component Patterns啟發而寫

線上Demo

有狀態元件、無狀態元件、預設屬性、Render回撥、元件注入、泛型元件、高階元件、受控元件

如果你瞭解我,你就已經知道我不編寫沒有型別定義的javascript程式碼,所以我從0.9版本後,就非常喜歡TypeScript了。除了有型別的JS,我也非常喜歡React庫,所以當把React和Typescript 結合在一起後,對我來說就像置身天堂一樣:)。整個應用程式和虛擬DOM中的完整的型別安全,是非常奇妙和開心的。

所以這篇文章說是關於什麼的呢?在網際網路上有各種關於React元件模式的文章,但沒有介紹如何將這些模式應用到Typescript中。此外,即將釋出的TS 2.8版本帶來了另人興奮的新功能如、如有條件的型別(conditional types)、標準庫中新預定義的條件型別、同態對映型別修飾符等等,這些新功能是我們能夠以型別安全的方式輕鬆地建立常見的React元件模式。

這篇文章篇幅會比較長,所以請你坐下放輕鬆,與此同時你將掌握Typescript下的 終極React元件模式。

所有的模式/例子均使用typescript 2.8版本和strict mode

準備開始

首先,我們需要安裝typescript和tslibs幫助程式庫,以便我們生出的程式碼更小

yarn add -D typescript@next
# tslib 將僅用與您的編譯目標不支援的功能
yarn add tslib
複製程式碼

有了這個,我們可以初始化我們的typescript配置:

# 這條命令將在我們的工程中建立預設配置 tsconfig.json 
yarn tsc --init
複製程式碼

現在我們來安裝 react、react-dom 和它們的型別定義。

yarn add react react-dom
yarn add -D @types/{react,react-dom}
複製程式碼

棒極啦!現在我們可以開始進入我們的元件模式吧,不是嗎?

無狀態元件

你猜到了,這些是沒有state的元件(也被稱為展示型元件)。在部分時候,它們也是純函式元件。讓我們用TypeScript建立人造的無狀態Button元件。

同使用原生JS一樣,我們需要引入React以便我們可以使用JSX

import React from 'react'

const Button = ({ onClick: handleClick, children }) => (
  <button onClick={handleClick}>{children}</button>
)
複製程式碼

雖然 tsc 編譯器現在還會跑出錯誤!我們需要顯式的告訴我們的元件/函式我們的props是什麼型別的。讓我們定義我們的 props:

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和其他(defaultProps、displayName等等…),所以我們不用每次都自己編寫!

所以最後的無狀態元件是這樣的:

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

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

const Button: SFC<Props> = ({ onClick: handleClick, children }) => (
  <button onClick={handleClick}>{children}</button>
);
複製程式碼

有狀態元件

讓我們使用我們的Button元件來建立有狀態的計數器元件。

首先我們需要定義initialState

const initialState = { clicksCount: 0 }
複製程式碼

現在我們將使用TypeScript來從我們的實現中推斷出State的型別。

這樣我們不需要分開維護我們的型別定義和實現,我們只有唯一的真相源,即我們的實現,太好了!

type State = Readonly<typeof initialState>
複製程式碼

另外請注意,該型別被明確對映為使所有的屬性均為只讀的。我們需要再次使用State型別來顯式地在我們的class上定義只讀的state屬性。

readonly state: State = initialState
複製程式碼

這麼做的作用是什麼?

我們知道我們在React中不能像下面這樣直接更新state

this.state.clicksCount = 2;
this.state = { clicksCount: 2 }
複製程式碼

這將導致執行時錯誤,但在編譯時不會報錯。通過顯式地使用Readonly對映我們的type State,和在我們的類定義中設定只讀的state屬性,TS將會讓我們立刻知道我們做錯了。

例子:編譯時的State型別安全

22.gif | left | 827x289

整個容器元件/有狀態元件的實現:

我們的容器元件還沒有任何Props API,所以我們需要將Compoent元件的第一個泛型引數定義為Object(因為在React中props永遠是物件{}),並使用State型別作為第二個泛型引數。

import React, { Component } from 'react';

import Button from './Button';

const initialState = { clicksCount: 0 };
type State = Readonly<typeof initialState>;

class ButtonCounter extends Component<object, State> {
  readonly state: State = initialState;

  render() {
    const { clicksCount } = this.state;
    return (
      <>
        <Button onClick={this.handleIncrement}>Increment</Button>
        <Button onClick={this.handleDecrement}>Decrement</Button>
        You've clicked me {clicksCount} times!
      </>
    );
  }

  private handleIncrement = () => this.setState(incrementClicksCount);
  private handleDecrement = () => this.setState(decrementClicksCount);
}

const incrementClicksCount = (prevState: State) => ({
  clicksCount: prevState.clicksCount + 1,
});
const decrementClicksCount = (prevState: State) => ({
  clicksCount: prevState.clicksCount - 1,
});
複製程式碼

你可能已經注意到了我們將狀態更新函式提取到類的外部作為純函式。這是一種常見的模式,這樣我們不需要了解渲染邏輯就可以簡單的測試這些狀態更新函式。此外,因為我們使用了TypeScript並將State顯式地對映為只讀的,它將阻止我們在這些函式中做一些更改狀態的操作:

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

// 這樣講丟擲編譯錯誤:
//
// [ts] Cannot assign to 'clicksCount' because it is a constant or a read-only property.
複製程式碼

非常酷是吧?:)


預設屬性

讓我們擴充套件我們的Button元件,新增一個string型別的顏色屬性。

type Props = {
  onClick(e: MouseEvent<HTMLElement>): void;
  color: string;
};
複製程式碼

如果我們想定義預設屬性,我們可以在我們的元件中通過Button.defaultProps = {…}來定義。

通過這樣做,我們需要改變我們的屬性型別定義來標記屬性是可選有預設值的。

所以定義是這樣的(注意?操作符)

type Props = {
  onClick(e: MouseEvent<HTMLElement>): void;
  color?: string;
};
複製程式碼

此時我們的元件實現是這樣的:

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

儘管這樣在我們簡單的例子中可用的,這有一個問題。因為我們在strict mode模式洗啊,可選的屬性color的型別是一個聯合型別undefined | string

比如我們想對color屬性做一些操作,TS將會丟擲一個錯誤,因為它並不知道它在React建立中通過Component.defaultProps中已經定義了:

33.gif | left | 813x255

為了滿足TS編譯器,我們可以使用下面3種技術:

  • 使用__!操作符__在render函式顯式地告訴編譯器這個變數不會是undefined,儘管它是可選的,如:<button onClick={handleClick!}>{children}</button>
  • 使用__條件語句/三目運算子__來讓編譯器明白一些屬性是沒有被定義的:<button onClick={handleClick ? handleClick : undefined}>{children}</button>
  • 建立可服用的__withDefaultProps__高階函式,它將更新我們的props型別定義和設定預設屬性。我認為這是最簡潔乾淨的方案。

我們可以很簡單的實現我們的高階函式(感謝TS 2.8種的條件型別對映):

export const withDefaultProps = <
  P extends object,
  DP extends Partial<P> = Partial<P>
>(
  defaultProps: DP,
  Cmp: ComponentType<P>,
) => {
  // 提取出必須的屬性
  type RequiredProps = Omit<P, keyof DP>;
  // 重新建立我們的屬性定義,通過一個相交型別,將所有的原始屬性標記成可選的,必選的屬性標記成可選的
  type Props = Partial<DP> & Required<RequiredProps>;

  Cmp.defaultProps = defaultProps;

  // 返回重新的定義的屬性型別元件,通過將原始元件的型別檢查關閉,然後再設定正確的屬性型別
  return (Cmp as ComponentType<any>) as ComponentType<Props>;
};
複製程式碼

現在我們可以使用withDefaultProps高階函式來定義我們的預設屬性,同時也解決了之前的問題:

const defaultProps = {
  color: 'red',
};

type DefaultProps = typeof defaultProps;
type Props = { onClick(e: MouseEvent<HTMLElement>): void } & DefaultProps;

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

const ButtonWithDefaultProps = withDefaultProps(defaultProps, Button);
複製程式碼

或者直接使用內聯(注意我們需要顯式的提供原始Button元件的屬性定義,TS不能從函式中推斷出引數的型別):

const ButtonWithDefaultProps = withDefaultProps<Props>(
  defaultProps,
  ({ onClick: handleClick, color, children }) => (
    <button style={{ color }} onClick={handleClick}>
      {children}
    </button>
  ),
);
複製程式碼

現在Button元件的屬性已經被正確的定義被使用的,預設屬性被反應出來並且在型別定義中是可選的,但在實現中是必選的!

{
    onClick(e: MouseEvent<HTMLElement>): void
    color?: string
}
複製程式碼

44.png | left | 827x83

元件使用方法仍然是一樣的:

render() {
    return (
        <ButtonWithDefaultProps
            onClick={this.handleIncrement}
        >
        	Increment
        </ButtonWithDefaultProps>
    )
}
複製程式碼

當然這也使用與通過class定義的元件(得益於TS中的類結構起源,我們不需要顯式指定我們的Props泛型型別)。

它看起來像這樣:

const ButtonViaClass = withDefaultProps(
  defaultProps,
  class Button extends Component<Props> {
    render() {
      const { onClick: handleClick, color, children } = this.props;
      return (
        <button style={{ color }} onClick={handleClick}>
          {Children}
        </button>
      );
    }
  },
);
複製程式碼

再次,它的使用方式仍然是一樣的:

render() {
  return (
    <ButtonViaClass onClick={this.handleIncrement}>Increment</ButtonViaClass>
  );
}
複製程式碼

比如說你需要構建一個可展開的選單元件,它需要在使用者點選它時顯示子內容。我們就可以使用各種各樣的元件模式來實現它。

render回撥/render屬性模式

實現元件的邏輯可複用的最好方式將元件的children放到函式中去,或者利用render屬性API——這也是為什麼Render回撥也被稱為函式子元件。

讓我們用render屬性方法實現一個Toggleable元件:

import React, { Component, MouseEvent } from 'react';
import { isFunction } from '../utils';

const initialState = {
  show: false,
};

type State = Readonly<typeof initialState>;
                      
type Props = Partial<{
  children: RenderCallback;
  render: RenderCallback;
}>;

type RenderCallback = (args: ToggleableComponentProps) => JSX.Element;
type ToggleableComponentProps = {
  show: State['show'];
  toggle: Toggleable['toggle'];
};

export class Toggleable extends Component<Props, State> {
  readonly state: State = initialState;

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

    if (render) {
      return render(renderProps);
    }

    return isFunction(children) ? children(renderProps) : null;
  }

  private toggle = (event: MouseEvent<HTMLElement>) =>
    this.setState(updateShowState);
}

const updateShowState = (prevState: State) => ({ show: !prevState.show });
複製程式碼

這裡都發生了什麼,讓我們來分別看看重要的部分:

const initialState = {
  show: false,
};
type State = Readonly<typeof initialState>;
複製程式碼
  • 這裡我們和前面的例子一樣宣告瞭我們的state

現在我們來定義元件的props(注意這裡我們使用了Partitial對映型別,因為我們所有的屬性都是可選的,不用分別對每個屬性手動新增?識別符號):

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

type RenderCallback = (args: ToggleableComponentProps) => JSX.Element;
type ToggleableComponentProps = {
  show: State['show'];
  toggle: Toggleable['toggle'];
};
複製程式碼

我們需要同時支援child作為函式,和render屬性作為函式,它們兩者都是可選的。為了避免重複程式碼,我們定義了RenderCallback作為我們的渲染函式定義:

type RenderCallback = (args: ToggleableComponentProps) => JSX.Element
複製程式碼

在讀者眼中看起來比較奇怪的部分是我們最後的別名型別:type ToggleableComponentProps

type ToggleableComponentProps = {
  show: State['show'];
  toggle: Toggleable['toggle'];
};
複製程式碼

這裡我們使用了TypeScript的__查詢型別(lookup types)__,所以我們又不需要重複地去定義型別了:

  • show: State['show']我們利用已有的state型別定義了show屬性
  • toggle: Toggleable['toggle']我們利用了TS從類實現推斷類型別來定義toggle屬性。很好用而且非常強大。

剩下的實現部分很簡單,標準的render屬性/children作為函式的模式:

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

    if (render) {
      return render(renderProps);
    }

    return isFunction(children) ? children(renderProps) : null;
  }
  // ...
}
複製程式碼

現在我們可以把函式作為children傳給Toggleable元件了:

<Toggleable>
  {({ show, toggle }) => (
    <>
      <div onClick={toggle}>
        <h1>some title</h1>
      </div>
      {show ? <p>some content</p> : null}
    </>
  )}
</Toggleable>
複製程式碼

或者我們可以把函式作為render屬性:

<Toggleable
  render={({ show, toggle }) => (
    <>
      <div onClick={toggle}>
        <h1>some title</h1>
      </div>
      {show ? <p>some content</p> : null}
    </>
  )}
/>
複製程式碼

感謝TypeScript,我們在render屬性的引數有了智慧提示和正確的型別檢查:

55.gif | left | 674x370

如果我們想複用它(比如用在多個選單元件中),我們只需要建立一個使用Toggleable邏輯的心元件:

type Props = { title: string }
const ToggleableMenu: SFC<Props> = ({ title, children }) => (
  <Toggleable
    render={({ show, toggle }) => (
      <>
        <div onClick={toggle}>
          <h1>title</h1>
        </div>
        {show ? children : null}
      </>
    )}
  />
)
複製程式碼

現在我們全新的__ToggleableMenu__元件已經可以在Menu元件中使用了:

export class Menu extends Component {
  render() {
    return (
      <>
        <ToggleableMenu title="First Menu">Some content</ToggleableMenu>
        <ToggleableMenu title="Second Menu">Some content</ToggleableMenu>
        <ToggleableMenu title="Third Menu">Some content</ToggleableMenu>
      </>
    );
  }
}
複製程式碼

並且它也像我們期望的那樣工作了:

66.gif | left | 647x479

這中模式在我們想更改渲染的內容,而不關心狀態改變的情況下非常有用:可以看到,我們將渲染邏輯移到ToggleableMenu元件的額children函式中了,但把狀態管理邏輯保留在我們的Toggleable元件中!

元件注入

為了讓我們的元件更靈活,我們可以引入元件注入模式。

什麼是元件注入模式呢?如果你對React-Router比較熟悉,那你已經在下面這樣路由定義的時候使用這種模式了:

<Route path="/foo" component={MyView} />
複製程式碼

這樣我們不是把函式傳遞給render/children屬性,而是通過component屬性“注入”元件。為此,我們可以重構,把我們的內建render屬性函式改成一個可複用的無狀態元件:

type MenuItemProps = { title: string };
const MenuItem: SFC<MenuItemProps & ToggleableComponentProps> = ({
  title,
  toggle,
  show,
  children,
}) => (
  <>
    <div onClick={toggle}>
      <h1>{title}</h1>
    </div>
    {show ? children : null}
  </>
);
複製程式碼

有了這個,我們可以使用render屬性重構ToggleableMenu

type Props = { title: string };
const ToggleableMenu: SFC<Props> = ({ title, children }) => (
  <Toggleable
    render={({ show, toggle }) => (
      <MenuItem show={show} toggle={toggle} title={title}>
        {children}
      </MenuItem>
    )}
  />
);
複製程式碼

這個完成之後,讓我們來開始定義我們新的API——compoent屬性。

我們需要更新我們的屬性API。

  • children現在可以是函式或者ReactNode(當component屬性被使用時)
  • component是我們新的API,它可以接受實現了ToggleableComponentProps屬性的元件,並且它需要是設定為any的泛型,這樣各種各樣的實現元件可以新增其他屬性到ToggleableComponentProps並通過TS的驗證
  • props我們引入可以傳入任意屬性的定義。它被定義成any型別的可索引型別,這裡我們放鬆了嚴格的型別安全檢查...
// 我們需要使用我們任意的props型別來建立 defaultProps,預設是一個空物件
const defaultProps = { props: {} as { [name: string]: any } };
type Props = Partial<
  {
    children: RenderCallback | ReactNode;
    render: RenderCallback;
    component: ComponentType<ToggleableComponentProps<any>>;
  } & DefaultProps
>;
type DefaultProps = typeof defaultProps;
複製程式碼

下一步,我們需要新增新的屬性API到ToggleableComponentProps上,以便使用者可以通過<Toggleable props={...} />來使用我們的props屬性:

export type ToggleableComponentProps<P extends object = object> = {
  show: State['show'];
  toggle: Toggleable['toggle'];
} & P;
複製程式碼

然後我們需要更新我們的render函式:

  render() {
    const {
      component: InjectedComponent,
      props,
      render,
      children,
    } = 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);
    }

    return isFunction(children) ? children(renderProps) : null;
  }
複製程式碼

完整的Toggleable元件實現如下,支援 render 屬性、children作為函式、元件注入功能:

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

import { isFunction, getHocComponentName } from '../utils';

const initialState = { show: false };
const defaultProps = { props: {} as { [name: string]: any } };

type State = Readonly<typeof initialState>;
type Props = Partial<
  {
    children: RenderCallback | ReactNode;
    render: RenderCallback;
    component: ComponentType<ToggleableComponentProps<any>>;
  } & DefaultProps
>;

type DefaultProps = typeof defaultProps;
type RenderCallback = (args: ToggleableComponentProps) => JSX.Element;

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

export class Toggleable extends Component<Props, State> {
  static readonly defaultProps: Props = defaultProps;
  readonly state: State = initialState;

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

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

    if (render) {
      return render(renderProps);
    }

    return isFunction(children) ? children(renderProps) : null;
  }

  private toggle = (event: MouseEvent<HTMLElement>) =>
    this.setState(updateShowState);
}

const updateShowState = (prevState: State) => ({ show: !prevState.show });
複製程式碼

我們最終使用component屬性的ToggleableMenuViaComponentInjection元件是這樣的:

const ToggleableMenuViaComponentInjection: SFC<ToggleableMenuProps> = ({
  title,
  children,
}) => (
  <Toggleable component={MenuItem} props={{ title }}>
    {children}
  </Toggleable>
);
複製程式碼

請注意,這裡我們的props屬性沒有嚴格的型別安全檢查,因為它被定義成索引物件型別{ [name: string]: any }:

77.gif | left | 827x279

我們可以還是像之前一樣使用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元件實現為一個泛型元件!

首先我們需要把我們的屬性泛型化。我們使用預設的泛型引數,所以我們不需要在沒必要的時候顯式地提供型別(針對 render 屬性和 children 作為函式)。

type Props<P extends object = object> = Partial<
  {
    children: RenderCallback | ReactNode;
    render: RenderCallback;
    component: ComponentType<ToggleableComponentProps<P>>;
  } & DefaultProps<P>
>;
複製程式碼

我們也需要把ToggleableComponnetProps更新成泛型的。不,等等,它已經是泛型啦!所以還不需要做任何更改。

需要更新的是type DefaultProps,因為不支援從宣告實現推倒出泛型型別定義,所以需要把它重構成傳統的型別定義->實現:

type DefaultProps<P extends object = object> = { props: P };
const defaultProps: DefaultProps = { props: {} };
複製程式碼

就快好啦!

現在讓我們把元件類也泛型化。再次說明,我們使用了預設的屬性,所以在沒有使用元件注入的時候不需要去指定泛型引數!

export class Toggleable<T = {}> extends Component<Props<T>, State> {}
複製程式碼

這樣就完成了嗎?嗯…,我們可以在JSX中使用泛型型別嗎?

壞訊息是,不能...

但我們可以在泛型元件上引入ofType的工場模式:

export class Toggleable<T = {}> extends Component<Props<T>, State> {
  static ofType<T extends object>() {
    return Toggleable as Constructor<Toggleable<T>>;
  }
}
複製程式碼

完整的 Toggleable 元件實現,支援 Render 屬性、Children 作為函式、帶泛型 props 屬性支援的元件注入:

import React, {
  Component,
  ReactNode,
  ComponentType,
  MouseEvent,
  SFC,
} from 'react';

import { isFunction, getHocComponentName } from '../utils';

const initialState = { show: false };
// const defaultProps = { props: {} as { [name: string]: any } };

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

type DefaultProps<P extends object = object> = { props: P };
const defaultProps: DefaultProps = { props: {} };
type RenderCallback = (args: ToggleableComponentProps) => JSX.Element;

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

export class Toggleable<T = {}> extends Component<Props<T>, State> {
  static ofType<T extends object>() {
    return Toggleable as Constructor<Toggleable<T>>;
  }
  static readonly defaultProps: Props = defaultProps;
  readonly state: State = initialState;

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

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

    if (render) {
      return render(renderProps);
    }

    return isFunction(children) ? children(renderProps) : null;
  }

  private toggle = (event: MouseEvent<HTMLElement>) =>
    this.setState(updateShowState);
}

const updateShowState = (prevState: State) => ({ show: !prevState.show });
複製程式碼

有了static ofType工廠函式後,我們可以建立正確型別的泛型元件了。

type MenuItemProps = { title: string };
// ofType 是一種標識函式,返回的是相同實現的 Toggleable 元件,但帶有制定的 props 型別
const ToggleableWithTitle = Toggleable.ofType<MenuItemProps>();

type ToggleableMenuProps = MenuItemProps;
const ToggleableMenuViaComponentInjection: SFC<ToggleableMenuProps> = ({
  title,
  children,
}) => (
  <ToggleableWithTitle component={MenuItem} props={{ title }}>
    {children}
  </ToggleableWithTitle>
);
複製程式碼

並且所有的東西都還像一起一樣工作,但這次我有的 props={} 屬性有了正確的型別檢查。鼓掌吧!

Type Safe | left

高階元件

因為我們已經建立了帶render回撥功能的Toggleable元件,實現HOC也會很容易。(這也是 render 回撥函式模式的一個大優勢,因為我們可以使用HOC來實現)

讓我們開始實現我們的HOC元件吧:

我們需要建立:

  • displayName (以便我們在devtools可以很好地除錯)
  • WrappedComponent (以便我們能夠獲取原始的元件——對測試很有用)
  • 使用hoist-non-react-staticsnpm包中的hoistNonReactStatics
import React, { ComponentType, Component } from 'react';
import hoistNonReactStatics from 'hoist-non-react-statics';

import { getHocComponentName } from '../utils';

import {
  Toggleable,
  Props as ToggleableProps,
  ToggleableComponentProps,
} from './RenderProps';

// OwnProps 是內部元件上任意公開的屬性
type OwnProps = object;
type InjectedProps = ToggleableComponentProps;

export const withToggleable = <OriginalProps extends object>(
  UnwrappedComponent: ComponentType<OriginalProps & InjectedProps>,
) => {
  // 我們使用 TS 2.8 中的條件對映型別來得到我們最終的屬性型別
  type Props = Omit<OriginalProps, keyof InjectedProps> & OwnProps;

  class WithToggleable extends Component<Props> {
    static readonly displayName = getHocComponentName(
      WithToggleable.displayName,
      UnwrappedComponent,
    );
    static readonly UnwrappedComponent = UnwrappedComponent;

    render() {
      const { ...rest } = this.props;

      return (
        <Toggleable
          render={renderProps => (
            <UnwrappedComponent {...rest} {...renderProps} />
          )}
        />
      );
    }
  }

  return hoistNonReactStatics(WithToggleable, UnwrappedComponent);
};
複製程式碼

現在我們可以使用HOC來建立我們的Toggleable選單元件了,並有正確的型別安全檢查!

const ToggleableMenuViaHOC = withToggleable(MenuItem)
複製程式碼

一切正常,還有型別安全檢查!好極了!

99.gif | left | 812x293

受控元件

這是最後一個元件模式了!假設我們想從父元件中控制我們的Toggleable元件,我們需要Toggleable元件配置化。這是一種很強大的模式。讓我們來實現它吧。

當我說受控元件時,我指的是什麼?我想從Menu元件內控制所以的ToggleableManu元件的內容是否顯示。

100.gif | left | 656x512

我們需要像這樣更新我們的ToggleableMenu元件的實現:

// 更新我們的屬性型別,以便我們可以通過 show 屬性來控制是否顯示
type Props = MenuItemProps & { show?: boolean };

// 注意:這裡我們使用了結構來建立變數別,為了不和 render 回撥函式的 show 引數衝突
// -> { show: showContent }

// Render 屬性
export const ToggleMenu: SFC<ToggleableComponentProps> = ({
  title,
  children,
  show: showContent,
}) => (
  <Toggleable show={showContent}>
    {({ show, toggle }) => (
      <MenuItem title={title} toggle={toggle} show={show}>
        {children}
      </MenuItem>
    )}
  </Toggleable>
);

// 元件注入
const ToggleableWithTitle = Toggleable.ofType<MenuItemProps>();

export const ToggleableMenuViaComponentInjection: SFC<Props> = ({
  title,
  children,
  show: showContent,
}) => (
  <ToggleableWithTitle
    component={MenuItem}
    props={{ title }}
    show={showContent}
  >
    {children}
  </ToggleableWithTitle>
);

// HOC不需要更改
export const ToggleMenuViaHOC = withToggleable(MenuItem);
複製程式碼

有了這些更新後,我們可以在Menu中新增狀態,並傳遞給ToggleableMenu

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

export class Menu extends Component<object, State> {
  readonly state: State = initialState;
  render() {
    const { showContents } = this.state;
    return (
      <>
        <button onClick={this.toggleShowContents}>toggle showContent</button>
        <hr />
        <ToggleableMenu title="First Menu" show={showContents}>
          Some Content
        </ToggleableMenu>
        <ToggleableMenu title="Second Menu" show={showContents}>
          Another Content
        </ToggleableMenu>
        <ToggleableMenu title="Third Menu" show={showContents}>
          More Content
        </ToggleableMenu>
      </>
    );
  }
}
複製程式碼

讓我們為了最終的功能和靈活性最後一次更新Toggleable元件。為了讓 Toggleable 變成受控元件我們需要:

  1. 新增show屬性到PropsAPI上
  2. 更新預設的屬性(因為show是可選的)
  3. 從Props.show更新元件的初始化state,因為現在我們狀態中值可能取決於父元件傳來的屬性
  4. 在componentWillReceiveProps生命週期函式中從props更新state

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) })
    }
  }
}
複製程式碼

最終支援所有所有模式(Render屬性/Children作為函式/元件注入/泛型元件/受控元件)的 Toggleable 元件:

import React, { Component, MouseEvent, ComponentType, ReactNode } from 'react'

import { isFunction, getHocComponentName } from '../utils'

const initialState = { show: false }
const defaultProps: DefaultProps = { ...initialState, props: {} }
type State = Readonly<typeof initialState>
export type Props<P extends object = object> = Partial<
  {
    children: RenderCallback | ReactNode
    render: RenderCallback
    component: ComponentType<ToggleableComponentProps<P>>
  } & DefaultProps<P>
>
type RenderCallback = (args: ToggleableComponentProps) => JSX.Element
export type ToggleableComponentProps<P extends object = object> = {
  show: State['show']
  toggle: Toggleable['toggle']
} & P
type DefaultProps<P extends object = object> = { props: P } & Pick<State, 'show'>

export class Toggleable<T extends object = object> extends Component<Props<T>, State> {
  static ofType<T extends object>() {
    return Toggleable as Constructor<Toggleable<T>>
  }
  static readonly defaultProps: Props = defaultProps
  readonly state: State = { show: this.props.show! }

  componentWillReceiveProps(nextProps: Props<T>, nextContext: any) {
    const currentProps = this.props

    if (nextProps.show !== currentProps.show) {
      this.setState({ show: Boolean(nextProps.show) })
    }
  }
  render() {
    const { component: InjectedComponent, children, render, props } = this.props
    const renderProps = { show: this.state.show, toggle: this.toggle }

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

    if (render) {
      return render(renderProps)
    }

    return isFunction(children) ? children(renderProps) : new Error('asdsa()')
  }
  private toggle = (event: MouseEvent<HTMLElement>) => this.setState(updateShowState)
}

const updateShowState = (prevState: State) => ({ show: !prevState.show })
複製程式碼

最終的Toggleable HOC 元件 withToggleable

只需要稍作修改 -> 我們需要在HOC元件中傳遞 show 屬性,並更新我們的OwnPropsAPI

import React, { ComponentType, Component } from 'react'
import hoistNonReactStatics from 'hoist-non-react-statics'

import { getHocComponentName } from '../utils'

import {
  Toggleable,
  Props as ToggleableProps,
  ToggleableComponentProps as InjectedProps,
} from './toggleable'

// OwnProps is for any public props that should be available on internal Component.props
// and for WrappedComponent
type OwnProps = Pick<ToggleableProps, 'show'>

export const withToogleable = <OriginalProps extends object>(
  UnwrappedComponent: ComponentType<OriginalProps & InjectedProps>
) => {
  // we are leveraging TS 2.8 conditional mapped types to get proper final prop types
  type Props = Omit<OriginalProps, keyof InjectedProps> & OwnProps
  class WithToggleable extends Component<Props> {
    static readonly displayName = getHocComponentName(
      WithToggleable.displayName,
      UnwrappedComponent
    )
    static readonly WrappedComponent = UnwrappedComponent
    render() {
      // Generics and spread issue
      // https://github.com/Microsoft/TypeScript/issues/10727
      const { show, ...rest } = this.props as Pick<Props, 'show'> // we need to explicitly pick props we wanna destructure, rest is gonna be type `{}`
      return (
        <Toggleable
          show={show}
          render={renderProps => <UnwrappedComponent {...rest} {...renderProps} />}
        />
      )
    }
  }

  return hoistNonReactStatics(WithToggleable, UnwrappedComponent as any) as ComponentType<Props>
}
複製程式碼

總結

使用 TypeScript 和 React 時,實現恰當的型別安全元件可能會很棘手。但隨著 TypeScript 2.8中新加入的功能,我們幾乎可以在所有的 React 元件模式中編寫型別安全的元件。

在這遍非常長(對此十分抱歉)文章中,感謝TypeScript,我們已經學會了在各種各樣的模式下怎麼編寫嚴格型別安全檢查的元件。

在這些模式中最強的應該是Render屬性模式,它讓我們可以在此基礎上不需要太多改動就可以實現其他常見的模式,如元件注入、高階元件等。

文中所有的demo都可以在我的 Github 倉庫中找到。

此外,需要明白的是,本文中演示的模版型別安全,只能在使用 VDOM/JSX 的庫中實現。

  • Angular 模版有 Language service 提供型別安全,但像 ngFor 等簡單的構造檢查好像都不行...
  • Vue 的模版不像 Angular,它們的模版和資料繫結只是神奇的字串(但這有可能在未來會改變。儘管你可以在模版中使用VDOM,但因為各種型別的屬性定義,它使用起來十分笨重(這怪 snabdom...))

和往常一樣,如果你有任何問題,可以在這或者 twitter(@martin_hotell)聯絡我,另外,快樂的型別檢查夥伴們,乾杯!

對我們團隊感興趣的可以關注專欄,關注github或者傳送簡歷至'tao.qit####alibaba-inc.com'.replace('####', '@'),歡迎有志之士加入~

原文地址:github.com/ProtoTeam/b…

相關文章