前面说过,组件化是使用 Vue 引入之后带来的一大优点。未有 Vue 之前——那个年代——苦苦寻思没有一个好的组件化方案。在当初 JavaScript 连类都尚未健全的情况下,真是费煞了苦心,最简单的“对象”是有了,可那不能构建复杂的大型 UI 系统,颗粒度太低了。对象、组件的关系,一小一大分别自然很清楚。那么加多一个“类 Class”呢?你能理清楚这些名词的关系吗?面向对象与组件有什么区别与联系呢?
其实不清楚没啥关系。码多了,你自然明白:)。关于 JS Class,我写过不少博文(https://blog.csdn.net/zhangxin09/category_782926.html),——尽管现在已经没啥卵用了:)。
总之,没有一个恰当组件化方案,是不行的。即使你有了类方案,构建一个新实例(new XXX()
),那也不能”妄称“为组件。君不见,组件里面可不止 JavaScript 啊,还有标签和样式,都不是一门语言呢。如何把它们三者既辩证又统一地结合一起,确实是重中之重。另一边厢,标准的 WebComponent 方案现在不知怎么样,反正现在一个前端框架,就有其自己的组件化方案。
Vue 于 TypeScript 下的问题
回到 Vue 的话题上。话说,Vue 的组件蔚然一股新风气,JavaScript 下很好,但到了 TypeScript 下面,加多了类型提示,长成这样子。
可见区别也不大。不过问题来了,this
对象指针不明确,各个函数“各为其主”,不能连结为一个组件身上。这体现为上图红色框的报错,还有 mouted()
里面访问的 this.positionFixed
属性,就算没有报错,但编译器只能当作是 any
类型,不能明确为组件已经声明过的 positionFixed : boolean
类型——我都声明过了却都还不能用,不能正确地提示类型和约束——那是不是太蠢太不合理了啊?
坦白说,尽管我们人类写的时候,晓得那是一个整体,一个对象、一个组件。但是 IDE、编译器可不那么想,它没有更多的讯息告诉它那是一个整体,一个对象、一个组件。那么该怎么办呢?我所知道的有两个对应的方法。
用 interface
声明
为了解决满屏报错不和谐的局面,我们可以通过 interface 补充完整的类型信息,供 TS 编译知道。interface 直译接口,在 OOP 中非常常见,地位仅次于 Class,不过吊诡的是,不同人、不同语言往往对 interface 概念理解出入甚大,抽象嘛~加了点玄幻的味道进去自然不好理解。不过总的来说,和 Java 的 interface 一样,TS 的接口不能写实现,编译后没有对应的 JS 代码,仅仅声明一个对象是什么,结构有什么(包括了那些字段或属性、方法)。TS 的接口可以表征一个普通的 JS 对象,又叫 Object、JSON、Map,反正叫什么都好,就是 var obj = {};
或者 var obj = new Object();
的意思。
下面就是补充 interface
信息的例子。
还是之前那个例子,我们添加点东西。代码虽有点长,不过我们只关注红色框里面的就好。首先声明了 CalendarInput
的接口,说明具体成员有哪些,这里仅仅列出了属性,其实还可以声明方法(只是这里没用到而已);然后在组件 watch
里面的函数、mounted()
函数都是声明了第一个“参数” this
及其类型为 CalendarInput
。
我们学习 JS 的时候,都知道 this
其实是一个“隐藏的参数”,总会传入。好了,现在把这个 this
“显式”声明出来,不就搞定了函数“各为其主”的问题吗?总之,在函数出现的时候你都可以指定 this
这个类型如何,而不管这个函数在哪里的位置。
函数里面会访问到 Vue API 如 $el、$refs……
没有在 TS 里面声明过它们是会报错的。对此解决的方法很简单,接口支持 extends
继承,新建一个叫 Vue 的类,包含了所有 API 成员,然后继承它就可以了。TS 神奇的是,接口可以继承 declare class
——这是在 Java 不可能的。
declare class Vue {
public $el: HTMLElement;
public $props: any;
public $refs: any;
public BUS: any;
public $parent: Vue;
public $options: any;
public $children: any[];
public static options: any;
constructor(cfg: any) {
}
public $watch(...any): void;
public $set(...any): void;
public $destroy() { }
public $emit(e: string, ...obj: any) { }
public static component(string, Object): void {
}
public static set(...any): void {
}
public static extend(...any): any {
}
public ajResources = { // 我自己扩展的,非 vue 官方 API
imgPerfix: "",
ctx: ""
};
}
它以 *.dt.ts
库声明的形式保存。
这是我对应上述问题的第一个方案,解决是解决了,但略显不够优雅,所以它只能作为最后的手段,总是可以解决的。——什么,你看不出哪里不优雅?好吧,公布答案就是代码重复,interface 声明了一次,实现又要重复一次,——冗余了:),而且还有每个方法 this:XXX
。有见及此,还是得寻求更好的解决方案:)
官方方案
显然,比较简单直接的方法就是采用类 Class,把组件写成类就行。而且现在很多人在 Vue + TS 环境下开发,此问题有足够的“能见度”,自然不会被官方缺席,官方文档就有介绍如何在 TS 环境下开发 Vue 的问题。话分两头说,先看 Vue 2.x 版本的,官方推荐的是 Class Component。写法就是继承 Class
那样子了,见截图如下。
除了官方这个方法,还有基于它更好的 vue-property-decorator,用法参见 https://blog.csdn.net/marker__/article/details/105784520。
通过 TS 注解+装饰器(Decorator)是比较好的解决方案,但是笔者观察后还是放弃了,原因是笔者还不太熟练新玩意 npm、import 等等这些的:( 好吧,学不动了,只能拿“避免复杂”当作推搪的理由。
然后我把目光转向了新版 Vue 3。不知为何,Vue 团队好像不太感冒 Class 风格,推荐一种莫名其妙的组合式(Composition) API,于是我表示——变动太大,也学不动了。
无论是 vue 2 还是 3,官方方案终究难合我口味,——装饰器模式的思路是对的,采用注解的方法我也想到,不就是 Java 那样子的手段嘛~但是我不清楚 TS 里面能否对注解进行反射(Reflection)……好吧,我承认,又要自己动手搞一个“轮子”了。
“轮子”方案
首先还是我原来 Java 所熟悉的注解手段。咋一看,TS 语言层面支持注解,但我不理解编译中怎么处理注解,可否反射得到注解的信息?这是我的存疑,因为编译到 JS 后就类型消除了,注解也不存了,怎么可以得到注解的信息?调研一番,TS 官方并没有反射一说,而是装饰器(Decorators)来处理注解。一看,又是高大上的东西,而且好像还不是反射的意思。
于是只能放弃注解的方案。这个难题先放一放,让我们想想怎么由 Vue.component()
的写法转为 Class 风格的写法。实际上这个问题一点也不复杂,几行代码的一个函数就行。核心思想就是反射获取 Class 各成员然后组装成为 {xxx……}
,然后送入到 Vue.component()
第二个参数中去。Vue 组件允许有什么的成员,Class 就可以出现那些什么的成员,例如组件生存周期的函数 mounted()
,类身上有这个方法,那么直接对应 {……mounted() {}……}
。Class 本身就是一个 key/value 结构,妇孺皆懂,操作方便:),而这个 mounted
字眼是约定好的、固定的,在反射的循环中轻易获取:
for(var i in obj) {
// i 就是 Class 成员名词,字符串类型
}
那么再组装一下组件的配置参数,十分轻松。如果是组件方法,要求是在 methods : {xxx, yyy……}
下面的,那么也没关系,一切不是 Vue API 的方法(typeof obj[i] == 'function'
且排除 Vue API 的)都视作是组件方法,扔到 methods : {xxx, yyy……}
即可。
Vue API 就那么十来个,所以转换不麻烦,逐个对应即可。比较棘手的是 vue 组件属性有两种:data 和 props,如果加上实例字段(不参与数据驱动的),就有三种属性。TS Class 只有一种属性,怎么区分呢?所以前面才会说用注解来解决。但既然注解不可行,又只能另辟蹊径了。——实际上说出来也很简单,甚至有点返璞归真的感觉。
仔细想想,组件的 data 和 props 的写法有显著区别的,props 要么 xxx: String | Number | Boolean
声明了类型,要么 xxx: {type: String, default : "abc"}
,特征足够完备,我们在反射时候即可判断为 props。至于 data,也好办,我们只需要判断是 props,那么剩下的就是 data。
一开始的思路还是那样,可我很快就遇到问题,实际情况没那么简单,主要以下两点:
- props 要么
String | Number | Boolean
,要么{type: String, default : "abc"}
,虽然是属性挂在类身上,但访问this.xxx
时报错,类型不对。正确方式是xxx:string = "";
- data 最终为 function 类型,这个没问题,换作类属性写成
xxx:string = "";
也可以,this.xxx
能正确访问。问题是有时在data(){……}
函数中可以作一些初始化的工作,如下图。那么此时就必须强制写成函数类型了,
类属性还是要写出。其实什么值无所谓,只要类型正确就可以了。
所以有点美中不足,既要写原先的 vue 写法,又要写类属性的写法,多少会造成重复,特别在声明 props 的时候。
然而还有一个遗憾,或者说比较尴尬的地方就是,除了写好类,还要调用一个逻辑“登记组件”:(。这是无法避免的(至少当前是)。仅仅声明的一个类,其实不会使用它的(new
实例化),因为使用组件是在标签中是使用 <aj-xxx>
的,所以你只能在手动调用一次逻辑执行这个类,就是转化为 vue 组件了。
new XXX().register();
这个 方法是固定的,因为我们组件类都继承于 VueComponent
类,他是一个抽象类包含了 register()
方法。详细 VueComponent
类源码如下。
/**
* 为让 Vue 组件使用 Class 风格,通过一个类似语法糖的转换器
* 这是实验性质的
*/
export abstract class VueComponent {
/**
* 组件名字,必填
*/
abstract name: string;
$el: HTMLElement = document.body;
public $props: any;
props: { [key: string]: any } = {};
public BUS: any;
public $parent: any;
public $options: any;
public $destroy() { }
public $emit(e: string, ...obj: any) { }
/**
* 转换为 ClassAPI
*/
register(instanceFields?: string[]): void {
let cfg: { [key: string]: any } = {
props: {},
methods: {}
};
let dataFields: { [key: string]: any } = {};
for (var i in this) {
if (i == 'constructor' || i == 'name' || i == 'register' || i == '$destroy' || i == "$emit" || i == "$options")
continue;
let value: any = this[i];
if (isVueCfg(i))
cfg[i] = value;
else if (isSimplePropsField(value))
cfg.props[i] = value;
else if (typeof value == 'function')
cfg.methods[i] = value;
else if (isPropsField(i, this.props))
cfg.props[i] = this.props[i];
else // data fiels
dataFields[i] = value;
}
// 注意如果 类有了 data(){},那么 data 属性将会失效(仅作提示用),改读取 data() {} 的
if (!cfg.data)
cfg.data = () => {
return dataFields;
}
console.log(cfg)
Vue.component(this.name, cfg);
}
}
可见,代码层面几行就搞定了,并不复杂。笔者这里尝试用文字去介绍反而比较多。不忘初心,为的就是简单办好事,所以也舍弃了一些高大全的考量。大家若认可,不妨将就用下:)。
最后补充一下,如果这个 vue 组件比较简单,那么按照原来的写法也是挺好的,不一定全都为类。