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 和严格模式
准备
磨刀不误砍柴工。首先我们要安装好 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 表示:怪我咯)。