在vue2中使用ts

6,352 阅读9分钟

介绍

文本所介绍的内容是使用 TypeScript 编写 Vue2.6.11 前端应用,具体 demo 地址可访问: vue-ts-demo

总结几个月来在 ts 环境 中使用 vue 的经验,提炼一个最小可运行案例,该案例将包括:

  1. 搭建 ts 项目,配置 tsconfig.json
  2. 单文件组件(template 组件)的使用
  3. tsx 组件的使用
  4. vue-router 的 ts 方案
  5. vuex 的 ts 方案
  6. api 类型定义的建议

项目搭建与配置

ts 环境下 vue2 版本的项目可直接使用官方的脚手架 vue-cli 进行搭建,根据项目组情况判断是否需要使用 tsx、css 预处理+css module、单元测试。

项目创建完成,默认生成一份tsconfig.json文件。ts 配置项解释可以参考TypeScript 官方教程

package.json中默认安装vue-class-component,该库通过装饰器模式实现了 vue 的 ts 适配,也是官方推荐的使用 ts 方式。不过更建议使用vue-property-decorator包,因为后者在前者基础上进行了修改与扩充。vue-class-component拥有的功能vue-property-decorator都具备,并且功能更强大,也更易于使用。

对于使用 Vuex 的项目,建议安装vuex-module-decorators包,这是在 ts 环境下中使用 vuex 的一种解决方案。

由于 vue 对 jsx 的支持问题,如果想实现如同 react 的组件 props 的智能提示,需要安装vue-tsx-support

单文件组件(template 组件)的使用

组件实例

vue-class-component 允许我们通过使用类语法声明 vue 组件,需要使用@Component装饰器。

  import { Vue, Component } from 'vue-property-decorator';

  @Component
  export default class Index extends Vue {

  }

  //相当于
  <script>
    module.export = {

    }
  </script>

生命周期

生命周期钩子的使用和原先使用的区别:在类语法中直接将生命周期生命为方法(方法名称和生命周期名称一致)。

  import { Vue, Component } from 'vue-property-decorator';

  @Component
  export default class Index extends Vue {
    created() {
      console.log('created');
    }
    mounted() {
      console.log('mounted');
    }
  }

  //相当于
  <script>
    module.export = {
      created() {
        console.log('created');
      }
      mounted() {
        console.log('mounted');
      }
    }
  </script>

响应式数据 data

类语法中可以直接定义为类的实例属性作为组件的响应式数据。原始类型的数据不需要定义类型,ts 可以实现类型推断,但是复杂的类型需要定义。
其中值得注意的一点是:当数据的值是 undefined 或者只定义未赋初值,vue-class-component不会将该属性修饰为响应式数据!这会导致异常。推荐方案是进行赋初值,或者扩展一个 null 类型再赋值未 null。

  import { Vue, Component } from 'vue-property-decorator';

  type User = {
    name: string;
    age: number;
  };
  @Component
  export default class Index extends Vue {
    message = 'hello world';
    info: User = { name: 'test', age: 25 };
    //如果数据的值是undefined或者未赋初值,则不会成为响应式数据。解决方案:追加类型定义null
    count: number;
  }

  //相当于
  <script>
    module.export = {
      data:function(){
        return {
          message: 'hello world',
          info: { name: 'test', age: 25 };
        }
      }
    }
  </script>

计算属性 computed

类语法中的计算属性的实现,是通过 get 取值函数。

  import { Vue, Component } from 'vue-property-decorator';

  @Component
  export default class Index extends Vue {
    //computed定义
    get introduction() {
      return `姓名:${this.info.name}, 年龄:${this.info.age}`;
    }
  }

  //相当于
  <script>
    module.export = {
      computed:{
        introduction() {
          return `姓名:${this.info.name}, 年龄:${this.info.age}`;
        }
      }
    }
  </script>

数据监听 watch

类语法实现响应式的数据监听,是由vue-property-decorator依赖提供@Watch装饰器来完成

  import { Vue, Component } from 'vue-property-decorator';

  @Component
  export default class Index extends Vue {
    //watch定义,其中Wacth装饰器第一个参数:响应式数据字符串(也可以定义为'a.b');
    //第二个参数options成员[immediate,deep]分别对应的是原生的用法
    @Watch('$route', { immediate: true })
    changeRouter(val: Route, oldVal: Route) {
      console.log('$route watcher: ', val, oldVal);
    }
  }

  //相当于
  <script>
    module.export = {
      watch:{
        '$route':function(val,oldVal) {
          console.log('$route watcher: ', val, oldVal);
        }
      }
    }
  </script>

方法 methods

在类语法实现原生 vue 的方法的方式,即通过直接定义类方法成员。

  import { Vue, Component } from 'vue-property-decorator';

  @Component
  export default class Index extends Vue {
    hello(){
      console.log('hello world');
    }
  }

  //相当于
  <script>
    module.export = {
      methods:{
        hello(){
          console.log('hello world');
        }
      }
    }
  </script>

引入组件

和原生写法一致,都需要先引入在注册,区别在于类语法注册在修饰器中。组件使用方式和 vue 原生一致。

  import { Vue, Component } from 'vue-property-decorator';
  import Header from '../component/header/index.vue';

  @Component({
    components: {
      Header,
    },
  })
  export default class Index extends Vue {
  }

  //相当于
  <script>
    module.export = {
      components: {
        Header,
      }
    }
  </script>

组件属性 props

类语法实现组件 props 定义是通过装饰器@Prop实现

  import { Vue, Component, Prop } from 'vue-property-decorator';
  import { User } from '@/types/one';

  @Component
  export default class Header extends Vue {
    @Prop({ type: String, default: '标题' }) readonly title?: string;
    //复杂类型type参数的值为Object,默认值需要以函数形式返回
    @Prop({ type: Object, default: () => ({ name: '-', age: '-' }) }) readonly author!: User;
  }


  //相当于
  <script>
    module.export = {
      props:{
        title:{
          type: String,
          required: false,
          default: '标题'
        },
        author:{
          type: Object,
          required: true,
          default: { name: '-', age: '-' }
        }
      }
    }
  </script>

事件触发

ts 环境下 vue 的事件触发方式和 js 环境下是一致的,区别只是事件回调定义的地方不同(ts 定义为类的实例方法,js 定义在 methods 属性中)。

ref 使用

在类语法中使用 ref 需要借助vue-property-decorator提供的@Ref装饰器,使用方法如下:

//模板和原生vue保持一致
<template>
  <div class="container">
    <Header ref="header" title="首页" :author="info" />
  </div>
</template>

<script lang="ts">
  import { Vue, Component, Watch, Ref } from 'vue-property-decorator';
  import { Route, NavigationGuardNext } from 'vue-router';
  import Header from '../component/header/index.vue';

  @Component({
    components: {
      Header,
    },
  })
  export default class Index extends Vue {
    @Ref('header') readonly headerRef!: Header;
  }
</script>

//相当于
<script>
  export default  {
    computed():{
      headerRef:{
        cache:false,
        get(){
          return this.$refs.header as Header
        }
      }
    }
  }
</script>

mixins 使用

类语法使用 mixins 需要继承vue-property-decorator提供的 Mixins 函数所生成的类。 Mixins 函数的参数是 Vue 实例类,正确使用会用 mixin 成员的的智能提示,使用方式如下:

// mixins.js
  import Vue from 'vue';
  import Component from 'vue-class-component';

  // You can declare mixins as the same style as components.
  @Component
  export class Hello extends Vue {
    /**
    *  mixin中的响应式数据
    */
    mixinText = 'Hello mixins';

    obj: { name: string } = { name: 'han' };
  }

//index.vue
<script lang="ts">
  import {  Component, Mixins, Watch, Ref } from 'vue-property-decorator';
  @Component
  export default class Index extends Mixins(Hello) {

    created(){
      console.log(this.mixinText,this.obj.name);
    }
  }
</script>

//相当于
<script>
  export default{
    mixins:{
      data(){
        return {
          mixinText:'Hello mixins',
          obj: { name: 'han' }
        }
      }
    },
    created(){
      console.log(this.mixinText,this.obj.name);
    }
  }
</script>

slots 和 scopedSlots

slots 和 scopedSlots 的使用方式和原生 vue 保持一致。

tsx 组件的使用

如果在项目中需要使用 jsx,默认 vue-cli 创建项目会提示是否支持 jsx,但是由于 vue 对 jsx 的支持不完善,导致在使用不像 react 那样可以提示组件 props 的类型定义,使用上非常难受。因此引入vue-tsx-support解决该问题。详情请见:vue-tsx-support(github 文档)

至于 在 vue 中如何使用 jsx,推荐在 Vue 中使用 JSX 的正确姿势,该文详细介绍了 vue 实现 jsx 的原理以及几种 props 的区别和使用。

tsx 组件的很多地方和 template 组件使用方式一致,但是 props 定义、scopedSlots 定义和使用,以及引入第三方组件之后的处理方式有差异。其他地方例如生命周期、data、computed、watch、methods、事件触发、ref 使用都是一致的。

配置

下载完vue-tsx-support,我们需要配置tsconfig.json,设置内容如下:

 "compilerOptions": {
    "jsx": "preserve",
    "jsxFactory": "VueTsxSupport",
    "...": "..."
  },

之后,我们需要在项目入口处引入import 'vue-tsx-support/enable-check';

现在 tsx 组件的 props 智能提示开始生效。

组件定义的方式

vue-tsx-support支持的 tsx 组件定义方式可以使用类似与原生 vue 的对象的写法,或者类语法编写。更推荐使用类语法编写组件,这样和模板写法也更相近。

如果喜欢接近原生 vue 的对象风格,可以参考:官方文档

使用类语法编写组件有两种方式:

  1. 通过继承vue-tsx-support提供的 Component 类来编写
  2. 通过继承 Vue 类并且声明_tsx成员

项目中一直在使用前者,但是最近总结经验,发现后者更好些。主要是继承 Component 之后使用 mixins 想要有智能提示的话,需要将定义挂载在 Vue 上,不够友好。因此推荐使用:通过继承 Vue 类并且声明_tsx成员,下文都是针对该方案的说明。

组件实例

声明 tsx 组件,文件后缀必须为.tsx,这点和 react 不同,react 在 ts 文件中也是可以使用 jsx 的,但是 vue 不可以。如果一定要在.ts文件中,可以使用定义 jsx 原始方式,具体可参照vue 官网:

在 tsx 文件中,声明组件的方式和 template 组件是一致的。

import { Vue, Component, Prop } from 'vue-property-decorator';
import * as tsx from 'vue-tsx-support';

@Component
export default class Header extends Vue {
}

dataProps 定义

首先我们需要和 template 组件一样将所有的 props 定义好。

然后根据情况,如果可以将所有 data 数据、computed 方法、方法定义设置为私有,这样可以使用vue-tsx-support提供的AutoProps别名,来声明 Props。如果有成员需要设置为 public,可以使用 tsx 提供的PickProps别名

//AutoProps
import { Vue, Component, Prop } from 'vue-property-decorator';
import * as tsx from 'vue-tsx-support';
import { User } from '@/types/one';
import styles from './index.less';

@Component
export default class Header extends Vue {
  _tsx!: tsx.DeclareProps<tsx.AutoProps<Header>>;

  @Prop({ type: String, default: '标题' }) readonly title?: string;
  @Prop({ type: Object, default: () => ({ name: '-', age: '-' }) }) readonly author!: User;

  private goAboutMe() {
    this.$router.push('/about');
  }

  render() {
    return (
      <div class={styles.header}>
        <div class={styles.title}>
          <h1>{this.title}</h1>
          <span onClick={this.goAboutMe}>
            作者:
            <span>{this.author.name}</span>
          </span>
        </div>
      </div>
    );
  }
}

//PickProps
import { Vue, Component, Prop } from 'vue-property-decorator';
import * as tsx from 'vue-tsx-support';
import { User } from '@/types/one';
import styles from './index.less';

@Component
export default class Header extends Vue {
  _tsx!: tsx.DeclareProps<tsx.PickProps<Header, 'title' | 'author'>>;

  @Prop({ type: String, default: '标题' }) readonly title?: string;
  @Prop({ type: Object, default: () => ({ name: '-', age: '-' }) }) readonly author!: User;

   goAboutMe() {
    this.$router.push('/about');
  }

  render() {
    return (
      <div class={styles.header}>
        <div class={styles.title}>
          <h1>{this.title}</h1>
          <span onClick={this.goAboutMe}>
            作者:
            <span>{this.author.name}</span>
          </span>
        </div>
      </div>
    );
  }
}

eventProps 定义

_tsx成员的类型可以定义为交叉类型,将事件类型定义混入到_tsx中就可以了

import * as tsx from 'vue-tsx-support';
import { Vue, Component, Prop } from 'vue-property-decorator';
export default class Header extends Vue {
  _tsx!: tsx.DeclareProps<tsx.AutoProps<Header>> & tsx.DeclareOnEvents<{ onClick: string }>;
  render(){
    return <div></div>
  }
}

scopedSlotsProps 定义

vue 中的 scopedSlots 相当于 react 中的 renderProp。

tsx 组件中定义如下:

import * as tsx from 'vue-tsx-support';
import { Vue, Component, Prop } from 'vue-property-decorator';
export default class Header extends Vue {
  //这样就声明了两个scopedSlot,默认的scopedSlot参数类型为空,header参数类型为string
  $scopedSlots!: tsx.InnerScopedSlots<{ default?: void,header?:string }>;
  render(){
    return <div></div>
  }
}

mixins 使用

mixins 使用和 template 组件保持一致

第三方组件 props 推断

由于 vue 实现的 jsx 没有参数类型提示,因此引入第三方组件也是没有 props 提示。所有我们需要使用vue-tsx-support来进行 jsx 支持。

这里我创建一份propsCovert.ts文件,使用vue-tsx-support提供的 ofType 方法来对第三方组件的 props 进行定义推导。

递归第三方组件的 dataProps,并将其类型推导出。eventProps 定义为索引类型,参数类型定义为 any。scopedSlotsProps 同样定义为索引类型,参数类型定义为 any。

之后每次使用第三方组件,只要用 antdPropsConvert 方法包装下即可在使用时得到 props 的智能提示。

如果是单页应用,也可以创建一份组件清单文件,在该文件中转换所有的组件并导出,这样就省的一次次转换。

//propsConvert.ts

import { ofType } from 'vue-tsx-support';

type PowerPartial<T> = {
  // 如果是 object,则递归类型
  [U in keyof T]?: T[U] extends Function ? Function : T[U] extends object ? PowerPartial<T[U]> : T[U];
};
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
type OmitVue<T> = PowerPartial<Omit<T, keyof Vue>>;

interface AnyEvent {
  [key: string]: any;
}
interface AnyScopedSlots {
  [key: string]: any;
}

function antdPropsConvert<T extends Vue>(componentType: new (...args: any[]) => T) {
  return ofType<OmitVue<T>, AnyEvent, AnyScopedSlots>().convert(componentType);
}
export { antdPropsConvert };


// sider.tsx
import { Vue, Component } from 'vue-property-decorator';
import * as tsx from 'vue-tsx-support';
import { Button as AButton } from 'ant-design-vue';
import styles from './index.less';
import { antdPropsConvert } from '@/utils/propsConvert';

const Button = antdPropsConvert(AButton);

@Component
export default class Sider extends Vue {
  _tsx!: tsx.DeclareOnEvents<{ onClick: string }>;

  $scopedSlots!: tsx.InnerScopedSlots<{ default?: void }>;

  render() {
    return (
      <div class={styles.sider}>
        {this.$scopedSlots.default && this.$scopedSlots.default()}
        <div>
          <Button
            type="primary"
            onClick={() => {
              this.$emit('click', '事件触发参数');
            }}
          >
            事件触发
          </Button>
        </div>
      </div>
    );
  }
}

事件修饰符

如何在 tsx 组件中使用事件修饰符,推荐官方教程,modifiers

遗留问题

在单文件组件模式中,文件跳转正常(ctrl+鼠标点击可以跳转到定义),但是暂未实现路径的智能提示。 在.tsx | .ts文件引入.vue,路径智能提示正常,但是会发生无法跳转到 vue 文件的情况。

vue-router 的 ts 方案

vue-router官方已经支持 ts,在我们使用vue-cli创建了 ts 项目之后就可以使用。
但是如果我们需要在组件中定义路由钩子函数,需要先在全局进行注册

// class-component-hooks.js
import Component from 'vue-class-component';

// Register the router hooks with their names
Component.registerHooks(['beforeRouteEnter', 'beforeRouteLeave', 'beforeRouteUpdate']);

然后需要给 Vue 类型扩展定义

import Vue from 'vue';
import { Route, NavigationGuardNext } from 'vue-router';
declare module 'vue/types/vue' {
  // Augment component instance type
  interface Vue {
    beforeRouteEnter?(to: Route, from: Route, next: NavigationGuardNext<Vue>): void;

    beforeRouteLeave?(to: Route, from: Route, next: NavigationGuardNext<Vue>): void;

    beforeRouteUpdate?(to: Route, from: Route, next: NavigationGuardNext<Vue>): void;
  }
}

使用前,在项目的入口文件引入注册文件即可。

import '@/utils/class-component-hooks';
import Vue from 'vue';
import 'vue-tsx-support/enable-check';
import App from './App';
import router from './router';
import store from '@/modules';
Vue.config.productionTip = false;
new Vue({
  router,
  store,
  render: (h) => h(App),
}).$mount('#app');

然后在组件中定义路由钩子,即可获得准确的提示。

vuex 的 ts 方案

为了在 ts 环境中使用 vuex,vue 社区推出了vuex-module-decorators,其工作方式和vue-property-decorator一致,都是通过装饰器来实现。

模块创建

vuex-module-decorators中常使用的成员:VuexModule, Module, Mutation, Action, getModule

创建步骤:

  1. 定义 Module 实例之前,我们需要先定义 state 的接口,这是为了之后vuex-module-decorators进行类型检测。
  2. 自定义 Module 类型,继承 VuexModule 类型,并实现 state 的接口
  3. 使用@Module装饰器装饰自定义 module,如果是动态 Module(意味着引入的时候自动注入到 vuex 中),需要传参dynamic, store, name给 Module 函数
  4. 定义 action 和 mutation 我们都需要使用对应的装饰器@Action、@Mutation
  5. 导出自定义 Module,将自定义 Module 作为函数参数传递给 getModule 函数,该 module 中所有的 state,action,mutation 都绑定在导出对象上

完整示例:

import { VuexModule, Module, Mutation, Action, getModule } from 'vuex-module-decorators';
import store from './index';

type TodoItem = {
  id: string;
  content: string;
  isDone: boolean;
};
type TodoListState = {
  todos: TodoItem[];
};
const todos: TodoItem[] = [
  {
    id: '0',
    content: 'todo-item1',
    isDone: false,
  },
  {
    id: '1',
    content: 'todo-item2',
    isDone: true,
  },
  {
    id: '2',
    content: 'todo-item3',
    isDone: false,
  },
];
@Module({ dynamic: true, store, name: 'todoListModule' })
class TodoListModule extends VuexModule implements TodoListState {
  todos: TodoItem[] = [];

  //获取当前的todoList
  @Action
  async getAllTodoItems() {
    const data = await new Promise<TodoItem[]>((resolve) => {
      setTimeout(resolve, 1000, todos);
    });
    this._saveTodos(data);
  }

  @Mutation
  private _saveTodos(data: TodoItem[]) {
    this.todos = data;
  }
}
export default getModule(TodoListModule);

store 创建和使用

创建 store 实例,由于项目是使用动态导入 module,因此很简洁。
如果需要在入口文件定义好全部 module,可以参照官方教程

import Vue from 'vue';
import Vuex from 'vuex';

Vue.use(Vuex);

// Declare empty store first, dynamically register all modules later.
const Store = new Vuex.Store<{}>({});
export default Store;

vuex 使用和原生 vue 一致,都是引入 store 的入口文件,然后将其传入 Vue 实例中

在组件中使用 vuex(动态导入 Module)

使用步骤:

  1. 需要导入对应的 module 文件
  2. 导入 state,因为 state 成员通过计算属性使用,因此在 ts 中需要通过 get 函数导入
  3. 调用 action 或者 mutation 方法,直接调用对应的 Module 即可
import { Component, Vue } from 'vue-property-decorator';
import TodoListModule from '@/modules/todoList';

@Component
export default class Index extends Vue {
  get todos() {
    return TodoListModule.todos;
  }

  created() {
    TodoListModule.getAllTodoItems().then(() => {
      console.log('todos', this.todos);
    });
  }
}

api 类型定义的建议

在项目中,定义 api 接口的类型是个麻烦事,尤其是接口很多的情况下。如果手动定义,成本会很大,也影响效率。当接口修改(这是常常发生的),我们将不得不进行同步的修正。

因此我建议使用阿里团队出品的pont库,该库有效的解决了 api 接口定义的麻烦问题。

详情请见官网:pont