cj0x39e

又学了点 TypeScript

两年前,因为项目使用 Anguar ,以此入了 TypeScript 的坑。后来,工作的技术栈渐渐转向了 React 到现在的 Vue,这期间都用的 ES6,TS 便渐渐生疏了。最近,公司新来的同事在搭一个新的项目,因为之前聊的时候知道他用过 TS,便建议了他用 TS 来弄了。虽然 Vue3 已经可用了,但是由于 Proxy 的兼容性可能带来较大的隐患,还是选用 Vue2 + TS(下文所述都是 Vue2.x)。实际上从之前一些瞥见的资料,了解到 Vue 对 TS 不是很友好,但是 TS 在前端项目中的占有率日益增长,在公司内有必要来推进了。而且同事对此有一定的经验,项目算比较小,正是实验好时机。新项目的架子搭好后,仔细看了下,不知道是之前的 Angular + TS 搭配的太自然,还是之前我用 any 太多 😁,Vue + TS 整的像变了个框架,复杂的很。而且 TS 也多了些新的特性需要理解,借此机会分析下 Vue + TS 以及捡捡太久未摸的 TS。

Vue 本身不是使用 TS 编写的,所以使用 Modules .d.ts (opens new window) 的方式以支持 TS 的类型推断(见类型定义仓库 (opens new window))。查看 Vue 的 package.json 可以看到 typings 的配置如下:

{
  "typings": "types/index.d.ts" // 使用 types 字段作用一样
}

此配置就是告知 TS 对 Vue 相关的类型在哪里找。但是我们不能使用如下的写法写 Vue 了:

export default {
  // Vue 的选项配置
};

这里孤零零的一个对象字面量,TS 显然是不能知道这里是应该受 Vue 选项约束的。因此得使用如下方式:

export default Vue.extend({});

因为 Vue.extend 方法,是在 vue.d.ts 中有定义,以下是截取的定义片段:

...
extend<Data, Methods, Computed, PropNames extends string = never>(options?: ThisTypedComponentOptionsWithArrayProps<V, Data, Methods, Computed, PropNames>): ExtendedVue<V, Data, Methods, Computed, Record<PropNames, any>>;
...
// https://github.com/vuejs/vue/blob/529016bca92f6f098e903b1f77c70d3b0dadefaa/types/vue.d.ts#L86

从代码中可以看到 Vue 为了使各种配置能够实现较好的推断效果,声明写的挺复杂,其中我比较好奇的是它怎么实现的 this 推断的(特别是后期通过配置自定义的属性)?通过阅读其定义代码,发现了这样一句:

export type ThisTypedComponentOptionsWithRecordProps<
  V extends Vue,
  Data,
  Methods,
  Computed,
  Props
> = object &
  ComponentOptions<
    V,
    DataDef<Data, Props, V>,
    Methods,
    Computed,
    RecordPropsDefinition<Props>,
    Props
  > &
  ThisType<CombinedVueInstance<V, Data, Methods, Computed, Readonly<Props>>>;
// https://github.com/vuejs/vue/blob/529016bca92f6f098e903b1f77c70d3b0dadefaa/types/options.d.ts#L58

其中的 ThisType 就是实现 this 推断的关键。ThistType 的作用就是可以定义上下文的类型,要注意的是必须要开启 --noImplicitThis 以配合使用(更多解释说明见官方文档 (opens new window))。下面我通过一个简化的 Vue 定义简单演示:

interface DemoVue {
  readonly $el: Element;
  $destroy(): void;
}

type DefaultData<V> = object | ((this: V) => Object);
type DefaultMethods<V> = { [key: string]: (this: V, ...args: any[]) => any };

interface ComponentOptions<
  V extends DemoVue,
  Data = DefaultData<V>,
  Methods = DefaultMethods<V>
> {
  data?: Data;
  methods?: Methods;
}

/**
 * 该联合类型的声明的后半部分 ((this: V) => Data)
 * 使得我们在使用 data () { return { xx: 'xx' } } 形式声明数据时,其返回的对象作为 Data 类型,
 * 结合 ThisType<Data> 使 this 受到 Data 的约束
 */
type DataDef<Data, V> = Data | ((this: V) => Data);

type CombinedVueInstance<Instance extends DemoVue, Data, Methods> = Data &
  Methods &
  Instance;

/**
 * 在交叉类型的最后部分 ThisType 的泛型类型值我们给定的为 CombinedVueInstance 类型,
 * 可在上方代码看到实际上其为实例对象、Data、Methods 的交叉类型,所以最终 this 受到 CombinedVueInstance 的约束
 */
type ThisComponentOptions<V extends DemoVue, Data, Methods> = object &
  ComponentOptions<V, DataDef<Data, V>, Methods> &
  ThisType<CombinedVueInstance<V, Data, Methods>>;

interface DemoVueConstructor<V extends DemoVue = DemoVue> {
  extend<Data, Methods>(options: ThisComponentOptions<V, Data, Methods>): any;
}

const DemoVue: DemoVueConstructor = {
  extend() {},
};

DemoVue.extend({
  data() {
    return {
      count: 1,
    };
  },
  methods: {
    add() {
      this.count++;
    },
  },
});

比如我们在 VSCode 里,把鼠标放在 this 上时,显示的 this 类型如下面的代码所示,可以看到 this 已经包含我们在 data 和 methods 中定义的属性了。

CombinedVueInstance<DemoVue, {
    count: number;
}, {
    add(): void;
}>

然而在 TS + Vue 的实践中,一般不采用 Vue.extend() 的方式书写,而是使用 Class 的形式,也就是应用 vue-class-component (opens new window) 提供的装饰器。由于没有大量的 TS + Vue 项目经验,两种方式之间具体孰优孰劣并不清楚。但可以比较直观的感受到的就是 Vue.extend() 这种在对象字面量中配合类型定义确实结构不太清晰。

让我们来看看 vue-class-component 提供的装饰器干了些什么。下面是官方文档 (opens new window)所提供的此方式的示例:

import Vue from "vue";
import Component from "vue-class-component";

// @Component 修饰符注明了此类为一个 Vue 组件
@Component({
  // 所有的组件选项都可以放在这里
  template: '<button @click="onClick">Click!</button>',
})
export default class MyComponent extends Vue {
  // 初始数据可以直接声明为实例的 property
  message: string = "Hello!";

  // 组件方法也可以直接声明为实例的方法
  onClick(): void {
    window.alert(this.message);
  }
}

嗯,确实是比较方便和清晰的,那这个 @Component 装饰器到底做了什么?查看其源码,这个装饰器的最终逻辑会走到该函数:

...
export function componentFactory (
  Component: VueClass<Vue>,
  options: ComponentOptions<Vue> = {}
): VueClass<Vue> {
      ...
      const Extended = Super.extend(options)
      ...
      return Extended
  }
...
// https://github.com/vuejs/vue-class-component/blob/16433462b40aefecc030919623f17b0ec9afe61c/src/component.ts#L24

其中比较关键的就是这句 const Extended = Super.extend(options) , 这里的 Super 要么就是 Vue 类,要么是继承了 Vue 的子类,也就是说 Super.extend 实际上就是调用的 Vue.extend(),所以并没有什么魔法,殊途同归。然后该函数主要的逻辑就是把我们在 Class 上声明的属性和方法以特定的逻辑转为 Vue.extend 配置对象的对应配置项。

另外还有一个 vue-property-decorator (opens new window) 也算是 Vue + TS 的标配了,它主要是使用 vue-class-component 提供的 createDecorator (opens new window) 方法扩展了几个装饰器。比如声明 props 可以使用下面的方式,而不是使用配置项:

@Component
export default class YourComponent extends Vue {
  @Prop(Number) readonly propA: number | undefined;
  @Prop({ default: "default value" }) readonly propB!: string;
  @Prop([String, Boolean]) readonly propC: string | boolean | undefined;
}

当然其最终的逻辑也是帮我们把以类成员方式书写的配置转化为 Vue.extend 配置对象的对应配置项。

© 2021 cj0x39e / I konw nothing