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 modules
的 tree shaking
,这样引入默认就会有按需加载的效果。
管理TS类型声明
在项目中我们各个模块下通常都会有各自不同的数据类型,我们通常会在 src
目录下创建一个单独的目录来统一管理这些文件,一般命名为 typings
、types
或者 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.all
、Array.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
的字面量定义,在这里我们每个 columns
的 align
属性都是 center
, 并且每个 columns
的 dataIndex
和 key
属性的值都是一样的,那么我们一个个去写就有点浪费时间且不好维护,这种情况我们就可以使用类来定义这种数据结构:
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
来举例说说上面的几种情况:
包没有自己的类型声明:这种情况比较少见,但也存在,比如一些很久之前的库,由于某些原因作者已经停止维护了,那么确实可能出现这些情况。我们一般可以有以下的解决方案:
- 去
npm
仓库下的@types
目录下看看有没有相关的包,因为可能有好心人已经对这些库进行了维护,写了相关的的TS声明文件并上传到了这里。比如js-cookie
这个库就没有TS声明文件,我们就可以通过下面的命令来安装:
yarn add @types/js-cookie -D
当然也可能会出现该目录下也没有的情况。这种时候如果我们还需要的话,只能是自己去声明了。
- 去
我们需要显式的声明这些包声明的某些数据类型:比如上面讲到的我们将
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..........
这样子我们在调用这个实例的各种属性方法的时候都会得到对应的类型提示以及类型检查。当然这只是举了一个例子,希望大家能举一反三,在其他组件的使用上面按照这个思路来(其实官方示例也有相关的使用示例,大家跟着示例的代码写就好)。
我们需要扩展包里面的某些类型:有时候我们需要扩展某些第三方库里面声明的类型的时候需要对其做些重写或者扩展的话,也可以使用模块声明来搞定,比如我之前遇到过需要在
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的语法更像其他的强类型语言,对与我们想要去拓展其他语言的时候上手更快。