系列引言
最近准备培训新人, 为了方便新人较快入手 React 开发并编写高质量的组件代码, 我根据自己的实践经验对React 组件设计的相关实践和规范整理了一些文档, 将部分章节分享了出来. 由于经验有限, 文章可能会有某些错误, 希望大家指出, 互相交流.
由于篇幅太长, 所以拆分为几篇文章. 主要有以下几个主题:
类型检查
静态类型检查对于当今的前端项目越来越不可或缺, 尤其是大型项目. 它可以在开发时就避免许多类型问题, 减少低级错误的; 另外通过类型智能提示, 可以提高编码的效率; 有利于书写自描述的代码(类型即文档); 方便代码重构(配合 IDE 可以自动重构). 对于静态类型检查的好处这里就不予赘述, 读者可以查看这个回答flow.js/typescript 这类定义参数类型的意义何在?.
Javascript 的类型检查器主要有Typescript和Flow, 笔者两者都用过, Typescript 更强大一些, 可以避免很多坑, 有更好的生态(例如第三方库类型声明), 而且 VSCode 内置支持. 而对于 Flow, 连 Facebook 自己的开源项目(如 Yarn, Jest)都抛弃了它, 所以不建议入坑. 所以本篇文章使用 Typescript(v3.3) 对 React 组件进行类型检查声明
建议通过官方文档来学习 Typescript. 笔者此前也整理了 Typescript 相关的思维导图(mindnode)
当然 Flow 也有某些 Typescript 没有的特性: typescript-vs-flowtype
React 组件类型检查依赖于@types/react和@types/react-dom
直接上手使用试用 
 
目录
1. 函数组件
React Hooks 出现后, 函数组件有了更多出镜率. 由于函数组件只是普通函数, 它非常容易进行类型声明
1️⃣ 使用ComponentNameProps 形式命名 Props 类型, 并导出
2️⃣ 优先使用FC类型来声明函数组件
FC是FunctionComponent的简写, 这个类型定义了默认的 props(如 children)以及一些静态属性(如 defaultProps)
import React, { FC } from 'react';
 
 
 
  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?: React.ReactNode; }
  export function MyComponent(props: MyComponentProps) {   return <div>hello react</div>; }
  | 
 
3️⃣ 不要直接使用export default导出组件.
这种方式导出的组件在React Inspector查看时会显示为Unknown
export default (props: {}) => {   return <div>hello react</div>; };
  | 
 
如果非得这么做, 请使用命名 function 定义:
export default function Foo(props: {}) {   return <div>xxx</div>; }
  | 
 
4️⃣ 默认 props 声明
实际上截止目前对于上面的使用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; }
 
 
 
 
 
  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>;
  | 
 
5️⃣ 泛型函数组件
泛型在一下列表型或容器型的组件中比较常用, 直接使用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 />; }
 
  function Test() {   return (     <List       list={[1, 2, 3]}       renderItem={i => {                }}     />   ); }
   | 
 
如果要配合高阶组件使用可以这样子声明:
export const List = React.memo(props => {   return <div />; }) as (<T>(props: ListProps<T>) => React.ReactElement)
  | 
 
6️⃣ 子组件声明
使用Parent.Child形式的 JSX 可以让节点父子关系更加直观, 它类似于一种命名空间的机制, 可以避免命名冲突. 相比ParentChild这种命名方式, Parent.Child更为优雅些. 当然也有可能让代码变得啰嗦.
import React, { PropsWithChildren } from 'react';
  export interface LayoutProps {} export interface LayoutHeaderProps {}  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>;
  | 
 
7️⃣ Forwarding Refs
React.forwardRef 在 16.3 新增, 可以用于转发 ref, 适用于 HOC 和函数组件.
函数组件在 16.8.4 之前是不支持 ref 的, 配合 forwardRef 和 useImperativeHandle 可以让函数组件向外暴露方法
 
  import React, { useState, useImperativeHandle, FC, useRef, useCallback } from 'react';
  export interface MyModalProps {   title?: React.ReactNode;   onOk?: () => void;   onCancel?: () => void; }
 
 
 
  export interface MyModalMethods {   show(): void; }
  export const MyModal = React.forwardRef<MyModalMethods, MyModalProps>((props, ref) => {   const [visible, setVisible] = useState();
       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>   ); };
 
  | 
 
8️⃣ 配合高阶组件使用
经常看到新手写出这样的代码:
// 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
  | 
 
2. 类组件
相比函数, 基于类的类型检查可能会更好理解(例如那些熟悉传统面向对象编程语言的开发者).
1️⃣ 继承 Component 或 PureComponent
import React from 'react';
 
 
 
  export interface CounterProps {   defaultCount: number;  }
 
 
 
  interface State {   count: number; }
 
 
 
 
  export class Counter extends React.Component<CounterProps, State> {   
 
    public static defaultProps = {     defaultCount: 0,   };
    
 
    public state = {     count: this.props.defaultCount,   };
    
 
    public componentDidMount() {}   
 
    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 }));   }; }
   | 
 
2️⃣ 使用static defaultProps定义默认 props
Typescript 3.0开始支持对使用 defaultProps 对 JSX props 进行推断, 在 defaultProps 中定义的 props 可以不需要’?’可选操作符修饰. 代码如上 👆
3️⃣ 子组件声明
类组件可以使用静态属性形式声明子组件
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>;   } }
  | 
 
4️⃣ 泛型
export class List<T> extends React.Component<ListProps<T>> {   public render() {} }
  | 
 
3. 高阶组件
在 React Hooks 出来之前, 高阶组件是 React 的一个重要逻辑复用方式. 相比较而言高阶组件比较重, 且难以理解, 容易造成嵌套地狱(wrapper). 另外对 Typescript 类型化也不友好(以前会使用Omit来计算导出的 props). 所以新项目还是建议使用 React Hooks.
一个简单的高阶组件:
import React, { FC } from 'react';
 
 
 
  export interface ThemeProps {   primary: string;   secondary: string; }
 
 
 
  export function withTheme<P>(Component: React.ComponentType<P & ThemeProps>) {   
 
    interface OwnProps {}
    
 
    type WithThemeProps = P & OwnProps;
    
 
    const WithTheme = (props: WithThemeProps) => {          const fakeTheme: ThemeProps = {       primary: 'red',       secondary: 'blue',     };     return <Component {...fakeTheme} {...props} />;   };
    WithTheme.displayName = `withTheme${Component.displayName}`;
    return WithTheme; }
 
  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>;
 
 
 
  export interface ThemeProps {   primary: string;   secondary: string; }
  export const withTheme: HOC<ThemeProps> = Component => props => {      const fakeTheme: ThemeProps = {     primary: 'red',     secondary: 'blue',   };   return <Component {...fakeTheme} {...props} />; };
 
  | 
 
使用高阶组件还有一些痛点:
- 无法完美地使用 ref(这已不算什么痛点)
- 在 React.forwardRef 发布之前, 有一些库会使用 innerRef 或者 wrapperRef, 转发给封装的组件的 ref.
 
- 无法推断 ref 引用组件的类型, 需要显式声明.
 
 
- 高阶组件类型报错很难理解
 
4. Render Props
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); };
 
  <ThemeConsumer>   {({ primary }) => {     return <div style={{ color: primary }} />;   }} </ThemeConsumer>;
   | 
 
5. Context
Context 提供了一种跨组件间状态共享机制
import React, { FC, useContext } from 'react';
  export interface Theme {   primary: string;   secondary: string; }
 
 
 
  export interface ThemeContextValue {   theme: Theme;   onThemeChange: (theme: Theme) => void; }
 
 
 
  export const ThemeContext = React.createContext<ThemeContextValue>({   theme: {     primary: 'red',     secondary: 'blue',   },   onThemeChange: noop, });
 
 
 
  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); }
  | 
 
6. 杂项
1️⃣ 使用handleEvent命名事件处理器.
如果存在多个相同事件处理器, 则按照handle{Type}{Event}命名, 例如 handleNameChange.
export const EventDemo: FC<{}> = props => {   const handleClick = useCallback<React.MouseEventHandler>(evt => {     evt.preventDefault();        }, []);
    return <button onClick={handleClick} />; };
  | 
 
2️⃣ 内置事件处理器的类型
@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 => {   
 
    const handleChange = useCallback<ChangeEventHandler<HTMLInputElement>>(evt => {     console.log(evt.target.value);   }, []);
    return <input onChange={handleChange} />; };
  | 
 
3️⃣ 自定义组件暴露事件处理器类型
和原生 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>; };
  | 
 
4️⃣ 获取原生元素 props 定义
有些场景我们希望原生元素扩展一下一些 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; }
 
 
 
  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>   ); };
  | 
 
5️⃣ 不要使用 PropTypes
有了 Typescript 之后可以安全地约束 Props 和 State, 没有必要引入 React.PropTypes, 而且它的表达能力比较弱
6️⃣ styled-components
styled-components 是目前最流行的CSS-in-js库, Typescript 在 2.9 支持泛型标签模板. 这意味着可以简单地对 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
7️⃣ 为没有提供 Typescript 声明文件的第三方库自定义模块声明
笔者一般习惯在项目根目录下(和 tsconfig.json 同在一个目录下)放置一个global.d.ts. 放置项目的全局声明文件
 
  declare module 'awesome-react-component' {      import * as React from 'react';   export const Foo: React.FC<{ a: number; b: string }>; }
 
  | 
 
了解更多如何定义声明文件
8️⃣ 为文档生成做好准备
目前社区有多种 react 组件文档生成方案, 例如docz, styleguidist还有storybook. 它们底层都使用react-docgen-typescript对 Typescript 进行解析. 就目前而言, 它还有些坑, 而且解析比较慢. 不管不妨碍我们使用它的风格对代码进行注释:
import * as React from 'react'; import { Component } from 'react';
 
 
 
  export interface ColumnProps extends React.HTMLAttributes<any> {      prop1?: string;      prop2: number;   
 
    prop3: () => void;      prop4: 'option1' | 'option2' | 'option3'; }
 
 
 
  export class Column extends Component<ColumnProps, {}> {   render() {     return <div>Column</div>;   } }
   | 
 
9️⃣ 开启 strict 模式
为了真正把 Typescript 用起来, 应该始终开启 strict 模式, 避免使用 any 类型声明.
扩展资料