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

mingzhong

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

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

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 表示:怪我咯)。
阅读 17.9k

世界很美好,时间很宝贵。

1.8k 声望
3.2k 粉丝
0 条评论

世界很美好,时间很宝贵。

1.8k 声望
3.2k 粉丝
宣传栏