正确使用vue3的ts类型声明
前言
使用了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,但其实它的定义格式还是一样的,都可以定义type
、required
等等属性。
我们创建一个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,必须执行以下步骤:
- 定义类型化的
InjectionKey
。 - 将 store 安装到 Vue 应用时提供类型化的
InjectionKey
。 - 将类型化的
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;
}
此时我们再回去调用,你会发现有提示和类型推断了!
本文系作者 @木灵鱼儿 原创发布在木灵鱼儿站点。未经许可,禁止转载。
全部评论 1
杰哥
Google Chrome Windows 10