React 中嵌套 prop-injection-HOC 返回值的正确 Typescript 类型

IT技术 reactjs typescript
2021-05-17 19:53:42

我想知道在以下场景中我是否对 API HOC 的返回类型有正确的输入:

  • 我有一个 Authentication HOC, withAuthentication,它将身份验证服务注入到组件的 props 中。

  • 我有一个 API HOC,withRestApi它注入 API 调用,它本身使用withAuthentication.

  • MyComponent需要利用withRestApi的注入函数,并且有自己的props。

(在实际的应用程序中,withAuthentication还需要一个 react-router HOC,但在这个例子中我试图让事情尽可能简单。)

到目前为止,我的代码基于James Ravencroft 在 HOCs 和 Typescript 上的优秀帖子,另外还有这篇关于注入 HOC props 的 SO 帖子,这有助于解决 HOC props 暴露给包装组件的父级的问题。

我想要实现的是:

  • this.props.getResultOfApiCallMyComponent内部访问,但对任何父组件隐藏。
  • this.props.isAuthenticatedWithRestApi内部访问,但对任何父组件隐藏。
  • 能够设置componentPropMyComponent从父组件。

代码如下:

MyBase.tsx,封闭组件纯粹是为了演示MyComponent's prop 的使用:

import * as React from 'react';

import MyComponent from './MyComponent';

class MyBase extends React.Component {
    public render() {
        return (
            <>
                <h1>RESULT</h1>
                <MyComponent componentProp={'Prop belonging to MyComponent'} />
            </>
        );
    }
}

export default MyBase;

MyComponent.tsx,它使用 API:

import * as React from 'react';

import { IWithRestApiProps, withRestApi } from './WithRestApi';

interface IMyComponentProps extends IWithRestApiProps {
    componentProp: string;
}

class MyComponent extends React.Component<IMyComponentProps> {
    public render() {
        return (
            <>
                <h2>Component prop: {this.props.componentProp}</h2>
                <h2>API result: {this.props.getResultOfApiCall()}</h2>
            </>
        );
    }
}

export default withRestApi(MyComponent);

WithAuthentication.tsx(把这个放在第一位,因为这不是问题......据我所知):

import * as React from 'react';

export interface IWithAuthenticationProps {
    isAuthenticated: () => boolean;
}

export const withAuthentication = <P extends IWithAuthenticationProps>(Component: React.ComponentType<P>):
    React.ComponentType<Pick<P, Exclude<keyof P, keyof IWithAuthenticationProps>>> =>
    class WithAuthentication extends React.Component<P> {
        public render() {

            const { isAuthenticated, ...originalProps } = this.props as IWithAuthenticationProps; 

            return (
                <Component
                    {...originalProps}
                    isAuthenticated={this.isAuthenticated}
                />
            );
        }

        private readonly isAuthenticated = (): boolean => {
            return true;
        }
    }

WithRestApi.tsx,其中包含打字问题。

import * as React from 'react';

import { IWithAuthenticationProps, withAuthentication } from './WithAuthentication';

export interface IWithRestApiProps extends IWithAuthenticationProps {
    getResultOfApiCall: () => string;
}

export const withRestApi = <P extends IWithRestApiProps>(Component: React.ComponentType<P>):
    React.ComponentType<Pick<P, Exclude<keyof P, keyof IWithRestApiProps>>> =>
    withAuthentication(class WithRestApi extends React.Component<P> {
        public render() {

            const { getResultOfApiCall, ...originalProps } = this.props as IWithRestApiProps; 

            return (
                <Component
                    {...originalProps}
                    getResultOfApiCall={this.getApiData}
                />
            );
        }

        private readonly getApiData = () => {
            if (this.props.isAuthenticated()) {
                return 'Some API result';
            } else {
                return 'Not authenticated';
            }
        }
    }) as React.ComponentType<P>; // TODO - remove this type assertion...?

这段代码生成了,但正如你所看到的,我不得不将withApiHOC的返回值类型断言React.ComponentType<P>. 如果没有那个断言,我会看到这个 Typescript 错误:

[ts]
Type 'ComponentType<Pick<P, Exclude<keyof P, "isAuthenticated">>>' is not assignable to type 'ComponentType<Pick<P, Exclude<keyof P, "getResultOfApiCall" | "isAuthenticated">>>'.
  Type 'ComponentClass<Pick<P, Exclude<keyof P, "isAuthenticated">>, ComponentState>' is not assignable to type 'ComponentType<Pick<P, Exclude<keyof P, "getResultOfApiCall" | "isAuthenticated">>>'.
    Type 'ComponentClass<Pick<P, Exclude<keyof P, "isAuthenticated">>, ComponentState>' is not assignable to type 'ComponentClass<Pick<P, Exclude<keyof P, "getResultOfApiCall" | "isAuthenticated">>, ComponentState>'.
      Type 'Pick<P, Exclude<keyof P, "isAuthenticated">>' is not assignable to type 'Pick<P, Exclude<keyof P, "getResultOfApiCall" | "isAuthenticated">>'.
        Type 'Exclude<keyof P, "getResultOfApiCall" | "isAuthenticated">' is not assignable to type 'Exclude<keyof P, "isAuthenticated">'.

这是我第一次遇到对 的需求Pick..Exclude,所以对于在这种情况下如何匹配类型有点模糊。我想知道我在这里使用它的方式是否可以改进以消除对类型断言的需要?

1个回答

第一个问题是 HOC 中的组件类定义不正确,如果您启用strictFunctionTypes编译器选项,您就会看到外来propsWithAuthentication不包括isAuthenticatedWithAuthentication生成该props本身。所以 props 类型WithAuthentication应该排除IWithAuthenticationProps

// ...
class WithAuthentication extends React.Component<Pick<P, Exclude<keyof P, keyof IWithAuthenticationProps>>> {
    public render() {
        const originalProps = this.props;
        // ...
    }
}
// ...

和类似的WithRestApi(并删除类型断言):

// ...
withAuthentication(class WithRestApi extends React.Component<Pick<P, Exclude<keyof P, keyof IWithRestApiProps>> & IWithAuthenticationProps> {
    public render() {
        const originalProps = this.props;
        // ...
    }
    // ...
}
// ...

现在,你是在伤害的世界,因为typescript是无法简化的复杂组合Pick,并Exclude在类型WithRestApi.tsx错误(由我精心打印)是:

Type 'ComponentType<
  Pick<
    Pick<P, Exclude<keyof P, "getResultOfApiCall" | "isAuthenticated">>
      & IWithAuthenticationProps,
    Exclude<Exclude<keyof P, "getResultOfApiCall" | "isAuthenticated">, "isAuthenticated">
  >
>'
is not assignable to type
'ComponentType<Pick<P, Exclude<keyof P, "getResultOfApiCall" | "isAuthenticated">>>'.

我们可以查看此错误消息并意识到这两种类型对于 的任何选择都是等效的P,但是 TypeScript 没有必要的代数规则来证明这一点。

所以我会建议一种不同的方法。对于每个 HOC,不是为内部 props 类型声明一个类型变量并通过排除事物来定义外部 props 类型,而是为外部 props 类型声明一个类型变量并将内部 props 类型定义为交集。TypeScript 在简化和执行涉及交集的推理方面要好得多。缺点是 props 类型MyComponent必须写成匹配表单的交集,而不仅仅是一个接口。您可以定义类型别名以生成必要的交集。解决方案:

WithAuthentication.tsx

import * as React from 'react';

// The `{[withAuthenticationPropsMarker]?: undefined}` constituent ensures that
// `WithAuthenticationProps<{}>` is still an intersection so that the inference
// rule that throws out matching constituents between
// `WithAuthenticationProps<{}>` and `WithAuthenticationProps<OrigProps>` still
// works.  In case that rule isn't applicable, the checker tags each union or
// intersection type with the first type alias reference it sees that produces
// the union or intersection type, and there's an inference rule that matches up
// the type arguments of union or intersection types produced by instantiating
// the same type alias.  Normally this is fragile because it depends on the
// desired type alias being the first one seen in the compilation, but our use
// of a unique marker should ensure that nothing else can produce and tag the
// intersection type before we do.
const withAuthenticationPropsMarker = Symbol();
export type WithAuthenticationProps<OrigProps> = OrigProps &
    {[withAuthenticationPropsMarker]?: undefined} & {
    isAuthenticated: () => boolean;
};

export const withAuthentication = <P extends {}>(Component: React.ComponentType<WithAuthenticationProps<P>>):
    React.ComponentType<P> =>
    class WithAuthentication extends React.Component<P> {
        public render() {
            return (
                <Component
                    {...this.props}
                    isAuthenticated={this.isAuthenticated}
                />
            );
        }

        private readonly isAuthenticated = (): boolean => {
            return true;
        }
    }

WithRestApi.tsx

import * as React from 'react';

import { WithAuthenticationProps, withAuthentication } from './WithAuthentication';

const withRestApiPropsMarker = Symbol();
export type WithRestApiProps<OrigProps> = OrigProps &
    {[withRestApiPropsMarker]?: undefined} & {
    getResultOfApiCall: () => string;
}

export const withRestApi = <P extends {}>(Component: React.ComponentType<WithRestApiProps<P>>):
    React.ComponentType<P> =>
    withAuthentication(class WithRestApi extends React.Component<WithAuthenticationProps<P>> {
        public render() {
            // @ts-ignore : "Rest types may only be created from object types"
            // https://github.com/Microsoft/TypeScript/issues/10727
            let {isAuthenticated, ...otherPropsUntyped} = this.props;
            let otherProps: P = otherPropsUntyped;

            return (
                <Component
                    {...otherProps}
                    getResultOfApiCall={this.getApiData}
                />
            );
        }

        private readonly getApiData = () => {
            if (this.props.isAuthenticated()) {
                return 'Some API result';
            } else {
                return 'Not authenticated';
            }
        }
    });

我的组件.tsx

import * as React from 'react';

import { WithRestApiProps, withRestApi } from './WithRestApi';

type MyComponentProps = WithRestApiProps<{
    componentProp: string;
}>;

class MyComponent extends React.Component<MyComponentProps> {
    public render() {
        return (
            <>
                <h2>Component prop: {this.props.componentProp}</h2>
                <h2>API result: {this.props.getResultOfApiCall()}</h2>
            </>
        );
    }
}

export default withRestApi(MyComponent);

后续:隐藏isAuthenticatedwithRestApi包裹的组件

在类型中隐藏 prop 是将定义更改WithRestApiProps为:

export type WithRestApiProps<OrigProps> = OrigProps & {
    getResultOfApiCall: () => string;
}

props仍将在运行时传递。如果你想避免这种情况,你可以WithRestApi.render改为:

    public render() {
        // @ts-ignore : "Rest types may only be created from object types"
        // https://github.com/Microsoft/TypeScript/issues/10727
        let {isAuthenticated, ...otherPropsUntyped} = this.props;
        let otherProps: P = otherPropsUntyped;

        return (
            <Component
                {...otherProps}
                getResultOfApiCall={this.getApiData}
            />
        );
    }