写在最前:本文转自掘金
1. 前言
- 我们都知,
JavaScript
是一门非常灵活的编程语言,这种灵活也使他的代码质量参差不齐,维护成本高,运行时错误多。 -
TypeScript
是添加了类型系统的JavaScript
,适用于任何规模的项目,TS的类型系统很大程度上弥补了JS的缺点。 - 类型系统按照[类型检查的时机]来分类,可以分为动态类型和静态类型:
· 动态类型是指运行时才会进行类型检查,这种语言的类型错误往往会导致运行时的错误,JS属于动态类型,它是一门解释型语言,没有编译阶段。
· 静态类型是只编译阶段就能确定每个变量的类型,这种语言的类型错误往往会导致语法错误。由于TS在运行前需要先编译成JS,而在编译阶段就会进行类型检查,所以TS属于静态类型。 - TS增强了编辑器的功能,包括代码补全、接口提示、跳转到定义、代码重构等,这在很大程度上提高了开发效率。TS的类型系统可以为大型项目带来更高的可维护性,以及更少的bug。
- 为了提升开发幸福感,下面将详细介绍如何在项目中用好TS。
2. 在项目中的实践
2.1 善用类型注释
- 我们可以通过
/** */
形式的注释给TS类型做标记提示:
/** person information**/
interface User{
name: string;
age: number;
sex: 'male' | 'female' ;
}
const p:User = {
name: "Lucky",
age:20,
sex:"female"
}
当鼠标悬浮在使用到该类型的地方时,编辑器会有更好的提示:
2.2 善用类型扩展
- TS 中定义类型有两种方式:接口(
interface
)和类型别名(type alias
)。在下面的例子中,除了语法不一样,定义的类型是一样的:
// interface
interface Point{
x: number;
y: number;
}
interface SetPoint{
(x: number, y: number): void;
}
// type
type Point = {
x: number;
y: number;
}
type SetPoint = (x: number, y: number)=> void;
- 接口和类型别名均可以扩展:
// Interface extends interface
interface PartialPointX{
x: number;
}
interface Point extends PartialPointX{
y: number;
}
// Type alias extends type alias
type PartialPointX={
x:number;
}
type Point = PartialPointX & {y:number;};
- 接口和类型别名并不互斥的,也就是说,接口可以扩展类型别名,类型别名也可以扩展接口:
// Interface extends type alias
type PartialPointX={
x: number;
}
interface Point extends PartialPointX{
y: number;
}
// Type alias extends interface
interface PartialPointX{
x:number;
}
type Point = PartialPointX & {y:number;};
- 接口和类型别名的选用时机
· 在定义公共API(如编辑一个库)时使用interface
,这样可以方便使用者继承接口;
· 在定义组件属性(Props
)和状态(State
)时,建议使用type
,因为type
的约束性更强;
·type
类型不能二次编辑,而interface
可以随时扩展。
2.3 善用声明文件
- 声明文件必须以
.d.ts
为后缀。一般来说,TS会解析项目中所有的*.ts
文件,因此也包含以.d.ts
结尾的声明文件。 - 只要
ts.config.json
中的配置包含了typing.d.ts
文件,那么其他的所有*.ts
文件就都可以获得声明文件的类型定义。
2.3.1 第三方声明文件
- 当在TS项目中使用第三方库时,我们需要引用它的声明文件,才能获得对应的代码补全、接口提示等功能。
- 针对多数第三方库,社区已经帮我们定义好了它们的声明文件,我们可以直接下载下来使用。一般推荐使用
@types
统一管理第三方库的声明文件,@types
的使用非常简单,直接用npm
或yarn
安装对应的声明模块即可。以lodash
为例:
npm install @types/lodash --save-dev
// or
yarn add @types/lodash --dev
2.3.2 自定义声明文件
- 当一个库没有提供声明文件,就需要我们自己写声明文件,以
antd-dayjs-webpack-plugin
为例,当在config.ts
中使用antd-dayjs-webpack-plugin
时,若当编辑器没有找到它的声明文件,则会发生报错 - 为了解决编辑器的报错提示,我们可以采用它提供的另一种方法:添加一个包含
declare module 'antd-dayjs-webpack-plugin'
; 的新声明文件。我们也可以不用新增文件,在前面提到的typing.d.ts
添加下面的内容即可:
declare module 'antd-dayjs-webpack-plugin';
全局变量
当我们需要在多个ts文件中使用同一个typescript
类型时,常见做法会在constant.ts
文件中声明相关类型,并将其export
出去给其他ts文件import
使用,无疑会产生很多繁琐的代码。前面我们提到,只要在tsconfig.json
中配置包含了我们自定义的声明文件*.d.ts
,则声明文件中的类型都能被项目中.ts
文件获取到。因此我们可以将多个ts文件都需要使用的全局类型卸载声明文件中,需要使用该类型的ts文件不需要import就可以直接使用。
命名空间
在代码量较大的情况下,为了避免各种变量名冲突,可将相同模块的函数、类、接口等放置在命名空间内。
在ts文件使用:
// src/views/Domain/index.ts
const cloumns: Domains.ListItem[] = []
...
// src/views/Department/index.ts
const columns: Departments.ListItem[] = []
2.4 善用 TypeScript 支持的JS新特性
2.4.1 可选链
let age = user && user.info && user.info.getAge // 写法冗余且容易命中 `Uncaught TypeError: Cannot read property ...`
let age = user?.info?.getAge //如果其中有属性不存在,会返回`null`或者`undefined`
2.4.2 空值合并运算符
当左侧的操作数为null
或者undefined
时,返回其右侧操作数,否则返回左侧操作数。
const user = {
level: 0,
}
let level1 = user.level ?? '暂无等级' // 0
let level2 = user.other_level ?? ''暂无等级' // 暂无等级
与||
不同,或操作符为false
值(例如,' '
或0
)时返回右侧操作数
2.5 善用访问限定
TS的类定义时允许使用private
、protected
、public
三种访问修饰符声明成员访问限制,并在编译期间检查,如果不加任何修饰符,默认为public
访问级别:
class Person {
private name: string;
private age: number;
// static 关键字可以将类里面的属性和方法定义为类的静态属性和方法
public static sex: string = 'Male';
constructor(name: string, age: number){
this.name = name;
this.age = age;
}
public run(): void {
console.log(this.name + '在跑步')
}
public setName(name:string): void {
this.name = name;
}
}
let p: Person = new Person('Tony', 22);
console.log(Person.sex); // Male
p.run(); //Tony在跑步
console.log(p.name) // name为私有属性
2.6 善用类型收窄
TypeScript 类型收窄就是从宽类型转换成窄类型的过程,其常用于处理联合类型变量的场景。主要有以下方法收窄变量类型:
- 类型断言
- 类型守卫
- 双重断言
2.6.1 类型断言
类型断言可以明确地告诉TS值的详细类型。其语法如下:
值 as 类型
// or
<类型>值
在 tsx 语法中必须使用前者。当TS不确定一个联合类型的变量到底是哪个类型的时候,我们只能访问此联合类型的所有类型中共有的属性和方法。
如何不完整的去实现接口结构
例如,有一下接口,并被obj
变量实现,但赋值空对象会报错
interface IStruct {
foo: string;
bar: {
barPropA: string;
barPropB: number;
barMethod: () => void;
baz: {
handler: () => Promise;
};
};
}
const obj: IStruct = {} // 报错
这个还是就可以使用类型断言帮助实现先定义,后赋值,同时类型提示仍然存在:
const obj = {
}
需要注意的是,类型断言只能够欺骗TS编译器,无法避免运行时的错误,反而滥用类型断言可能会导致运行时错误
2.6.2 类型守卫
类型守卫主要有以下几种方式:
- typeof :用于判断
number
,string
,boolean
,symbol
四种类型; - instanceof:用于判断一个实例是否属于某个类;
- in:用于判断一个属性/方法是否属于某个对象
typeof
可以利用 typeof
实现类型收窄和never
类型的特性做全面性检查,如下面代码所示:
type Foo = string | number
function controlFlowAnalysisWithNever(foo: Foo){
if(typeof foo === 'string'){
// 这里foo收窄为 string类型
}else if (typeof foo === 'number'){
// 这里foo 被收窄为number类型
} else {
// foo 在这里是never
const check: never = foo;
}
}
可以看到,在最后的else
分支里面,我们把收窄为never
的foo
赋值给一个显式声明的never
变量,如果一切逻辑正确,那么是能够编译通过。假如某天某人修改了Foo
的类型,而忘记修改controlFlowAnalysisWithNever
方法中的控制流程,这时候else
分支的foo
类型无法赋值给never
类型,产生一个编译错误。通过使用never
避免出现新增了联合类型没有对应的实现,确保了方法总是穷尽Foo
的所有类型,从而保证代码的安全性。
但如果我们将typeof
判断逻辑提取到函数外部进行复用
function isString(input: unknown): boolean {
return typeof input === "string";
}
function foo(input: string | number) {
if (isString(input)) {
// 类型“string | number”上不存在属性“replace”。
(input).replace("linbudu", "linbudu599")
}
if (typeof input === 'number') { }
// ...
}
奇怪的事情发生了,如果 isString
返回了 true
,那input
肯定是string
类型。但因为TS 无法做到跨函数上下文来进行类型的信息收集判断。为了弥补该项的不足,TS引入了is
关键字来显式地提供类型信息:
function isString(input: unknown): input is string {
return typeof input === "string";
}
function foo(input: string | number) {
if (isString(input)) {
// 正确了
(input).replace("linbudu", "linbudu599")
}
if (typeof input === 'number') { }
// ...
}
isString
函数称为类型守卫,在它的返回值中,我们不再使用 boolean
作为类型标注,而是使用 input is string
这么个奇怪的搭配,拆开来看它是这样的:
- input 函数的某个参数;
- is string,即 is 关键字 + 预期类型,即如果这个函数成功返回为
true
,那么is
关键字前这个入参的类型,就会被这个类型守卫调用方后续的类型控制流分析收集到。
注意,类型守卫函数中并不会对判断逻辑和实际类型的关联进行检查:
// 只会显式的认为 input is number
function isString(input: unknown): input is number {
return typeof input === "string";
}
function foo(input: string | number) {
if (isString(input)) {
// 报错,在这里变成了 number 类型
(input).replace("linbudu", "linbudu599")
}
if (typeof input === 'number') { }
// ...
}
从这个角度来看,其实类型守卫有些像类型断言,但是类型守卫更宽容,更信任你一些。你指定什么类型,它就是什么类型。
这里提供开发中两个常用的两个类型守卫:
export type Falsy = false | "" | 0 | null | undefined;
export const isFalsy = (val: unknown): val is Falsy => !val;
// 不包括不常用的 symbol 和 bigint
export type Primitive = string | number | boolean | undefined;
export const isPrimitive = (val: unknown): val is Primitive => ['string', 'number', 'boolean' , 'undefined'].includes(typeof val);
instanceof
使用instanceof
运算符收窄变量的类型:
class Man {
handsome = "handsome";
}
class Women{
beautiful = "beautiful"
}
function Human(arg: Man | Woman){
if(arg instanceof Man){
console.log(arg.handsome)
}else{
console.log(arg.beautiful)
}
}
in
使用in
做属性检查
interface A{
a: string;
}
interface B{
b: string;
}
function foo(x: A | B){
if("a" in x){
return x.a;
}
return x.b
}
2.6.3 双重断言
当我们要为某个值作类型断言时,我们需要确保编辑器推断出的值的类型和新类型有重叠,否则,无法简单地作类型断言,任何类型都可以被断言为any
,而any
可以被断言为任何类型。
如果我们仍然想使用那个类型,可以使用双重断言
function handler(event: Event){
const element = event as any as HTMLElement;
}
TS3.0中新增了一种unknown
类型,它是一种更加安全的any
的副本。所有东西都可以被标记成是unknown
类型,但是unkonwn
必须在进行类型判断和条件控制之后才可以被其他类型,并且在类型判断和条件控制之前也不能进行任何操作
2.7 善用常量枚举
2.8 善用高级类型
除了string
、number
、boolean
这种基础类型外,我们还应该了解一些类型声明中的一些高级用法。
2.8.1 类型索引(keyof)
keyof
类似于Object.keys
,用于获取一个接口中key的联合类型:
interface Button{
type: string;
text: string;
}
type ButtonKeys = keyof Button
// 等效于
type ButtonKeys = "type" | "text"
2.8.2 类型约束(extends)
TS
中extends
关键词不同于在Class
后使用extends
的继承作用,一般在泛型内使用,它主要作用是对泛型加以约束:
type BaseType = string | number | boolean // 这里表示copy 的参数
function copy(arg: T):T{
return arg
}
const arr = copy([]) // error
extends
经常和keyof
一起使用,例如我们有一个getValue
方法专门获取对象的值,但是这个对象并不确定,我们就可以这样做:
function getValue(obj: T, key: K){
return obj[key]
}
const obj = {a:1}
const a = getValue(obj, 'b') //error
当传入对象没有key
时,编辑器则会报错
2.8.3 类型映射(in)
in
关键词的作用主要是做类型的映射,遍历已有接口的key
或者是遍历联合类型。以内置的泛型接口Readonly
为例,它的实现如下:
type Readonly ={
readonly [P in keyof T]: T[P];
}
// 它的作用是将所有接口变为只读
interface Obj {
a: string;
b: string;
}
type ReadOnlyObj = Readonly
//等效于
interface Obj {
readonly a: string;
readonly b: string;
}
2.8.3 条件类型(U?X:Y)
条件类型的语法规则和三元表达式一致,经常用于类型不确定的情况
T extends U ? X : Y
上面的意思就是,如果 T 是 U 的子集,就是类型 X,否则为类型 Y。以内置的泛型接口 Extract 为例,它的实现如下:
type Extract = T extends U ? T : never;
TypeScript 将使用 never
类型来表示不应该存在的状态。上面的意思是,如果 T 中的类型在 U 存在,则返回,否则抛弃。
假设我们两个类,有三个公共的属性,可以通过Extract
提取这三个公共属性:
interface Worker{
name: string;
age: number;
email:string;
salary: number;
}
interface Worker{
name: string;
age: number;
email:string;
grade: number;
}
type CommonKeys = Extract
// 'name' | 'age' | 'email'
2.8.4工具泛型
TS 中内置了很多工具泛型,前面介绍过Readonly
、'Extract' 这两种,内置的泛型在TS内置的lib.es5.d.ts
中都有定义,所以不需要任何依赖就可以直接使用。下面介绍几个常见的工具泛型的作用和使用方法。
Exclude
,如果T
中的类型在U
不存爱,则返回,否则抛弃。
interface Worker{
name: string;
age: number;
email:string;
salary: number;
}
interface Worker{
name: string;
age: number;
email:string;
grade: number;
}
type CommonKeys = Exclude
// 'salary'
Partial
用于将一个接口所有属性设置为可选状态:
interface Person {
name: string;
sex: string;
}
type NewPerson = Partial
// {name?:string;sex?:string}
Required
的作用是将所有接口可选属性改为必选的
interface Person {
name?: string;
sex?: string;
}
type NewPerson = Required
// {name:string;sex:string}
Pick
主要作用提取接口的某几个属性:
interface Todo{
title: string;
completed: boolean;
description: string
}
type TodePrevied = Pick
// {title: string;completed:boolean}
Omit
的作用是剔除接口的某几个属性
interface Todo{
title: string;
completed: boolean;
description: string
}
type TodePrevied = Omit
// {title: string;completed:boolean}
2.8.5 工具泛型获取组件实例的类型
InstanceType
是 ts 自带的类型, 能够直接获取组件完整的实例类型
import Child from './child.vue'
import {ElImage} from 'element-plus'
type ElImageCtx = InstanceType(typeof ElImage);
type ChildCtx = InstanceType(typeof Child);
...
setup() {
const child = ref(null);
const elImgRef = ref(null)
onMounted(() => {
child.value?.num ;// 可以直接访问到
elImgRef.value?. // 对于 element组件,可以访问到很多的属性
})
}