总结TypeScript在项目开发中的应用实践体会
作者:@wangly19,本文已授权掘金开发者社区公众号独家使用,包括但不限于编辑、标注原创等权益。
前言
从2020年
年底的时候,我开始使用Typescript
进行项目的开发。期间团队也开始转向Typescript
。
在这期间,做过很多尝试,也阅读过一些优质的文章和源码。现如今,大多数开源项目都将Typescript
做为开发的主力军。
在这期间,我查阅的大多数文章都是在进行一个Typescript
的基础使用,开发实践这一块更是少之又少,少有的一些干货文啃起来也非常的不过瘾。
相信在读的各位收藏夹里面已经有很多份学习Typescript
的小文章都在吃灰,看了一遍但到了项目中依旧无从下手,该如何去进行开发?
独乐乐不如众乐乐,本篇文章就从开发的角度来聊聊,探讨下Typescript
在真实项目中开发的实践心得和开发体验。
当你看完文章时,我建议先思考团队是否需要Typescript
。以及Typescript
是否可以解决当前项目生产的困境。
如果对于为什么使用TypeScript
产生疑惑,那么可以移步你为什么不使用 TypeScript?,它是一个非常棒的讨论话题。
必知必会的特性
在TypeScript
中,有一些好用的特性
和功能
对于日常开发来说是比较常见的。下面就罗列一些较为实用的知识点作为一个小小的备忘录。
Readonly
有了Readonly
,可以声明更加严谨的可读属性,亦或者变量。
在ES6
当中,可以通过const
进行常量量声明,切声明后不可修改,如果进行修改的话会直接Cannot assign to 'a' because it is a constant.
进行异常抛错。
虽然不能更改整个值,但是如果值是一个引用类型的话,依旧可以对其内部的属性进行修改。那么从只读
的概念上来说,显然不具备当前的能力。
而使用Typescript
当中的readonly
关键字对属性或者是变量进行声明,那么将会在编译时就发出告警。那么在声明部分
条件类型(Conditional Type)
如果你不知道条件类型,那么来看一段@vue/reactivity
中的代码吧。
export type DeepReadonly<T> = T extends Builtin
? T
: T extends Map<infer K, infer V>
? ReadonlyMap<DeepReadonly<K>, DeepReadonly<V>>
: T extends ReadonlyMap<infer K, infer V>
? ReadonlyMap<DeepReadonly<K>, DeepReadonly<V>>
: T extends WeakMap<infer K, infer V>
......
其中DeepReadonly
通过extends
的方式继承父类然后通过? :
表达式来进行一个类型三目运算符
的操作进行一个类型的条件判断。
通过一个简单的案例来进行理解,当泛型T
为string
类型的时候,那么B
为1
,反之为2
。可以看到同样的一个类型,因为传入的泛型T
不一样,结果自然而然的有了出入。
namespace
命名空间(namespace)
是一个比较常见的东西,它常用于组织一份类型区域防止类型之间的重命名冲突,需要配置 declare
输出到外部环境才能够使用,非常便捷的在于使用declare namespace
在工程项目中可以不需要引入任何类型而直接可以访问。
declare
declare
是用于声明形式存在的。
declare var/let/const
用来声明全局的变量。declare function
用来声明全局方法(函数)declare class
用来声明全局类declare namespace
用来声明命名空间declare module
用来声明模块...
在这里需要注意的是Global augmentations have the same behavior and limits as module augmentations.
Declaration Merging
什么意思呢?
image.png大体上翻译成大白话就是:
declare
与declare global
它们功能是一样的。在d.ts
中,使用declare
与declare global
两个作用是相等的。
因此,在d.ts
进行declare
,它默认是全局的,在使用declare global
显得有点画蛇添足了。
那么什么时候使用declare
, 又什么时候使用declare global
?
在模块文件中定义
declare
,如果想要用作全局就可以使用declare global
完成该需求。
那么,可以来看个🌰栗子,看完之后就大体上懂了,都是一些比较常见的实例。
- 在使用
TypeScript
开发的时候想为一些API
添加一些自定义的属性,或者进行一些覆盖。 - 在使用
vue
的时候,通过import
引入的vue
组件大多会提示错误。
如何解决?
可以通过对模块的定义来进行.vue
文件模块进行一个declare module
在内部可以将其export
为相关类型。在这里vue2
和vue3
不太一样。
declare module '*.vue' {
///
export ...
}
模块类型
在渐进式的过程中,很多代码和包都可能没有对应的.d.ts
。因此需要对部分文件进行.d.ts
的类型文件编写,那么,你真的知道ES Module
和 CommonJS Module
之间的导入吗?
ES Module
的引入方式大家都知道,但是如何对其声明.d.ts
,就看下面这个用例。
我对config/index.js
创建了一个index.d.ts
作为其声明文件,并且导出了config
对象。那么,我如何将类型提供给引入方呢?
首先,知道index.js
导出是一个对象,那么declare const
一个类型出来,然后通过export = config
的形式对导出进行声明。那么在通过import { xxx } from '@/config
就可以获悉具体的类型了。
declare const config: BaseConfig & EnvConfig
export = config
如图,baseApi
拥有了HTTP
| HTTPS
的类型。
模板字符串类型
模板字符串是一个非常有意思的东西,它能够对文本进行一定程度上的约束,如上面baseApi
在项目中被定义为了HTTP | HTTPS
的类型。约定当前值中必须包含http://
或者是https://
才算校验成功。
// global.d.ts
declare type HTTP = `http://${string}`
declare type HTTPS = `https://${string}`
// @/config/index.d.ts
type baseApi = HTTP | HTTPS
同样的,在使用dva
中,也可以利用特性对type
进行namespace
和action
的组合,这样在写dispatch
时,可以有一定的提示和约束能力。
想看更多实践可以看ssh
的TypeScript 4.1 新特性:字符串模板类型,Vuex 终于有救了?这篇文章深入一下。
函数重载
函数重载
是一个非常常用的特性,它大多数用于多态函数。大多数同学可能都不怎么使用。但是它能够定义不同的参数类型。需要有多个重载签名
和一个实现签名
。
重载签名
:就是对参数形式的不同书写,可以定义多种模式。实现签名
:对函数内部方法的具体实现。
getter/setter
get/set存取器
是在class
当中比较实用的一个功能,它保证了类中变量的私有化。在外部时时不能直接对其更改的,如果大家了解javaBean
的话理解起来并不是很困难。
在class
中声明一个带_
下标的变量,那么就可以通过get
, set
对其进行设置值。
在实例中当我们调用.name
的时候,其实本身就是调用了其get
的方式,而设置值时,则是调用set
方法,
image.png需要注意的是,
._name
值也输出了,但是TypeScript
会进行提示你._name是私有的
不允许你访问。
枚举
对于typescript
思想来说,(enum
)是对代码具有侵入式的,它的实现方式可以看其编译成javascript
后的代码。
枚举(enum
)的使用场景在于可以定义部分行为和状态。通过一个🌰可以来看下:
对其某个任务的行为定义在枚举当中,这样做可以进行一些状态复用,避免在页面写太多status === 1
的代码,因为没人知道1
代表什么,有什么含义,不利于维护。
将其定义成enum
的标注用于标识状态,如:status === Status.START
。
枚举可以看一篇阿宝哥的小文章一文让你彻底掌握 TS 枚举
泛型
image.png泛型是TypeScript
当中必知必会的一个属性,在很多的时候,类型推导在开始时很难进行推倒。相比于使用 any 类型,使用泛型来创建可复用的组件要更好,因为泛型会保留参数类型。
泛型
很多时候作用于对一个类型的多种形态定义,能够非常灵活的对一个类型进行定义和延伸推导。
那么,来看几个比较简单的实例
简单的泛型
type Generics<T> = {
name: string
age: number
sex: T
}
interface Generics<T> {
name: string
age: number
sex: T
}
image.png简单的函数泛型
function setSex<T> (sex: T) {
}
setSex<'男'>('女')
image.png泛型类
class Person<T> {
private sex: T;
constructor(readonly type: T) {
this.sex = type;
}
}
const person = new Person<'男'>('女')
image.png对于
泛型
的实践来说,使用是需要一定理解,复杂的泛型
使用会非常的复杂。
工具类型
TypeScript
当中也提供了一些非常好用的工具类型,能够配合我们更好的使用工具类型。
Readonly & Partial
Readonly
可以将类型转换为只读对象,使用方式是Readonly<T>
。
一个实栗立即了解:
interface Person{
name: string;
}
type Person2 = Readonly<Person>;
const a: Person2 = {
name: 'wangly19'
}
const b: Person = {
name: 'wangly19'
}
a.name = 'wangly19 new'
b.name = 'wangly19 new'
image.pngRecord
Record
能够快速创建对象类型。它的使用方式是Record<K, V>
,能够快速的为object
创建统一的key
和value
类型。
Pick & Omit
Pick
:主要作用是从一组属性中拿出某个属性,并将其返回,那么先来看一下实例。
Pick
的使用方法是Pick<P, K>
,如(P)类型中拥有name
,age
,desc
三个属性,那么K
为 name
则最终将取到只有name
的属性,其他的将会被排出。
Omit
:主要作用是从一组属性中排除某个属性,并将排除属性后的结果返回。
Omit
的使用方法是Omit<P, K>
,与Pick
的结果是相反的,如果说Pick
是取出,那么Omit
则是过滤的效果,具体可以看下面的案例。
Exclude & Extract
Exclude
:从一个联合类型中排除掉属于另一个联合类型的子集
来看下,Exclude
使用形式是Exclude<T, S>
,如果T
中的属性在S
不存在那么就会返回。
interface A {
show: boolean,
hidden: boolean,
status: string
}
interface B {
show: boolean,
name: string
}
type outPut = Exclude<keyof A, keyof B>
image.pngExtract
:跟Exclude相反,从从一个联合类型中取出属于另一个联合类型的子集
举一反三,如果Exclude
是取差集,那么Extract
就是取交集。会返回两个联合类型
中相同的部分。
interface A {
show: boolean,
hidden: boolean,
status: string
}
interface B {
show: boolean,
name: string
}
type outPut = Extract<keyof A, keyof B>
image.pngPartial
Partial
是一个将类型转为可选类型的工具,对于不明确的类型来说,需要将所有的属性转化为可选的?.
形式,转换成为可选的属性类型。
其他
TypeScript
的工具类型有很多,不只是官方提供,在日常实践中,也会定义非常多的工具类型。那么在了解工具类型的同时,更多的是知晓这些工具类型是如何来的,怎么实现。
相信我,当你弄懂后,你对于使用Typescript
会有一个新的认识,写起来会更加的得心应手。
实践场景
看完了太多的理论东西,那么来看看在日常实践中是如何真实实践一把呢?
Dva的实践
如果使用过Dva
开发的朋友可能知道,dispatch
的类型提示非常的艰难,因此,在开发的时候重新定义了Dispatch
的类型,用来做一些主动的类型提示。
对于Modal
的类型作为一些基本定义,然后对DvaDispatch
进行部分的注入和推导。
type
拥有modal
中namespace
和effects & reducers
的类型推导。Promise
返回值的主动暴露。
- 如何使用?
// 方案一
const dispatch: DvaDispatch<DeskTopModel> = useDispatch();
dispatch<null>({
...
})
// 方案二
dispatch<DeskTopModel, null>({
...
})
那么DeskTopModel
是什么呢?
没错,就是model
的类型声明,在其中,对每一项effects
和reducers
都进行详细的定义,根据这些信息就可以推导出当前type
的类型了。
export interface DeskTopModel {
namespace: 'desktop',
state: DeskTopModelState,
effects: {
getTableSourceData: Effect
},
reducers: {
saveTableData: Reducer<DeskTopModelState>
}
}
对于Dva
来说,很多时候都需要在Effect
后做某事,这个时候有两个方式,一是callback
,另外一个就是Promise
回调。
而通过Promise
方式,进行返回值的推导可以让使用dispatch
拥有返回类型的能力。
Dva Dispatch
/**
* ActionType, 推导当前effect & reducer
* @default string
*/
type ActionType<M extends Model | string> = M extends Model ?
`${M['namespace']}/${(keyof M['effects'] | keyof M['reducers']) & string}` :
string
/**
* dvaDispatch新增类型
* @example
* dispatch: DvaDispatch<Store>
*/
export type DvaDispatch<S = void> = <T = undefined, R = undefined>(action: {
type: ActionType<
S extends Model
? S
: T extends Model
? T
: string>,
payload?: any
loading?: boolean,
toast?: Taro.showToast.Option
[key: string]: any
}) =>
T extends Model
? R extends undefined
? undefined
: Promise<R>
: T extends undefined
? undefined
: Promise<T>
Service Response 实践
Service Response
是什么?
在于后端通信时,会返回很多的数据,那么在使用TypeScript
的时候怎么去定义这些类型呢?又怎么在团队协作中进行合作呢?
在大部分实验当中,我们是这样做的。
创建API命名空间
绝大多数数据,都是存放在API
的命名空间当中。它的目录如下:
-- index.d.ts
-- api1.d.ts
-- api2.d.ts
-- api3.d.ts
...
团队协作当中,index.d.ts
多数为公共类型。而其他文件中的则是模块类型。举个例子,Request
的返回类型。
declare namespace API {
type commonResult<T = any> = {
data: T,
code: string,
showMessage: false | {
method: 'message' | 'notification',
type: 'success' | 'error' | 'info' | 'warning',
message: string,
description?: string
}
}
}
而对应请求方案配置则对应相应的api
文件。
如home.ts
声明了配置转请求函数
的方式。
// #home.ts
module.exports = {
getVisualizationListApi: 'GET /service-admin/v1/visualization/table/list'
addVisualizationItemApi: 'GET /service-admin/v1/visualization/table/add'
}
然后对应的在types
下声明一个新的.d.ts
类型声明文件。比如:home.ts
对应home.d.ts
。
那么在多人协作下,每个人负责的模块本身来说都不会冲突。在项目迭代管理中,大多数都是一个人对应一个小模块的开发节奏,彼此不会有太大的重复。
// #home.d.ts
declare namespace API {
type VisualizationListResponse = {}
type VisualizationActionResponse = {}
}
所有的
declare namespace API
都会合并。在namespace
之间依旧可以使用API.xx(其他模块的type)来结合声明类型。
如何使用?
在进行namespace
的声明定义后,可以在需要使用的地方,无需任何引入直接访问API
,然后通过API.VisualizationListResponse
就可以访问到定义的VisualizationListResponse
类型。
资源
- 你为什么不使用 TypeScript?
- Declaration Merging
- TypeScript 4.1 新特性:字符串模板类型,Vuex 终于有救了?
- 一文让你彻底掌握 TS 枚举
- TypeScript 高级用法
- 一文读懂 TypeScript 泛型及应用( 7.8K字)
- 在线Typescript,Playground
- utility-types
如何深入学习TypeScript?
当了解TypeScript
后,想学习进阶的使用方式,可以看看一些类型库的源码,这些源码内很多TypeScript
的操作都能够在其中看到。
比较好的如:utility-types
, 里面有一些实用的基本类型,可以对源码进行阅读,阅读难度不大,多动手实践下就会对类型有一个更加清晰的明确。
总结
TypeScript
是一把双刃剑,对开发者来说具有一定门槛,在使用不当的时候,其实对于项目来说会变得更加的复杂,可读性并没有过多的提升。
根据自身团队的实际情况,慢慢推动TypeScript
的基建,保持当前生态体系下的框架和库对TypeScript
的支持度良好的情况下逐步替换到TypeScript
是一个不错的选择。
打个比方:如果你现在使用的是vue2
,那么不妨可以考虑下,用TypeScript
写组件真的好吗?
TypeScript
上手需要一定的学习的学习成本和任务负担,并不是说会javaScript
就会TypeScript
,其中OOP
的思想来说,对团队成员其实是有一定的影响的。尤其是在敏捷项目开发下,影响还是蛮大的。
因此,如果项目迭代本身高频快,那么在估量开发需求时,质量和效率很明显并不能兼得之。可以慢慢的进行推动。
TypeScript
不会防止屎山的出现,也没有大多数人传言中的那么香。只是很多吹捧的人会把屎山说香。它只是一个类型系统,并没有传的那么神乎其神,能做的只是杜绝了很多奇技淫巧
,让代码可以在一个较为正常的环境下进行开发。
如何推动?
- 进行
TypeScript
的分享,帮助团队成员加深对TypeScript
理解。 - 使用
TypeScript
进行公共组件
和方法
的书写和切换。 - 对目前使用的框架和库进行
TypeScript
最佳实践。 - 对
TypeScript
进行基础类型的定义,方便团队成员使用。 - 关注
TypeScript
新动向,了解新特性。