TypeScript 2.8下的终极React组件模式

译者简介 zqlu 蚂蚁金服·数据体验技术团队javascript

翻译自Ultimate React Component Patterns with Typescript 2.8,做者Martin Hocheljava

这篇博客受React Component Patterns启发而写react

在线Demogit

有状态组件、无状态组件、默认属性、Render回调、组件注入、泛型组件、高阶组件、受控组件

若是你了解我,你就已经知道我不编写没有类型定义的javascript代码,因此我从0.9版本后,就很是喜欢TypeScript了。除了有类型的JS,我也很是喜欢React库,因此当把React和Typescript 结合在一块儿后,对我来讲就像置身天堂同样:)。整个应用程序和虚拟DOM中的完整的类型安全,是很是奇妙和开心的。github

因此这篇文章说是关于什么的呢?在互联网上有各类关于React组件模式的文章,但没有介绍如何将这些模式应用到Typescript中。此外,即将发布的TS 2.8版本带来了另人兴奋的新功能如、若有条件的类型(conditional types)、标准库中新预约义的条件类型、同态映射类型修饰符等等,这些新功能是咱们可以以类型安全的方式轻松地建立常见的React组件模式。typescript

这篇文章篇幅会比较长,因此请你坐下放轻松,与此同时你将掌握Typescript下的 终极React组件模式。shell

全部的模式/例子均使用typescript 2.8版本和strict modenpm

准备开始

首先,咱们须要安装typescript和tslibs帮助程序库,以便咱们生出的代码更小json

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…