我在业务项目中的一些Typescript实践

努力让学习成为一种习惯,自信来源于充分的准备

如果你觉得该文章对你有帮助,欢迎大家点赞关注分享

前言

TypeScript是由微软开发的一种开源的编程语言。它是JavaScript的超集,这意味着所有有效的 JavaScript代码都是有效的TypeScript代码,同时TypeScript通过引入静态类型系统和面向对象特性,使得大型项目的开发和维护更加容易和可靠。它是一种非常有价值的JavaScript增强型语言

本文不会深入介绍Typescript相关概念以及原理。只介绍自己实际业务项目中Typescipt的一些实践

项目技术栈为:vue + axios + vue-router + vuex

核心库版本如下:

版本
vue ^2.6.10
typescript ^4.5.5
vue-router ^3.1.3
vuex ^3.1.2
axios ^0.19.0

以下实践只是个人摸索尝试的一些总结,抛砖引玉。如果大家有不同的意见、更好的尝试,欢迎留言讨论

请求封装

接口请求是我们日常业务代码中非常常见且重要的一环,利用Typescript定义接口类型不仅可以提高我们开发的效率,避免因数据类型不对或者字段是否存在等导致的一系列潜在错误(经常会有接口数据返回的某个对象为null, 然后使用了X.x导致的TypeError错误),另外还有一种情况如果后端修改了接口响应的数据接口但是没有通知到前端,然后你找他问的时候,他说原本就是这样。这时候你就可以把之前定义的接口类型给他看,找他battle😎

一般我们业务代码中的请求方法是这样的

js 复制代码
// axiosInstance为axios实例,加了一些拦截器
import axiosInstance from '../inteceptor'

export const requestA = (params = {}) => {
    return axiosInstance.post('/a/b/c', params)
}

注意:以下罗列的请求、响应结构均是以我负责的业务为例子,大家各个业务肯定会有所不同,但基本思路是一致的。另外一些老的接口可能结构不同且难以调整,需要适当做兼容处理

请求结构

js 复制代码
// 列表请求(普通请求就是具体业务相关字段了)
{
  page: 1,
  pageSize: 10,
  // 具体业务请求字段
  xxx
}

响应结构

js 复制代码
// 普通响应结构
{
  // 有些业务可能会以errno定义,本质都一样
  result: 0,
  errCode: "SUCCESS",
  errInfo: "成功",
  data: {
    // 具体业务响应数据
    xxx
  }
}

// 列表响应结构
{
  result: 0,
  errCode: 'SUCCESS',
  errInfo: '成功',
  data: {
    list: [],
    pagination: {
      totalPage: 0,
      pageSize: 20,
      total: 0,
      currentPage: 1
    }
  }
}

好了,接下来我们来定义TS类型

封装请求外层

ts 复制代码
import { AxiosPromise } from 'axios'

/* 请求分页 */
export interface ReqPage {
  page: number
  pageSize: number
}

/* 响应分页 */
export interface ResPage<T = undefined> {
  extra: Record<string, unknown>
  list: T extends undefined ? Record<string, unknown>[] : T[]
  pagination: {
    total?: number
    totalPage?: number
    pageSize?: number
    currentPage?: number
  }
}

declare const enum Result {
  NO_ERROR = 0,
  NO_DATA = 2,
  NO_LOGIN = 6
}
/* 响应体*/
export interface RootResBody<T> {
  result: Result
  errCode: string
  errInfo: string
  data: T
}

/* axios请求响应封装 
   ReqT: 请求type
   ResT: 响应type
*/
export type ReqResType<ReqT = undefined, ResT = undefined> = (
  params: ReqT
) => AxiosPromise<RootResBody<ResT>>

具体业务接口方法使用(这里为了方便,把接口与请求放在一起了,实际业务接口类型会有一个专门的文件夹存放,看各种团队的规范了)

ts 复制代码
interface QueryUserParams {
  id: string
}

interface QueryUserInfoDetail {
  id: string
  name: string
  height: string
  weight: string
  gender: 'male' | 'female'
}

type QueryUserInfoAxios = ReqResType<QueryUserParams, QueryUserInfoDetail>

const queryUserInfo: QueryUserInfoAxios = params => {
  return axiosInstance.post('/api/getUserInfo', params)
}

const userInfo = await queryUserInfo({id: '1'})

我们在使用响应数据的时候就会有提示了,还是很方便的

如果是分页接口我们可以这样使用

ts 复制代码
interface QueryUserParams extends ReqPage {
  id: string
}
type QueryUserInfoPageAxios = ReqResType<QueryUserParams, ResPage<QueryUserInfoDetail>>

// 或者
interface QueryUserParams {
  id: string
}
type QueryUserInfoPageAxios = ReqResType<QueryUserParams & ReqPage, ResPage<QueryUserInfoDetail>>

// 使用
const queryUserInfo: QueryUserInfoPageAxios = params => {
  return axiosInstance.post('/api/getUserInfo', params)
}

const userInfo = await queryUserInfo({id: '1'})

但其实上面两种方式我都不太建议,分页的类型更多其实是接口层面的扩展,和业务类型应该分开

业务请求、响应类型我们往往在业务代码也需要用到(formData表单数据结构、table表格数据结构、详情数据结构等),我们更应该把页面接口封装在底层,而不应该和业务类型耦合

基于这个理念,我们进一步改造下底层类型

ts 复制代码
/* axios请求响应封装 
   ReqT: 请求type
   ResT: 响应type
   Page: 分页
*/
export type ReqResType<ReqT = undefined, ResT = undefined, Page = false> = (
  params: Page extends false ? ReqT : ReqT & ReqPage
) => AxiosPromise<RootResBody<Page extends false ? ResT : ResPage<ResT>>>


// 使用
// 不需要分页(默认)
type QueryUserInfoAxios = ReqResType<QueryUserParams, QueryUserInfoDetail>

// 需要分页
type QueryUserInfoAxios = ReqResType<QueryUserParams, QueryUserInfoDetail, true>

const queryUserInfo: QueryUserInfoAxios = params => {
  return axiosInstance.post('/api/getUserInfo', params)
}

这样一来,分页相关封装在底层,我们只需要关注业务接口类型,通过简单的传参决定是否需要分页

最后还有一个体验细节点,上面这种写法,当我们鼠标挪动到类型上显示是这样的

我们没办法看到接口类型内部的信息,解决这个问题我们可以手动写一个展开类型

ts 复制代码
// 用于展开详细信息;
type ExpandRecursive<T> = T extends object ? (T extends infer O ? { [K in keyof O]: ExpandRecursive<O[K]> } : never) : T
  
export type ReqResType<ReqT = undefined, ResT = undefined, Page = false> = (
  params: ExpandRecursive<Page extends false ? ReqT : ReqT & ReqPage>
) => AxiosPromise<ExpandRecursive<RootResBody<Page extends false ? ResT : ResPage<ResT>>>>

这时候我们再次鼠标挪动到类型上显示是这样的

不分页

分页

vue3自带的UnwrapRef类型的也有一样的效果

目录结构

在业务项目中会有许多不同的接口类型定义(全局类型、第三方扩展、纯业务类型等),我们不可能所有类型都统一定义在一个文件里面。不同的类型需要规范在不同,这里简要说下我自己业务项目是怎么规范、定义接口类型的

需要额外说一点的是:项目中对于纯类型声明文件,统一采用.d.ts结尾

.ts文件是 TypeScript 的实现源代码,包含了类型定义和实现逻辑。

.d.ts文件是 TypeScript 的类型声明文件,只包含类型定义,不包含实现逻辑,对于.d.ts,Typescript不会对其编译将其转化成js

全局类型

全局类型统一存放在global.d.ts文件中

举个🌰

ts 复制代码
// main.ts
// vue组件引入
import App from './App.vue'
// 保存文件
import { saveAs } from 'file-saver'

// global.d.ts
declare module '*.vue' {}

declare module 'file-saver' {
  export function saveAs(blob: any, name: any): void
}

declare const __DEV__: boolean

统一放在项目src目录最外层

第三方库的声明&兼容扩展

在日常业务开发过程中,我们时不时需要拓展其声明类型(最常见的场景就是vue.prototype上定义方法) ,常用的第三库的类型拓展文件使用xxx.d.ts命名

举个🌰

ts 复制代码
// vue.d.ts
import Vue from 'vue'
import { Moment } from 'moment'

declare module 'vue/types/vue' {
  interface Vue {
    $statistics: (param: Record<string, unknown>) => void
    $moment: (...args) => Moment
  }
}

// jsx.d.ts
import Vue, { VNode } from 'vue'

declare global {
  namespace JSX {
    // tslint:disable no-empty-interface
    interface Element extends VNode {}
    // tslint:disable no-empty-interface
    interface ElementClass extends Vue {}
    interface IntrinsicElements {
      [elem: string]: any
    }
  }
}

// vuex.d.ts
import type { Store } from '@/vuex'

declare module '@vue/runtime-core' {
  // 声明自己的 store state
  interface State {
    count: number
  }

  // 为 `this.$store` 提供类型声明
  interface ComponentCustomProperties {
    $store: Store<State>
  }
}

// ComponentOptions 声明于 types/options.d.ts 之中
declare module 'vue/types/options' {
  interface ComponentOptions<V extends Vue> {
    store?: Store
  }
}

// axios.d.ts
import { AxiosInstance } from 'axios'

declare module 'vue/types/vue' {
  interface VueConstructor<V extends Vue> {
    http: AxiosInstance;
  }
}

统一放在项目src目录最外层

业务接口类型

在前面封装请求类型的时候提到过一个问题:一些请求/响应类型其实在具体业务组件也会用到

这里为了方便阅读,把上面的代码贴过来

ts 复制代码
interface QueryUserParams {
  id: string
  userName?: string
}

interface QueryUserInfoDetail {
  id: string
  name: string
  height: string
  weight: string
  gender: 'male' | 'female'
}

type QueryUserInfoAxios = ReqResType<QueryUserParams, QueryUserInfoDetail>

const queryUserInfo: QueryUserInfoAxios = params => {
  return axiosInstance.post('/api/getUserInfo', params)
}

const userInfo = await queryUserInfo({id: '1'})

这里的QueryUserParamsQueryUserInfoDetail在业务组件我们可能会这么用

vue 复制代码
setup() {
    const formData = ref<QueryUserParams>({
       id: ''
       userName: ''
    })
    const userInfo = ref<QueryUserInfoDetail>({} as QueryUserInfoDetail)
    
    onMounted(() => {
       queryUserInfo({...unref(formData)}).then(res => {
          userInfo.value = res.data.data
       })
    })
}

显然这里的QueryUserParamsQueryUserInfoDetail是与业务强相关的,我们应该重命名为 UserFormDataUserInfo。我们不应该将这两个业务强相关的类型放在请求文件里。这么说可能有些绕,我们看下例子

之前的目录结构

/api/types/index.d.ts存放请求相关公用的类型(比如分页,底层请求封装类型等)

ts 复制代码
// /api/User/types/index.d.ts
import { ReqResType } from '@/api/types/index'

interface QueryUserParams {
  id: string
  userName?: string
}

interface QueryUserInfoDetail {
  id: string
  name: string
  height: string
  weight: string
  gender: 'male' | 'female'
}

type QueryUserInfoAxios = ReqResType<QueryUserParams, QueryUserInfoDetail>

// /api/User/index.ts
import { axiosInstance } from '../axios_inteceptor'
import { QueryUserInfoAxios } from './types'

const queryUserInfo: QueryUserInfoAxios = params => {
  return axiosInstance.post('/api/getUserInfo', params)
}

我们在业务组件文件夹定义一个types用于存放业务相关的类型

ts 复制代码
// page/aaUser/types/index.d.ts

export interface UserFormData {
  id: string
  userName?: string
}

export interface UserInfoDetail {
  id: string
  name: string
  height: string
  weight: string
  gender: 'male' | 'female'
}

最后请求接口处修改引入路径即可,说了这么多,表达的观点是:业务类型状态跟随业务组件走

另外还有以下目录约束

  1. 多业务流转类型和公共状态枚举类型放在最外层types里(按功能命名文件夹)

  2. api接口文件类型单独文件定义,放在api接口文件同级types文件内,无需单独定义的简单结构的类型可直接写在接口上(比如一些详情接口请求入参可能就只有一个id,这时候我们直接在接口上定义即可)

整体项目的目录如下:

使用技巧

接下来这块是我自己日常体会下来,没啥难度但是非常实用的技巧

枚举

基本使用场景

当定义一些带名字的常量的时候,使用枚举可以清晰地表达意图或创建一组有区别的用例,同时枚举让代码拥有更好的类型提示,把常量真正地约束在一个命名空间下

同样的代码功能,使用枚举经过babel转化后大小会更小

这里不具体深入介绍了,想了解的可以参考枚举

在业务中枚举最常用的场景之一就是常量声明,消除魔法数字。从而提高代码的可读性

ts 复制代码
enum TaskStatus {
  WAITING = 0,
  COMPUTING = 1,
  SUCCESS = 2,
  NOMATCH = 3,
  FAILED = 4
}

// 当我们写业务相关代码的时候
// ❌(谁知道0是什么)
if (value === 0) {xxx}

// ✅
if (value === TaskStatus.WAITING) {xxx}

常量枚举

对于枚举类型,如果不需要利用到枚举的双向映射(我自己业务目前都没用到过),尽量使用常量枚举(减少编译产物,提高性能),上面例子中的枚举可以优化成

ts 复制代码
const enum TaskStatus {
  WAITING = 0,
  COMPUTING = 1,
  SUCCESS = 2,
  NOMATCH = 3,
  FAILED = 4
}

我们可以简单看下两者编译后的产物

never的使用

never在日常业务开发使用的场景还是比较少的。但有一种场景never起到的作用非常大

ts 复制代码
/**
* 按钮操作类型
*/
type OperateType = "edit" | "audit" | "publish" | "unpublish" | "unpublish2";

// 依据按钮操作类型判断按钮是否可以禁用
export const judgeOperateBtnIsDisabled = (
  type: OperateType,
): boolean => {
  switch (type) {
    case "edit":
      return true
    case "audit":
      return false
    case "publish":
      return true
    case "unpublish":
      return false
    // 这里我们没有对unpublish2的情况做相关处理
    default:
      return false;
  }
};

当我们type新增的时候,switch里却没有添加对应类型逻辑的时候。ts不会有任何的警告,我们很可能就直接忘记了,也许有人会说,这种不会遗忘吧大概率,增加类型的同时马上增加逻辑不就好了。这里举的例子比较简单,业务逻辑一旦复杂且多人维护的时候,就难说了

那么有没有一种方法,type类型增加了,switch代码逻辑里面也有对应的提示了。答案就是 never,我们稍微改造下

ts 复制代码
export const judgeOperateBtnIsDisabled = (
  type: OperateType,
): boolean => {
  switch (type) {
    xxxx
    default:
      const n: never = type;
      return false;
  }
};

这样ts就会有对应提示了

最后

typescript在刚开始接触使用的时候,老实说增加了开发的时间。甚至会觉得有些麻烦,但是熟练之后,真的离不开了,给予的类型提示和类型安全校验真的太舒服了😌

到这里,就是本篇文章的全部内容了

如果你觉得该文章对你有帮助,欢迎大家点赞关注分享

如果你有疑问或者出入,评论区告诉我,我们一起讨论

相关推荐
腾讯TNTWeb前端团队6 小时前
helux v5 发布了,像pinia一样优雅地管理你的react状态吧
前端·javascript·react.js
范文杰9 小时前
AI 时代如何更高效开发前端组件?21st.dev 给了一种答案
前端·ai编程
拉不动的猪9 小时前
刷刷题50(常见的js数据通信与渲染问题)
前端·javascript·面试
拉不动的猪9 小时前
JS多线程Webworks中的几种实战场景演示
前端·javascript·面试
FreeCultureBoy10 小时前
macOS 命令行 原生挂载 webdav 方法
前端
uhakadotcom11 小时前
Astro 框架:快速构建内容驱动型网站的利器
前端·javascript·面试
uhakadotcom11 小时前
了解Nest.js和Next.js:如何选择合适的框架
前端·javascript·面试
uhakadotcom11 小时前
React与Next.js:基础知识及应用场景
前端·面试·github
uhakadotcom11 小时前
Remix 框架:性能与易用性的完美结合
前端·javascript·面试
uhakadotcom11 小时前
Node.js 包管理器:npm vs pnpm
前端·javascript·面试