兩年React老兵的總結 - 類型檢查篇

系列引言

最近準備培訓新人, 爲了方便新人較快入手 React 開發並編寫高質量的組件代碼, 我根據本身的實踐經驗對React 組件設計的相關實踐和規範整理了一些文檔, 將部分章節分享了出來. 因爲經驗有限, 文章可能會有某些錯誤, 但願你們指出, 互相交流.html

因爲篇幅太長, 因此拆分爲幾篇文章. 主要有如下幾個主題:前端

文章首發於 掘金平臺專欄

類型檢查

靜態類型檢查對於當今的前端項目愈來愈不可或缺, 尤爲是大型項目. 它能夠在開發時就避免許多類型問題, 減小低級錯誤的; 另外經過類型智能提示, 能夠提升編碼的效率; 有利於書寫自描述的代碼(類型即文檔); 方便代碼重構(配合 IDE 能夠自動重構). 對於靜態類型檢查的好處這裏就不予贅述, 讀者能夠查看這個回答flow.js/typescript 這類定義參數類型的意義何在?.node

Javascript 的類型檢查器主要有TypescriptFlow, 筆者二者都用過, Typescript 更強大一些, 能夠避免不少坑, 有更好的生態(例如第三方庫類型聲明), 並且 VSCode 內置支持. 而對於 Flow, 連 Facebook 本身的開源項目(如 Yarn, Jest)都拋棄了它, 因此不建議入坑. 因此本篇文章使用 Typescript(v3.3) 對 React 組件進行類型檢查聲明react

建議經過官方文檔來學習 Typescript. 筆者此前也整理了 Typescript 相關的思惟導圖(mindnode)git

固然 Flow 也有某些 Typescript 沒有的特性: typescript-vs-flowtype

React 組件類型檢查依賴於@types/react@types/react-domgithub

直接上手使用試用
Edit typescript-react-playgroundtypescript

目錄編程




1. 函數組件

React Hooks 出現後, 函數組件有了更多出鏡率. 因爲函數組件只是普通函數, 它很是容易進行類型聲明


1️⃣ 使用ComponentNameProps 形式命名 Props 類型, 並導出


2️⃣ 優先使用FC類型來聲明函數組件

FCFunctionComponent的簡寫, 這個類型定義了默認的 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>;
}


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;
}

// 直接使用函數參數聲明
// 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>;


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 />;
}

// 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)


6️⃣ 子組件聲明

使用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>;


7️⃣ Forwarding Refs

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>
  );
};

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';

/**
 * 首先導出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 }));
  };
}


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';

/**
 * 聲明注入的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.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);
};

// Test
<ThemeConsumer>
  {({ primary }) => {
    return <div style={{ color: primary }} />;
  }}
</ThemeConsumer>;




5. Context

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);
}




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 => {
  /**
   * 能夠限定具體Target的類型
   */
  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;
}

/**
 * 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>
  );
};


5️⃣ 不要使用 PropTypes

有了 Typescript 以後能夠安全地約束 Props 和 State, 沒有必要引入 React.PropTypes, 並且它的表達能力比較弱


6️⃣ styled-components

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


7️⃣ 爲沒有提供 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 }>;
}

瞭解更多如何定義聲明文件


8️⃣ 爲文檔生成作好準備

目前社區有多種 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>;
  }
}

9️⃣ 開啓 strict 模式

爲了真正把 Typescript 用起來, 應該始終開啓 strict 模式, 避免使用 any 類型聲明.




擴展資料

相關文章
相關標籤/搜索