背景
为了 第四季度的 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
会随着 PropValidator
、RecordPropsDefinition
、ThisTypedComponentOptionsWithRecordProps
、VueConstructor
一层层将里面的 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
的作用就是:如果 T
有 prototype
属性,则返回这个属性对应的类型,否则就返回 T
本身。
至于 oneOfType
和 shape
这两个函数,我们需要先将 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
完善了 oneOfType
和 shape
的类型推导,不需要再手动指定类型了。
版权声明:除文章开头有特殊声明的情况外,所有文章均可在遵从 CC BY 4.0 协议的情况下转载。