如何让TypeScript完美兼容Vue2.x的JSX

4,201 阅读5分钟

TypeScript如何处理JSX

TypeScript参考了React的JSX,那么我们就以React为例,来分析TypeScript是如何处理React的JSX的。

<div id="main">
  <MyComponent title="my-component"></MyComponent>
</div>

这段代码足以囊括React的JSX使用的各种方面:宿主元素、宿主元素属性、自定义组件、自定义组件属性、Children,其实看似复杂的JSX片段无非就这几个概念。TypeScript为了能准确识别出这些概念,定义了若干个接口,并要求用户自主选择这些接口的实现形式,从而让TypeScript建立起与React JSX之间的映射关系。

TypeScript定义的若干接口如下:

React JSX

TypeScript JSX 定义

宿主元素

IntrinsicElements

宿主元素属性

IntrinsicAttributes

IntrinsicClassAttributes

自定义组件             

Element

ElementClass

关于这两者的区别:www.typescriptlang.org/docs/handbo…

自定义组件属性

函数组件:函数的第一个参数

Class组件:ElementAttributesProperty

Children

ElementChildrenAttribute

TypeScript如何处理React的JSX

我们以React提供的TypeScript JSX定义为例来分析:

declare global {
    namespace JSX {
        // tslint:disable-next-line:no-empty-interface
        interface Element extends React.ReactElement<any, any> { }
        interface ElementClass extends React.Component<any> {
            render(): React.ReactNode;
        }
        interface ElementAttributesProperty { props: {}; }
        interface ElementChildrenAttribute { children: {}; }

        // tslint:disable-next-line:no-empty-interface
        interface IntrinsicAttributes extends React.Attributes { }
        // tslint:disable-next-line:no-empty-interface
        interface IntrinsicClassAttributes<T> extends React.ClassAttributes<T> { }

        interface IntrinsicElements {
            // HTML
            ...
            form: React.DetailedHTMLProps<React.FormHTMLAttributes<HTMLFormElement>, HTMLFormElement>;
            h1: React.DetailedHTMLProps<React.HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>;
            ...
        }
   }
}

使用这个JSX配置,可以告诉TypeScript:

  1. 遇到了Element,去使用React.ReactElement做类型检查

  2. 对于Class组件的实例,即this,去使用React.Component做类型检查

  3. 遇到了Class类型的Element,它上面的属性,去使用ElementClass的props属性检查

  4. 遇到了函数类型的Element,它上面的属性,直接使用函数的第一个参数检查

    1. 注意,这条规则是TypeScript默认的,并不是上面的配置文件得来的

  5. 遇到Element,它的Children,去使用ElementClass的props的children属性检查

  6. 遇到IntrinsicElement,直接使用IntrinsicElements的定义检查,即这段代码:

    {
        // HTML
        ...
        form: React.DetailedHTMLProps<React.FormHTMLAttributes<HTMLFormElement>, HTMLFormElement>;
        h1: React.DetailedHTMLProps<React.HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>;
        ...
    }

  7. 遇到IntrinsicElement,它的普通属性和class属性,分别使用React.Attributes和React.ClassAttributes检查

TypeScript如何知道是Element(自定义组件)还是IntrinsicElement(宿主组件)的?

通过"<"之后的首字母的大小写来判断,如果是大写就是Element,如果是小写就是IntrinsicElement。


TypeScript如何处理Vue的JSX

再来看看我们在vue项目中常用的TypeScript的JSX配置:

import Vue, { VNode } from 'vue'

declare global {
  namespace JSX {
    // tslint:disable no-empty-interface
    interface Element extends VNode {}
    // tslint:disable no-empty-interface
    interface ElementClass extends Vue {}
    interface IntrinsicElements {
      [elem: string]: any
    }
  }
}

使用这个JSX配置,可以告诉TypeScript:

  1. 遇到了Element,去使用Vue的VNode做类型检查

  2. 对于Class组件的实例,即this,使用Vue做类型检查

  3. 遇到了Class类型的Element,它上面的属性,未定义

  4. 遇到了函数类型的Element,它上面的属性,直接使用函数的第一个参数检查

    1. 注意,这条规则是TypeScript默认的,并不是上面的配置文件得来的

  5. 遇到Element,它的Children,未定义

  6. 遇到IntrinsicElement,直接使用IntrinsicElements的定义检查

    1. 在这里虽然的确定义了IntrinsicElements,但是这种写法相当于:允许任何宿主标签,即便HTML中压根没有,比如<meituan></meituan>,定义不完全

  7. 遇到IntrinsicElement,它的普通属性和class属性,未定义

通过对比可以发现,我们在Vue中常用的TypeScript的JSX定义远不如React详细,我们通过这个配置甚至可以直接给出以下结论:

  1. Vue中的JSX的Class类型的组件,不具备props的校验能力

  2. Vue中的JSX的Children,不具备校验能力

  3. Vue中的JSX的宿主组件和宿主组件的属性,都不具备校验能力

这与我们的实践结果是完全一致的。

如何让TypeScript完美兼容Vue2.x

我们只需要把上述未定义或定义不完全的部分补上就可以了,我们先简单的思考一下:

  1. 遇到了Class类型的Element,它上面的属性,想当然的使用Vue中的$props属性

  2. 遇到Element,它的Children,想当然的使用Vue的$slots属性

  3. IntrinsicElement补充完整

对于第3点是可行的,只是一些工作量而已。但是对于第1点和第2点,就没有那么简单了。

对于第1点:Vue的$props压根就没有泛型的概念,就算你指定了$props也没用,TypeScript依然是摆设,如图:

  readonly $props: Record<string, any>;

对于第2点:Vue的$slots不仅没有泛型概念,还跟TypeScript的children定义存在冲突。TypeScript定义的children应该是一个Element或者Element数组,但是在Vue中$slots却是一个key:value的对象。

  readonly $slots: { [key: string]: VNode[] | undefined };
  readonly $scopedSlots: { [key: string]: NormalizedScopedSlot | undefined };

所以,我们没法简单的补充Vue的JSX定义。我们需要给予Vue的props泛型的能力,我们还需要抹平Vue的slots和TypeScript的children之间定义的差异。我们必须做一些HACK。

vue-tsx-support的解决方案

通过引入vue-tsx-support提供的JSX定义文件,便可以实现我们的诉求,它的定义内容如下:

import * as base from "./types/base";
import * as builtin from "./types/builtin-components";
import "./types/vue";

declare global {
    namespace JSX {
        interface Element extends base.Element {}
        interface ElementClass extends base.ElementClass {}
        interface ElementAttributesProperty extends base.ElementAttributesProperty {}

        interface IntrinsicElements extends base.IntrinsicElements {
            // allow unknown elements
            [name: string]: any;

            // builtin components
            transition: base.TsxComponentAttrs<builtin.TransitionProps>;
            "transition-group": base.TsxComponentAttrs<builtin.TransitionGroupProps>;
            "keep-alive": base.TsxComponentAttrs<builtin.KeepAliveProps>;
        }
    }
}

可以看到,vue-tsx-supoort把关键的Element、ElementClass、ElementAttributesProperty和IntrinsicElements这四个定义,使用自己定义的JSX类型做了替换。

通过查看代码,我们发现base.Element和bae.ElementClass其实还是VNode和Vue,IntrinsicElements也比较好理解,所以接下来我们主要分析ElementAttributesProperty。

ElementAttributesProperty的定义如下:

export interface ElementAttributesProperty {
  _tsxattrs: any;
}

也就是说,vue-tsx-support告诉TypeScript在遇到Class组件的属性时,应该去ElementClass的_tsxattrs去做类型检查。那么这个_tsxattrs属性又是哪里来的呢?

通过vue-tsx-support官方给出的使用例子可以看到:

import * as tsx from "vue-tsx-support";
const MyComponent = tsx.componentFactory.create({
    props: {
        text: { type: String, required: true },
        important: Boolean,
    },
    ...
});

我们必须使用vue-tsx-support的方法来创建Vue实例,所以vue-tsx-support必定会对我们的Vue实例做一些"手脚"。一路的寻找,我们最终会发现这段代码:

export class Component<
  Props,
  EventsWithOn = {},
  ScopedSlotArgs = {}
> extends Vue {
  _tsxattrs!: TsxComponentAttrs<Props, EventsWithOn, ScopedSlotArgs>;
  $scopedSlots!: {
    [K in keyof ScopedSlotArgs]: InnerScopedSlot<ScopedSlotArgs[K]>
  };
}

也就是说,vue-tsx-supoort会使用自己定义的Component去扩展我们的Vue实例,并且在上面添加了_tsxattrs属性,修改了$scopedSlots属性。更重要的是,在这里我们看到Component是支持泛型的,那么这就给了我们在书写Vue组件时,指定props和scopedSlots类型的能力。

最后,为了OO开发,我们会结合使用vue-class-component和vue-tsx-supoort:

import component from "vue-class-component";
import * as tsx from "vue-tsx-support";

interface MyComponentProps {
    text: string;
    important?: boolean;
}

@component({
    props: {
        text: { type: String, required: true },
        important: Boolean
    },
    /* snip */
})
class MyComponent extends tsx.Component<MyComponentProps> {
    /* snip */
}

唯一遗憾的是,我们必须把props声明两次,一次给vue-class-component,一次给vue-tsx-component。

到这里,我们基本解决了Vue的JSX中Class组件属性的类型校验。

依然没有解决的问题——slots

vue-tsx-support虽然给予了Vue的props泛型的能力,让TypeScript得意分析Class组件的属性。但是困扰我们的另一个问题依然没有解决,那就是slots与children之间的定义冲突问题。也就是说,即便用了vue-tsx-supoort,TypeScript依然无法对Element的Children进行检查。

scopedSlots其实就是React的renderProps,它本质上还是props,跟slots没关系。

题外话

可能我们会有这样的疑问:Vue3都要出来了,还在这儿费劲折腾Vue2的TypeScript有什么意义呢?

经常看到有人说:Vue3是用TypeScript写的,所以TypeScript的支持肯定没问题。但是通过本文和《为什么使用TypeScript开发Vue2.x体验很差的分析可以得知,这句话其实是不严谨的。


所以,在这里折腾还有意义的,不仅加深了我们对TypeScript的理解,而且还有助于我们独立思考。

参考

github.com/vuejs/vue

github.com/wonderful-p…

完。