TypeScript实战使用技巧分享

发布于:2024-03-29 ⋅ 阅读:(14) ⋅ 点赞:(0)

TypeScript使用分享

前言

本次技术分享是想将自己使用TypeScript(TS)的经验给大家做一个技术分享。主要目的是分享我使用TS的方式或者习惯,以及怎么在项目中更好的使用它,而不是对TS这门语言的学习。并非说需要大家都去这样写,每个人有自己的编码风格。因为按照我的经历,我当时学习TS是没有太多理解上的问题的,更多的疑惑是怎么在项目中使用它,使用后到底带来哪些好处。这次技术分享仅仅从我个人的观点出发,分享一下我使用TS的习惯或者经验,希望多少能对大家有些帮助。

关于TypeScript

TypeScript是什么

按照官方的说法:TS是JavaScript的超集,为Javascript的生态增加了类型机制,并最终将代码编译为纯粹的JavaScript代码。大家一定要记住一点:TypeScript 的使用是为了方便我们开发,提高我们的开发效率,减少开发过程中出现的错误,编写更加易于阅读和维护的代码。牢记这个宗旨,对我们使用 TypeScript 是有很大的帮助的。

为什么要使用TypeScript

JavaScript是一门弱类型语言,变量的数据类型具有动态性,只有执行的时候才能确定变量的类型,这种后知后觉的认错方法会让开发者成为调试大师,但无益于编程能力的提升,还会降低开发效率。TypeScript的类型机制可以有效的杜绝变量类型引起的误用问题,而且开发者可以根据情况来确定是严格限制变量类型还是宽松限制。不过,添加类型限制后,也有副作用:增大了开发者的学习曲线,增加了设定类型的开发时间,但这些付出相对于代码的健壮性和可维护性,都是值得的。
类型注释是TypeScript的内置功能之一,文本编辑器(VsCode)可以对代码执行更好的静态分析,这样,我们就可以通过自动编译工具的帮助,在编写代码时减少错误,提高生产力。
简单的讲:规范使用 TypeScript 的工程,代码逻辑会更清晰、更利于阅读、更方便维护甚至还能利用类型推断减少代码运行报错的可能性(因为很多错误TS会在编译阶段甚至编码阶段就提醒给开发者,这是JS做不到的,JS是直接运行,不需要编译的)。

高级类型

TypeScript 的基本类型这里就不说了,大概说一下常用的高级类型:

  • 联合类型:
    let data: number | string = 0
    
    这样的类型声明就是联合类型。
  • 交叉类型
    interface NameData {
      name: string
    }
    interface AgeData {
      age: number
    }
    const a:NameData & AgeData = {name: "Mike", age: 18}
    
  • 字面量类型
    const a: 0 | 1 | 2 = 1
    

关于泛型

这里我觉得还是可以大概说一下我对泛型和泛型使用的理解。泛型是设计是为了增强我们的类型和代码设计的可拓展性和可复用性。而且泛型设计的时候我们还可以对泛型进行约束,这一点也是很重要的,现在空口讲可能不太好理解。我们继续往下后面遇到再说。

type关键字的使用

type关键字用来声明一个类型,被声明的这个类型我们一般称之为类型别名,比如:

export type Id = number | string

const data = { a: 0, b: 1 }
export type MyData = typeof data

怎么使用TypeScript(Vue3.x)中

由于我们目前的项目涉及到 TypeScript 的技术栈都是 Vue3 + TS + ant-design-vue,因此我这边着重按照这样的技术栈去给大家分享一下我的使用经验。

创建项目

创建项目就使用 vue 官方文档的说明去创建就好,但是要记住涉及到是否使用 TypeScript 的选项要选是。然后按照 ant-design-vue 的官方文档在项目中引入 ant-design-vue UI组件。个人建议使用 import { Button } from 'ant-design-vue';的方式引入,因为官方文档解释了,默认支持 ES modulestree shaking,这样引入默认就会有按需加载的效果。

管理TS类型声明

在项目中我们各个模块下通常都会有各自不同的数据类型,我们通常会在 src 目录下创建一个单独的目录来统一管理这些文件,一般命名为 typingstypes或者 models。个人更倾向于 models这个命名。因为model本身就是模型的意思,我们的数据结构(数据类型)本身也是一个模型,另外我们如果习惯使用面向对象的编程模式,我们定义的一些类也可以放在该目录,因为类本身是一种数据结构,但也算一种数据类型。

interface的使用
interface基本使用

使用TS要相对从比较宏观的角度去考虑,比如在一个系统中,我们先要对我们可能会用到的数据结构有一个大致的掌握。通常来说后台的数据结构通常会带有 id 字段,而且这个 id 字段可能并不一定就统一是 number 类型或者统一是 string 类型,那么我们就可以在我们的 /models/index.ts 模块做这样的配置。

export type Id = number | string

export interface BaseData {
  id:Id
}

这样子我们就定义好了一个最基础的 interface,但是这个 interface 是有缺陷的,因为我们目前这样类型的 id 字段被推断出来可能是 字符串 也可能是 number,因此我们并不能确定id的具体类型,但是如果我们按照下面的写法把id的类型写死为 number,那么如果有其他的数据 id 类型为 string 的,这个 BaseData 显然不再适用了。

export interface BaseData {
  id: number
}

因此这种时候我们就需要使用到泛型了,我们可以给 BaseData 设置一个泛型参数T,然后在使用这个接口的时候我们可以显式的传入泛型参数,来确定id这个字段的具体类型,当然每次都写参数稍显麻烦,我们就可以给个默认值,比如系统中的数据id字段为 number 的数量远大于 string,我们可以这样设计:

export interface BaseData<T = number> {
  id: T
}

这样子我们的 BaseData 就比较完善了,但是这还不是最终的版本,因为这里其实还是有一点点问题的,我们目前对泛型参数 T 没有做任何的限制,因此我们可以传入任意的类型的,包括数组、对象甚至其他任意的数据类型,显然我们只希望我们的id属性只能是 string 或者 number,因此这里我们就可以用到 泛型约束 这个概念,简单的讲就是对我们的泛型参数进行限制,比如这个例子中我们只希望我们的泛型传入 string 或者 number,我们可以这样做:

export interface BaseData<T extends Id = number> {
  id: T
}

这个泛型参数的声明解读就是:接受一个泛型参数,这个参数必须适配 Id 类型,默认值是 number。这样,一个相对比较优秀的基本数据类型就定义好了,既可扩展还对扩展性做了我们业务上需要的限制。

interface继承

在TS中,interface 是可以继承的,并且和 java 不一样,TS中的 interface 是可以集成多个的,下面举个例子大家就能明白了。

export interface NameData {
  name: string
}

export interface DeviceInfo extends BaseData, NameData {
  sn: string
  .........
}

这个例子中我们声明的 interface DeviceInfo继承自 BaseData和NameData,会继承里面的属性,因此最终DeviceInfo的数据结构是这样的:

{
  id: number;
  name: string;
  sn: string;
}

注:在继承多个接口的时候需要保证这几个接口之间没有属性key相同但是类型不同的情况,否则会报错。
再比如我们调用设备详情接口返回的数据可能会多于设备列表中列表返回来的字段,那么我么你可以声明一个新的接口继承自该接口:

export interface DeviceDetail extends DeviceInfo {
  mac: string;
  lastOnlineTime: string;
  ...........
}
class实现interface

接口还有一个作用就是约束类,这个现在说也是不好理解,后面说到类的时候再说。

枚举的使用场景

要使用枚举我们首先要明白枚举适用于哪些场景。通常来说我们使用枚举是为了代码更加语义化,比如各种状态值,后端定义的可能是 01、02、03这之类的code码,如果在前端做逻辑判断的时候如果直接在代码逻辑里面些code码判断,会导致代码非常难以阅读。哪怕是你自己在开发的时候可能都会随时盯着后端的文档一个个去对比code码的意思。再或者说前端自己的一些状态存储,比如一个设备创建页面,我们可能为了复用这个组件会在组件内部维护一个"模式"变量,来确定这个组件的使用场景,这种时候我们同样可以使用枚举。
TypeScript 中,枚举分为两种

  • 默认值枚举

  • 自定义值的枚举
    如果抉择使用这两种枚举的场景呢?按照我的经验我一般是这样子区分的:如果这个枚举值是纯前端使用,使用默认值枚举就好,因为没人会关心这个枚举值具体值是多少,只需要知道他代表的是那种状态就好,比如上面提到的设备创建模式。

    enum CreateMode {
      CREATE,
      EDIT,
      VIEW_ONLY
    }
    

    如果这个值是前后端交互需要共同用到的,就必须用自定义枚举值,比如后端文档给出了设备状态有以下几种:

    code 意义
    01 在线
    02 离线
    03 未激活

    这种情况我们就可以定义这样的枚举:

    enum OnlineStatus {
      ONLINE = "01",
      OFFLINE = "02",
      UN_ACTIVATED = "03",
    }
    

    这样子我们在做逻辑判断就可以看出用不用枚举的差别了:

    if (onlineStatus === "01") {
      ..........
    }
    
    if (onlineStatus === OnlineStatus.ONLINE) {
      ..........
    }
    

    我们还可以将枚举配合 Map 来使用,用枚举值映射 Map 中的值,比如上面这个在线状态的枚举,在实际业务中我们可能会对每个状态都有相应的一些信息,比如对应的文字说明,样式之类的:

    const OnlineStatusConfig = new Map([
      [OnlineStatus.ONLINE, { color: "green", label: "在线", icon: 'online-icon' }],
      [OnlineStatus.OFFLINE, { color: "gray", label: "离线", icon: 'offline-icon' }],
      [OnlineStatus.ONLINE, { color: "orange", label: "未激活", icon: 'un-activated-icon' }],
    ])
    

    在这个 Map 中,我们可以更加清晰的看到在线状态和与之相对应的信息之间的映射关系,然后在处理业务展示逻辑的时候更加清晰:

    <OnlineStatusComponent :config="OnlineStatusConfig.get(xxxxx)" />
    

    而不是:

    if (status === OnlineStatus.ONLINE) {
      return { color: "green", label: "在线", icon: 'online-icon' }
    } else if (status === OnlineStatus.OFFLINE) {
      return { color: "gray", label: "离线", icon: 'offline-icon' }
    } else if (status === OnlineStatus.ONLINE) {
      return { color: "orange", label: "未激活", icon: 'un-activated-icon' }
    }
    

    直接映射的优点就是我们不用再去写过多的 if else 判断,特别是在可能得结果值数量很多的情况,映射逻辑更加清晰:
    这样子,代码的可读性和可维护性会有明显的提升。

class的使用

class 作为ES6中新的特性,其本身也是构造函数的语法糖,class的出现本身也是为了强化 JavaScript 这门语言的面向对象编程的特性,类本身是对一类数据结构的抽象,这种数据结构里面既可以包含属性还可以包含方法,类的属性(和方法)包含了静态属性和实例属性这两种:

  • 静态属性:直接使用 XXX.xxx 访问的属性,比如:Promise.allArray.from 这一类直接通过类访问的属性(或方法)称为静态属性(或方法)。在类里面需要使用 static 关键字定义。
  • 实例属性:只能通过实例化后的实例调用的属性。
    例如:
class Device {
  static staticProp = "A_STATIC_PROP"

  constructor(public name: string) { }

  sayName() {
      console.log(this.name);
  }
} 

console.log(Device.staticProp) // A_STATIC_PROP
const device = new Device("Industrial Router")
device.sayName() // Industrial Router

在这个类中,包含了构造器、静态属性和实例方法。这里需要注意,我们没有在类中定义name属性,但是类实例却能访问,这是 TypeScript 的一个特性:在类的构造器中,构造器参数如果使用 public, private, protected 这类的属性修饰符,那么这些参数会被自动添加为类的属性。

构造函数

类的本质其实就是构造函数及其原型,比如上面的例子我们如果用构造函数和原型来重写:

function Device (name: string) {
  this.name = name
}

Device.staticProp = "A_STATIC_PROP"

Device.prototype.satName = function() {
  console.log(this.name)
}

console.log(Device.staticProp) // A_STATIC_PROP
const device = new Device("Industrial Router")
device.sayName() // Industrial Router
属性修饰符

类中的常见属性修饰符有以下几种:

  • public:公有属性,该修饰符修饰的属性在类的内部、子类、以及实例中都可以访问。
  • private:私有属性,该修饰符修饰的属性只能在类的内部访问,不能再实例和子类中访问。
  • protected:被保护的属性,该修饰符修饰的属性可以在类的内部和子类中访问,不能在类的实例中访问。

在下面这个例子中,我们可以简单的看一下这些修饰符的使用:

class Animal {
    public info: { name: string; reversedName: string; age: number }

    constructor(
        public name: string,
        public age: number,
    ) {
        this.initInfo()
    }

    protected reverseName() {
        return this.name?.split('').reverse().join('')
    }

    private initInfo() {
        this.info = {
            name: this.name,
            age: this.age,
            reversedName: this.reverseName()
        }
    }
}

class Dog extends Animal {
    constructor(name: string, age: number, public gender: "male" | "female") {
        super(name, age)
        // this.initInfo()  属性“initInfo”为私有属性,只能在类“Animal”中访问。
        // 由于调用了super方法,super其实就是调用父类的构造函数,因此父类的构造函数已经执行
      // 父类的 initInfo方法也被执行了,只是这里子类不能直接调用
    }

    public sayReversedName() {
        console.log(this.reverseName())
  }
}

const dog = new Dog("wangcai", 10, 'male')

dog.sayReversedName()
console.log(dog.info)
console.log(dog)
// dog.initInfo() 属性“initInfo”为私有属性,只能在类“Animal”中访问。
// dog.reverseName() 属性“reverseName”受保护,只能在类“Animal”及其子类中访问。
什么时候使用类

在我以前写前端的时候会有这样的问题:什么时候可以使用类?好像我在做项目的时候根本用不到,或者说不使用类同样能完成我们的项目需求。这是肯定的,因为 JavaScript 中,类的本质其实就是构造函数及其原型。我们使用对象的时候更多的也是通过字面量的形式去声明或者使用工厂函数去生成。
但是后面慢慢意识到,面相对象编程其实只能说是一种编程范式,在 JavaScript 中,这并不强制。如果我们慢慢有了面向对象编程的意识,我么你就会意识到很多可复用的逻辑或者数据都可以用类定义,下面举一些常见的例子:
比如前端开发中,通常会用到表格组件,通过传入 columns 来定义组件的列,那么这个 columns 数据结构就可以用类来定义:

const columns: TableColumnsType = [
  {
      title: 'Cloud Gateway Name',
      dataIndex: 'name',
      key: 'name',
      align: 'center',
  },
  {
      title: 'Network',
      dataIndex: 'network',
      key: 'network',
      align: 'center',
  },
  {
      title: 'Location',
      dataIndex: 'location',
      key: 'location',
      align: 'center',
  },
  {
      title: 'Bandwidth',
      key: 'bandwidth',
      dataIndex: 'bandwidth',
      align: 'center',
  },
]

上面这个是 columns 的字面量定义,在这里我们每个 columnsalign 属性都是 center, 并且每个 columnsdataIndexkey 属性的值都是一样的,那么我们一个个去写就有点浪费时间且不好维护,这种情况我们就可以使用类来定义这种数据结构:

class TableColumns implements TableColumnType {
  public key:string

  constructor (public dataIndex: string, public title:string, public align: AlignType = 'center', public fixed?:FixedType) {
    this.init()
  }

  private init () {
      this.key = this.dataIndex
  }
}
......................................................................
const columns = [
    new TableColumns('name', 'Cloud Gateway Name', 'left'),
    new TableColumns('network', 'Network'),
    new TableColumns('location', 'Location'),
    new TableColumns('bandwidth', 'Bandwidth'),
]

注意:这里我们就可以讲讲 implements 关键字的用法了:在这个例子中,我们定义 TableColumns 的使用使用了 implements 关键字并且指向了 ant-design-vue 暴露的 TableColumnType 这个接口,因为我们在设计这个类的时候,我们希望这个类的实例是直接交给 Table 组件的 columns 这个 props 的。因此我们使用 implements 约束这个类,TS就会帮我们检查我们是否正确实现了这个类。比如这个类中的 align 属性在接口中的类型定义为 AlignType,如果在类里面我们定义为其他不兼容的类型,TS就会报错提醒你:

  class TableColumns implements TableColumnType {
    public key:string
  
    constructor (public dataIndex: string, public title:string, public align: number, public fixed?: FixedType) {
        this.init()
    }
    ........................
  }

这是后TS会报错提示你:类“TableColumns”错误实现接口“ColumnType<any>”。属性“align”的类型不兼容。因此这里TS的类型系统其实就帮我们避免了某些开发过程中出现的错误。
这样子定义好类后我们在 new 的时候也会有相应的语法和类型提示,对开发来说是非常友好的。

TS与第三方库配合使用

我们的项目通常都会引入一定数量的第三方包,通常目前这些包的作者都会使用TS对自己的包所暴露出的所有数据进行类型声明,我们在使用的时候,引用的这些数据通常都会有自己的类型,我们不需要额外操作。但是也难免会遇到相对特殊的情况,比如以下几种场景,就涉及到和第三方包的配合使用:

  • 这个包的作者可能没有给到类型声明
  • 我们需要显式的声明这些包声明的某些数据类型
  • 我们需要扩展包里面的某些类型

我们可以以我们的技术栈 ant-design-vue + vue3 来举例说说上面的几种情况:

  1. 包没有自己的类型声明:这种情况比较少见,但也存在,比如一些很久之前的库,由于某些原因作者已经停止维护了,那么确实可能出现这些情况。我们一般可以有以下的解决方案:

    • npm 仓库下的 @types 目录下看看有没有相关的包,因为可能有好心人已经对这些库进行了维护,写了相关的的TS声明文件并上传到了这里。比如 js-cookie 这个库就没有TS声明文件,我们就可以通过下面的命令来安装:
    yarn add @types/js-cookie -D
    

    当然也可能会出现该目录下也没有的情况。这种时候如果我们还需要的话,只能是自己去声明了。

  2. 我们需要显式的声明这些包声明的某些数据类型:比如上面讲到的我们将 columns 交给表格组件 Table,我们在显式用字面量的声明这些数据的时候最好是显式的给到类型,这有助于我们在写代码的时候编辑器能够通过TS类型系统给到更多的代码提示以及代码检查。除了上面说的例子还有其他的,比如我们使用form表单的时候会用 ref 去获取表单组件的实例。如果我们直接定义 const formRef = ref(),这样子其实也可以,但是没有类型提示,我们没有充分利用 ant-design-vue 给我们暴露的这些类型声明。我们在调用表单实例的时候也不会有相应的语法提示和代码检查。所以我们需要利用它暴露的类型系统给这个 ref 加上类型:

    import type { FormInstance } from 'ant-design-vue'
    
    const formRef = ref<FormInstance>()
    
    
    formRef.value.validates..........
    

    这样子我们在调用这个实例的各种属性方法的时候都会得到对应的类型提示以及类型检查。当然这只是举了一个例子,希望大家能举一反三,在其他组件的使用上面按照这个思路来(其实官方示例也有相关的使用示例,大家跟着示例的代码写就好)。

  3. 我们需要扩展包里面的某些类型:有时候我们需要扩展某些第三方库里面声明的类型的时候需要对其做些重写或者扩展的话,也可以使用模块声明来搞定,比如我之前遇到过需要在 vue-router 中的每个路由配置加上一些属性,如果我们直接在路由配置对象中添加那么TS会报错,比如我们添加auth属性会报错:对象字面量只能指定已知属性,并且“auth”不在类型“RouteRecordRaw”中。,这种情况我们就需要对这个第三方包的接口进行扩展:

    declare module 'vue-router' {
        interface _RouteRecordBase {
            auth?: AUTHORITY[]
        }
    }
    

    这样子就完成了对第三方声明的接口进行扩展的操作。
    说到这儿简单补充一下类型声明:有的时候难免会遇到一些特殊情况,比如我们需要再 window 对象上定义一些属性或方法,但是TS会报错:

    console.log(window.myProp) // 类型“Window & typeof globalThis”上不存在属性“myProp”。
    

    这种情况我们可以对这个属性做声明:

    declare global {
        interface Window {
            myProp: string
        }
    }
    
    window.myProp
    

总结

上面主要分享了TS的使用经验和TS有关特性的简要说明,那么回到最初的问题,我们为什么使用TS?
我任务主要从以下几个观点总结一下:

  • 更好的开发支持:严格使用TS后,我们的项目中涉及到的所有数据将不再是没有类型的,编辑器会对我们的代码做出更好的理解和提示,特别是对于一些第三方库的使用。
  • 更清晰的代码逻辑:严格使用TS后,TS的类型系统会约束我们的有些不合理的操作,帮助我们在开发的时候就规避掉许多可能出现的错误。使得我们的代码逻辑更加清晰。更利于阅读。
  • 更强的项目可维护性:严格使用TS后,我们的代码中的数据结构(包括前后端交互的数据结构和前端某些自己维护的数据结构等)更加清晰。维护的时候能减少工作量。特别是对于二开的人员更加友好。
  • 利于自己提升:随着TS在前端领域越来越的普及,用好TS对我们的职业发展肯定是有利的。并且TS的语法更像其他的强类型语言,对与我们想要去拓展其他语言的时候上手更快。
本文含有隐藏内容,请 开通VIP 后查看