原文: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 和严格模式
准备
磨刀不误砍柴工。首先我们要安装好 typescript
和 tslib
,使用 tslib
可以让我们生成的代码更加紧凑。
yarn add -D typescript
# tslib 弥补编译目标不支持的功能,如
yarn add tslib
然后,就可以使用 tsc
命令来初始化项目的 TypeScript 配置了。
# 为项目创建 tsconfig.json ,使用默认编译设置
yarn tsc --init
接着,安装 react
,react-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>
的类型别名,并且它预定义了 children
、displayName
和 defaultProps
等属性。所以,我们用不着自己写,可以直接拿来用。
于是,最终的代码长这样:
状态组件
让我们来创建一个有状态的计数组件,并在其中使用我们上面创建的 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 可以实时地把错误用法反馈给开发者,从而避免错误。
比如:
由于容器组件 ButtonCounter
还没有任何属性,所以我们把 Component
的第一个泛型参数组件属性类型设置为 object
,因为 props
属性在 React 中总是 {}
。第二个泛型参数是组件状态类型,所以这里使用我们前面定义的 State
类型。
你可能已经注意到,在上面的代码中,我们把组件更新函数独立成了组件类外部的纯函数。这是一种常用的模式,这样的话我们就可以在不需要了解任何组件内部细节的情况下,单独对这些更新函数进行测试。此外,由于我们使用了 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 = {...}
实现。这样的话,就需要把类型 Props
的 color
标记为可选属性。像下面这样(多了一个问号):
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
了。
为了告诉 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
实现起来非常简单。
注意:Omit
并没有成为 TypeScript 2.8 预定义的条件映射类型,因此需要自行实现:declare type Omit<T, K> = Pick<T, Exclude<keyof T, K>>;
下面我们用它来解决上面的问题:
或者更简单的:
现在,Button
的组件属性已经定义好,可以被使用了。在类型定义上,默认属性也被标记为可选属性,但是在是现实上仍然是必选的。
{
onClick(e: MouseEvent<HTMLElement>): void
color?: string
}
在使用方式上也是一模一样:
render(){
return (
<ButtonWithDefaultProps
onClick={this.handleIncrement}
>
Increment
</ButtonWithDefaultProps>
)
}
withDefaultProps
也能用在直接使用 class
定义的组件上,如下图所示:
这里多亏了 TS 的类结构源,我们不需要显式定义 Props
泛型类型
ButtonViaClass
组件的用法也还是保持一致:
render(){
return (
<ButtonViaClass
onClick={this.handleIncrement}
>
Increment
</ButtonViaClass>
)
}
接下来我们会编写一个可展开的菜单组件,当点击组件时,它会显示子组件内容。我们会用多种不同的组件模式来实现它。
渲染回调/渲染属性模式
要想让一个组件变得可复用,最简单的办法是把组件子元素变成一个函数或者新增一个 render
属性。这也是渲染回调(Render Callback)又被称为子组件函数(Function as Child Component)的原因。
首先,让我们来实现一个拥有 render
属性的 Toggleable
组件:
存在不少疑惑?
让我们来一步一步看各个重要部分的实现:
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
组件了:
或者给 render
属性传递渲染函数:
得益于强大的 TS ,我们在编码的时候还可以有代码提示和正确的类型检查:
如果我们想复用它,可以简单的创建一个新组件来使用它:
这个全新的 ToggleableMenu
组件现在就可以用在菜单组件中了:
而且效果也正如我们所预期:
这种方式非常适合用在需要改变渲染内容本身,而又不想使用状态的场景。因为我们把渲染逻辑移到了 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
组件:
其使用方式如下:
这里要注意:我们自定义的 props
属性并没有安全的类型检查,因为它被定义为索引类型 { [name: string]: any }
。
在菜单组件的渲染中,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>>
}
}
完整的实现版本如下:
有了 static ofType
静态方法之后,我们就可以创建正确的类型检查泛型组件了:
一切都跟之前一样,但是这次我们的 props
有了类型检查!
高阶组件
既然我们的 Toggleable
组件已经实现了 render
属性,那么实现高阶组件(High Order Component, HOC)就很容易了。渲染回调模式的最大好处之一就是,它可以直接用于实现 HOC。
下面让我们来实现这个 HOC。
我们需要新增以下内容:
-
displayName
(用于调试工具展示,便于阅读) -
WrappedComponent
(用于访问原组件,便于测试) - 使用
hoist-non-react-statics
包的hoistNonReactStatics
方法
这样我们就可以以 HOC 的方式来创建 Toggleable
菜单项了, 而且仍然保持了对属性的类型检查。
const ToggleableMenuViaHOC = withToggleable(MenuItem)
受控组件
压轴大戏来了!
我们来实现一个可以通过父组件进行高度配置的 Toggleable
,这种是一种非常强大的模式。
可能有人会问,受控组件(Controlled Component)是什么?在这里意味着,我想要同时控制 Menu
组件中所有 ToggleableMenu
的内容是否显示,看看下面的动态你应该就知道是什么了。
为了实现该目标,我们需要修改下 ToggleableMenu
组件,修改后的内容如下:
然后,我们还需要在 Menu
中新增一个状态,并且把它传递给 ToggleableMenu
。
最后,还需要修改 Toggleable
最后一次,让它变得更加无敌和灵活。
修改内容如下:
- 新增
show
属性到Props
- 更新默认属性(因为
show
是可选的) - 更新默认状态,使用属性
show
的值来初始化状态show
,因为我们希望该值只能来自于其父组件 - 使用
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
组件诞生了:
同时,使用 Toggleable
的 withToggleable
也还要做些轻微调整,以便传递 show
属性和类型检查。
总结
使用 TS 来实现对 React 组件进行正确的类型检查其实是相当难的。但是随着 TS 2.8 新功能的发布,我们几乎可以随意使用通用的 React 组件模式来实现类型安全的组件。
在本篇超长文中,多亏了 TS,我们学习了如何实现具有多种模式且类型安全的组件。
综合来看,其实最强大的模式非属性渲染(Render Prop)莫属,有了它,我们可以不费吹灰之力就可以实现组件注入和高阶组件。
文中所有的示范代码托管于作者的 GitHub 仓库。
最后,还有一点要强调的是,本文中涉及的类型安全模板可能只适用于使用 VDOM/JSX 的库:
- 使用语言服务的 Angular 模板也具备类型检查,但是在有些地方也还是会失效,比如
ngFor
中 - Vue 模板目前也还没有类似 Angular ,所以它的模板和数据绑定实际上是魔术字符串。不过这可能在未来会改变。虽然也可以对模板字符串使用 VDOM,不过用起来应该会很笨重,因为有太多属性类型定义。(snabdom 表示:怪我咯)。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。