前言

使用了ts最头疼的是什么,除了类型声明应该没有第二家了,那么在vue3中如何正确的声明ts类型,代表着我们踏出了认识vue3的第一步,这非常重要,所以为此水个文章,分享给有需要的人。

Volar 插件

一开始我对于Volar并没有太大的需要,因为一直使用的Vetur,而且这个插件刚出来时并不完善,各种视频up讲的那个一键分屏功能其实也并不好用,虽然是个很有意思的东西,但是没有那种非要使用它的点,所以当时的我怀着这么一个疑问?为什么要用Volar ?

现在我就通过两张图告诉你,它有多香!

我们在template里面写代码,绑定变量最烦的是什么,就是我们写了个对象,但是忘了它的属性有哪些啊,使用了Volar配合ts类型声明,就可以再template里面实现代码提示和类型提示,这特么不得吹爆啊,所以还在犹豫什么,用起来。

并且当你的属性或者变量不存在时,template中也会有对应的波浪线错误提示。

data的类型声明

vue3中数据的声明一般使用ref或者reactive;这两个方法都是使用的泛型定义的类型,默认情况下他会自动推断出你书写的数据。

<script lang="ts">
import { defineComponent, reactive, ref } from "vue";

export default defineComponent({
  setup() {
    const data = reactive({
      name: "测试数据",
      age: 16,
      status: false,
    });

    const data2 = ref(false);

    return {
      data,
      data2,
    };
  },
});
</script>

但是有时候我们可能需要一个空数组来接受来自api的数据,此时泛型就无法推断出数据类型了,所以我们需要手动定义。

例:

<script lang="ts">
import { defineComponent, reactive, ref } from "vue";

export default defineComponent({
  setup() {
    type Item = {
      name: string;
      age: number;
      status: boolean;
    };

    const data = reactive<Array<Item>>([]);

    return {
      data,
    };
  },
});
</script>

ref也是同理,不会可以看下官方的这个文档,有具体的泛型源码参考:响应式核心

那么使用vue2的方式声明data,类型该怎么声明呢?

<script lang="ts">
import { defineComponent } from "vue";
type Item = {
  name: string;
  age: number;
  status: boolean;
};

export default defineComponent({
  data() {
    return {
      data: [] as Array<Item>,
    };
  },
});
</script>

我们可以通过as的方式进行断言处理,如果是静态的属性,其实ts也可以自行推断出来,可以不用自己手动断言类型。

补充:

如果你使用reactive,然后响应的值会被解包处理,那么可能通过泛型的方式传入的类型会出现问题,于是官方推荐我们使用这种形式的类型声明,当然你也可以默认就使用这种方式,这也是官方推荐的。

<script lang="ts">
import { defineComponent, reactive } from "vue";

export default defineComponent({
  setup() {
    type Item = {
      name: string;
      age: number;
      status: boolean;
    };

    const data: Array<Item> = reactive([]);

    return {
      data,
    };
  },
});
</script>

props的类型声明

vue3的props使用其实和vue2是一样的,只不过它对于setup组件式语法增加了一个新的定义方法defineProps,但其实它的定义格式还是一样的,都可以定义typerequired等等属性。

我们创建一个test组件,跟以往一样书写一个props属性,需要接收一个string类型的id值来进行显示

<template>
  <div>
    {{ id }}
  </div>
</template>

<script lang="ts">
import { defineComponent } from "vue";

export default defineComponent({
  props: {
    id: {
      type: String,
      required: true,
    },
  },
});
</script>

父级调用:

<template>
  <div>
    <test id="我是id" />
  </div>
</template>

<script lang="ts">
import { defineComponent } from "vue";
import test from "./components/test.vue";

export default defineComponent({
  components: {
    test,
  },
});
</script>

你会发现和以往并没有什么不同,但是,你我们props的type类型好像一直都是一种宽泛的定义,我们能不能更加精准的控制id的内容呢,比如我希望id的值是字符类型的1和2,不能有其它的值,能做到吗?

答案是可以的,我们可以这么写:

<script lang="ts">
import { defineComponent } from "vue";
import type { PropType } from "vue";

export default defineComponent({
  props: {
    id: {
      type: String as PropType<"1" | "2">,
      required: true,
    },
  },
});
</script>

需要注意的是,vue3的props类型全部需要通过官方提供的PropType的泛型来实现,必须基于它,而且我们引入的时候需要注意,如果是一个类型声明,我们必须在import的时候加上type声明,以表示我们引入的是一个类型声明而不是一个变量。

此时我们再回到父级,你会发现报错了!

非常明确的给出了提示,非常棒。

下面是defineProps的用法:

当我们使用setup组件时,这个defineProps会自动引入,无需手动import

<template>
  <div>
    {{ id }}
  </div>
</template>

<script lang="ts" setup>
//定义props
defineProps({
  id: {
    type: String,
    required: true,
  },
});
</script>

精准控制:

<script lang="ts" setup>
import type { PropType } from "vue";

//定义props
defineProps({
  id: {
    type: String as PropType<"1" | "2">,
    required: true,
  },
});
</script>

computed的类型声明

computed其实可以看成是一个函数,函数的类型咋声明,它就咋声明。

<template>
  <div>{{ data }}</div>
</template>

<script lang="ts">
import { defineComponent } from "vue";

export default defineComponent({
  computed: {
    data(): string {
      return "数据";
    },
  },
});
</script>

除了返回值的类型声明,我们可以声明形参类型。

<template>
  <div>{{ data }}</div>
</template>

<script lang="ts">
import { defineComponent } from "vue";
import type { RouteLocationNormalizedLoaded } from "vue-router";

export default defineComponent({
  computed: {
    data({ $route }: { $route: RouteLocationNormalizedLoaded }): string | symbol {
      return $route.name || "暂无";
    },
  },
});
</script>

methods的类型声明

这个其实和computed是一样的,就不过多介绍了。

vuex的类型声明

以目前vuex的结构来说,很难有一个很好的静态类型推断,为啥?因为它的模块数据的调用是this.$store.state.模块名.xxx这样获取的,但是它的模块数据实际的注册却是通过modules对象挂载。不和根state对象在一起。

所以我们在类型声明的时候很难去通过声明一个根State类型来定义所有,必须给模块属性加上?.;否则就会导致最开始根state就校验不过去,因为根state的对象属性里面并不包含模块的属性,这些属性是通过modules后加上去的。

这也就导致我们在调用模块的属性的时候不得不也加上?.

this.$store.state.模块名?.xxx

甚至我们使用!.让ts不再报undefined值的错误。

this.$store.state.模块名!.xxx

为此我们也只能是增加调用state的类型提示,以及Mutation、Action的方法中state的自行推断和形参校验,其他就没有了。

getters也能自行推断出state,其他就没了,如果能接受,我们往下看。

this.$store类型推断

首先我先创建一个store目录,创建一个index.ts文件用于存放根state和vuex实例。

store/index.ts

import { createStore } from "vuex";

export type State = {
  name: string;
};

const store = createStore({
  state: {
    name: "vuex",
  },
});

export default store;

main.ts挂载store

import { createApp } from "vue";
import App from "./App.vue";
import store from "./store";


createApp(App).use(store).mount("#app");

此时vuex就初始化好了,我们需要创建一个全局声明模块,用于扩充vue的this对象,会用到vue提供的ComponentCustomProperties方法,文档地址:增加组件实例类型以支持自定义全局属性

在src目录下创建一个vuex.d.ts文件,写入以下内容:

import { ComponentCustomProperties } from "vue";
import { Store } from "vuex";
import { State } from "./store";

declare module "vue" {
  interface ComponentCustomProperties {
    $store: Store<State>;
  }
}

引入刚刚导出的State根类型声明,在这使用。

其实这个文件名也不一定非得叫vuex.d.ts,自行调整。

此时我们通过this.$store就能获得代码提示和类型推断。

useStore类型推断

为了 useStore 能正确返回类型化的 store,必须执行以下步骤:

  1. 定义类型化的 InjectionKey
  2. 将 store 安装到 Vue 应用时提供类型化的 InjectionKey
  3. 将类型化的 InjectionKey 传给 useStore 方法。

首先我们创建injection key:

store/index.ts

import { createStore, Store } from "vuex";
import { InjectionKey } from "vue";

export type State = {
  name: string;
};
export const key: InjectionKey<Store<State>> = Symbol();

const store = createStore({
  state: {
    name: "vuex",
  },
});

export default store;

use(store)注册vuex的时候将key作为第二个参数传入:

import { createApp } from "vue";
import App from "./App.vue";
import store, { key } from "./store";


createApp(App).use(store, key).mount("#app");

然后,当我们在使用useStore的时候,也需要传入key。

<script lang="ts">
import { defineComponent } from "vue";
import { useStore } from "vuex";
import { key } from "./store";

export default defineComponent({
  setup() {
    const store = useStore(key);

    const data = computed(() => store.state.name);

    return {
      data,
    };
  },
});
</script>

你会发现现在通过useStore拿到的对象,有代码提示了。

但是这样非常麻烦,因为使用useStore是一个非常频繁的事情,所以官方推荐自己手写useStore封装。

创建一个自己的hooks:

import { useStore as baseUseStore } from "vuex";
import { key } from "./store";

export function useStore () {
  return baseUseStore(key)
}

包一层后,再去统一使用这个就不用每次都传key了。

模块化处理

我们创建一个模块,并声明模块的类型为Module,这个类型可以从vuex中导入进来,它接收两个泛型:

Module<S, R>
  • S:表示当前模块自己的state类型声明
  • R:表示根state的类型声明,这个之前就定义过,import引入即可。

store/modules/user.ts

import type { Module } from "vuex";
import { State } from "../index";

const userState = {
  name: "用户名",
};

export type UserState = typeof userState;

export default {
  namespaced: true,
  state: userState,
  mutations: {
    setName(state, payload: string) {
      state.name = payload;
    },
  },
} as Module<UserState, State>;

这里,mutations这些对象中,函数接收的第一个参数state已经可以通过Module来推断出类型了。

通过typeof,可以不需要手动再写一份类型定义,这种方式适合不存在可选属性的时候。

下面就是注册模块了!

store/index.ts

import { createStore, Store } from "vuex";
import { InjectionKey } from "vue";
import user, { UserState } from "./modules/user";

export type State = {
  name: string;
  user?: UserState;
};

export const key: InjectionKey<Store<State>> = Symbol();

const store = createStore({
  state: {
    name: "vuex",
  },
  modules: {
    user,
  },
});

export default store;

这里就涉及到我一开始说的那个问题了,模块的数据最终是通过state获取,但是数据挂载是通过modules对象,所以我们在定义根的state的类型声明的时候,就不得不使用?:方式,来表示这个模块可以不存在,不然就必须在具体的state数据定义那写上真实数据,这显然不是我们需要的。

使用的时候:

<script lang="ts">
import { defineComponent, computed } from "vue";
import { useStore } from "vuex";
import { key } from "./store";

export default defineComponent({
  setup() {
    const store = useStore(key);

    const name = computed(() => store.state.user?.name);

    return {
      name,
    };
  },
});
</script>

虽然已经有了代码提示,但是在调用的时候你会发现会自动加上可选链?.,因为user可能不存在,所以就导致它里面的属性也是未知的。

所以,有时候我们还不得不这么写:

const name = computed(() => store.state.user!.name);

强制断言数据是存在的。

补充:

我们的state推断已经有了,但是对于getters、commit、dispatch那种理想状态的提示是没有的。

而且现在vuex的代替品:pinia 也已经正式服役了,如果是新项目,可以考虑使用这个,这个对于ts的适配会更加的好。

对于上述vuex的ts推断,官方也是有对应文章的:TypeScript 支持

vue-router的类型推断

vue-router 4.x对于ts的提示是非常友好的,基本上能遇到的都有类型推断,如果你不清楚是什么类型,可以将鼠标放置在对象上,即可查看具体详情。

这里就说一下自定义routes数组类型推断,在之前的文章我提到过通过routes对象来生成对应的侧边栏菜单,但是有的路由并不希望显示在侧边栏,所以一般会增加一个自定义的属性hidden: true这种,但是由于ts类型校验的问题,vue-router的默认类型RouteRecordRaw是没有我们的自定义属性的。导致校验失败。

import { createRouter, createMemoryHistory } from "vue-router";

const router = createRouter({
  history: createMemoryHistory(),
  routes: [
    {
      path: "/",
      component: import("./views/Home.vue"),
      hidden: true,
    },
  ],
});

我们可以声明一个基于RouteRecordRaw的类型来断言处理。

import { createRouter, createMemoryHistory } from "vue-router";
import type { RouteRecordRaw } from "vue-router";

type MyRouteRecordRaw = RouteRecordRaw & {
  hidden?: boolean;
};

const router = createRouter({
  history: createMemoryHistory(),
  routes: [
    {
      path: "/",
      component: import("./views/Home.vue"),
      hidden: true,
    },
  ] as MyRouteRecordRaw[],
});

使用这种方式我们还可以针对meta对象进行精细的控制,官方默认的meta类型是一个宽泛的类型声明:

export declare interface RouteMeta extends Record<string | number | symbol, unknown> {
}

如果我们想准确控制,也可以通过MyRouteRecordRaw来个性化定制。

例:

import { createRouter, createMemoryHistory } from "vue-router";
import type { RouteRecordRaw } from "vue-router";

type MyRouteRecordRaw = RouteRecordRaw & {
  hidden?: boolean;
  meta: {
    auth: Array<string>; //鉴权
    title: string; //页面标题
    icon: string; //页面图标
  };
};

const router = createRouter({
  history: createMemoryHistory(),
  routes: [
    {
      path: "/",
      component: import("./views/Home.vue"),
      hidden: true,
      meta: {
        auth: [],
        title: "首页",
        icon: "el-icon-menu",
      },
    },
  ] as MyRouteRecordRaw[],
});

通过这种约束,后续添加的新路由都得有这些配置,这就是ts静态检验的魅力。

axios的类型推断

axios很早就实现了对ts的支持,所以的他的类型推断也是非常完善的,我们这边就提一下对于异步返回值的类型推断。

创建一个axios实例对象:

import axios from "axios";

const http = axios.create();

export default http;

发起请求:

type Response = {
  userId: number;
  id: number;
  title: string;
  completed: boolean;
};
http.get<Response>("https://jsonplaceholder.typicode.com/todos/1").then((res) => {
  console.log(res);
});

当我们去查看res的类型推断是,其实并不是我们想要的。

它抛出的类型其实是axios最原始的数据对象:

{
  config: ...,
  data: ...,
  headers: ...,
  request: ...,
  status: 200,
  statusText: ""
}

其中data才是后端返回的数据,为了方便使用,我们一般会使用拦截器进行一次脱壳处理:

http.interceptors.response.use((response) => {
  return response.data;
});

但是你会发现,我们的返回内容确实已经脱壳了,但是类型推断还是之前的AxiosResponse<Response, any>

解决办法就是我们自己用函数包装一下,控制下return出去的类型推断:

import  axios  from  "axios";
import  type { AxiosResponse } from  "axios";

const  http  =  axios.create();

export function apiGet<T>(url: string): Promise<T> {
  return new Promise((resolve, reject) => {
    http
      .get(url)
      .then((res: AxiosResponse<T>) => resolve(res.data))
      .catch((err) => reject(err));
  });
}

export default http;

使用时:

apiGet<Response>("https://jsonplaceholder.typicode.com/todos/1").then((res) => {
  console.log(res.title);
});

此时我们再去查看类型推断,已经变成了Response

此时已经满足了我们的需求,但是在真实的项目中,data的数据他其实是被后端包了一层状态的,一般会包含后端提供的状态码,消息、数据等等,举个例子:

{
  result: 1,
  info: "",
  data: ...
}

所以如果我们想要脱壳拿到真正的数据,其实还得再脱一层,这时,我们需要一些特殊的用法。

import axios from "axios";
import type { AxiosResponse } from "axios";

const http = axios.create();

type BaseResponse = {
  result: number;
  info: "";
  data: any;
};

http.interceptors.response.use((response: AxiosResponse<BaseResponse>) => {
  return response.data.data;
});

export function apiGet<T>(url: string): Promise<T> {
  return new Promise((resolve, reject) => {
    http
      .get(url)
      .then((res: unknown) => {
        return resolve(res as T);
      })
      .catch((err) => reject(err));
  });
}

export default http;

由于axios实例的请求,then得到的参数类型被写死了,一直是一个AxiosResponse;但是实际上我们已经在拦截器里进行脱壳处理,这里拿到的是具体的后端数据,所以我们需要先用unknown覆盖旧的类型推断,然后as重新声明一下抛出的类型。

最终效果使用就符合我们的预期了。

vite的环境变量属性类型推断

webpack的由于还是js类型,所以没法给它做环境变量的推断,目前只有vite可以。

我们创建一个环境变量:.env.development写入以下内容:

VITE_BASE_URL="https://www.xxx.com"

此时我们通过import.meta.env.VITE_BASE_URL是没有代码提示和类型推断的。

在src目录下创建一个env.d.ts文件,填入以下内容:

interface ImportMetaEnv {
  VITE_BASE_URL: string;
}

此时我们再回去调用,你会发现有提示和类型推断了!

分类: vue 项目实战 标签: vuevue3TypeScriptts类型推断

评论

全部评论 1

  1. 杰哥
    杰哥
    Google Chrome Windows 10
    Volar厉害啊有点香。但是还是保重身体啊。26岁还3点睡,这怎么顶得住啊

目录