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 表示:怪我咯)。
相关文章
相关标签/搜索