原文: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 和严格模式
磨刀不误砍柴工。首先咱们要安装好 typescript
和 tslib
,使用 tslib
可让咱们生成的代码更加紧凑。json
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 随便编写一个无状态的按钮组件。bash
就像使用纯 JavaScript 同样,咱们须要引入 react
以支持 JSX 。
(译注:TypeScript 中,要支持 JSX,文件拓展名必须为 .tsx
)app
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>
<button onClick={handleClick ? handleClick: undefined}>{children}</button>
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 的库:
ngFor
中