谈谈写TypeScript实践而来的心得体会
前言
前段时间,我所在的好几个群聊都各自部署了机器人,虽然表现形式有点像“你问我答”,但只要慢慢改造,可玩度还是很高的。经过一番摸索之后,笔者也自己写了一个,但由于当时对TypeScript
并没有很深的理解,担心写出的代码不够优雅或由于某些原因需要重写时所耗费的精力较大,所以便急匆匆的选定了JavaScript
作为本次开发的不二之选,但问题也随之而来
项目背景大概就是上述内容,下面说说我为什么要从JavaScript迁移至TypeScript、如何完成繁重的迁移工作,以及迁移过程中遇到的问题有哪些,并探讨一些TypeScript的特性
有必要使用TypeScript吗
在这里就不再赘述TypeScript
优点几何,而是从两个大概方向进行分析
项目本身
生命周期
对于长期维护的项目是非常有必要的。因为通过智能的vscode代码提示、以及良好的类型编程习惯,后期无论是维护还是扩展项目,上手时的开发体验都要优于同类JavaScript项目
而对于项目足够小,生命周期也足够短,再考虑上开发成本等问题时,这种类型的项目就不如选用JavaScript进行快速开发
业务复杂度
如果模块化、组件化在项目中较为盛行,那我想这个项目本身的业务也是偏向复杂的,由此可推断出该项目维护起来的难度也不会太低。而在面对这种大型项目时,倘若选用TypeScript进行开发,那不管是开发效率还是调试项目的成本问题上,TypeScript总会优于JavaScript
但对于业务简单的项目来说,比如后台管理系统,就算页面的数量再庞大,其实对于项目本身的复杂度也提高不了多少(数据就那么多,产生递增的只会是curd),所以只要额外避免bug数量过多,其实就没有必要选用TypeScript作为开发语言
不能理解为小项目就应该使用JavaScript,而大项目就应该使用TypeScript。如果一个小项目的数据流交互复杂,那么用TypeScript也是足够有必要的
协作关系
多人开发
开发效率往往也与开发人员的数量挂钩,如果团队在开发时需要频繁地沟通,那必然影响效率,而这只是正确的沟通结果,若是由于某个细节出错,那就又要在后期调试中耗费一番功夫。但如果使用TypeScript作为首选语言,出于类型即文档的本能,这样就可以最大程度的减少沟通成本
独自开发
如果是独自负责的项目,那么用不用TypeScript的选择权就把握在了自己手里。但选择时也应该好好比较后再决定(毕竟重构项目仍是较为头疼的问题)。选择时要从大的方面进行比较,比如选择TypeScript是否会增加开发成本、JavaScript是松散类型的语言、使用TypeScript可以获得更智能的代码提示...
有没有必要使用TypeScript,还是要看其实际用途,毕竟不同的场景所需的东西也是不同的
类型编程会导致TypeScript的开发成本变重吗
对于长期使用TypeScript
开发项目的程序员可能会对“类型驱动开发
”这种模式并不感冒,因为在项目初期建立时,我们就要考虑类型如何写、怎么写比较好?换句话说,可能tab.ts
、app.ts
尚未生成之前,interface.ts
在项目中就已经家喻户晓了。而在接下来的开发中,只需要遵循已定义好的结构去书写代码,在调试时便能减少一大堆错误,并且在此期间的开发体验也是极好的
但对于刚上手TypeScript的朋友来说,“类型驱动开发“这种模式简直就是折磨,可能你每写三行代码,就至少会有一行用到了提前定义好的类型,而这种频繁去定义类型的操作,只会根据业务的复杂度去递增。但必须要清楚的是,你在类型层面所投入的成本越多,你在项目中收获的类型安全与智能的代码提示也就越多
如何将JavaScript项目迁移至TypeScript
项目(小秋GitHub)在1.x版本使用JavaScript进行扩展与维护,但迁移至TypeScript后,回想起之前使用JavaScript的开发体验(代码提示、调试成本等)实在不如现在。下面具体说说我是如何完成迁移工作的
配置项
首先保证项目中必须存在tsconfig.json
,并根据自己的需求对该配置文件进行修改。而对于配置项严格模式(strict
)与存在隐式any时是否报错(noImplicitAny
)来说,为了保证代码的安全性,前者应当始终开启
而对于后者来说,如果是迁移项目时对TypeScript还不是特别熟练,可以选择关闭
关闭noImplicitAny后,TypeScript将不检查代码中存在的隐式any,当你一旦发现自己有能力驾驭TypeScript这门语言后,还是很有必要开启此选项的
文件后缀
换句话说,这一步就是在TypeScript与“anyscript”中进行抉择
有了tsconfig.json
文件后,便可以将*.js
文件一个一个修改为*.ts
,而每修改一个*.js
文件,就应当全部解决掉该文件所暴露出来的类型不匹配问题,至于如何解决掉此时的类型不匹配问题,我认为大概可以从以下两点着手进行
any
能够使用type alias
或interface
去解决类型不匹配问题,就绝不使用any
解决,即使最后决定要用any
了,也不推荐使用显式注解any(: any
),而是推荐使用类型断言形式的any(as any
)
由于项目刚迁移至TypeScript时最常出现的问题就是类型不匹配,所以必然会经常使用any
,但同时也要减少对any
的依赖,因为当使用any
时,就相当于告诉TypeScript编译器不要对此进行任何类型检查
推断机制
必须要清楚TypeScript的自动推断机制是怎样的,以及出现类型不匹配的原因是什么。还要注意,能够使用隐式注解就使用隐式注解,不要为了显式注解而做着重复劳动的工作
依赖声明
如果你的项目中用到了第三方库,那么你必须要安装其声明文件
如果某个第三方库没有类型声明文件(*.d.ts
),那么你就得手动去编写该类型声明,其后可以通过三斜线指令(/// <reference ... />
)去引入,或者通过declare
关键字完成类型声明
如果第三库拥有自己的声明文件,那么直接安装即可。比如我在此项目中用到了mysql
,那么可以通过npm i @types/mysql
进行安装
理解TypeScript
TypeScript是结构化类型语言
TypeScript属于结构化类型语言,而不是名义化类型语言。结构化类型是一种编程设计风格,换句话说,结构化只关心对象有哪些属性,而不管属性使用什么名称
可以看到在isMale
函数中,只要传递进来的值符合PersonSex
接口即可,而传递进来的值的名称无所谓,即只看结构,而不看结构名称。因此这也可以称作鸭子类型(即不以貌取人)
类型层级
TypeScript中类型的结构如下图所示
下面介绍各类型之间的层级关系
子类型
子类型:给定两个类型A
和B
,假设B
是A
的子类型,那么在需要A
的地方都可以放心使用B
例如Array
是Object
的子类型、Tuple
是Array
的子类型、所有类型都是any
的子类型、never
是所有类型的子类型
超类型
超类型:给定两个类型A
和B
,假设B
是A
的超类型,那么在需要B
的地方都可以放心使用A
超类型与子类型正好相反
例如Array
是Tuple
的超类型、Object
是Array
的超类型、any
是所有类型的超类型、never
不是任何类型的超类型
型变的四种方式
- 不变,只能是T
- 协变,可以是 <: T
- 逆变,可以是 >: T
- 双变,可以是 <: T 或 >: T
协变与逆变则可以理解为:
- A <: B 指 “A类型是B类型的子类型,或者为同种类型”
- A >: B 指 “A类型是B类型的超类型,或者为同种类型”
A类型是否可赋值给B类型
TypeScript在判断A
类型是否可赋值给B
类型时,对于非枚举类型来说,只要满足以下两者之一即可
- A <: B
- A是any
类型检查
JavaScript属于动态绑定类型,因此会在运行时检查数据类型,以及自动转换数据类型;而TypeScript则不同,它属于渐进编译式静态类型,可以理解为在编辑器中书写代码时就已经绑定好了类型(若类型错误,则立即出现一条红色波浪线),这也就是平常所说的类型只在类型检查这一步进行,而不是在编译时对类型进行检查
类型检查时应该注意,类型检查器是根据使用的类型和具体的用法来给出判断,例如
因为counts
被自动推导为number[]
,所以counts
中的所有元素,TS均认为是number
类型,所以filter
函数的形参为number
类型,而num * '1'
相当于number * string
,显然,这在TypeScript中是不合法的
显式注解与自动推导
不同的语言有不同的类型系统,因此类型系统一般可分为两种:显式类型及自动推导,而TypeScript两种类型系统全部具备
不过在多数情况下来说,最好让TypeScript自动推导,而非使用显式注解
类型字面量
TypeScript中除去number
、string
等其它类型外,还存在一种特殊的类型,即范围最窄的类型,例如1
、'1'
、true
这种类型都是字面量类型(不是值,是类型)
值得注意的是,使用字面量的形式,一旦被赋值就无法再次修改了,因为字面量类型属于范围最窄的类型
并集类型的注意点
并集的概念为:一个并集类型(|)的值不一定属于并集中的某一个成员,还可以同时属于每个成员。如果并集不相交,那么值只能属于并集类型中的某个成员,不能同时属于每个成员
虽然概念与其实际用法相对简单,但要额外注意并集与类型字面量的互相结合,要注意区分是不是字面量类型
上图中,person
对象的类型为Person
,而Person
的类型为ZhangSan
与LiSi
的并集类型,而ZhangSan
与LiSi
均是字面量类型,虽然person.name
的类型为张三
,这也就随之符合了类型ZhangSan
的name
部分,但person.age
的类型5
却不能够分配给类型ZhangSan
的age
部分,因为不能将字面量类型5
分配给字面量类型3
,所以可做如下修改
或者干脆将ZhangSan
与LiSi
修改为string
和number
类型
any/unknow
any
代表任意类型,即Top Type层级;而unknown
代表未知类型,也属于Top Type层级。但unknown
相对于any
来说是比较安全的,因为在执行操作时,不能假定unknown
类型的值为某种类型的值
这时TypeScript会提示我们count
为unknown
类型,因为unknown
类型不能够进行任何操作,所以count + 1
便抛出了错误,此时只需向TypeScript证明某个值是某个类型即可,例如
这种做法借助TypeScript基于流的推导这一特性,由此来向TypeScript证明,在if
块的范围之内,count
始终为number
类型,而if
块之外,依旧为unknown
类型,如果觉得这种判断方式较为复杂,则可以使用is
关键字(类型防护)对其进行简单封装
但是要注意,这种方式会产生额外的JavaScript代码,即3-5行(isNumber
函数)也会被一同编译!
如果想表达一个未知值,更合理的方式就是使用unknown
void/undefined
在JavaScript中,以下两个函数的返回值相同
const bar = function(){}
const bar = function(){
return undefined
}
而在TypeScript中,函数返回值为void
时,表示函数没有返回值,如果函数返回值为undefiend
,则代表函数存在返回值,只是返回值为undefiend
TypeScript会认为undefined是实际且有存在意义的类型值,而void则是无意义的类型值
this
this
不仅可以用做值,也可以用作类型
class Set {
add() {
// 返回一个this实例
return this
}
}
对于链式api来说,这是一个特别便利的特性
object类型
object
只能表示该值是一个JavaScript对象(而且不为null),这个JavaScript对象指的是广义的对象(即万物皆对象),例如object
、function
、array
、number
等
{}类型
{}
类型,除null
和undefined
之外的任何类型都可以赋值给空对象类型,使用起来比较复杂,所以应尽量避免使用空对象类型
Object与{}的区别
使用{}
时,可以把Object
原型内置的方法(例如toString
和hasOwnProperty
)定义为任何类型,而Object
则要求声明的类型必须可赋值给Object
原型内置的类型。例如
obj
之所以报错是因为obj
指定的类型为Object
,而Object.toString
方法的返回值必须为string
类型,但obj.toString
方法却返回了number
类型,所以TypeScript才会抛出一条红色波浪线
枚举
在日常开发中,由于种种原因,我们可能会用到constant.ts
,比如
// ./phone/constant.ts
const apple = 'apple'
const xiaomi = 'xiaomi'
export {
apple,
xiaomi
}
使用这种形式无非就是为了在代码书写时更加方便且安全,但频繁的定义constant.ts
文件似乎也较为不妥,而枚举的出现也正是为了解决这一问题
枚举(enum
)是一种无序数据结构,用来把键映射到值上(可以理解为用来列举类型中包含的各个值)
枚举可以显式指定值,也可以交由TypeScript自行推导
// 显式指定值
enum Phone {
Apple = 'apple',
Xiaomi = 'xiaomi',
Oppo = 'Oppo'
}
console.log(Phone['Apple'])
当为枚举显式指定值时,访问某个键其实就是访问的该键所提前指定好的值。而如果是TS自行推导值,则会稍有变化
可以看到Price.Apple
被推导为了0
,并开始以此类推,Price.Xiaomi
是 1
, Price.Oppo
是2
这种现象你可以理解为类似于数组下标的机制
那如果有的键显式指定值,有的键不显示指定值呢?
可以看到手动为Apple
、Xiaomi
指定了值,所以值不变。而Oppo
则是由TypeScript自动推导为了4700
,因为Xiaomi
为4699
。所以最好为枚举中的每个成员显式赋值
此外,枚举还支持反向查找。这里可以理解为根据value
找出key
通过上面的描述,你可能会觉得使用enum
是极容易出现问题的,为此可以使用更加安全的枚举,即const enum
,注意,const enum
是enum
的子集
const enum
不允许反向查找,例如
除此之外,enum
与const enum
生成后的JavaScript代码也并不相同,使用const enum
生成的JavaScript代码更像constant.js
无论是enum
还是const enum
,在使用时仍然会遇到安全性问题,为此应当避免使用枚举,同样的意图在TypeScript中有着大量更好的表达
TypeScript一直在努力修复由枚举所带来的安全性问题,但是否真的需要使用枚举,笔者觉得还是要看自己需求,以及目前所处的TypeScript是哪个版本
重载函数
重载函数,其实就是有多个调用签名的函数。简单来说,即调用一个函数的多种形式,之所以可以对函数进行重载,得益于TypeScript的静态类型系统
比如将数组的Filter
函数以重载的形式表达出来
type Filter = {
(arr: number[], f: (item: number) => boolean) : number[], // (A)
(arr: string[], f: (item: string) => boolean) : string[],
(arr: boolean[], f: (item: boolean) => boolean) : boolean[],
}
类型Filter
以A
举例来说,它的第一个形参arr
需为number
类型的数组,第二个参数f
为回调函数,该回调函数的形参item
为number
类型,且返回值为boolean
类型,而整个Filter
函数的返回值为number
类型的数组
同样的需求仍然可以使用泛型进行表达
类型Filter
的第一个形参为T
类型的数组,第二个形参为回调函数,该回调函数的形参item
为T
类型,且该回调函数返回值为boolean
类型。随后Filter
函数返回T
类型的数组
下面来看newArr
与item
的类型
由于传入_fliter
函数的数组是number
类型,所以形参item
是number
类型。而_fliter
函数的返回值也为number
类型的数组
泛型作用域
如果把TypeScript比作一个函数,那么泛型就是它的形参
声明泛型的位置不仅限定泛型的作用域,还决定TypeScript什么时候为泛型绑定具体的类型
下面的Filter
写法,在使用Filter
类型时就必须传入T
类型
type Filter<T> = {
(arr: T[]): T[]
}
而下面这种写法,只需要在调用函数时传入泛型T
即可
type Filter = {
<T>(arr: T[]): T[]
}
条件类型与infer
条件类型所想表达的意思其实与三目一样,例如
type A<T> = T extends unknown ? T[] : never
代表如果A <: unknown
,则返回T[]
,否则返回never
类型
而在条件类型中声明泛型时,不使用尖括号语法(<T>
),而是使用infer
语法,即 infer T
,例如
type A<T> = T extends (infer U) ? U : T
条件类型与infer常常结合使用,例如将对象中的键由下划线命名法
转换为驼峰命名法
type alias与interface
作用域
let
与const
拥有自己的块级作用域,而类型别名(type alias
)也不例外,在作用域链中,内部的类型别名将覆盖外部同名称的类型别名
两者区别
类型别名与接口的关系可以看成函数表达式与函数声明之间的关系,除去一些细微差别之外,作用基本相同
类型别名更为通用
类型别名可以接受任何类型,而接口则只能接受结构
// 无法使用接口重写以下类型
type Count = number
type Counts = Count | 2
类型扩展
interface
可以使用extends
或implements
关键字实现接口的扩展
而type alias
使用&
或|
等类型运算符来实现扩展
但是在扩展接口时,interface
会检查扩展的接口是否可赋值给被扩展的接口。换句话说,两个同名接口之间不能发生冲突
声明合并
同一作用域中的多个同名类型别名将导致编译时错误
同一作用域中的多个同名接口将自动合并
除此之外,利用接口的合并特性,还可以安全的扩展原型
// 为数组增加一个total方法
interface Array {
total<T>(item: T[]): T[]
}
类型缩窄与类型拓宽
let与const关键字推导时的区别
var
、let
在推导时会尽量将类型拓宽,下图中可以看到let
会将1
推导为number
类型
而const
在推导时会尽量将类型缩窄,即把值1
推导为了类型字面量1
但如果const
关键字所对应的是一个对象,例如
const person = {
name: '鲨鱼辣椒',
age: 5
}
通过编辑器可以观察到,此时对象中的类型并不会进行类型缩窄,而是会与let
的特性保持一致,即类型拓宽。person.name
被推导为了string
类型,person.age
被推导为了number
类型,这是因为JavaScript对象总是可变的,所以在TypeScript看来,创建对象之后你可能会更新对象的字段,所以TypeScript对这些字段的值进行了类型拓宽,但如果必须要缩窄类型,应该怎么办呢?
此时可以手动将其断言为const
类型
通过上述示例可以发现const推断出的是字面量,而var/let推断出的则是字面量的基本类型
null/undefined
使用let
关键字初始化为null
或undefiend
的变量将自动拓宽为any
类型
而使用const
关键字则不会拓宽为any
类型
适当缩窄
当类型拓宽的变量离开声明时所在的作用域后,TypeScript将为其分配一个具体类型
function test() {
const arr = [] // any[]
arr.push(1) // number[]
arr.push('a') // string[]
return arr // (number|string)[]
}
const result = test()
result.push(true) // 报错,不能将boolean分配给number或string类型
const类型
这里所说的是const
类型,而不是定义变量的const
关键字。在类型声明时使用const
类型可以禁止类型拓宽
const
类型不仅可以阻止类型拓宽,还可以把对象中的成员递归设为readonly
(不管数据结构的嵌套层级有多深)
tuple
在定义如下语句时,TypeScript会对arr
中的成员进行类型拓宽
面对这种情况时,可以使用as const
来禁止类型拓宽
const arr = [1, '2', true] as const
但也可使用如下方法
此时传入tuple
函数时,TypeScript会认为传入的是字面量,而不是字面量的基本类型
至于为什么会对类型进行拓宽处理,上一小节“let与const关键字推导时的区别”已经讲过
声明空间
类型声明空间与变量声明空间
在TypeScript中存在两种类型的声明空间,即类型声明空间与变量声明空间。类型是类型,变量是变量,两者不能够混用
type Num = 1
const num = Num // 错误
const obj = {}
type Obj = obj // 错误
因为是两个声明空间,所以变量名与类型名完全可以冲突
const obj = {}
type Obj = {}
全局作用域
默认情况下,在一个新的TypeScript文件中书写代码时,它处于全局命名空间中
// a.ts
const foo = 1
而当处于全局命名空间时,很容易发生命名冲突等问题,因此可以使用export
解决
// a.ts
const foo = 1
export { foo }
此时整个a.ts
就是一个本地作用域。可以理解为export
会在当前文件中创建一个本地作用域
导入值与类型
使用import
导入时,可以通过type
关键字区分导入的是值还是类型
// a为值(变量声明空间)
import { a } from './index.ts'
// b为类型(类型声明空间)
import type { b } from './index.ts'
何时为对象增加值
通常会有这么一种需求,就是先创建一个空对象,但并不立即对它进行赋值,而是在程序运行的某个时刻再为其赋值,下面来演示这种情况
之所以person.name
与person.age
会抛出错误,是因为person
被推导为了空对象,而往空对象中增添新键,这在TypeScript看来是不合法的,那应该如何解决呢?
最好的解决方案
最好的解决方案无疑是在初始化时,立即为对象进行赋值,但这往往不够灵活
const person = {
name: '鲨鱼辣椒',
age: 4
}
快速解决方案
如果项目周期短,想要寻求快速解决方案,那么使用any
也未尝不可,不过这样也就丧失了TypeScript所提供的类型安全以及智能的代码提示
const person = {} as any
person.name = '鲨鱼辣椒'
person.age = 4
折中的解决方案
中庸之道就是提前定义好类型,这样既达到了灵活性,又能够保证类型安全
interface Person {
name?: string,
age?: number
}
const person: Person = {}
person.name = '鲨鱼辣椒'
person.age = 4
这种方案也是比较常见的解决方案,比如可以将后端返回的信息规定为一个接口,而在使用时,只需要遵循该接口对返回的数据进行操作即可
interface UserInfo {
code: number,
status: string,
data: any
}
或者你可以将某一处所用到的所有类型,都集中在当下的interface.ts中
文末
文末至此,该项目在迁移至TypeScript时所遇到的问题和解决方案大都已呈现在上方,其它特性以及TS类型体操并没有花太多笔墨进行着重描述,大概是因为没有在项目中深度使用的原因。并且笔者对TypeScript本身的理解是类型即文档,而使用TypeScript的很大一部分原因也正是想获得更多的类型安全以及智能的代码提示,以此来大幅提升开发体验。对于类型体操来说,我个人认为实际项目中可能并不会有太多地方需要秀一下TS类型体操,仅此而已
由于水平有限,文中错误之处在所难免,敬请读者指正。若喜欢本文章,则可一键三连,感谢!
转载自:https://juejin.cn/post/7161198580202471432