Background
In order to finish Q4 KPI (crossed out) cooperate with the UI/UX redesign of the entire frontend project, we need to write a component library ourselves. The first project we aimed to do was written in Vue, so we decided to write a Vue component library first. Because I did not have too many tasks at that time, building the scaffold became my responsibility.
Technical Choices
My personal opinion is: TypeScript (or other statically typed languages) is the future, but Vue's support for TypeScript is not that good.
After reading the documentation several times, I found that the official documentation mentioned that you can use ES6 Class or traditional Object. I tried:
- Object cannot provide elegant hints for errors related to
this. After all, although the writing styleVue.extend({ xxx })can check the parameter types, TypeScript does not recognizethishere. - The writing style of Class, combined with Ant Design's vue-types, performs well, and even without additional plugins (such as Vetur), it can provide auto-completion. In addition, the latest TypeScript project created by
vue-clialso uses the Class writing style by default. Therefore, I finally decided to use Class.
During the process of searching for information, I accidentally found the project vue-property-decorator, which is a wrapper of the official project vue-class-component. It is more comfortable to use, so I decided to use it.
As for the compilation, I used Rollup, because compared to Webpack, its configuration file is extremely short, it natively supports ES Module, natively supports multiple inputs and outputs, and can automatically split packages. It almost does not require any effort. Except for some utility functions, the configuration file looks like this:
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
},
}),
],
};The reason for generating an es and a lib is to support on-demand import like Element and Ant Design. The configuration of less.output is also for this purpose, but it is a function, which is a bit more complicated.
Component Example
A typical component looks like this. The decorators can distinguish Data, Computed, Method, and lifecycle functions, and the rest is handed over to 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>The official recommendation of vue-property-decorator is to write the content of Vue.extend into @Component. However, for TypeScript, only by directly extending a value of Vue.extend, the Class can carry these Props. The official example code is also written like this.
Validation of PropTypes
Since Ant Design's vue-types is written in JavaScript, I must add types to it, otherwise no matter what I do, all Props will be any. What's the point of writing it?
The essence of vue-types is to generate an Object used for validation in Vue, for example:
export default {
props: {
str: {
type: String,
default: 'default value',
},
},
}By reading the type definition file of Vue, I found that the type of the props object should be PropOptions:
export interface PropOptions<T=any> {
type?: PropType<T>;
required?: boolean;
default?: T | null | undefined | (() => T | null | undefined);
validator?(value: T): boolean;
}Then I found that PropOptions will pass the T inside to V extends Vue layer by layer with PropValidator, RecordPropsDefinition, ThisTypedComponentOptionsWithRecordProps, VueConstructor. For example, we only need to make PropTypes.string return a PropOptions<string>, then this.str written in the component will be recognized as a string!
Basic Types
With this idea, all basic types are easy to write (I copied the code from Ant Design, and I just added types to it. To save space, I only focus on the part related to types):
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
};Complex Types
As for the following more complex types, we need to use generics:
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
};Usage of infer
A more interesting type is instanceOf, because in JavaScript, new Date() instanceof Date === true, but if I make the return value of this function PropOptions<T>, this Prop can only pass the constructor of Date instead of a real instance. For example, I can only write this.date.now() (a static method of Date) instead of this.date.getYear() (a method of Date instance). Normal people should not put constructors into Props, so it can be basically determined that instanceOf should return PropOptions<InstanceType<T>>, which is the instance type of T.
So how should InstanceType be written? We noticed that all constructors have prototype, and the keys in it will appear in the instance. Therefore, we can write a utility type to extract the type of prototype:
interface ConstructorType<T> {
prototype: T;
}
type InstanceType<T> = T extends ConstructorType<infer R> ? R : T;Here, the infer keyword is used to extract the type. At this time, the role of InstanceType is: if T has the prototype property, return the type corresponding to this property, otherwise return T itself.
As for the two functions oneOfType and shape, we need to first extract R (which may be a basic type or a tuple) from T = PropOptions<R>, so we have such a utility type:
type RemovePropOptions<T> = NonNullable<T extends PropOptions<infer R> ? R : null>;For oneOfType, we only need to first convert the tuple with PropOptions into a union type (using the built-in TypeScript utility type ValueOf), and then remove the PropOptions, which is the data type that needs to be validated:
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
};For shape, we only need to remove the PropOptions of each item in T (using the keyof keyword to enumerate), and then we get the data type that needs to be validated:
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
};So far, all the work related to vue-types has been completed.
Anecdote: Type Issues of Third-party Libraries
We unanimously decided to use vue-popper to manage pop-ups such as Select, Dropdown, and Tooltip, but this library is a pure JavaScript library and does not have type files. Although we don't mind this because its API is not too much and can be directly viewed in the documentation, TypeScript will report an error: Cannot find module 'vue-popperjs'.. As a temporary solution, I added a file:
// TODO: add real types for vue-popperjs
declare module 'vue-popperjs';Then import it in the component where it is needed:
import '../../vue-popperjs-types.d.ts';The compilation reported an error: Error: Could not resolve '../../vue-popperjs-types.d' from src/components/dropdown/dropdown.vue?rollup-plugin-vue=script.ts.
The reason should be that the TypeScript plugin of Rollup found that there was no content after compiling .d.ts, so it did not generate a specific JavaScript file for subsequent Vue plugins to use. I also thought of a special thing in TypeScript that has a Microsoft style: Triple-Slash Directives. It can be used to reference a file, so I tried:
/// <reference path="../../vue-popperjs-types.d.ts" />Then it worked...
Summary
With the above operations, the Script part has been able to fully utilize the features of TypeScript, but for the Template part, and using this library in third-party projects, we can only hope for the update of Vetur. Currently, if you want to achieve this, you need to write a JSON file specifically, but after weighing it, I found it not worth it. If Vetur can directly read TypeScript type definition files, that would be perfect.
Update 2019-12-12
Improved the type inference of oneOfType and shape, no need to specify the type manually.