vuex4+typescript实现智能提示

693 阅读12分钟

前言

vue3已经被作为默认版本发布快三个月了,但是目前还没有使用vue3+ts独立开发过项目,刚好自己在后台系统方面涉猎较少,于是决定使用vue3+ts开发一个后台管理系统,在数据管理时,对于跨了很多层级的组件间的共享数据就采用vuex进行储存和获取,为了充分发挥ts的功能,决定实现在使用vuex调用方法时智能提示方法名,也算是督促自己多学学ts

个人学习笔记,欢迎指正

ts基础语法

既然作为学习笔记,当然要记录一下自己学到的一些ts语法(很基础的东西我就不再记录了)

interface vs type

interface 是一种约束或规范,通常用接口来定义对象的类型

interface Person{
    name:string,
    age:number,
    height?:number
}

let Yzh:Person ={
    name:"yzh",
    age:19
    //height由于接口加了?,所以可有可无,但是前两个属性必须有
}

type(类型别名)用来给一个类型起一个新名字,可以同接口一样描述对象或函数,不同之处在于其还可以声明基本类型别名,联合类型,元组等类型

type Person{
    name:string,
    age:number
}

let Yzh:Person ={
    name:"yzh",
    age:19
}

typeof vs keyof

typeof 操作符用来获取一个变量或者对象的类型

//对于不知道类型的变量,可以借助该操作符获取
import {useRoute} from "vue-Router";

const route =useRoute()
type RouteType =typeof route

keyof操作符可以提取其属性的名称,返回一个联合类型(后续会提到)

type Person{
    name:string,
    age:number
}

type keys =keyof Person; //"name" | "age"

泛型 && 泛型约束 && 泛型推断

泛型可以理解为在编译期间不确定方法的类型,在方法调用时,由程序员指定泛型具体指向什么类型

泛型的作用:通常解决类,接口,方法的复用性,以及对非特定数据类型的支持

//举一个应用场景,定义一个函数,接收什么一个任意类型的数据然后返回该数据
function backArg(arg:any):any{
    return arg
}
//貌似是实现了,但是有一个问题,该问题隐含的是接收类型和返回类型要一致,即设置any虽然实现了可以接收任意类型的参数但是不能保证接收类型与返回类型一致

function backArg<T>(arg:T):T{
    return arg
}
backArg<string>("success");
backArg<number>(200)
//即先借助<>定义一个泛型类型,然后在实际调用中根据不同情况我们传递给它相应类型

一个常见的问题,获取一个object的某个属性值,但是我们获取时,编译器不知道该object类型是否有该属性就会报错

对于这种情况,如果我们知道object的具体属性,可以直接定义一个接口进行约束,就不会有相应问题,但问题如果不知道该object的属性值都有什么该怎么办,采用类型约束

const userInfo ={
    name:"yzh",
    age:19
}

function getKey(o:object,name:string){
    return o[name]; //Error:'string' can't be used to index type '{}'.
}

//采用类型约束
function getKey<T extends object,K extends keyof T>(obj:T,key:K):T[K]{
    return obj[key]
}
//keyof T为一个联合类型(设为U),K extends U表示K受U约束,可以简单理解为extends后的内容为前面泛型的接口
//即K需要为联合类型U中属性值的一种

//值得一提的是:这里的extends不同于面向对象的继承,而是表示一种约束关系

泛型推断(infer)

//获取函数返回值类型
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;
//获取函数参数类型
type ParamType<T> = T extends (...args: infer R) => any ? R : T;

在上面这个示例中,T extends U ? X:Y的形式为条件判断

infer R代表待推断的返回值类型,第一个在函数返回值位置,所以在调用时泛型R就会为接收函数T的返回值类型,第二个在函数参数位置,所以在调用时泛型R就会接收函数T的参数类型(条件判断只是为了处理接收的不是函数的情况(其实返回never更好(我感觉.....)))

"extends keyof" vs "in keyof"

keyof的作用上面已经解释过了,作用于一个类型(interface或type或class),返回一个包含其所有key值的联合类型,下面主要解释一下extends和in在这上面用法的区别

<T,K extends keyof T>

表示泛型K只能是泛型T的一个公共属性名称

extends用于约束泛型参数的类型

type Optional ={[K in keyof T]?:T[k]}

这里定义了一个映射类型

in 在这里多少带点循环的意思,可以这样去理解:

interface Person {
  age: number;
  name: string;
}

type Optional<T> = { 
  [K in keyof T]?: T[K] 
};
//可以这样理解其功能
Optional<Person> ={
    age?:number(Person['age']),
    name?:string
}

值得一提的是:in操作符与extends操作符在作用和返回值上都有本质不同,in操作符前的K值表示in后面T的任何一个具体的属性值(任何可以用循环解释),而extends意义为约束前面的K为keyof返回的联合对象中的任何一个可能的值 StackOverflow上面有一篇解释的很清楚的文章

交叉类型(&)/联合类型(|)

交叉类型:把几个类型的成员合并,形成一个拥有这几个类型所有成员的新类型(是取并而不是取交)

//基础类型不存在交叉,一个类型不可能既是string又是number
interface T1 {
    name:string
}

interface T2 {
    age:number
}

type T3 =T1 & T2

let Yzh:T3 ={
    name:"yzh",
    age:19
}

联合类型:表示取值可以为多种类型中的一种

function add(parm1:string | number,parm2:string | number){
  if(typeof parm1 === 'string' || typeof parm2 === 'string'){
    return `${parm1}${parm2}`
  }
  return parm1 + parm2
}

Omit / Pick

Omit<K,T>:用于从指定类型中,排除指定的属性,返回一个新的属性 K为对象类型,T为要从K中剔除的属性名称

type OmitUser = {
  name:string;
  age:number;
  sex:string;
}

type NewOmitUser = Omit<OmitUser,'sex'>
//等价于
type OmitUser1 = {
  name:string;
  age:number;
}

Pick :从一个类型中,取出几个想要的属性

interface Test{
  type:string,
  text:string
}
type Test1 = Pick<ITest,'text'>;

索引类型,映射类型

Vuex简单介绍

Vuex允许我们将store分割为模块(module),每个模块维护自己的state,getter,action和mutation

但是默认情况下,action和mutation仍然注册在全局命名空间中,getter也是,因此需要注意不要在不同的,无命名空间的模块中定义两个相同的getter从而导致错误

为了解决这个问题,你可以添加namespaced:true,但需要注意的是:一旦你这么做了,就需要在组件内调用Vuex的相关方法时在其前面加上模块空间的名称(这一点需要关注,因为稍后会进行相关的操作)

先介绍一下我的Vuex目录管理

image.png

其中,index.ts就是Vuex的入口文件,help.ts是用来实现Vuex方法智能提示的关键文件(helpCopy.ts无用,之前练习时写的,不用在意),type文件夹下用于定义接口(其实也用处不大),modules文件下则是将需要统一管理的变量分成三个模块分别管理

index.ts

// store.ts 粘贴自官网示例(https://vuex.vuejs.org/zh/guide/typescript-support.html)
import { InjectionKey } from 'vue'
import { createStore, useStore as baseUseStore, Store } from 'vuex'
import {Tab} from "@/store/type/index"
import { CommonStore } from './help'

import tabs,{TabsState} from "./modules/tabs"
import menu,{MenuState} from "./modules/menu"
import user,{UserState} from "./modules/user"

// 向外暴露自定义state接口
export type RootState = {
  tabs:TabsState,
  menu:MenuState,
  user:UserState
}
// 导入模块
export const modules ={
  tabs:tabs,
  menu:menu,
  user:user
}

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

export const store = createStore<RootState>({
  modules
}) as CommonStore //commonStore就是替换了dispatch和commit方法定义的新的Store定义

// 定义自己的 `useStore` 组合式函数
export function useStore ():CommonStore {
  return baseUseStore(key)
}

这里主要谈一下Injection<Store<Rootstate>>的作用,主要是为了在组件中useStore返回类型化的store,但是这个类型化仅仅是值对于state而言,却不会使得dispatch方法和commit方法知晓有哪些方法需要调用,具体原因可以查看源码中Store类的定义:

export declare class Store<S> {
  constructor(options: StoreOptions<S>);

  readonly state: S;
  readonly getters: any;
  ......
  dispatch: Dispatch;
  commit: Commit;
  ......//这里省略了一些方法的定义,详见node_modules/vuex/types/index.d.ts文件
}

其实智能提示的原理很简单,也即替换掉store中的dispath与commit方法的类型,换上带有指定函数的类型,即可在调用过程中产生提示,下面开始进入正题:

vuex智能提示

获取mutations等函数类型

第一步就是获取mutations,actions和getters中函数的类型,因为智能提示的目标就是当调用dispatch,commit或getters,能智能提示vuex中定义了哪些方法

import {modules,RootState} from "./index"

// 获取modules类型
type Modules =typeof modules

type GetMutation<T> =T extends {mutations:infer G} ? G:never;

type GetMutations<T> ={
    [K in keyof T]:GetMutation<T[K]>
}

type mutationsObj =GetMutations<Modules>

第一步就是获取vuex所有模块的类型定义

image.png

第二步,遍历该类型中各个模块,获取各模块的类型定义,也就是[K in keyof T]:GetMutations<T[K]> ,其中T[K]就是各模块的类型定义

第三步,从类型定义中拿到mutations属性的函数定义,这里使用泛型推断,泛型推断上面已经提过了,这里再说一遍:其实和三元表达式很想,如果T extends {mutations:infer G}成立,那么就返回G,否则返回never,infer G就是我们希望获取的类型定义。(或者说我们要推断的类型就是G)

image.png

第四步,就是再得到一个模块名--mutations中函数类型对应的类型(目的是为了便于稍后将模块名拼接到各方法前)

image.png

构造带前缀的方法名

前面说过,vuex支持将状态分模块管理,但是一旦每个模块加上namespaced:true后,在使用其方法时,必须带上模块名前缀,即由store.getters['getStatus']-> store.getters['menu/getStatus']

type AddPrefix<prefix,keys> =`${prefix & string}/${keys & string}`

type GetKey<T,K> =AddPrefix<K,keyof T>; 

type GetKeys<T> ={
    [K in keyof T]:GetKey<T[K],K> 
}[keyof T] 

type GetFunc<T,A,B> =T[A & keyof T][B & keyof T[A & keyof T]]
type GetMethod<T> ={
    [K in GetKeys<T>]:K extends `${infer A}/${infer B}` ? GetFunc<T,A,B> :unknown
}

type gettersTest =GetMethod<gettersObj>

首先简单说一下思路,之前我们已经得到了模块名---mutations等函数类型的类型,于是仍然借助keyof得到模块名K和对应的函数类型T[K],然后再对T[K]使用keyof拿到函数名称,最后将两者拼接在一起即可得到新的方法名,然后就要把新方法名与之前旧方法名对应的函数类型对应在一起,返回该类型

第一步,就是获取拼接的新方法名,也就是借助GetKeys函数实现,拿到各模块的名称和mutations等包含的函数类型交给GetKey,GetKey中再调用AddPrefix进行拼接

image.png

第二步,就是将新方法名与就方法名的函数类型对应起来,这里需要把GetKeys方法得到的带有前缀的名称再通过泛型推断拆分出来,然后借助GetFunc函数类型获取新方法名之前对应的函数类型

这里着重讲一下GetFunc函数的作用:GetFunc<T,A,B>,其中T就是之前得到的mutationsObj(actionsObj...),A是模块名,B是老方法名。那么获取老方法类型就很简单,先通过mutationsObj[A]得到模块内的各函数类型,然后再mutationsObj[A][B]就可以获取老方法的函数类型了

但是看了代码之后你还会困惑一点:

type GetFunc<T,A,B> =T[A & keyof T][B & keyof T[A & keyof T]]
//为什么不是?
type GetFunc<T,A,B> =T[A][B]

//因为在ts中我们对一个对象进行索引时索引值受到索引签名约束

/*
简单讲一下索引签名:
interface Person{
    [key:string]:string,
    age:number //Error
}
当你声明了一个索引签名后,所有明确的成员都必须符合索引签名,也即age的值的类型也要是string,并且满足Person类型的对象的索引值智能是string类型
let person:Person;
person[1] =1; //Error
*/

//这里A & keof T 也是这个道理,使得索引值满足类型T的索引签名

image.png

重写dispatch等方法

思路很简单,即借助Omit将我们需要的方法先删除,然后借助&交叉符号拼接上我们重写的方法

export type CommonStore =Omit<VuexStore<RootState>,"commit" | "dispatch" | "getters">
&
{
    commit<K extends keyof mutationsTest,P extends Parameters<mutationsTest[K]>[1]>(
        key:K,
        payload?:P,
        options?:CommitOptions
    ):ReturnType<mutationsTest[K]>
}
&
{
    getters:{
        [K in keyof gettersTest]:ReturnType<gettersTest[K]>
    }
}
&
{
    dispatch<K extends keyof actionsTest>(
        key:K,
        payload?:Parameters<actionsTest[K]>[1],
        options?:DispatchOptions
    ):ReturnType<actionsTest[K]>
}

首先看一下源码中对于dispatch,commit,getters的类型声明

//getters定义:
readonly getters: any;

//dispatch定义
export interface Dispatch {
  (type: string, payload?: any, options?: DispatchOptions): Promise<any>;
  <P extends Payload>(payloadWithType: P, options?: DispatchOptions): Promise<any>;
}

//commit定义
export interface Commit {
  (type: string, payload?: any, options?: CommitOptions): void;
  <P extends Payload>(payloadWithType: P, options?: CommitOptions): void;
}

可以看到,

ReturnType和Parameters是typescript中内置的两个类型,均接收一个函数类型T,前者获取T的返回值类型,后者获取T的参数类型

第一步:借助Omit剔除dispatch,commit,getters方法

第二步:借助&重写这些方法

getters方法重写最简单,只需要定义好其索引即可 [K in keyof gettersTest]:ReturnType<gettersTest[K]>

dispatchcommit方法要复杂一些,原因在于:这两个函数还可能接收额外的参数,并且根据源码中的类型声明可知,其除了接收字符串+payload之外,还可以接收一个对象(不过我们重写之后仅支持字符串+payload的形式了.....)

commit<K extends keyof mutationsTest,P extends Parameters<mutationsTest[K]>[1]>(
        key:K,
        payload?:P,
        options?:CommitOptions
    ):ReturnType<mutationsTest[K]>
/*
K即为带有前缀的方法名,也就是第一个参数
P就是payload的类型约束,取<mutationsTest[K]>[1]也就是state的类型

注意:这里为什么不用写K & keyof T,原因在于前面对于K已经提供了限制。
*/

dispatch就同理可得了,不再赘述

后记

自己也是从这里开始真正对于typescript有了新的认知,被其用法所震撼到了,但是通过写这篇博文下来,也深刻意识到了自己还有诸多不足,对于ts真是所知愈多,所不知便愈多,也希望看到这里的朋友对于文章中出现的理解不当的地方和有出错的地方积极指正,也感谢能读到这里的朋友。