Vue 3中 tsx 对自定义组件中事件的监听报类型错误问题

6,543 阅读7分钟

在Vue3中使用tsx写法可以享受到tx带来的智能提示,提升代码规范性与开发效率,但严格的类型检查也会为我们带来一些麻烦,例如在自定义组件中监听事件就会报类型错误。

注:Vue 版本号 3.0.0

报错场景 - 原生事件

相信对Vue3有初步了解的小伙伴都能愉快地用template写法封装一个简单的组件并使用:

// ClickMe.vue
<template>
  <div>{{ title }}</div>
</template>

<script lang="ts">
export default {
  name: 'Click Me',
  props: {
    title: {
      type: String,
      required: true
    }
  }
}
</script>

// App.vue
<template>
  <div>
    <ClickMe title="点我" @click="handleClick" />
  </div>
</template>

<script lang="ts">
import ClickMe from '@/components/ClickMe.vue'

export default {
  name: 'App',
  components: {
    ClickMe
  },
  setup () {
    const handleClick = () => {
      console.log('Hello World')
    }
    return {
      handleClick
    }
  }
}
</script>

现在将代码写法从template改为tsx,ClickMe组件改造很顺利:

// ClickMe.tsx
import { defineComponent } from 'vue'

export default defineComponent({
  name: 'Click Me',
  props: {
    title: {
      type: String,
      required: true
    }
  },
  setup (props) {
    return () => (
      <div>{ props.title }</div>
    )
  }
})

再将App.vue改成App.tsx的时候就发现编译器报错了:

// App.tsx
import ClickMe from '@/components/ClickMe'

export default {
  name: 'App',
  setup () {
    const handleClick = () => {
      console.log('Hello World')
    }
    return () => (
      <div>
        <ClickMe title="点我" onClick={ handleClick } />
      </div>
    )
  }
}

编译器会在这一行提示错误 <ClickMe title="点我" onClick={ handleClick } /> 因为ClickMe组件中并没有onClick这个prop。

原生事件 - 解决方案一

tsx只会对采用大驼峰命名法(PascalCase)的组件进行类型检查,如果我们在全局或者组件内部局部注册组件,再用短线命名法(kebab-case)在tsx中使用就OK了。

// App.tsx
import ClickMe from '@/components/ClickMe'

export default {
  name: 'App',
  // 局部注册
  components: {
    ClickMe
  },
  setup () {
    const handleClick = () => {
      console.log('Hello World')
    }
    return () => (
      <div>
        <click-me title="点我" onClick={ handleClick } />
      </div>
    )
  }
}

原生事件 - 解决方案二

方案一是把问题解决了,但ts带来的类型检查与智能提示也都没有了,有种练了七伤拳的感觉。这种做法可能更适合于引用全局组件或者props类型不明确的第三方组件,对于自定义的局部组件可以有更优雅的解决方案。

提取Props

首先对 ClickMe 组件的props进行改造,将Props定义提取出来:

// ClickMe.tsx
import { defineComponent } from 'vue'

const ClickMePropsDefine = {
  title: {
    type: String,
    required: true
  }
} as const

export default defineComponent({
  name: 'Click Me',
  props: ClickMePropsDefine,
  setup (props) {
    return () => (
      <div>{ props.title }</div>
    )
  }
})

类型声明

声明一个通用的组件基础Props定义:

// @/types/index.ts
export const BasePropsDefine = {
  onClick: Function
  // 可在此处添加其他原生事件声明
} as const

组件类型声明

组合props类型定义,并由此声明新的组件类型:

// ClickMe.tsx
import { BasePropsDefine } from '@/types'

const PropsDefineWrapper = {
  ...BasePropsDefine, // 通过props定义写前面,便于自定义props对其进行覆盖
  ...ClickMePropsDefine
} as const

type ClickMeType = DefineComponent<typeof PropsDefineWrapper>

export 组件

显示声明组件类型后再导出

// ClickMe.tsx
const ClickMe: ClickMeType = defineComponent({
  name: 'Click Me',
  props: ClickMePropsDefine,
  setup (props) {
    return () => (
      <div>{ props.title }</div>
    )
  }
})

export default ClickMe

将App.tsx中之前添加的局部组件注册去掉,并将ClickMe组件换回PascalCase写法,发现类型错误没了,而且ts的类型检查与智能提示都保留下了。

// App.tsx
import ClickMe from '@/components/ClickMe'

export default {
  name: 'App',
  setup () {
    const handleClick = () => {
      console.log('Hello World')
    }
    return () => (
      <div>
        <ClickMe title="点我" onClick={ handleClick } />
      </div>
    )
  }
}

原生事件 - 解决方案三

解决方案二弥补了解决方案一的不足,但美中不足的是,代码过于繁琐,每次写一个自定义组件就会写大量重复的逻辑。作为一个程序员,遇到这种情况当然是要将重复的逻辑抽离出来封装成一个公共函数咯。

源码分析

先来看看vue3源码对defineComponent函数的声明:

export declare function defineComponent<Props, RawBindings = object>(setup: (props: Readonly<Props>, ctx: SetupContext) => RawBindings | RenderFunction): DefineComponent<Props, RawBindings>;

export declare function defineComponent<Props = {}, RawBindings = {}, D = {}, C extends ComputedOptions = {}, M extends MethodOptions = {}, Mixin extends ComponentOptionsMixin = ComponentOptionsMixin, Extends extends ComponentOptionsMixin = ComponentOptionsMixin, E extends EmitsOptions = EmitsOptions, EE extends string = string>(options: ComponentOptionsWithoutProps<Props, RawBindings, D, C, M, Mixin, Extends, E, EE>): DefineComponent<Props, RawBindings, D, C, M, Mixin, Extends, E, EE>;

export declare function defineComponent<PropNames extends string, RawBindings, D, C extends ComputedOptions = {}, M extends MethodOptions = {}, Mixin extends ComponentOptionsMixin = ComponentOptionsMixin, Extends extends ComponentOptionsMixin = ComponentOptionsMixin, E extends EmitsOptions = Record<string, any>, EE extends string = string>(options: ComponentOptionsWithArrayProps<PropNames, RawBindings, D, C, M, Mixin, Extends, E, EE>): DefineComponent<Readonly<{
    [key in PropNames]?: any;
}>, RawBindings, D, C, M, Mixin, Extends, E, EE>;

export declare function defineComponent<PropsOptions extends Readonly<ComponentPropsOptions>, RawBindings, D, C extends ComputedOptions = {}, M extends MethodOptions = {}, Mixin extends ComponentOptionsMixin = ComponentOptionsMixin, Extends extends ComponentOptionsMixin = ComponentOptionsMixin, E extends EmitsOptions = Record<string, any>, EE extends string = string>(options: ComponentOptionsWithObjectProps<PropsOptions, RawBindings, D, C, M, Mixin, Extends, E, EE>): DefineComponent<PropsOptions, RawBindings, D, C, M, Mixin, Extends, E, EE>;

defineComponent函数声明了4个重载类型,第一个重载类型对应的是<script setup>写法,而后面三个对应的分别是props为空,Array和Object的情况,针对使用场景来说,props为数组的情况绕开了ts的类型检查,所以只有第二个跟第四个重载类型是需要进行封装的。

基础Props类型声明

首先将BasePropsDefine改成类型声明

// @/types/index.ts
export type BasePropsDefine = {
  readonly onClick: FunctionConstructor;
}

函数声明

接下来定义一个defineTypeComponent函数及其两种重载类型:

import { BasePropsDefine } from '@/types'
import { ComponentOptionsMixin, ComponentOptionsWithObjectProps, ComponentOptionsWithoutProps, ComponentPropsOptions, ComputedOptions, DefineComponent, defineComponent, EmitsOptions, MethodOptions } from 'vue'

// 组件无自定义Props时重载类型
export function defineTypeComponent<Props = {}, RawBindings = {}, D = {}, C extends ComputedOptions = {}, M extends MethodOptions = {}, Mixin extends ComponentOptionsMixin = ComponentOptionsMixin, Extends extends ComponentOptionsMixin = ComponentOptionsMixin, E extends EmitsOptions = EmitsOptions, EE extends string = string>(options: ComponentOptionsWithoutProps<Props, RawBindings, D, C, M, Mixin, Extends, E, EE>): DefineComponent<BasePropsDefine, RawBindings, D, C, M, Mixin, Extends, E, EE>;
// 组件有自定义Props时重载类型
export function defineTypeComponent<PropsOptions extends Readonly<ComponentPropsOptions>, RawBindings, D, C extends ComputedOptions = {}, M extends MethodOptions = {}, Mixin extends ComponentOptionsMixin = ComponentOptionsMixin, Extends extends ComponentOptionsMixin = ComponentOptionsMixin, E extends EmitsOptions = Record<string, any>, EE extends string = string>(options: ComponentOptionsWithObjectProps<PropsOptions, RawBindings, D, C, M, Mixin, Extends, E, EE>): DefineComponent<BasePropsDefine & PropsOptions, RawBindings, D, C, M, Mixin, Extends, E, EE>;
// 直接调用defineComponent函数
export function defineTypeComponent (options: any) {
  return defineComponent(options)
}

上述代码复制粘贴Vue3源码中defineComponent函数的重载类型即可,只需要对其返回类型加入自定义BasePropsDefine稍作修改即可:

// 组件无自定义Props时的返回类型
DefineComponent<BasePropsDefine, RawBindings, D, C, M, Mixin, Extends, E, EE>;
// 组件有自定义Props时的返回类型
DefineComponent<BasePropsDefine & PropsOptions, RawBindings, D, C, M, Mixin, Extends, E, EE>;

含Props组件中使用

在 ClickMe.tsx 中引入新定义的方法:

// ClickMe.tsx
import { defineTypeComponent } from '@/utils'

const ClickMePropsDefine = {
  title: {
    type: String,
    required: true
  }
} as const

export default defineTypeComponent({
  name: 'Click Me',
  props: ClickMePropsDefine,
  setup (props) {
    return () => (
      <div>{ props.title }</div>
    )
  }
})

没有报错,且类型检查与智能提示都在。

无Props组件中使用

再试一下将prop去掉:

// ClickMe.tsx
import { defineTypeComponent } from '@/utils'

export default defineTypeComponent({
  name: 'Click Me',
  // props: ClickMePropsDefine,
  setup (props) {
    return () => (
      <div>点我</div>
    )
  }
})

// App.tsx
import ClickMe from '@/components/ClickMe'

export default {
  name: 'App',
  setup () {
    const handleClick = () => {
      console.log('Hello World')
    }
    return () => (
      <div>
        <ClickMe onClick={ handleClick } />
      </div>
    )
  }
}

也没有问题,说明两个重载方法都生效了,大工告成!

拓展 - 自定义指令

自定义指令报类型错误的解决方案与原生事件的监听几乎完全一致,对于局部注册指令采用前两种解决方案均可以,对于全局注册指令可选择解决方案三,在BasePropsDefine中新增全局指令的类型属性即可。

报错场景 - 自定义事件

现在再来看看自定义事件的情况,改造一下Click.tsx与App.tsx:

// ClickMe.tsx
import { defineTypeComponent } from '@/utils'

const ClickMePropsDefine = {
  title: {
    type: String,
    required: true
  },
  onHello: Function
} as const

export default defineTypeComponent({
  name: 'Click Me',
  props: ClickMePropsDefine,
  emits: ['hello'],
  setup (props, { emit }) {
    const handleClick = () => {
      emit('hello')
    }
    return () => (
      <div onClick={ handleClick }>{ props.title }</div>
    )
  }
})

// App.tsx
import ClickMe from '@/components/ClickMe'

export default {
  name: 'App',
  setup () {
    const handleHello = () => {
      console.log('Hello World')
    }
    return () => (
      <div>
        <ClickMe title="点我" onHello={ handleHello } />
      </div>
    )
  }
}

果然又报错了,编译器又提示onHello不在组件上,将自定义事件加到BasePropsDefine中显然是不合理了,所以上述的解决方案三可以被排出掉,而另外两种方法倒是都可以。

自定义事件 - 解决方案一

原生事件 - 解决方案一

自定义事件 - 解决方案二

原生事件 - 解决方案二

自定义事件 - 解决方案三

解决方案一绕过类型检查与智能提示,解决方案二又过于繁琐,这里有一个非常简单的方法,在props定义中加入onHello即可,简单有效地通过了类型检查,且不影响监听事件正常执行

// ClickMe.tsx
const ClickMePropsDefine = {
  title: {
    type: String,
    required: true
  },
  onHello: Function
} as const

到这里又要思考了,那么为什么在监听原生事件的时候不这么写呢。因为这样写的话原生事件的监听函数会去监听从子组件中emit的如click这样的事件,而并不会去监听原生事件click,由于子组件中并没有emit相应的事件,所以原生事件的监听函数并不能够执行。

引申思考

在template写法中,我们常常利用attrs属性让父组件直接给孙组件传值,孙组件的props类型显然不会在子组件中定义,所以父组件直接给孙组件传值也必定引起编译器报错,这里也提供几种思路,供大家参考。

思路一

同上述解决方案一,通过全局或局部组件注册,再用 kebab-case 命名法绕开类型检查。

思路二

同上述解决方案二,将孙组件的props定义也加入到PropsDefineWrapper中。

思路三

同原生事件 - 解决方案三,对BasePropsDefine进行拓展

// @/types/index.ts
export type BasePropsDefine = {
  readonly onClick: FunctionConstructor;
  readonly [key: string]: any;
}
// 或者
export type BasePropsDefine = {
  readonly onClick: FunctionConstructor;
} & Record<string, any>

但是这样定义后,类型检查与智能提示都没了,ts的功能大打折扣。

思路总结

三种思路都能保证编译器不报错,但是也都有各自的缺陷。思路一与思路三无法享受到ts的加持,思路二代码过于繁琐,而自定义事件的解决方案三不能用,因为这样会在子组件接收对应属性,无法通过attr传给孙组件。目前暂未想到更完美的思路,等有了之后再行更新。