之幸甘木

之幸甘木 查看完整档案

杭州编辑浙江理工大学  |  数字媒体技术 编辑  |  填写所在公司/组织填写个人主网站
编辑

个人动态

之幸甘木 收藏了文章 · 1月11日

两年React老兵的总结 - 类型检查篇

系列引言

最近准备培训新人, 为了方便新人较快入手 React 开发并编写高质量的组件代码, 我根据自己的实践经验对React 组件设计的相关实践和规范整理了一些文档, 将部分章节分享了出来. 由于经验有限, 文章可能会有某些错误, 希望大家指出, 互相交流.

由于篇幅太长, 所以拆分为几篇文章. 主要有以下几个主题:

文章首发于掘金平台专栏

类型检查

静态类型检查对于当今的前端项目越来越不可或缺, 尤其是大型项目. 它可以在开发时就避免许多类型问题, 减少低级错误的; 另外通过类型智能提示, 可以提高编码的效率; 有利于书写自描述的代码(类型即文档); 方便代码重构(配合 IDE 可以自动重构). 对于静态类型检查的好处这里就不予赘述, 读者可以查看这个回答flow.js/typescript 这类定义参数类型的意义何在?.

Javascript 的类型检查器主要有TypescriptFlow, 笔者两者都用过, Typescript 更强大一些, 可以避免很多坑, 有更好的生态(例如第三方库类型声明), 而且 VSCode 内置支持. 而对于 Flow, 连 Facebook 自己的开源项目(如 Yarn, Jest)都抛弃了它, 所以不建议入坑. 所以本篇文章使用 Typescript(v3.3) 对 React 组件进行类型检查声明

建议通过官方文档来学习 Typescript. 笔者此前也整理了 Typescript 相关的思维导图(mindnode)

当然 Flow 也有某些 Typescript 没有的特性: typescript-vs-flowtype

React 组件类型检查依赖于@types/react@types/react-dom

直接上手使用试用
Edit typescript-react-playground

目录




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 类型声明.




扩展资料

查看原文

之幸甘木 收藏了文章 · 2020-12-31

【译】TypeScript中的React高阶组件

原文链接:https://medium.com/@jrwebdev/...

高阶组件(HOCs)在React中是组件复用的一个强大工具。但是,经常有开发者在结合TypeScript使用中抱怨道很难去为其设置types。

这边文章将会假设你已经具备了HOCs的基本知识,并会根据由浅入深的例子来向你展示如何去为其设置types。在本文中,高阶组件将会被分为两种基本模式,我们将其命名为enhancersinjectors

  • enhancers:用附加的功能/props来包裹组件。
  • injectors:向组件注入props。

请注意,本文中的示例并不是最佳实践,本文主要只是展示如何在HOCs中设置types。


Enhancers

我们将从enhancers开始,因为它更容易去设置types。此模式的一个基本示例是一个向组件添加loading props的HOC,并且将其设置为true的时候展示loading图。下面是一个没有types的示例:

const withLoading = Component =>
  class WithLoading extends React.Component {
    render() {
      const { loading, ...props } = this.props;
      return loading ? <LoadingSpinner /> : <Component {...props} />;
    }
  };

然后是加上types

interface WithLoadingProps {
  loading: boolean;
}

const withLoading = <P extends object>(Component: React.ComponentType<P>) =>
  class WithLoading extends React.Component<P & WithLoadingProps> {
    render() {
      const { loading, ...props } = this.props;
      return loading ? <LoadingSpinner /> : <Component {...props as P} />;
    }
  };

这里发生了一些事情,所以我们将把它分解:

interface WithLoadingProps {
  loading: boolean;
}

在这里,声明一个props的interface,将会被添加到被包裹的组件上。

<P extends object>(Component: React.ComponentType<P>)

这里我们使用泛型:P表示传递到HOC的组件的props。React.ComponentType<P>React.FunctionComponent<P> | React.ClassComponent<P>的别名,表示传递到HOC的组件可以是类组件或者是函数组件。

class WithLoading extends React.Component<P & WithLoadingProps>

在这里,我们定义从HOC返回的组件,并指定该组件将包括传入组件的props(P)和HOC的props(WithLoadingProps)。它们通过 & 组合在一起。

const { loading, ...props } = this.props;

最后,我们使用loading props有条件地显示加loading图或传递了自己props的组件:

return loading ? <LoadingSpinner /> : <Component {...props as P} />;
注意:由于typescript中可能存在的bug,因此从typescript v3.2开始,这里需要类型转换(props as p)。

我们的withloading HOC也可以重写以返回函数组件而不是类:

const withLoading = <P extends object>(
  Component: React.ComponentType<P>
): React.FC<P & WithLoadingProps> => ({
  loading,
  ...props
}: WithLoadingProps) =>
  loading ? <LoadingSpinner /> : <Component {...props as P} />;

这里,我们对对象rest/spread也有同样的问题,因此通过设置显式的返回类型React.FC<P & WithLoadingProps>来解决这个问题,但只能在无状态功能组件中使用WithLoadingProps。

注意:React.FC是React.FunctionComponent的缩写。在早期版本的@types/react中,是React.SFC或React.StatelessFunctionalComponent。

Injectors

injectors是更常见的HOC形式,但更难为其设置类型。除了向组件中注入props外,在大多数情况下,当包裹好后,它们也会移除注入的props,这样它们就不能再从外部设置了。react redux的connect就是是injector HOC的一个例子,但是在本文中,我们将使用一个更简单的例子,它注入一个计数器值并回调以增加和减少该值:

import { Subtract } from 'utility-types';

export interface InjectedCounterProps {
  value: number;
  onIncrement(): void;
  onDecrement(): void;
}

interface MakeCounterState {
  value: number;
}

const makeCounter = <P extends InjectedCounterProps>(
  Component: React.ComponentType<P>
) =>
  class MakeCounter extends React.Component<
    Subtract<P, InjectedCounterProps>,
    MakeCounterState
  > {
    state: MakeCounterState = {
      value: 0,
    };

    increment = () => {
      this.setState(prevState => ({
        value: prevState.value + 1,
      }));
    };

    decrement = () => {
      this.setState(prevState => ({
        value: prevState.value - 1,
      }));
    };

    render() {
      return (
        <Component
          {...this.props as P}
          value={this.state.value}
          onIncrement={this.increment}
          onDecrement={this.decrement}
        />
      );
    }
  };

这里有几个关键区别:

export interface InjectedCounterProps {  
  value: number;  
  onIncrement(): void;  
  onDecrement(): void;
}

我们给将要注入到组件的props声明一个interface,该接口将被导出,以便这些props可由被HOC包裹的组件使用:

import makeCounter, { InjectedCounterProps } from './makeCounter';

interface CounterProps extends InjectedCounterProps {
  style?: React.CSSProperties;
}

const Counter = (props: CounterProps) => (
  <div style={props.style}>
    <button onClick={props.onDecrement}> - </button>
    {props.value}
    <button onClick={props.onIncrement}> + </button>
  </div>
);

export default makeCounter(Counter);
<P extends InjectedCounterProps>(Component: React.ComponentType<P>)

我们再次使用泛型,但是这次,你要确保传入到HOC的组件包含注入到其中的props,否则,你将收到一个编译错误。

class MakeCounter extends React.Component<
  Subtract<P, InjectedCounterProps>,    
  MakeCounterState  
>

HOC返回的组件使用Piotrek Witek’s的utility-types包中的subtract,它将从传入组件的props中减去注入的props,这意味着如果它们设置在生成的包裹组件上,则会收到编译错误:
![TypeScript compilation error when attempting to set value on the wrapped component
](https://cdn-images-1.medium.c...*xTKe3DWJdC7nAVQnM4bvbg.png)

Enhance + Inject

结合这两种模式,我们将在计数器示例的基础上,允许将最小和最大计数器值传递给HOC,而HOC又被它截取并使用,而不将它们传递给组件:

export interface InjectedCounterProps {
  value: number;
  onIncrement(): void;
  onDecrement(): void;
}

interface MakeCounterProps {
  minValue?: number;
  maxValue?: number;
}

interface MakeCounterState {
  value: number;
}

const makeCounter = <P extends InjectedCounterProps>(
  Component: React.ComponentType<P>
) =>
  class MakeCounter extends React.Component<
    Subtract<P, InjectedCounterProps> & MakeCounterProps,
    MakeCounterState
  > {
    state: MakeCounterState = {
      value: 0,
    };

    increment = () => {
      this.setState(prevState => ({
        value:
          prevState.value === this.props.maxValue
            ? prevState.value
            : prevState.value + 1,
      }));
    };

    decrement = () => {
      this.setState(prevState => ({
        value:
          prevState.value === this.props.minValue
            ? prevState.value
            : prevState.value - 1,
      }));
    };

    render() {
      const { minValue, maxValue, ...props } = this.props;
      return (
        <Component
          {...props as P}
          value={this.state.value}
          onIncrement={this.increment}
          onDecrement={this.decrement}
        />
      );
    }
  };

这里,Subtract与types交集相结合,将组件自身的props与HOCs自身的props相结合,减去注入组件的props:

Subtract<P, InjectedCounterProps> & MakeCounterProps

除此之外,与其他两种模式相比,没有真正的差异需要强调,但是这个示例确实带来了一些高阶组件的问题。这些并不是真正特定于typescript的,但值得详细说明,以便我们可以讨论如何使用typescript来解决这些问题。

首先,MinValue和MaxValue被HOC拦截,而不是传递给组件。但是,你也许希望它们是这样的,这样你就可以基于这些值禁用递增/递减按钮,或者向用户显示一条消息。如果用HOC,你也可以简单地修改它来注入这些值,但是如果你没有(例如,它来自一个NPM包),这就将会是一个问题。

其次,由HOC注入的prop有一个非常通用的名称;如果要将其用于其他目的,或者如果要从多个HOC注入prop,则此名称可能与其他注入的prop冲突。您可以将名称更改为不太通用的解决方案,但就解决方案而言,这不是一个很好的解决方案!

查看原文

之幸甘木 赞了回答 · 2020-12-22

解决使用typescript的react项目中,如何声明antd的formRef的类型?

参考 antd 中关于 form 部分的源代码

const Form = React.forwardRef<FormInstance, FormProps>(InternalForm) as <Values = any>(
  props: React.PropsWithChildren<FormProps<Values>> & { ref?: React.Ref<FormInstance<Values>> },
) => React.ReactElement;

这么写就ok了

const ref = React.createRef<FormInstance>();

关注 2 回答 1

之幸甘木 关注了问题 · 2020-12-18

从tsx源码抽离出CSS文件,怎么通过 style-loader 等进行处理?

我的需求是从 tsx 代码中抽离样式到独立的 css 文件,再把样式文件经过 style-loader, css-loader 等进行处理。

目前我开发了一个 loader,做到了解析修改源码和生成 css 内容

export default function (source) {
  const webpackEnv = this
  
  // 修改源码,抽离 css
  const { code, css } = transform(source)
  
  // 获取输出路径
  const context = webpackEnv.rootContext
  const outputPath = interpolateName(webpackEnv, '[contenthash].[ext]',
    {
      context,
      content: source
    }
  )
  
  // TODO: 将 css 文件经过 style-loader 处理
  webpackEnv.emitFile(outputPath, css)
  
  // 返回源码
  return code
}

我遇到的是问题是:
1、调用 webpack 的 emitFile api 没有生成 css 文件
2、怎么将生成的文件通过 style-loader, css-loader 处理
3、怎么将处理后的文件链接到 html 文件中

关注 2 回答 0

之幸甘木 提出了问题 · 2020-12-18

解决使用typescript的react项目中,如何声明antd的formRef的类型?

代码如下:

class App extends Component<Tprops, Tstate>{

   formRef: React.RefObject<unknown> 
   
   ......
   this.formRef = React.createRef();
   
   dosth = () => {
     // 报错 类型“RefObject<unknown>”上不存在属性“validateFields”。
     this.formRef.validateFields(...);
   }
   
   render(){
     return <Form ref={formRef}>
      ...
     </Form>
   }
}

我应该如何声明formRef这个属性的类型呢?

关注 2 回答 1

之幸甘木 提出了问题 · 2020-12-18

解决使用typescript的react项目中,如何声明antd的formRef的类型?

代码如下:

class App extends Component<Tprops, Tstate>{

   formRef: React.RefObject<unknown> 
   
   ......
   this.formRef = React.createRef();
   
   dosth = () => {
     // 报错 类型“RefObject<unknown>”上不存在属性“validateFields”。
     this.formRef.validateFields(...);
   }
   
   render(){
     return <Form ref={formRef}>
      ...
     </Form>
   }
}

我应该如何声明formRef这个属性的类型呢?

关注 2 回答 1

之幸甘木 收藏了文章 · 2020-12-09

Redux入门教程(快速上手)

满满的干货,耐心看完,你会发现redux原来这么可爱。

典型的Web应用程序通常由共享数据的多个UI组件组成。通常,多个组件的任务是负责展示同一对象的不同属性。这个对象表示可随时更改的状态。在多个组件之间保持状态的一致性会是一场噩梦,特别是如果有多个通道用于更新同一个对象。

举个小栗子,一个带有购物车的网站。在顶部,我们用一个UI组件显示购物车中的商品数量。我们还可以用另一个UI组件,显示购物车中商
品的总价。如果用户点击添加到购物车按钮,则这两个组件应立即更新当前的数据。如果用户从购物车中删除商品、更改数目、使用优惠券或者更改送货地点,则相关的UI组件都应该更新出正确的信息。
可以看到,随着功能范围的扩大,一个简单的购物车将会很难保持数据同步。

在这篇文章中,我将介绍Redux框架,它可以帮助你以简单易用的方式构建复杂项目并进行维护。为了使学习更容易,我们将使用一个简化的购物车项目来学习Redux的工作原理。你需要至少熟悉React库,因为你以后需要将其与Redux集成。

学习前提

在我们开始以前,确保你熟悉以下知识:

同时,确保你的设备已经安装:

什么是Redux

Redux是一个流行的JavaScript框架,为应用程序提供一个可预测的状态容器。Redux基于简化版本的Flux框架,Flux是Facebook开发的一个框架。在标准的MVC框架中,数据可以在UI组件和存储之间双向流动,而Redux严格限制了数据只能在一个方向上流动。 见下图:

图片描述

在Redux中,所有的数据(比如state)被保存在一个被称为store的容器中 → 在一个应用程序中只能有一个。store本质上是一个状态树,保存了所有对象的状态。任何UI组件都可以直接从store访问特定对象的状态。要通过本地或远程组件更改状态,需要分发一个action分发在这里意味着将可执行信息发送到store。当一个store接收到一个action,它将把这个action代理给相关的reducerreducer是一个纯函数,它可以查看之前的状态,执行一个action并且返回一个新的状态。

理解不变性(Immutability)

在我们开始实践之前,需要先了解JavaScript中的不变性意味着什么。在编码中,我们编写的代码一直在改变变量的值。这是可变性。但是可变性常常会导致意外的错误。如果代码只处理原始数据类型(numbers, strings, booleans),那么你不用担心。但是,如果在处理Arrays和Objects时,则需要小心执行可变操作。
接下来演示不变性

  • 打开终端并启动node(输入node)。
  • 创建一个数组,并将其赋值给另一个变量。
> let a = [1, 2, 3]
> let b = a
> b.push(8)
> b
[1, 2, 3, 8]
> a
[1, 2, 3, 8]

可以看到,更新数组b也会同时改变数组a。这是因为对象和数组是引用数据类型 → 这意味着这样的数据类型实际上并不保存值,而是存储指向存储单元的指针。
将a赋值给b,其实我们只是创建了第二个指向同一存储单元的指针。要解决这个问题,我们需要将引用的值复制到一个新的存储单元。在Javascript中,有三种不同的实现方式:

  1. 使用Immutable.js创建不可变的数据结构。
  2. 使用JavaScript库(如UnderscoreLodash)来执行不可变的操作。
  3. 使用ES6方法执行不可变操作。

本文将使用ES6方法,因为它已经在NodeJS环境中可用了,在终端中,执行以下操作:

> a = [1,2,3]
[ 1, 2, 3 ]
> b = Object.assign([],a)
[ 1, 2, 3 ]
> b.push(8)
> b
[ 1, 2, 3, 8 ] // b output
> a
[ 1, 2, 3 ] // a output

在上面的代码中,修改数组b将不会影响数组a。我们使用Object.assign()创建了一个新的副本,由数组b指向。我们也可以使用操作符(...)执行不可变操作:

> a = [1,2,3]
[ 1, 2, 3 ]
> b = [...a, 4, 5, 6]
[ 1, 2, 3, 4, 5, 6 ]
> a
[ 1, 2, 3 ]

我不会深入这个主题,但是这里还有一些额外的ES6功能,我们可以用它们执行不可变操作:

配置Redux

配置Redux开发环境的最快方法是使用create-react-app工具。在开始之前,确保已经安装并更新了nodejsnpmyarn。我们生成一个redux-shopping-cart项目并安装Redux

create-react-app redux-shopping-cart

cd redux-shopping-cart
yarn add redux # 或者npm install redux

首先,删除src文件夹中除index.js以外的所有文件。打开index.js,删除所有代码,键入以下内容:

import { createStore } from "redux";

const reducer = function(state, action) {
  return state;
}

const store = createStore(reducer);

让我解释一下上面的代码:

  1. 首先,我们从redux包中引入createStore()方法。
  2. 我们创建了一个名为reducer的方法。第一个参数state是当前保存在store中的数据,第二个参数action是一个容器,用于:

    • type - 一个简单的字符串常量,例如ADD, UPDATE, DELETE等。
    • payload - 用于更新状态的数据。
  3. 我们创建一个Redux存储区,它只能使用reducer作为参数来构造。存储在Redux存储区中的数据可以被直接访问,但只能通过提供的reducer进行更新。

注意到,我在第二点中所提到state。目前,state为undefined或null。要解决这个问题,需要分配一个默认的值给state,使其成为一个空数组:

const reducer = function(state=[], action) {
  return state;
}

让我们更进一步。目前我们创建的reducer是通用的。它的名字没有描述它的用途。那么我们如何使用多个reducer呢?我们将用到Redux包中提供的combineReducers函数。修改代码如下:

// src/index.js

import { createStore } from "redux";
import { combineReducers } from 'redux';

const productsReducer = function(state=[], action) {
  return state;
}

const cartReducer = function(state=[], action) {
  return state;
}

const allReducers = {
  products: productsReducer,
  shoppingCart: cartReducer
}

const rootReducer = combineReducers(allReducers);

let store = createStore(rootReducer);

在上面的代码中,我们将通用的reducer修改为productReducercartReducer。创建这两个空的reducer是为了展示如何在一个store中使用combineReducers函数组合多个reducer。

接下来,我们将为reducer定义一些测试数据。修改代码如下:

// src/index.js

…

const initialState = {
  cart: [
    {
      product: 'bread 700g',
      quantity: 2,
      unitCost: 90
    },
    {
      product: 'milk 500ml',
      quantity: 1,
      unitCost: 47
    }
  ]
}

const cartReducer = function(state=initialState, action) {
  return state;
}

…

let store = createStore(rootReducer);

console.log("initial state: ", store.getState());

我们使用store.getState()在控制台中打印出当前的状态。你可以在终端中执行npm start或者yarn start来运行dev服务器。并在控制台中查看state

图片描述

现在,我们的cartReducer什么也没做,但它应该在Redux的存储区中管理购物车商品的状态。我们需要定义添加、更新和删除商品的操作(action)。我们首先定义ADD_TO_CART的逻辑:

// src/index.js

…

const ADD_TO_CART = 'ADD_TO_CART';

const cartReducer = function(state=initialState, action) {
  switch (action.type) {
    case ADD_TO_CART: {
      return {
        ...state,
        cart: [...state.cart, action.payload]
      }
    }

    default:
      return state;
  }
}

…

我们继续来分析一下代码。一个reducer需要处理不同的action类型,因此我们需要一个SWITCH语句。当一个ADD_TO_CART类型的action在应用程序中分发时,switch中的代码将处理它。
正如你所看到的,我们将action.payload中的数据与现有的state合并以创建一个新的state。

接下来,我们将定义一个action,作为store.dispatch()的一个参数。action是一个Javascript对象,有一个必须的type和可选的payload。我们在cartReducer函数后定义一个:

…
function addToCart(product, quantity, unitCost) {
  return {
    type: ADD_TO_CART,
    payload: { product, quantity, unitCost }
  }
}
…

在这里,我们定义了一个函数,返回一个JavaScript对象。在我们分发消息之前,我们添加一些代码,让我们能够监听store事件的更改。

…
let unsubscribe = store.subscribe(() =>
  console.log(store.getState())
);

unsubscribe();

接下来,我们通过分发消息到store来向购物车中添加商品。将下面的代码添加在unsubscribe()之前:

…
store.dispatch(addToCart('Coffee 500gm', 1, 250));
store.dispatch(addToCart('Flour 1kg', 2, 110));
store.dispatch(addToCart('Juice 2L', 1, 250));

下面是整个index.js文件:

// src/index.js

import { createStore } from "redux";
import { combineReducers } from 'redux';

const productsReducer = function(state=[], action) {
  return state;
}

const initialState = {
  cart: [
    {
      product: 'bread 700g',
      quantity: 2,
      unitCost: 90
    },
    {
      product: 'milk 500ml',
      quantity: 1,
      unitCost: 47
    }
  ]
}

const ADD_TO_CART = 'ADD_TO_CART';

const cartReducer = function(state=initialState, action) {
  switch (action.type) {
    case ADD_TO_CART: {
      return {
        ...state,
        cart: [...state.cart, action.payload]
      }
    }

    default:
      return state;
  }
}

function addToCart(product, quantity, unitCost) {
  return {
    type: ADD_TO_CART,
    payload: {
      product,
      quantity,
      unitCost
    }
  }
}

const allReducers = {
  products: productsReducer,
  shoppingCart: cartReducer
}

const rootReducer = combineReducers(allReducers);

let store = createStore(rootReducer);

console.log("initial state: ", store.getState());

let unsubscribe = store.subscribe(() =>
  console.log(store.getState())
);

store.dispatch(addToCart('Coffee 500gm', 1, 250));
store.dispatch(addToCart('Flour 1kg', 2, 110));
store.dispatch(addToCart('Juice 2L', 1, 250));

unsubscribe();

保存代码后,Chrome会自动刷新。可以在控制台中确认新的商品已经添加了。

图片描述

组织Redux代码

index.js中的代码逐渐变得冗杂。我把所有的代码都写在index.js中是为了起步时的简单易懂。接下来,我们来看一下如何组织Redux项目。首先,在src文件夹中创建一下文件和文件夹:

src/
├── actions
│ └── cart-actions.js
├── index.js
├── reducers
│ ├── cart-reducer.js
│ ├── index.js
│ └── products-reducer.js
└── store.js

然后,我们把index.js中的代码进行整理:

// src/actions/cart-actions.js

export const ADD_TO_CART = 'ADD_TO_CART';

export function addToCart(product, quantity, unitCost) {
  return {
    type: ADD_TO_CART,
    payload: { product, quantity, unitCost }
  }
}
// src/reducers/products-reducer.js

export default function(state=[], action) {
  return state;
}
// src/reducers/cart-reducer.js

import  { ADD_TO_CART }  from '../actions/cart-actions';

const initialState = {
  cart: [
    {
      product: 'bread 700g',
      quantity: 2,
      unitCost: 90
    },
    {
      product: 'milk 500ml',
      quantity: 1,
      unitCost: 47
    }
  ]
}

export default function(state=initialState, action) {
  switch (action.type) {
    case ADD_TO_CART: {
      return {
        ...state,
        cart: [...state.cart, action.payload]
      }
    }

    default:
      return state;
  }
}
// src/reducers/index.js

import { combineReducers } from 'redux';
import productsReducer from './products-reducer';
import cartReducer from './cart-reducer';

const allReducers = {
  products: productsReducer,
  shoppingCart: cartReducer
}

const rootReducer = combineReducers(allReducers);

export default rootReducer;
// src/store.js

import { createStore } from "redux";
import rootReducer from './reducers';

let store = createStore(rootReducer);

export default store;
// src/index.js

import store from './store.js';
import { addToCart }  from './actions/cart-actions';

console.log("initial state: ", store.getState());

let unsubscribe = store.subscribe(() =>
  console.log(store.getState())
);

store.dispatch(addToCart('Coffee 500gm', 1, 250));
store.dispatch(addToCart('Flour 1kg', 2, 110));
store.dispatch(addToCart('Juice 2L', 1, 250));

unsubscribe();

整理完代码之后,程序依然会正常运行。现在我们来添加修改和删除购物车中商品的逻辑。修改cart-actions.jscart-reducer.js文件:

// src/reducers/cart-actions.js
…
export const UPDATE_CART = 'UPDATE_CART';
export const DELETE_FROM_CART = 'DELETE_FROM_CART';
…
export function updateCart(product, quantity, unitCost) {
  return {
    type: UPDATE_CART,
    payload: {
      product,
      quantity,
      unitCost
    }
  }
}

export function deleteFromCart(product) {
  return {
    type: DELETE_FROM_CART,
    payload: {
      product
    }
  }
}
// src/reducers/cart-reducer.js
…
export default function(state=initialState, action) {
  switch (action.type) {
    case ADD_TO_CART: {
      return {
        ...state,
        cart: [...state.cart, action.payload]
      }
    }

    case UPDATE_CART: {
      return {
        ...state,
        cart: state.cart.map(item => item.product === action.payload.product ? action.payload : item)
      }
    }

    case DELETE_FROM_CART: {
      return {
        ...state,
        cart: state.cart.filter(item => item.product !== action.payload.product)
      }
    }

    default:
      return state;
  }
}

最后,我们在index.js中分发这两个action

// src/index.js
…
// Update Cart
store.dispatch(updateCart('Flour 1kg', 5, 110));

// Delete from Cart
store.dispatch(deleteFromCart('Coffee 500gm'));
…

保存完代码之后,可以在浏览器的控制台中检查修改和删除的结果。

使用Redux工具调试

如果我们的代码出错了,应该如何调试呢?

Redux拥有很多第三方的调试工具,可用于分析代码和修复bug。最受欢迎的是time-travelling tool,即redux-devtools-extension。设置它只需要三个步骤。

  • 首先,在Chrome中安装Redux Devtools扩展。
  • 然后,在运行Redux应用程序的终端里使用Ctrl+C停止服务器。并用npm或yarn安装redux-devtools-extension包。
yarn add redux-devtools-extension
  • 一旦安装完成,我们对store.js稍作修改:
// src/store.js
import { createStore } from "redux";
import { composeWithDevTools } from 'redux-devtools-extension';
import rootReducer from './reducers';

const store = createStore(rootReducer, composeWithDevTools());

export default store;

我们还可以把src/index.js中日志相关的代码删除掉。返回Chrome,右键单击该工具的图标,打开Redux DevTools面板:

图片描述

图片描述

可以看到,Redux Devtools很强大。你可以在action, statediff(方法差异)之间切换。选择左侧面板上的不同action,观察状态树的变化。你还可以通过进度条来播放actions序列。甚至可以通过工具直接分发操作信息。具体的请查看文档

集成React

在本文开头,我提到Redux可以很方便的与React集成。只需要简单的几步。

  • 首先,停止服务器,并安装react-redux包:
yarn add react-redux
  • 接下来,在index.js中加入React代码。我们还将使用Provider类将React应用程序包装在Redux容器中:
// src/index.js
…
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';

const App = <h1>Redux Shopping Cart</h1>;

ReactDOM.render(
  <Provider store={store}>
    { App }
  </Provider> ,
  document.getElementById('root')
);
…

目前,已经完成了集成的第一部分。可以启动服务器以查看效果。第二部分涉及到使用刚刚安装的react-redux包中的几个方法。通过这些方法将React组件与Redux的storeaction相关联。此外,还可以使用ExpressFeathers这样的框架来设置API。API将为我们的应用程序提供对数据库服务的访问。

感谢网友整理了本文的相关代码,如需要,请移步这里

在Redux中,我们还可以安装其他一些包,比如axios等。我们React组件的state将由Redux处理,确保所有组件与数据库API的同步。想要更进一步的学习,请看Build a CRUD App Using React, Redux and FeathersJS

总结

我希望本文能对你有所帮助。当然,还有很多相关的内容需要学习。例如,处理异步操作、身份验证、日志记录等。如果觉得Redux适合你,可以看看以下几篇文章:

这篇文章是看到比较简明的Redux教程。当然也是翻译过来哒,文中提到了很多延伸文章,我还在一个个学习当中,遇到不错的依然会翻译给大家的。

?喜欢的话记得收藏哦!

查看原文

之幸甘木 关注了专栏 · 2020-08-24

前端食堂

个人公众号:前端食堂 你的前端食堂,记得按时吃饭~

关注 2823

之幸甘木 关注了专栏 · 2020-08-24

SegmentFault 行业快讯

第一时间为开发者提供行业相关的实时热点资讯

关注 53534

之幸甘木 关注了用户 · 2020-08-24

敖丙 @aobing

关注 5765

认证与成就

  • 获得 0 次点赞
  • 获得 2 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 2 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2020-08-23
个人主页被 132 人浏览