R·ex / Zeng


音游狗、安全狗、攻城狮、业余设计师、段子手、苦学日语的少年。

TypeScript+Vue 组件库的细节问题

注意:本文发布于 1605 天前,文章中的一些内容可能已经过时。

背景

为了 第四季度的 KPI(划掉) 配合全组前端项目的 UI/UX 改版,我们需要自己写一套组件库。第一个吃螃蟹的项目是用 Vue 写的,所以我们决定先写一个 Vue 的组件库。因为我刚好比较闲,于是搭脚手架的任务就交给了我。

技术选型

我个人的观点是:TypeScript(或其它静态类型的语言)是未来的趋势,但 Vue 对 TypeScript 的支持并不是那么好。

经过再三查阅文档,我发现 官方文档 提到可以用 ES6 Class 或者传统的 Object。我试了一下:

  • Object 无法优雅的提示与 this 相关的错误。毕竟 Vue.extend({ xxx }) 这种写法虽然能校验参数类型,但在 TypeScript 看来,这儿压根就没有 this
  • Class 的写法配合上 Ant Design 的 vue-types 表现还不错,甚至不需要额外的插件(例如 Vetur)就可以补全,加上最新的 vue-cli 默认创建的 TypeScript 项目也用了 Class 的写法,于是最终决定用 Class。

在查找资料的过程中,偶然看到了 vue-property-decorator 这个项目,是把官方维护的 vue-class-component 又包了一层,用起来更舒服,于是也决定用它了。

至于编译,我用了 Rollup,因为它跟 Webpack 比起来,配置文件极短、原生支持 ES Module、原生支持多 Input 多 Output 还能自动拆包,几乎不需要耗费任何精力。除掉一些工具函数以外,配置文件就长这样:

export default {
  input: getEntries(),
  output: [
    { dir: 'es', format: 'esm', exports: 'named' },
    { dir: 'lib', format: 'cjs', exports: 'named' },
  ],
  // use regex for further extendability
  external: id => /^vue$/.test(id),
  plugins: [
    nodeResolve({ preferBuiltins: false }),
    commonjs(),
    globals(),
    builtins(),
    typescript({ target: 'es5' }),
    vue(),
    less({
      output: (css, file) => {
        // some codes
      },
    }),
  ],
};

之所以要生成一个 es 和一个 lib,是为了能像 Element 和 Ant Design 一样支持按需引入,less.output 的配置也是为了这个,但是这是个函数,稍微麻烦了一些。

组件示例

一个典型的组件大概是这样子,装饰器可以区分 Data、Computed、Method 和生命周期函数,剩下的就交给上面的 Vue.extend 了:

<template>
  <div>
    <span>{{ displayNum }} {{ innerState }}</span>
    <another-component />
    <button @click="onClick">Click me</button>
  </div>
</template>

<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import PropTypes from '../../utils/vue-types'; // copied from antd

import AnotherComponent from './AnotherComponent';

const CompProps = Vue.extend({
  components: {
    anotherComponent: AnotherComponent,
  },
  props: {
    num: PropTypes.number,
    str: PropTypes.string.def('default value'),
    width: PropTypes.oneOfType([
      PropTypes.string,
      PropTypes.number,
    ]),
    align: PropTypes.oneOf(['start', 'center', 'end']),
    date: PropTypes.instanceOf(Date),
  },
});

@Component
export default class Comp extends CompProps {
  // data
  innerState = 0;
  // computed
  get displayNum () {
    return this.num || 0;
  }
  // method
  onClick (e: MouseEvent) {
    this.innerState++;
  }
  // lifecycle hook
  mounted () {
    console.log('mounted');
  }
};
</script>

vue-property-decorator 官方推荐将上面的 Vue.extend 的内容写到 @Component 里面,但对于 TypeScript,只有直接 Extend 一个 Vue.extend 的值,Class 才能带上这些 Props,官方的 示例代码 也是这么写的。

对于 PropTypes 的校验

由于 Ant Design 的 vue-types 是用 JavaScript 写的,所以我必须要给它补上类型,否则不管怎么搞,所有的 Props 全是 any,那还写个锤子?

vue-types 的本质其实就是生成一个 Vue 中用来校验的 Object,例如:

export default {
  props: {
    str: {
      type: String,
      default: 'default value',
    },
  },
}

去读 Vue 的类型定义文件,发现 props 这个对象的类型应该是 PropOptions

export interface PropOptions<T=any> {
  type?: PropType<T>;
  required?: boolean;
  default?: T | null | undefined | (() => T | null | undefined);
  validator?(value: T): boolean;
}

之后发现 PropOptions 会随着 PropValidatorRecordPropsDefinitionThisTypedComponentOptionsWithRecordPropsVueConstructor 一层层将里面的 T 传递到 V extends Vue 中。举个例子,我们只需要让 PropTypes.string 返回一个 PropOptions<string>,那么在组件中写的 this.str 就会被认为是个字符串啦!

简单类型

有了这个思路,所有的基础类型都很好写(代码完全照抄的 Ant Design,我只是在里面加了类型。为了节约篇幅,只着重写类型相关的部分):

const VuePropTypes = {
  // begin of basic types
  get any () { return xxx as PropOptions<any>; },
  get func () { return xxx as PropOptions<Function>; },
  get bool () { return xxx as PropOptions<boolean>; },
  get string () { return xxx as PropOptions<string>; },
  get number () { return xxx as PropOptions<number>; },
  get array () { return xxx as PropOptions<any[]>; },
  get object () { return xxx as PropOptions<{ [key: string]: any }>; },
  get integer () { return xxx as PropOptions<number>; },
  get symbol () { return xxx as PropOptions<symbol>; },
  // end of basic types
};

复杂类型

至于下面几个比较复杂的东西,就要用到泛型了:

const VuePropTypes = {
  // begin of generics
  oneOf<T> (arr: T[]) { return xxx as PropOptions<T>; },
  arrayOf<T> (type: PropOptions<T>) { return xxx as PropOptions<T[]>; },
  objectOf<T> (type: PropOptions<T>) { return xxx as PropOptions<{ [key: string]: T }>; },
  // end of generics
};

对 infer 的使用

有个比较有意思的类型是 instanceOf,因为在 JavaScript 中,new Date() instanceof Date === true,但如果我让这个函数的返回值是 PropOptions<T> 的话,这个 Prop 就真的只能传 Date 的构造函数而不是一个真正的实例了。例如,我只能写 this.date.now()Date 的静态方法)而不能写 this.date.getYear()Date 实例的方法)。正常人应该不会往 Prop 里面塞构造函数,因此基本可以断定,instanceOf 就应当返回 PropOptions<InstanceType<T>>,也就是 T 的实例类型。

那么 InstanceType 具体该怎么写呢?我们注意到所有的构造函数都带有 prototype,而这其中的 Key 都会出现在实例中。因此我们可以写一个提取 prototype 的类型的工具类型:

interface ConstructorType<T> {
  prototype: T;
}
type InstanceType<T> = T extends ConstructorType<infer R> ? R : T;

这儿用了 infer 关键字来提取类型。此时 InstanceType 的作用就是:如果 Tprototype 属性,则返回这个属性对应的类型,否则就返回 T 本身。

至于 oneOfTypeshape 这两个函数,我们需要先将 T = PropOptions<R> 里面的 R(可能是基础类型,也可能是元组)摘出来,于是就有了这么个工具类型:

type RemovePropOptions<T> = NonNullable<T extends PropOptions<infer R> ? R : null>;

对于 oneOfType,只需要先将传入的带有 PropOptions 的元组转换为联合类型(使用 TypeScript 自带的 ValueOf 工具类型),然后将 PropOptions 剥除,就是需要校验的数据类型了:

type ArrayType = Array<PropOptions<any>>;
type OneOfTypePropOptions<T> = T extends ArrayType ? RemovePropOptions<ValueOf<T>> : T;

const VuePropTypes = {
  // begin of oneOfType
  oneOfType<T> (arr: T) { return xxx as PropOptions<OneOfTypePropOptions<T>>; }
  // end of oneOfType
};

对于 shape 来说,我们只需要将传入的 T 中的每一项(使用 keyof 关键字来枚举)的 PropOptions 都剥掉,就得到了需要校验的数据类型:

interface ShapeType {
  [key: string]: PropOptions<any>;
}
type ShapePropOptions<T> = T extends ShapeType ? { [K in keyof T]: RemovePropOptions<T[K]> } : T;

const VuePropTypes = {
  // begin of shape
  shape<T> (obj: T) { return xxx as PropOptions<ShapePropOptions<T>>; }
  // end of shape
};

至此,所有跟 vue-types 相关的工作就搞定了。

小插曲:第三方库的类型问题

我们几个同事一致决定用 vue-popper 来管理 Select、Dropdown、Tooltip 之类的弹框,但这个库是一个纯 JavaScript 库,并没有类型文件。虽然我们不太介意这个,因为用法比较少,可以直接看文档;但 TypeScript 会报错:Cannot find module 'vue-popperjs'.。作为一个临时解决方案,我补了一个文件:

// TODO: add real types for vue-popperjs
declare module 'vue-popperjs';

然后在需要的组件中引入:

import '../../vue-popperjs-types.d.ts';

结果编译报错了:Error: Could not resolve '../../vue-popperjs-types.d' from src/components/dropdown/dropdown.vue?rollup-plugin-vue=script.ts

究其原因,应该是 Rollup 的 TypeScript 插件在编译完 .d.ts 之后发现没有内容,于是就没有生成具体的 JavaScript 文件以供后续的 Vue 插件使用。我又想到了 TypeScript 里面有个特别具有微软风格的东西:三斜线指令。它可以用来引用一个文件,于是我试验了一下:

/// <reference path="../../vue-popperjs-types.d.ts" />

然后就可以了……

总结

在上面一通蛇皮操作下,Script 的部分已经能完全利用上 TypeScript 的特性,但对于 Template 部分,以及在第三方项目中使用这个库,就只能寄期望于 vetur 的更新了。目前如果想要做到这点,需要专门写一个 JSON 文件,但权衡了一番之后发现不值得。如果 vetur 可以直接读取 TypeScript 的类型定义文件,那就完美了。


Update 2019-12-12

完善了 oneOfTypeshape 的类型推导,不需要再手动指定类型了。

Disqus 加载中……如未能加载,请将 disqus.com 和 disquscdn.com 加入白名单。

这是我们共同度过的

第 3040 天