如何为 React HoC 编写类型定义

How to write type definitions for React HoCs

我有一个高阶组件可以为我处理 Firestore 数据。我是打字稿的新手,我无法让这些类型按我希望的方式工作。

Here are the full files + some extra ts definitions

我有几个问题:

React.Component 不推断类型定义:

type WithFirestoreHoC<Props = {}> = (
  config: WithFirestoreConfig<Props>,
) => (
  WrappedComponent: ComponentType<WithFirestore & Props>,
) => ComponentClass<Props, { error: Error; queries: {}; loaded: boolean }>;

const withFirestore: WithFirestoreHoC = ({
  queries,
  props: propPickList,
  loading: { delay = 200, timeout = 0 } = {},
}) => WrappedComponent =>
  class WithFirestoreConnect extends Component { ... }

configWrappedComponent 正在获取它们的类型定义(分别为 WithFirestoreConfig + ComponentType<WithFirestore & Props>

然而,WithFirestoreConnect 并没有推断它应该是 ComponentClass<Props, { error: Error; queries: {}; loaded: boolean }>

我不介意定义状态两次,但这对我从 type WithFirestoreHoC<Props = {}>class WithFirestoreConnect extends Component<Props, { error: Error; queries: {}; loaded: boolean }> { ... } 得到 Props 没有帮助,因为它找不到 [=19] =].

如何创建动态选择列表

WithFirestoreConfig 的一部分定义配置对象具有传递给 WrappedComponent

的道具列表

WrappedComponent: ComponentType<WithFirestore & Props>, 真的应该 WrappedComponent: ComponentType<WithFirestore & Pick<Props, config.propsPickList>,

有没有办法告诉 typescript 你在 config.propsPickList 中提供的内容将决定 WrappedComponent 应该期待什么道具?

推断 Firestore 类型

有 2 种类型的 Firestore 查询响应,一种是针对文档的,另一种是针对 Collections/Queries 的。如果这些可以在 config.queries 中定义为这样的东西,那就太棒了:

{ queries: { 
    docQuery: myDocument as DocumentReference<docDataType>, 
    collectionQuery: myDocument as CollectionReference<docDataType>,  
} }

因此 WrappedComponent 可以知道在另一端是否需要查询或文档数据结构。

这看起来超级复杂,所以我在这里有一个更简单的示例(它是创建单个订阅的快捷方式),它至少是实现我想要的东西的良好垫脚石:

export const withFirestoreDocument: <
  DataType = firestore.DocumentData,
  Props = {}
>(
  query: FirestoreQueryable<DataType>,
) => (
  WrappedComponent: ComponentType<DocumentSnapshotExpanded<DataType>>,
) => WithFirestoreHoC<Props> = query => WrappedComponent =>
  withFirestore({ queries: { _default: query } })(
    mapProps<
      DocumentSnapshotExpanded<DataType> & Props,
      { _default: DocumentSnapshotExpanded<DataType> } & Props
    >(({ _default, ...props }) => ({ ...props, ..._default }))(WrappedComponent),
  );

但是我被困在这里,因为我无法从函数的类型定义中获取 mapProp 的类型定义...正确的方法是什么?

React.Component 不推断类型定义: 使 Props 成为函数的类型参数而不是类型别名,然后在定义 withFirestore.

如何创建动态选择列表: 为选择列表元素的并集添加一个 PL 类型参数。当您让 TypeScript 在调用站点推断 PL 时,这将做正确的事情,尽管调用者可能会通过将 PL 指定为包含不在实际中的元素的联合类型来产生不合理的行为列表。

推断 Firestore 类型: 我不确定您要使用 withFirestoreDocument。您可以使用另一个 Q 类型参数和一些映射类型和条件类型来执行此操作,以从 Q.

生成注入道具的类型

这是我对 withFirestore.tsx 的修订版,包含所有新功能、一些不相关的修复以使其在我的环境中编译,并在底部添加了一个示例(它可能应该放在一个单独的文件中):

import * as React from 'react';
import { Component, ComponentClass, ComponentType } from 'react';
import {
  DocumentReference,
  Query,
  CollectionReference,
  DocumentSnapshotExpanded,
  QuerySnapshotExpanded
} from './firemodel';
import { firestore } from 'firebase';
import { pick, forEach, isEqual, isFunction } from 'lodash';
import { expandDocSnapshot, expandQuerySnapshot } from 'modules/providers/util';
import SmartLoader from 'modules/atoms/SmartLoader';

type FirestoreQueryable<DataType> =
  | DocumentReference<DataType>
  | Query<DataType>
  | CollectionReference<DataType>;

type FirestoryQueryableFunction<
  DataType,
  Props
> = (
  firestore: firestore.Firestore,
  props: Props,
) => Promise<FirestoreQueryable<DataType>>;

type QueryConfigEntry<Props> =
  FirestoreQueryable<any> | FirestoryQueryableFunction<any, Props>;

type QueryConfig<Props> = {
  [queryName: string]: QueryConfigEntry<Props>;
};

type FirestoreQueryableExpanded<Props, QE extends QueryConfigEntry<Props>> =
  QE extends FirestoreQueryable<any> ? FirestoreQueryableExpanded1<QE> :
  QE extends FirestoryQueryableFunction<any, Props> ? FirestoreQueryableExpanded1<ReturnType<QE>> : unknown;

type FirestoreQueryableExpanded1<QE extends FirestoreQueryable<any>> =
  QE extends CollectionReference<infer DataType> | Query<infer DataType> ? QuerySnapshotExpanded<DataType> :
  QE extends DocumentReference<infer DataType> ? DocumentSnapshotExpanded<DataType> : unknown;

interface WithFirestoreConfig<Props, PL extends keyof Props, Q extends QueryConfig<Props>> {
  /** Object containing the queries to be provided to WrappedComponent.
   * The queryName used is also the prop name the snapshot is passed in. */
  queries: Q;
  /** A list of props to whitelist passing to WrappedComponent.
   * Configs without a list will whitelist all props */
  props?: PL[];
  /** Loading config items */
  loading?: {
    /** Number of ms after which to display the loading icon */
    delay?: number;
    /** Number of ms after which to display the timeout message */
    timeout?: number;
  };
}

type WithFirestoreHoC = <Props>() => <PL extends keyof Props, Q extends QueryConfig<Props>>(
  config: WithFirestoreConfig<Props, PL, Q>,
) => (
  WrappedComponent: ComponentType<WithFirestore<Props, Q> & Pick<Props, PL>>,
) => ComponentClass<Props, { error: Error; queries: {}; loaded: boolean }>;

const withFirestore: WithFirestoreHoC =
  // An extra function call is needed so that callers can specify Props and
  // still have PL and Q inferred.  It can be removed when
  // https://github.com/Microsoft/TypeScript/issues/10571 is implemented.
  <Props extends {}>() =>
  // Note: if `props` is not passed, there will be no inference for PL and it
  // will default to its constraint, which is exactly the behavior we want as
  // far as typing is concerned.
  <PL extends keyof Props, Q extends QueryConfig<Props>>({
    queries,
    props: propPickList,
    loading: { delay = 200, timeout = 0 } = {},
  }: WithFirestoreConfig<Props, PL, Q>) => WrappedComponent =>
  class WithFirestoreConnect extends Component<Props, { error: Error; queries: WithFirestore<Props, Q>; loaded: boolean }> {
    subscriptions: {
      [queryName: string]: ReturnType<FirestoreQueryable<any>['onSnapshot']>;
    } = {};
    state = {
      error: null as Error,
      queries: {} as WithFirestore<Props, Q>,
      loaded: false,
    };
    componentDidMount() {
      this.restartSubscription();
    }

    cancelSubscriptions = () => {
      forEach(this.subscriptions, unsubscribe => unsubscribe());
      this.subscriptions = {};
    };

    restartSubscription = () => {
      // Open questions:
      //   - figuring out when all loaded (use a promise?)
      this.cancelSubscriptions();
      forEach(queries, async (q: QueryConfigEntry<Props>, key) => {
        let ref: FirestoreQueryable<any>;
        if (isFunction(q)) {
          // The fact that this is an async/await means that we can
          // create dependent queries within our FirestoreQueryableFunction
          ref = await q(firestore(), this.props);
        } else {
          // Narrowing is not working for some reason.
          ref = q as FirestoreQueryable<any>;
        }
        if (ref instanceof firestore.DocumentReference) {
          this.subscriptions[key] = ref.onSnapshot(
            snap => {
              this.setState({
                queries: Object.assign({}, this.state.queries, {[key]: expandDocSnapshot(snap)}),
              });
            },
            err => {
              console.error(JSON.stringify(err));
              this.setState({ error: err });
              this.cancelSubscriptions();
            },
          );
        } else if (
          ref instanceof firestore.CollectionReference ||
          ref instanceof firestore.Query
        ) {
          let ref2: {onSnapshot(os: (snap: firestore.QuerySnapshot) => void, oe: (err: Error) => void): () => void; } = ref;
          this.subscriptions[key] = ref2.onSnapshot(
            snap => {
              this.setState({
                queries: Object.assign({}, this.state.queries, {[key]: expandQuerySnapshot(snap)}),
              });
            },
            err => {
              console.error(JSON.stringify(err));
              this.setState({ error: err });
              this.cancelSubscriptions();
            },
          );
        }
      });
    };

    componentDidUpdate(prevProps: Props) {
      if (!isEqual(this.props, prevProps)) {
        this.restartSubscription();
      }
    }
    componentWillUnmount() {
      this.cancelSubscriptions();
    }
    render() {
      if (!this.state.loaded || this.state.error) {
        return (
          <SmartLoader
            error={this.state.error}
            timeout={timeout}
            delay={delay}
          />
        );
      }

      const whitelistedProps = propPickList
        ? pick(this.props, propPickList)
        : this.props;
      // Unsure what's wrong here ~ Matt
      let WrappedComponent2 = WrappedComponent as any;
      return <WrappedComponent2 {...whitelistedProps} {...this.state.queries} />;
    }
  };

export type WithFirestore<Props, Q extends QueryConfig<Props>> = {
  [queryName in keyof Q]: FirestoreQueryableExpanded<Props, Q[queryName]>;
}

export default withFirestore;

// EXAMPLE

interface MyDoc {
  y: number
}
declare let myDocRef: DocumentReference<MyDoc>;
declare let myCollRef: CollectionReference<MyDoc>;
let wrapped = withFirestore<{x: string}>()({
  queries: {
    myDoc: myDocRef,
    myColl: myCollRef
  },
})((props) => { return <>{props.myDoc.data.y + props.myColl.docs[props.x].data.y}</>; });