React + TS 2.8:终极组件设计模式指南

原文:Ultimate React Component Patterns with Typescript 2.8, Martin Hochel

本文的写作灵感来自于《React Component Patterns》,线上演示地址>>点我>>

熟悉我的朋友都知道,我不喜欢写无类型支持的 JavaScript,所以从 TypeScript 0.9 开始我就深深地爱上它了。
除了类型化的 JavaScript,我也非常喜欢 React,React 和 TypeScript 的结合让我感觉置身天堂:D。
在整个应用中,类型安全和 VDOM 的无缝衔接,让开发体验变得妙不可言!

所以本文想要分享什么信息呢?
尽管网上有很多关于 React 组件设计模式的文章,但是没有一篇介绍如何使用 TypeScript 来实现。
与此同时,最新版的 TypeScript 2.8 也带来了令人激动人心的功能,比如支持条件类型(Conditional Types)、标准库中预定义的条件类型以及同态映射类型修饰符等等,这些功能使我们能够更简便地写出类型安全的通用组件模式。

本文非常长,但是请不要被吓到了,因为我会手把手教你掌握终极 React 组件设计模式!

文中所有的设计模式和例子都使用 TypeScript 2.8 和严格模式

准备

磨刀不误砍柴工。首先我们要安装好 typescripttslib,使用 tslib 可以让我们生成的代码更加紧凑。

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 随便编写一个无状态的按钮组件。

就像使用纯 JavaScript 一样,我们需要引入 react 以支持 JSX 。
(译注:TypeScript 中,要支持 JSX,文件拓展名必须为 .tsx

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 等属性。所以,我们用不着自己写,可以直接拿来用。

于是,最终的代码长这样:

React + TS 2.8:终极组件设计模式指南

状态组件

让我们来创建一个有状态的计数组件,并在其中使用我们上面创建的 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 可以实时地把错误用法反馈给开发者,从而避免错误。

比如:

React + TS 2.8:终极组件设计模式指南

由于容器组件 ButtonCounter 还没有任何属性,所以我们把 Component 的第一个泛型参数组件属性类型设置为 object,因为 props 属性在 React 中总是 {}。第二个泛型参数是组件状态类型,所以这里使用我们前面定义的 State 类型。

React + TS 2.8:终极组件设计模式指南

你可能已经注意到,在上面的代码中,我们把组件更新函数独立成了组件类外部的纯函数。这是一种常用的模式,这样的话我们就可以在不需要了解任何组件内部细节的情况下,单独对这些更新函数进行测试。此外,由于我们使用了 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 了。

React + TS 2.8:终极组件设计模式指南

为了告诉 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 实现起来非常简单。

React + TS 2.8:终极组件设计模式指南

注意: Omit 并没有成为 TypeScript 2.8 预定义的条件映射类型,因此需要自行实现: declare type Omit<T, K> = Pick<T, Exclude<keyof T, K>>;

下面我们用它来解决上面的问题:

React + TS 2.8:终极组件设计模式指南

或者更简单的:

React + TS 2.8:终极组件设计模式指南

现在,Button 的组件属性已经定义好,可以被使用了。在类型定义上,默认属性也被标记为可选属性,但是在是现实上仍然是必选的。

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

React + TS 2.8:终极组件设计模式指南

在使用方式上也是一模一样:

render(){
  return (
    <ButtonWithDefaultProps 
      onClick={this.handleIncrement}
    >
      Increment
    </ButtonWithDefaultProps>
  )
}

withDefaultProps 也能用在直接使用 class 定义的组件上,如下图所示:

React + TS 2.8:终极组件设计模式指南

这里多亏了 TS 的类结构源,我们不需要显式定义 props 泛型类型

ButtonViaClass 组件的用法也还是保持一致:

render(){
  return (
    <ButtonViaClass
      onClick={this.handleIncrement}
    >
      Increment
    </ButtonViaClass>
  )
}

接下来我们会编写一个可展开的菜单组件,当点击组件时,它会显示子组件内容。我们会用多种不同的组件模式来实现它。

渲染回调/渲染属性模式

要想让一个组件变得可复用,最简单的办法是把组件子元素变成一个函数或者新增一个 render 属性。这也是渲染回调(Render Callback)又被称为子组件函数(Function as Child Component)的原因。

首先,让我们来实现一个拥有 render 属性的 Toggleable 组件:

React + TS 2.8:终极组件设计模式指南

存在不少疑惑?

让我们来一步一步看各个重要部分的实现:

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 组件了:

React + TS 2.8:终极组件设计模式指南

或者给 render 属性传递渲染函数:

React + TS 2.8:终极组件设计模式指南

得益于强大的 TS ,我们在编码的时候还可以有代码提示和正确的类型检查:

React + TS 2.8:终极组件设计模式指南

如果我们想复用它,可以简单的创建一个新组件来使用它:

React + TS 2.8:终极组件设计模式指南

这个全新的 ToggleableMenu 组件现在就可以用在菜单组件中了:

React + TS 2.8:终极组件设计模式指南

而且效果也正如我们所预期:

React + TS 2.8:终极组件设计模式指南

这种方式非常适合用在需要改变渲染内容本身,而又不想使用状态的场景。因为我们把渲染逻辑移到了 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 组件:

React + TS 2.8:终极组件设计模式指南

其使用方式如下:

React + TS 2.8:终极组件设计模式指南

这里要注意:我们自定义的 props 属性并没有安全的类型检查,因为它被定义为索引类型 { [name: string]: any }

React + TS 2.8:终极组件设计模式指南

在菜单组件的渲染中,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>>
  }
}

完整的实现版本如下:

React + TS 2.8:终极组件设计模式指南

有了 static ofType 静态方法之后,我们就可以创建正确的类型检查泛型组件了:

React + TS 2.8:终极组件设计模式指南

一切都跟之前一样,但是这次我们的 props 有了类型检查!

React + TS 2.8:终极组件设计模式指南

高阶组件

既然我们的 Toggleable 组件已经实现了 render 属性,那么实现高阶组件(High Order Component, HOC)就很容易了。渲染回调模式的最大好处之一就是,它可以直接用于实现 HOC。

下面让我们来实现这个 HOC。

我们需要新增以下内容:

  • displayName(用于调试工具展示,便于阅读)
  • WrappedComponent (用于访问原组件,便于测试)
  • 使用 hoist-non-react-statics 包的 hoistNonReactStatics 方法

React + TS 2.8:终极组件设计模式指南

这样我们就可以以 HOC 的方式来创建 Toggleable 菜单项了, 而且仍然保持了对属性的类型检查。

const ToggleableMenuViaHOC = withToggleable(MenuItem)

React + TS 2.8:终极组件设计模式指南

受控组件

压轴大戏来了!
我们来实现一个可以通过父组件进行高度配置的 Toggleable ,这种是一种非常强大的模式。

可能有人会问,受控组件(Controlled Component)是什么?在这里意味着,我想要同时控制 Menu 组件中所有 ToggleableMenu 的内容是否显示,看看下面的动态你应该就知道是什么了。

React + TS 2.8:终极组件设计模式指南

为了实现该目标,我们需要修改下 ToggleableMenu 组件,修改后的内容如下:

React + TS 2.8:终极组件设计模式指南

然后,我们还需要在 Menu 中新增一个状态,并且把它传递给 ToggleableMenu

React + TS 2.8:终极组件设计模式指南

最后,还需要修改 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 组件诞生了:

React + TS 2.8:终极组件设计模式指南

同时,使用 ToggleablewithToggleable 也还要做些轻微调整,以便传递 show 属性和类型检查。

React + TS 2.8:终极组件设计模式指南

总结

使用 TS 来实现对 React 组件进行正确的类型检查其实是相当难的。但是随着 TS 2.8 新功能的发布,我们几乎可以随意使用通用的 React 组件模式来实现类型安全的组件。

在本篇超长文中,多亏了 TS,我们学习了如何实现具有多种模式且类型安全的组件。

综合来看,其实最强大的模式非属性渲染(Render Prop)莫属,有了它,我们可以不费吹灰之力就可以实现组件注入和高阶组件。

文中所有的示范代码托管于作者的 GitHub 仓库

最后,还有一点要强调的是,本文中涉及的类型安全模板可能只适用于使用 VDOM/JSX 的库:

  • 使用语言服务的 Angular 模板也具备类型检查,但是在有些地方也还是会失效,比如 ngFor
  • Vue 模板目前也还没有类似 Angular ,所以它的模板和数据绑定实际上是魔术字符串。不过这可能在未来会改变。虽然也可以对模板字符串使用 VDOM,不过用起来应该会很笨重,因为有太多属性类型定义。(snabdom 表示:怪我咯)。

相关推荐