最近准备培训新人, 为了方便新人较快入手 React 开发并编写高质量的组件代码, 我根据本身的实践经验对React 组件设计的相关实践和规范整理了一些文档, 将部分章节分享了出来. 因为经验有限, 文章可能会有某些错误, 但愿你们指出, 互相交流.html
因为篇幅太长, 因此拆分为几篇文章. 主要有如下几个主题:前端
文章首发于 掘金平台专栏
静态类型检查对于当今的前端项目愈来愈不可或缺, 尤为是大型项目. 它能够在开发时就避免许多类型问题, 减小低级错误的; 另外经过类型智能提示, 能够提升编码的效率; 有利于书写自描述的代码(类型即文档); 方便代码重构(配合 IDE 能够自动重构). 对于静态类型检查的好处这里就不予赘述, 读者能够查看这个回答flow.js/typescript 这类定义参数类型的意义何在?.node
Javascript 的类型检查器主要有Typescript和Flow, 笔者二者都用过, Typescript 更强大一些, 能够避免不少坑, 有更好的生态(例如第三方库类型声明), 并且 VSCode 内置支持. 而对于 Flow, 连 Facebook 本身的开源项目(如 Yarn, Jest)都抛弃了它, 因此不建议入坑. 因此本篇文章使用 Typescript(v3.3) 对 React 组件进行类型检查声明react
建议经过官方文档来学习 Typescript. 笔者此前也整理了 Typescript 相关的思惟导图(mindnode)git
固然 Flow 也有某些 Typescript 没有的特性: typescript-vs-flowtypeReact 组件类型检查依赖于
@types/react
和@types/react-dom
github
目录编程
类型检查json
React Hooks 出现后, 函数组件有了更多出镜率. 因为函数组件只是普通函数, 它很是容易进行类型声明
ComponentNameProps
形式命名 Props 类型, 并导出 FC
类型来声明函数组件 FC
是FunctionComponent
的简写, 这个类型定义了默认的 props(如 children)以及一些静态属性(如 defaultProps)
import React, { FC } from 'react'; /** * 声明Props类型 */ export interface MyComponentProps { className?: string; style?: React.CSSProperties; } export const MyComponent: FC<MyComponentProps> = props => { return <div>hello react</div>; };
你也能够直接使用普通函数来进行组件声明, 下文会看到这种形式更加灵活:
export interface MyComponentProps { className?: string; style?: React.CSSProperties; // 手动声明children children?: React.ReactNode; } export function MyComponent(props: MyComponentProps) { return <div>hello react</div>; }
export default
导出组件.这种方式导出的组件在React Inspector
查看时会显示为Unknown
export default (props: {}) => { return <div>hello react</div>; };
若是非得这么作, 请使用命名 function
定义:
export default function Foo(props: {}) { return <div>xxx</div>; }
实际上截止目前对于上面的使用FC
类型声明的函数组件并不能完美支持 defaultProps:
import React, { FC } from 'react'; export interface HelloProps { name: string; } export const Hello: FC<HelloProps> = ({ name }) => <div>Hello {name}!</div>; Hello.defaultProps = { name: 'TJ' }; // ❌! missing name <Hello />;
笔者通常喜欢这样子声明默认 props:
export interface HelloProps { name?: string; // 声明为可选属性 } // 利用对象默认属性值语法 export const Hello: FC<HelloProps> = ({ name = 'TJ' }) => <div>Hello {name}!</div>;
若是非得使用 defaultProps, 能够这样子声明 👇. Typescript 能够推断和在函数上定义的属性, 这个特性在 Typescript 3.1开始支持.
import React, { PropsWithChildren } from 'react'; export interface HelloProps { name: string; } // 直接使用函数参数声明 // PropsWithChildren只是扩展了children, 彻底能够本身声明 // type PropsWithChildren<P> = P & { // children?: ReactNode; // } const Hello = ({ name }: PropsWithChildren<HelloProps>) => <div>Hello {name}!</div>; Hello.defaultProps = { name: 'TJ' }; // ✅ ok! <Hello />;
这种方式也很是简洁, 只不过 defaultProps 的类型和组件自己的 props 没有关联性, 这会使得 defaultProps 没法获得类型约束, 因此必要时进一步显式声明 defaultProps 的类型:
Hello.defaultProps = { name: 'TJ' } as Partial<HelloProps>;
泛型在一下列表型或容器型的组件中比较经常使用, 直接使用FC
没法知足需求:
import React from 'react'; export interface ListProps<T> { visible: boolean; list: T[]; renderItem: (item: T, index: number) => React.ReactNode; } export function List<T>(props: ListProps<T>) { return <div />; } // Test function Test() { return ( <List list={[1, 2, 3]} renderItem={i => { /*自动推断i为number类型*/ }} /> ); }
若是要配合高阶组件使用能够这样子声明:
export const List = React.memo(props => { return <div />; }) as (<T>(props: ListProps<T>) => React.ReactElement)
使用Parent.Child
形式的 JSX 可让节点父子关系更加直观, 它相似于一种命名空间的机制, 能够避免命名冲突. 相比ParentChild
这种命名方式, Parent.Child
更为优雅些. 固然也有可能让代码变得啰嗦.
import React, { PropsWithChildren } from 'react'; export interface LayoutProps {} export interface LayoutHeaderProps {} // 采用ParentChildProps形式命名 export interface LayoutFooterProps {} export function Layout(props: PropsWithChildren<LayoutProps>) { return <div className="layout">{props.children}</div>; } // 做为父组件的属性 Layout.Header = (props: PropsWithChildren<LayoutHeaderProps>) => { return <div className="header">{props.children}</div>; }; Layout.Footer = (props: PropsWithChildren<LayoutFooterProps>) => { return <div className="footer">{props.children}</div>; }; // Test <Layout> <Layout.Header>header</Layout.Header> <Layout.Footer>footer</Layout.Footer> </Layout>;
React.forwardRef
在 16.3 新增, 能够用于转发 ref, 适用于 HOC 和函数组件.
函数组件在 16.8.4 以前是不支持 ref 的, 配合 forwardRef 和 useImperativeHandle 可让函数组件向外暴露方法
/***************************** * MyModal.tsx ****************************/ import React, { useState, useImperativeHandle, FC, useRef, useCallback } from 'react'; export interface MyModalProps { title?: React.ReactNode; onOk?: () => void; onCancel?: () => void; } /** * 暴露的方法, 适用`{ComponentName}Methods`形式命名 */ export interface MyModalMethods { show(): void; } export const MyModal = React.forwardRef<MyModalMethods, MyModalProps>((props, ref) => { const [visible, setVisible] = useState(); // 初始化ref暴露的方法 useImperativeHandle(ref, () => ({ show: () => setVisible(true), })); return <Modal visible={visible}>...</Modal>; }); /******************* * Test.tsx *******************/ const Test: FC<{}> = props => { // 引用 const modal = useRef<MyModalMethods | null>(null); const confirm = useCallback(() => { if (modal.current) { modal.current.show(); } }, []); const handleOk = useCallback(() => {}, []); return ( <div> <button onClick={confirm}>show</button> <MyModal ref={modal} onOk={handleOk} /> </div> ); };
常常看到新手写出这样的代码:
// Foo.tsx const Foo: FC<FooProps> = props => {/* .. */}) export default React.memo(Foo) // 使用 // Demo.tsx import { Foo } from './Foo' // -> 这里面误使用命名导入语句,致使React.memo没有起做用
因此笔者通常这样子组织:
// Foo.tsx const Foo: FC<FooProps> = React.memo(props => {/* .. */})) export default Foo
上面的代码仍是有一个缺陷, 即你在React开发者工具看到的节点名称是这样的<Memo(wrappedComponent)></Memo(wrappedComponent)>
, 只是由于React Babel插件没法从匿名函数中推导出displayName致使的. 解决方案是显式添加displayName:
const Foo: FC<FooProps> = React.memo(props => {/* .. */})) Foo.displayName = 'Foo' export default Foo
相比函数, 基于类的类型检查可能会更好理解(例如那些熟悉传统面向对象编程语言的开发者).
import React from 'react'; /** * 首先导出Props声明, 一样是{ComponentName}Props形式命名 */ export interface CounterProps { defaultCount: number; // 可选props, 不须要?修饰 } /** * 组件状态, 不须要暴露 */ interface State { count: number; } /** * 类注释 * 继承React.Component, 并声明Props和State类型 */ export class Counter extends React.Component<CounterProps, State> { /** * 默认参数 */ public static defaultProps = { defaultCount: 0, }; /** * 初始化State */ public state = { count: this.props.defaultCount, }; /** * 声明周期方法 */ public componentDidMount() {} /** * 建议靠近componentDidMount, 资源消费和资源释放靠近在一块儿, 方便review */ public componentWillUnmount() {} public componentDidCatch() {} public componentDidUpdate(prevProps: CounterProps, prevState: State) {} /** * 渲染函数 */ public render() { return ( <div> {this.state.count} <button onClick={this.increment}>Increment</button> <button onClick={this.decrement}>Decrement</button> </div> ); } /** * ① 组件私有方法, 不暴露 * ② 使用类实例属性+箭头函数形式绑定this */ private increment = () => { this.setState(({ count }) => ({ count: count + 1 })); }; private decrement = () => { this.setState(({ count }) => ({ count: count - 1 })); }; }
static defaultProps
定义默认 props Typescript 3.0开始支持对使用 defaultProps 对 JSX props 进行推断, 在 defaultProps 中定义的 props 能够不须要'?'可选操做符修饰. 代码如上 👆
类组件可使用静态属性形式声明子组件
export class Layout extends React.Component<LayoutProps> { public static Header = Header; public static Footer = Footer; public render() { return <div className="layout">{this.props.children}</div>; } }
export class List<T> extends React.Component<ListProps<T>> { public render() {} }
在 React Hooks 出来以前, 高阶组件是 React 的一个重要逻辑复用方式. 相比较而言高阶组件比较重, 且难以理解, 容易形成嵌套地狱(wrapper)
. 另外对 Typescript 类型化也不友好(之前会使用Omit来计算导出的 props). 因此新项目仍是建议使用 React Hooks.
一个简单的高阶组件:
import React, { FC } from 'react'; /** * 声明注入的Props */ export interface ThemeProps { primary: string; secondary: string; } /** * 给指定组件注入'主题' */ export function withTheme<P>(Component: React.ComponentType<P & ThemeProps>) { /** * WithTheme 本身暴露的Props */ interface OwnProps {} /** * 高阶组件的props, 忽略ThemeProps, 外部不须要传递这些属性 */ type WithThemeProps = P & OwnProps; /** * 高阶组件 */ const WithTheme = (props: WithThemeProps) => { // 假设theme从context中获取 const fakeTheme: ThemeProps = { primary: 'red', secondary: 'blue', }; return <Component {...fakeTheme} {...props} />; }; WithTheme.displayName = `withTheme${Component.displayName}`; return WithTheme; } // Test const Foo: FC<{ a: number } & ThemeProps> = props => <div style={{ color: props.primary }} />; const FooWithTheme = withTheme(Foo); () => { <FooWithTheme a={1} />; };
再重构一下:
/** * 抽取出通用的高阶组件类型 */ type HOC<InjectedProps, OwnProps = {}> = <P>( Component: React.ComponentType<P & InjectedProps>, ) => React.ComponentType<P & OwnProps>; /** * 声明注入的Props */ export interface ThemeProps { primary: string; secondary: string; } export const withTheme: HOC<ThemeProps> = Component => props => { // 假设theme从context中获取 const fakeTheme: ThemeProps = { primary: 'red', secondary: 'blue', }; return <Component {...fakeTheme} {...props} />; };
使用高阶组件还有一些痛点:
没法完美地使用 ref(这已不算什么痛点)
React 的 props(包括 children)并无限定类型, 它能够是一个函数. 因而就有了 render props, 这是和高阶组件同样常见的模式:
import React from 'react'; export interface ThemeConsumerProps { children: (theme: Theme) => React.ReactNode; } export const ThemeConsumer = (props: ThemeConsumerProps) => { const fakeTheme = { primary: 'red', secondary: 'blue' }; return props.children(fakeTheme); }; // Test <ThemeConsumer> {({ primary }) => { return <div style={{ color: primary }} />; }} </ThemeConsumer>;
Context 提供了一种跨组件间状态共享机制
import React, { FC, useContext } from 'react'; export interface Theme { primary: string; secondary: string; } /** * 声明Context的类型, 以{Name}ContextValue命名 */ export interface ThemeContextValue { theme: Theme; onThemeChange: (theme: Theme) => void; } /** * 建立Context, 并设置默认值, 以{Name}Context命名 */ export const ThemeContext = React.createContext<ThemeContextValue>({ theme: { primary: 'red', secondary: 'blue', }, onThemeChange: noop, }); /** * Provider, 以{Name}Provider命名 */ export const ThemeProvider: FC<{ theme: Theme; onThemeChange: (theme: Theme) => void }> = props => { return ( <ThemeContext.Provider value={{ theme: props.theme, onThemeChange: props.onThemeChange }}> {props.children} </ThemeContext.Provider> ); }; /** * 暴露hooks, 以use{Name}命名 */ export function useTheme() { return useContext(ThemeContext); }
handleEvent
命名事件处理器.若是存在多个相同事件处理器, 则按照handle{Type}{Event}
命名, 例如 handleNameChange.
export const EventDemo: FC<{}> = props => { const handleClick = useCallback<React.MouseEventHandler>(evt => { evt.preventDefault(); // ... }, []); return <button onClick={handleClick} />; };
@types/react
内置了如下事件处理器的类型 👇
type EventHandler<E extends SyntheticEvent<any>> = { bivarianceHack(event: E): void }['bivarianceHack']; type ReactEventHandler<T = Element> = EventHandler<SyntheticEvent<T>>; type ClipboardEventHandler<T = Element> = EventHandler<ClipboardEvent<T>>; type CompositionEventHandler<T = Element> = EventHandler<CompositionEvent<T>>; type DragEventHandler<T = Element> = EventHandler<DragEvent<T>>; type FocusEventHandler<T = Element> = EventHandler<FocusEvent<T>>; type FormEventHandler<T = Element> = EventHandler<FormEvent<T>>; type ChangeEventHandler<T = Element> = EventHandler<ChangeEvent<T>>; type KeyboardEventHandler<T = Element> = EventHandler<KeyboardEvent<T>>; type MouseEventHandler<T = Element> = EventHandler<MouseEvent<T>>; type TouchEventHandler<T = Element> = EventHandler<TouchEvent<T>>; type PointerEventHandler<T = Element> = EventHandler<PointerEvent<T>>; type UIEventHandler<T = Element> = EventHandler<UIEvent<T>>; type WheelEventHandler<T = Element> = EventHandler<WheelEvent<T>>; type AnimationEventHandler<T = Element> = EventHandler<AnimationEvent<T>>; type TransitionEventHandler<T = Element> = EventHandler<TransitionEvent<T>>;
能够简洁地声明事件处理器类型:
import { ChangeEventHandler } from 'react'; export const EventDemo: FC<{}> = props => { /** * 能够限定具体Target的类型 */ const handleChange = useCallback<ChangeEventHandler<HTMLInputElement>>(evt => { console.log(evt.target.value); }, []); return <input onChange={handleChange} />; };
和原生 html 元素同样, 自定义组件应该暴露本身的事件处理器类型, 尤为是较为复杂的事件处理器, 这样能够避免开发者手动为每一个事件处理器的参数声明类型
自定义事件处理器类型以{ComponentName}{Event}Handler
命名. 为了和原生事件处理器类型区分, 不使用EventHandler
形式的后缀
import React, { FC, useState } from 'react'; export interface UploadValue { url: string; name: string; size: number; } /** * 暴露事件处理器类型 */ export type UploadChangeHandler = (value?: UploadValue, file?: File) => void; export interface UploadProps { value?: UploadValue; onChange?: UploadChangeHandler; } export const Upload: FC<UploadProps> = props => { return <div>...</div>; };
有些场景咱们但愿原生元素扩展一下一些 props. 全部原生元素 props 都继承了React.HTMLAttributes
, 某些特殊元素也会扩展了本身的属性, 例如InputHTMLAttributes
. 具体能够参考React.createElement
方法的实现
import React, { FC } from 'react'; export function fixClass< T extends Element = HTMLDivElement, Attribute extends React.HTMLAttributes<T> = React.HTMLAttributes<T> >(cls: string, type: keyof React.ReactHTML = 'div') { const FixedClassName: FC<Attribute> = props => { return React.createElement(type, { ...props, className: `${cls} ${props.className}` }); }; return FixedClassName; } /** * Test */ const Container = fixClass('card'); const Header = fixClass('card__header', 'header'); const Body = fixClass('card__body', 'main'); const Footer = fixClass('card__body', 'footer'); const Test = () => { return ( <Container> <Header>header</Header> <Body>header</Body> <Footer>footer</Footer> </Container> ); };
有了 Typescript 以后能够安全地约束 Props 和 State, 没有必要引入 React.PropTypes, 并且它的表达能力比较弱
styled-components 是目前最流行的CSS-in-js
库, Typescript 在 2.9 支持泛型标签模板
. 这意味着能够简单地对 styled-components 建立的组件进行类型约束
// 依赖于@types/styled-components import styled from 'styled-components/macro'; const Title = styled.h1<{ active?: boolean }>` color: ${props => (props.active ? 'red' : 'gray')}; `; // 扩展已有组件 const NewHeader = styled(Header)<{ customColor: string }>` color: ${props => props.customColor}; `;
了解更多styled-components 和 Typescript
笔者通常习惯在项目根目录下(和 tsconfig.json 同在一个目录下)放置一个global.d.ts
. 放置项目的全局声明文件
// /global.d.ts // 自定义模块声明 declare module 'awesome-react-component' { // 依赖其余模块的声明文件 import * as React from 'react'; export const Foo: React.FC<{ a: number; b: string }>; }
了解更多如何定义声明文件
目前社区有多种 react 组件文档生成方案, 例如docz
, styleguidist
还有storybook. 它们底层都使用react-docgen-typescript对 Typescript 进行解析. 就目前而言, 它还有些坑, 并且解析比较慢. 无论不妨碍咱们使用它的风格对代码进行注释:
import * as React from 'react'; import { Component } from 'react'; /** * Props注释 */ export interface ColumnProps extends React.HTMLAttributes<any> { /** prop1 description */ prop1?: string; /** prop2 description */ prop2: number; /** * prop3 description */ prop3: () => void; /** prop4 description */ prop4: 'option1' | 'option2' | 'option3'; } /** * 对组件进行注释 */ export class Column extends Component<ColumnProps, {}> { render() { return <div>Column</div>; } }
为了真正把 Typescript 用起来, 应该始终开启 strict 模式, 避免使用 any 类型声明.