重温 TypeScript

重温 TypeScript

虽然之前一直项目当中使用 Angular 来进行业务开发,但是总感觉对于 TypeScript 这一块的内容还是只停留在比较浅的使用层面,而最近又开始涉及到 React 相关内容,发现 React + TypeScript 算是业界标配了,所以就打算抽些时间深入的学习一下 TypeScript,在这里记录记录,也算是查漏补缺吧

针对于相关内容会分为两篇文章来进行梳理,本文当中主要梳理一些基础内容,也算是回顾一下 TypeScript 的基本用法,而在下一章 深入 TypeScript 当中我们则会来看一些 TypeScript 当中的进阶内容

本文当中涉及到的内容可能比较零散,因为便于自己回顾,一些比较熟络的知识点可能会一笔带过,对于想了解整体流程的同学可以参考 官方文档 来了解更多

什么是 TypeScript

TypeScriptJavaScript 的一个超集,主要提供了『类型系统』和对 ES6 的支持,它与 JavaScript 的基本区别有以下这些

TypeScript JavaScript
JavaScript 的超集用于解决大型项目的代码复杂性 一种脚本语言,用于创建动态网页
可以在编译期间发现并纠正错误 作为一种解释型语言,只能在运行时发现错误
强类型,支持静态和动态类型 弱类型,没有静态类型选项
最终被编译成 JavaScript 代码,使浏览器可以理解 可以直接在浏览器中使用
支持模块、泛型和接口 不支持模块,泛型或接口
社区的支持仍在增长,而且还不是很大 大量的社区支持以及大量文档和解决问题的支持

而函数之间的区别则有以下这些

TypeScript JavaScript
含有类型 无类型
箭头函数 箭头函数(ES2015
函数类型 无函数类型
必填和可选参数 所有参数都是可选的
默认参数 默认参数
剩余参数 剩余参数
函数重载 无函数重载

当然任何事物都是有两面性的,通常来说 TypeScript 的一些弊端在于

  • 有一定的学习成本,需要理解接口(Interfaces)、泛型(Generics)、类(Classes)、枚举类型(Enums)等概念
  • 短期可能会增加一些开发成本,毕竟要多写一些类型的定义,不过对于一个需要长期维护的项目,TypeScript 能够减少其维护成本
  • 集成到构建流程需要一些工作量
  • 可能和一些库结合的不是很完美

另外,除了 TypeScript 还有一个 Flow 可供选择,FlowFacebook 出品的 JavaScript 静态类型检查工具,它与 Typescript 不同的是,它可以部分引入,不需要完全重构整个项目,所以对于一个已有一定规模的项目来说,迁移成本更小,也更加可行,所以还是根据团队和项目的情况判断是否需要使用 TypeScript

另外我们再来简单的了解一下 TypeScript 的工作流程,这有助于我们更好的理解 TypeScript,其实简单来说,典型的 TypeScript 工作流程是下面这样的

在上图当中包含了三个 TypeScript 文件,而这些文件将被 TypeScript 编译器,根据配置的编译选项编译成对应三个不同的 .js 文件,对于大多数使用 TypeScript 开发的 Web 项目来说,我们还会对编译生成的文件进行打包处理,然后在进行部署

下面我们就来看看 TypeScript 当中的常用类型和一些基本概念,主要包括

  • 基础类型
  • 内置对象
  • 联合类型
  • 对象的类型(接口)
  • 数组的类型
  • 函数的类型

另外需要注意一点,如果本文当中未特殊指明示例是错误示范的话,则默认是编译通过

TypeScript 基础类型

我们先来看看 TypeScript 当中的一些基础类型,也是我们在平常经常见到的一些类型

布尔值

TypeScript 中,使用 boolean 定义布尔值类型(注意区分大小写)

1
let x: boolean = false

但是需要注意的是,如果使用的是构造函数 Boolean 创造的对象则不是布尔值

1
2
// ❌
let x: boolean = new Boolean(true)

事实上 new Boolean() 返回的是一个 Boolean 对象

1
2
// ✅
let x: Boolean = new Boolean(true)

TypeScript 中,booleanJavaScript 中的基本类型,而 BooleanJavaScript 中的构造函数,其他基本类型(除了 nullundefined)也是一样的

数值

使用 number 可以来定义数值类型,并且二进制与八进制等均可以使用

1
2
3
4
5
6
let x: number = 6
let x: number = 0xf00d
let x: number = 0b1010 // ES6 中的二进制表示法
let x: number = 0o744 // ES6 中的八进制表示法
let x: number = NaN
let x: number = Infinity

字符串

使用 string 来定义字符串类型

1
2
3
4
let x: string = 'zhangsan'

// 模板字符串
let y: string = `hello ${x}`

可以发现,对于 ES6 当中的模板字符串同样适用

任意值

任意值(Any)用来表示允许赋值为任意类型,与原始数据类型进行比对的话,如果是一个普通类型,在赋值过程中改变类型是不被允许的

1
2
3
// ❌
let x: string = 'zhangsan'
x = 7

但如果是 any 类型,则允许被赋值为任意类型

1
2
3
// ✅
let x: any = 'lisi'
x = 7

同时,在任意值上访问任何属性都是允许的

1
2
3
4
let x: any = 'zhangsan'

console.log(x.name)
console.log(x.name.firstName)

也允许调用任何方法

1
2
3
4
5
let x: any = 'lisi'

x.setName('wangwu')
x.setName('zhaoliu').sayHello()
x.name.setFirstName('zhangsan')

可以认为,『声明一个变量为任意值之后,对它的任何操作,返回的内容的类型都是任意值』,但是如果变量在声明的时候,未指定其类型,那么它会被识别为任意值类型,比如下面示例当中的 x 等价于 x: any

1
2
3
4
5
6
let x

x = 'zhangsan'
x = 7

x.setName('lisi')

虽然 any 使用起来很方便,但是可想而知,如果我们大量使用 any 类型,就无法使用 TypeScript 所提供的大量的保护机制,为了解决 any 带来的问题,TypeScript 3.0 引入了 unknown 类型

Unknown

就像所有类型都可以赋值给 any,所有类型也都可以赋值给 unknown,这使得 unknown 成为 TypeScript 类型系统的另一种顶级类型(另一种是 any),下面我们来看一下 unknown 类型的使用示例

1
2
3
4
5
6
7
8
9
10
11
12
let value: unknown

value = true // ✅
value = 42 // ✅
value = 'hello world' // ✅
value = [] // ✅
value = {} // ✅
value = Math.random // ✅
value = null // ✅
value = undefined // ✅
value = new TypeError() // ✅
value = Symbol('type') // ✅

可以发现,我们对 value 变量的所有赋值都被认为是类型正确的,下面我们再来看看将类型为 unknown 的值赋值给其他类型的变量时会发生什么

1
2
3
4
5
6
7
8
9
10
let value: unknown

let value1: unknown = value // ✅
let value2: any = value // ✅
let value3: boolean = value // ❌
let value4: number = value // ❌
let value5: string = value // ❌
let value6: object = value // ❌
let value7: any[] = value // ❌
let value8: Function = value // ❌

通过上面的示例可以发现,unknown 类型只能被赋值给 any 类型和 unknown 类型本身,其实也能理解,那就是只有能够保存任意类型值的容器才能保存 unknown 类型的值,毕竟我们不知道变量 value 中存储了什么类型的值

我们下面再来看看对类型为 unknown 的值执行操作时会发生什么

1
2
3
4
5
6
7
let value: unknown

value.foo.bar // ❌
value.trim() // ❌
value() // ❌
new value() // ❌
value[0][1] // ❌

value 变量类型设置为 unknown 后,这些操作都不再被认为是类型正确的,通过将 any 类型改变为 unknown 类型,我们已将允许所有更改的默认设置,更改为禁止任何更改

空值

某种程度上来说,void 类型像是与 any 类型相反,它表示没有任何类型,当一个函数没有返回值时,你通常会见到其返回值类型是 void

1
2
3
function alertName(): void {
alert('zhangsan')
}

声明一个 void 类型的变量没有什么用,因为你只能将它赋值为 undefinednull,但是需要注意,在非严格模式下,变量的值可以为 undefinednull,而严格模式下,变量的值只能为 undefined,所以使用场景较多的还是针对于没有返回值的函数

1
let x: void = undefined

但是这里有一个需要注意的地方,即当定义的函数返回值为空值 void 的时候,虽然在该函数内部写 return 时编译会报错,但是依然可以编译成功

Null 和 Undefined

TypeScript 中,可以使用 nullundefined 来定义这两个原始数据类型

1
2
let u: undefined = undefined
let n: null = null

void 的区别是,undefinednull 是所有类型的子类型,也就是说 undefined 类型的变量,可以赋值给 numberstring 类型的变量

1
2
3
4
// ✅
let x: undefined
let n: number = x
let s: string = x

void 类型的变量不能赋值给 numberstring 类型的变量

1
2
3
4
// ❌
let x: void
let n: number = x
let s: string = x

但是默认情况下像上面那样操作,编译器会提示错误,这是因为 tsconfig.json 里面有一个配置项是默认开启的

1
2
3
4
5
6
7
8
9
10
11
12
13
// tsconfig.json 
{
/* Strict Type-Checking Options */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
// 对 null 类型检查,设置为 false 就不会报错了
// "strictNullChecks": true, /* Enable strict null checks. */
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
}

其中 strictNullChecks 参数用于新的严格空检查模式,在严格空检查模式下,nullundefined 值都不属于任何一个类型,它们只能赋值给自己这种类型或者 any

object,Object 和 { }

我们这里主要看看这三者之间的区别,注意区分前两者的大小写

  • object 类型

object 类型是 TypeScript 2.2 引入的新类型,它用于表示非原始类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// node_modules/typescript/lib/lib.es5.d.ts
interface ObjectConstructor {
create(o: object | null): any
// ...
}

const proto = {}

Object.create(proto) // ✅
Object.create(null) // ✅
Object.create(undefined) // ❌
Object.create(1337) // ❌
Object.create(true) // ❌
Object.create("oops") // ❌
  • Object 类型

Object 类型是所有 Object 类的实例的类型,它由以下两个接口来定义,其中 Object 接口定义了 Object.prototype 原型对象上的属性

1
2
3
4
5
6
7
8
9
10
// node_modules/typescript/lib/lib.es5.d.ts
interface Object {
constructor: Function
toString(): string
toLocaleString(): string
valueOf(): Object
hasOwnProperty(v: PropertyKey): boolean
isPrototypeOf(v: Object): boolean
propertyIsEnumerable(v: PropertyKey): boolean
}

ObjectConstructor 接口定义了 Object 类的属性

1
2
3
4
5
6
7
8
9
10
11
12
// node_modules/typescript/lib/lib.es5.d.ts
interface ObjectConstructor {
/** Invocation via `new` */
new(value?: any): Object
/** Invocation via function calls */
(value?: any): any
readonly prototype: Object
getPrototypeOf(o: any): any
// ...
}

declare var Object: ObjectConstructor

这里需要注意的是,Object 类的所有实例都继承了 Object 接口中的所有属性

  • { } 类型

{ } 类型描述了一个没有成员的对象,当你试图访问这样一个对象的任意属性时,TypeScript 会产生一个编译时错误

1
2
3
4
const obj = { }

// ❌
obj.prop = 'zhangsan'

但是我们仍然可以使用在 Object 类型上定义的所有属性和方法,这些属性和方法可通过 JavaScript 的原型链隐式地使用

1
2
3
4
const obj = { }

// ✅
obj.toString()

Never

never 类型表示的是那些永不存在的值的类型,例如 never 类型是那些总是会抛出异常或根本就不会有返回值的函数表达式或箭头函数表达式的返回值类型(这个类型一般很少会用到,了解即可)

1
2
3
4
5
6
7
8
// 返回 never 的函数必须存在无法达到的终点
function error(message: string): never {
throw new Error(message)
}

function infiniteLoop(): never {
while (true) { }
}

TypeScript 中,可以利用 never 类型的特性来实现全面性检查,具体示例如下

1
2
3
4
5
6
7
8
9
10
11
12
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 分支里面,我们把收窄为 neverfoo 赋值给一个显示声明的 never 变量,如果一切逻辑正确,那么这里应该能够编译通过,但是如果我们修改了 Foo 的类型为 type Foo = string | number | boolean 但是忘记了修改 controlFlowAnalysisWithNever 方法中的控制流程

这时候 else 分支的 foo 类型会被收窄为 boolean 类型,导致无法赋值给 never 类型,这时就会产生一个编译错误,通过这个方式,我们可以确保 controlFlowAnalysisWithNever 方法总是穷尽了 Foo 的所有可能类型

通过这个示例,我们可以得出一个结论,即可以使用 never 来避免出现新增了联合类型没有对应的实现,目的就是写出类型绝对安全的代码,但是这里我们也需要注意区分 nevervoid 两者之间的区别

  • void 表示没有任何类型(可以被赋值为 nullundefined
  • never 表示一个不包含值的类型,即表示永远不存在的值
  • 拥有 void 返回值类型的函数能正常运行,拥有 never 返回值类型的函数无法正常返回,无法终止,或会抛出异常

内置对象

其实在上面介绍的一些原始数据类型,本质上它们都是 JavaScript 当中的 内置对象,它们已经在 TypeScript 中定义好了对应的类型,直接进行使用就行,内置对象是指根据标准在全局作用域(Global)上存在的对象,这里的标准是指 ECMAScript 和其他环境(比如 DOM)的标准

ECMAScript 标准提供的内置对象有 BooleanErrorDateRegExp 等,我们可以在 TypeScript 中将变量定义为这些类型

1
2
3
4
let b: Boolean = new Boolean(1)
let e: Error = new Error('Err')
let d: Date = new Date()
let r: RegExp = /[a-z]/

而常见的 DOMBOM 提供的内置对象有 DocumentHTMLElementEventNodeList 等,我们在开发过程当中也会经常用到这些类型

1
2
3
4
5
6
7
let body: HTMLElement = document.body

let allDiv: NodeList = document.querySelectorAll('div')

document.addEventListener('click', function (e: MouseEvent) {
// ...
})

而对于内置对象的定义文件,则在 TypeScript 核心库 的定义文件中,其中定义了所有浏览器环境需要用到的类型,并且是预置在 TypeScript 中的,当我们在使用一些常用的方法的时候,TypeScript 实际上已经帮我们做了很多类型判断的工作了,比如

1
2
3
4
5
// ✅
Math.pow(10, 2)

// ❌
Math.pow(10, '2')

在上面的例子中,Math.pow() 必须接受两个 number 类型的参数,它的类型定义如下

1
2
3
4
5
6
7
8
interface Math {
/**
* Returns the value of a base expression taken to a specified power.
* @param x The base value of the expression.
* @param y The exponent value of the expression.
*/
pow(x: number, y: number): number
}

再来看一个 DOM 中的例子

1
2
3
4
// ❌
document.addEventListener('click', function (e) {
console.log(e.targetCurrent)
})

在上面的例子中,addEventListener 方法是在 TypeScript 核心库中定义的

1
2
3
interface Document extends Node, GlobalEventHandlers, NodeSelector, DocumentEvent {
addEventListener(type: string, listener: (ev: MouseEvent) => any, useCapture?: boolean): void
}

所以 e 被推断成了 MouseEvent,而 MouseEvent 是没有 targetCurrent 属性的,所以报错了

此外我们还需要需要注意一点,那就是 TypeScript 核心库的定义中是不包含 Node.js 部分的,如果想用 TypeScriptNode.js,则需要引入第三方声明文件

1
npm install @types/node --save-dev

TypeScript 断言

我们在有时候可能会遇到这种情况,那就是我们会比 TypeScript 更了解某个值的详细信息,通常这会发生在你清楚地知道一个实体具有比它现有类型更确切的类型,在这种情况下我们就可以通过类型断言这种方式可以告诉编译器,我知道自己在干什么,类型断言好比其他语言里的类型转换,但是不进行特殊的数据检查和解构,它没有运行时的影响,只是在编译阶段起作用

类型断言

类型断言主要有两种形式,即 <>as,我们先来看看 <> 的形式

1
2
3
let x: any = 'abc'

let l: number = (<string>x).length

等同于

1
2
3
let x: any = 'abc'

let l: number = (x as string).length

但是建议尽量使用 as 来替 <> 表示类型断言,因为

  • TypeScript 可以使用 <> 来表示类型断言,但是在结合 JSX 的语法时将带来解析上的困难,因此 TypeScript.tsx 文件里禁用了使用 <> 的类型断言
  • 另外,as 操作符在 .ts 文件和 .tsx 文件里都可以使用

非空断言

如果在上下文中当类型检查器无法断定类型时,一个新的后缀表达式操作符 ! 可以用于断言操作对象是非 null 和非 undefined 类型,简单来说比如 x! 就是将从 x 值域中排除 nullundefined,下面我们先来看几个非空断言操作符的一些使用场景

  • 第一种情况,忽略 undefinednull 类型
1
2
3
4
5
6
function myFunc(maybeString: string | undefined | null) {
// Type 'string | null | undefined' is not assignable to type 'string'.
// Type 'undefined' is not assignable to type 'string'.
const onlyString: string = maybeString // ❌
const ignoreUndefinedAndNull: string = maybeString! // ✅
}
  • 第二种情况,调用函数时忽略 undefined 类型
1
2
3
4
5
6
7
8
type NumGenerator = () => number

function myFunc(numGenerator: NumGenerator | undefined) {
// Object is possibly 'undefined'.(2532)
// Cannot invoke an object which is possibly 'undefined'.(2722)
const num1 = numGenerator() // ❌
const num2 = numGenerator!() // ✅
}

因为 ! 非空断言操作符会从编译生成的 JavaScript 代码中移除,所以在实际使用的过程中,要特别注意,比如下面这个例子

1
2
3
4
const a: number | undefined = undefined
const b: number = a!

console.log(b)

以上 TypeScript 代码会编译生成以下 ES5 代码

1
2
3
4
5
'use strict'
const a = undefined
const b = a

console.log(b)

虽然在 TypeScript 代码中,我们使用了非空断言,使得 const b: number = a! 语句可以通过 TypeScript 类型检查器的检查,但在生成的 ES5 代码中,因为 ! 非空断言操作符被移除了,所以在浏览器中执行以上代码,在控制台会输出 undefined

确定赋值断言

TypeScript 2.7 版本中引入了『确定赋值断言』,即允许在实例属性和变量声明后面放置一个 ! 号,从而告诉 TypeScript 该属性会被明确地赋值,来看下面这个示例

1
2
3
4
5
6
7
8
9
10
let x: number

init()

// Variable 'x' is used before being assigned.
console.log(x * 2)

function init() {
x = 10
}

运行后我们可以发现,错误提示显示说变量 x 在赋值前已经被使用了,为了解决这个问题,我们可以使用确定赋值断言

1
2
3
4
5
6
7
8
9
10
let x!: number

init()

// ✅
console.log(x * 2)

function init() {
x = 10
}

我们通过 let x!: number 来确定赋值断言,这样一来 TypeScript 编译器就会知道该属性会被明确地赋值

联合类型

联合类型(Union Types)表示取值可以为多种类型中的一种,使用 | 分隔每个类型

1
2
3
4
5
6
let x: string | number

x = 'zhangsan' // ✅
x = 7 // ✅

x = true // ❌

上面示例当中的 let x: string | number 含义是允许 x 的类型是 string 或者 number,但是不能是其他类型

TypeScript 不确定一个联合类型的变量到底是哪个类型的时候,我们只能访问此联合类型的所有类型里共有的属性或方法

1
2
3
4
// ❌
function getLength(x: string | number): number {
return x.length
}

因为 length 不是 stringnumber 类型的共有属性,所以会报错,但是访问两者的的共有属性是没问题的,比如 toString() 方法

1
2
3
4
// ✅
function getLength(x: string | number): string {
return x.toString()
}

并且联合类型的变量在被赋值的时候,会根据类型推论的规则推断出一个类型

1
2
3
4
5
6
7
let x: string | number

x = 'zhangsan'
console.log(x.length) // 8

x = 7
console.log(x.length) // ❌

上例中,在赋值为 'zhangsan' 的时候,x 被推断成了 string,所以可以访问它的 length 属性,但是赋值为 7 的时候 x 被推断成了 number,所以访问它的 length 属性时就会报错

关于联合类型,这里我们来看一个它的相关应用场景,也就是『类型保护函数』,要自定义一个类型保护,只需要简单地为这个类型保护定义一个函数即可,这个函数的返回值是一个『类型谓词』

类型谓词的语法为 parameterName is Type 这种形式,其中 parameterName 必须是当前函数签名里的一个参数名,来看下面这个示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
interface Bird {
fly()
layEggs()
}

interface Fish {
swim()
layEggs()
}

function getSmallPet(): Fish | Bird {
return
}

let pet = getSmallPet()

pet.layEggs()

// ❌
pet.swim()

// ✅
(pet as Fish).swim()

在上面示例当中,当我们使用联合类型时,如果不用类型断言,默认只会获取两者共有的部分,在这种情况下,我们就可以采用类型谓词

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
interface Bird {
fly()
layEggs()
}

interface Fish {
swim()
layEggs()
}

function getSmallPet(): Fish | Bird {
return
}

let pet = getSmallPet()

// 使用类型谓词
function isFish(pet: Fish | Bird): pet is Fish {
return (pet as Fish).swim !== undefined
}

if (isFish(pet)) {
pet.swim()
} else {
pet.fly()
}

另外,我们可以借住 never 来区分的联合类型,比如下面这个示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
enum KindType {
square = 'square',
rectangle = 'rectangle',
circle = 'circle',
}

interface Square {
kind: KindType.square
size: number
}

interface Rectangle {
kind: KindType.rectangle
width: number
height: number
}

interface Circle {
kind: KindType.circle
radius: number
}

type Shape = Square | Rectangle | Circle

function area(s: Shape) {
// 如果联合类型中的多个类型,拥有共有的属性,那么就可以凭借这个属性来创建不同的类型保护区块
// 这里 kind 是共有的属性
switch (s.kind) {
case KindType.square:
return s.size * s.size
case KindType.rectangle:
return s.height * s.width
default:
return
}
}

// 以上代码有隐患,如果后续新增类型时,TS 检查以上代码时,虽然缺失后续新增的类型,但不会报错
area({ kind: KindType.circle, radius: 1 })

所以这里可以利用 never 来进行完善

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function area(s: Shape) {
switch (s.kind) {
case KindType.square:
return s.size * s.size
case KindType.rectangle:
return s.height * s.width
case KindType.circle:
return Math.PI * s.radius ** 2
default:
// 检查 s 是否是 never 类型
// 如果是 never 类型,那么上面的分支语句都被覆盖了,就永远都不会走到当前分支
// 如果不是 never 类型,就说明前面的分支语句有遗漏,需要补上
return ((e: never) => {
throw new Error(e)
})(s)
}
}

area({ kind: KindType.circle, radius: 1 })

更多可见 TypeScript 中的 never 类型具体有什么用?

对象的类型(接口)

TypeScript 中,我们使用接口(Interfaces)来定义对象的类型,接口(Interfaces)在面向对象语言中是一个很重要的概念,它是对行为的抽象,而具体如何行动需要由类(classes)去实现(implement),我们先来看一个简单的示例

1
2
3
4
5
6
7
8
9
interface Person {
name: string;
age: number;
}

let user: Person = {
name: 'zhangsan',
age: 18
}

我们定义了一个接口 Person,接着定义了一个变量 user,它的类型是 Person,这样我们就约束了 user 的结构必须和接口 Person 一致,但是需要注意的是,定义的变量比接口多一些或是少了一些属性都是不允许的

1
2
3
4
5
6
7
8
9
10
11
// ❌
let user: Person = {
name: 'zhangsan'
}

// ❌
let user: Person = {
name: 'zhangsan',
age: 18,
sex: 0
}

所以说,在赋值的时候,变量的结构必须和接口的结构保持一致

可选属性

有时候我们又希望不要完全匹配一个接口,那么这种情况下可以使用可选属性

1
2
3
4
5
6
7
8
9
10
11
12
13
interface Person {
name: string;
age?: number;
}

let user1: Person = {
name: 'zhangsan'
}

let user2: Person = {
name: 'zhangsan',
age: 18
}

但是此时仍然不允许添加未定义的属性

1
2
3
4
5
6
7
8
9
10
11
interface Person {
name: string;
age?: number;
}

// ❌
let user: Person = {
name: 'zhangsan',
age: 18,
sex: 0
}

任意属性

有时候我们希望一个接口允许有任意的属性,可以使用 [propName: type] 的方式来来进行定义

1
2
3
4
5
6
7
8
9
10
interface Person {
name: string;
age?: number;
[propName: string]: any;
}

let user: Person = {
name: 'zhangsan',
age: 18
}

但是这里有一个需要注意的地方,那就是『一旦定义了任意属性,那么确定属性和可选属性的类型都必须是它的类型的子集』,比如下面这个示例就会报错

1
2
3
4
5
6
7
8
9
10
11
12
// ❌
interface Person {
name: string;
age?: number;
[propName: string]: string;
}

let user: Person = {
name: 'zhangsan',
age: 18,
sex: '0'
}

这里我们将任意属性的值允许是 string,但是可选属性 age 的值却是 numbernumber 不是 string 的子属性,所以报错了,通常来说一个接口中只能定义一个任意属性,如果接口中有多个类型的属性,则可以采用联合类型的方式

1
2
3
4
5
6
7
8
9
10
11
interface Person {
name: string;
age?: number;
[propName: string]: string | number;
}

let user: Person = {
name: 'zhangsan',
age: 18,
sex: 0
}

另外需要注意的一点就是『索引签名』参数类型必须为 stringnumber

1
2
3
4
5
6
// ❌
interface Person {
name: string;
age?: number;
[propName: any]: any;
}

上面示例是会报错的,这是因为 TypeScript 只支持两种索引签名,那就是字符串和数字,虽然可以同时使用两种类型的索引,但是数字索引的返回值必须是字符串索引返回值类型的子类型,这是因为当使用 number 来索引时,JavaScript 会将它转换成 string 然后再去索引对象,比如下面这个示例就不会报错

1
2
3
4
5
6
7
8
9
10
11
12
// ✅
interface Person {
name: string;
age?: number;
[propName: string]: string | number;
}

let user: Person = {
name: 'zhangsan',
age: 18,
1: 1
}

其实在上面示例当中和写成 '1': 1 是完全一样的,即使我们定义的是 [propName: number],这是因为 JavaScript 中对象的数字索引,最终会转成字符串来取值的,比如使用 100number)去索引等同于使用 '100'string)去索引,因此两者需要保持一致

只读属性

有时候我们希望对象中的一些字段只能在创建的时候被赋值,那么可以用 readonly 定义只读属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
interface Person {
readonly id: number;
name: string;
age?: number;
[propName: number]: string | number;
}

let user: Person = {
id: 123,
name: 'zhangsan',
age: 18
}

// ❌
user.id = 456

在上面示例当中,我们使用 readonly 定义的属性 id 初始化后,又被赋值了,所以报错了,但是这里特别需要注意的一点就是『只读的约束是存在于第一次给对象赋值的时候,而不是第一次给只读属性赋值的时候』,比如下面这个示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
interface Person {
readonly id: number;
name: string;
age?: number;
[propName: number]: string | number;
}

// ❌
let user: Person = {
name: 'zhangsan',
age: 18
}

// ❌
user.id = 456

上例中有两处报错,第一处是在对 user 进行赋值的时候,没有给 id 赋值,第二处是在给 user.id 赋值的时候,由于它是只读属性,所以报错了

数组的类型

在 TypeScript 中,数组类型有多种定义方式,比较灵活,最简单的方法是使用『类型 + 方括号』来表示数组

1
let x: number[] = [1, 2, 3, 4, 5]

并且定义以后,数组的一些方法的参数也会根据数组在定义时约定的类型进行限制

1
2
3
4
5
6
7
let x: number[] = [1, 2, 3, 4, 5]

// ✅
x.push(6)

// ❌
x.push('6')

在上面示例当中,由于 push 方法只允许传入 number 类型的参数,但是却传了一个字符串类型的 6,所以报错了,另外我们也可以使用数组泛型(Array GenericArray<elemType> 来表示数组

1
let x: Array<number> = [1, 2, 3, 4, 5]

关于泛型的相关内容,我们会在后面章节当中详细来进行介绍,下面我们来看看如何使用接口来描述数组

1
2
3
4
5
interface NumberArray {
[index: number]: number;
}

let x: NumberArray = [1, 2, 3, 4, 5]

虽然接口也可以用来描述数组,但是我们一般不会这么来使用,因为这种方式比前两种方式要复杂许多,不过有一种情况比较特殊,那就是它常用来表示类数组,类数组(Array-like Object)不是数组类型,比如 arguments

1
2
3
4
// ❌
function sum() {
let args: number[] = arguments
}

由于 arguments 实际上是一个类数组,不能用普通的数组的方式来描述,而应该用接口

1
2
3
4
5
6
7
8
9
interface Args {
[index: number]: number;
length: number;
callee: Function;
}

function sum() {
let args: Args = arguments
}

在这个例子中,我们除了约束当索引的类型是数字时,值的类型必须是数字之外,也约束了它还有 lengthcallee 两个属性,事实上常用的类数组都有自己的接口定义,如 IArgumentsNodeListHTMLCollection

1
2
3
function sum() {
let args: IArguments = arguments
}

其实也就是我们之前提到过的『内置对象』,其中 IArgumentsTypeScript 中定义好了的类型,它实际上就是

1
2
3
4
5
interface IArguments {
[index: number]: any;
length: number;
callee: Function;
}

对于数组当中既存在数字又含有字符串的情况,我们可以考虑使用联合类型

1
let x: (number | string)[] = [1, '2', 3]

另外还有一种比较复杂的情况,那就是对象类型的数组,偷懒的话当然可以直接使用 any,但是如若结构不算太过复杂的话可以使用下面这种方式

1
const x: { name: string, age: number }[] = [{ name: 'zhangsan', age: 18 }]

还可以将上面的写法简化一下,利用类型别名的方式

1
2
3
type User = { name: string, age: number }

const x: User[] = [{ name: 'zhangsan', age: 18 }]

函数的类型

JavaScript 中,有两种常见的定义函数的方式,即函数声明(Function Declaration)和函数表达式(Function Expression

1
2
3
4
5
6
7
8
9
// 函数声明(Function Declaration)
function add(x, y) {
return x + y
}

// 函数表达式(Function Expression)
let add = function (x, y) {
return x + y
}

一个函数有输入和输出,要在 TypeScript 中对其进行约束,需要把输入和输出都考虑到,其中函数声明的类型定义较简单

1
2
3
function add(x: number, y: number): number {
return x + y
}

但是输入多余的(或者少于要求的)参数,是不被允许的

1
2
3
add(1, 2, 3)  // ❌

add(1) // ❌

但是如果我们要对一个函数表达式(Function Expression)来定义的话,可能会写成这样

1
2
3
let add = function (x: number, y: number): number {
return x + y
}

虽然是可以通过编译的,但是上面的代码其实只对等号右侧的匿名函数进行了类型定义,而等号左边的 add,是通过赋值操作进行类型推论而推断出来的,如果需要我们手动给 add 添加类型,则应该是这样

1
2
3
let add: (x: number, y: number) => number = function (x: number, y: number): number {
return x + y
}

但是这里注意不要混淆了 TypeScript 中的 =>ES6 当中的箭头函数(=>),在 TypeScript 的类型定义中,=> 用来表示函数的定义,左边是输入类型,需要用括号括起来,右边是输出类型

使用接口

我们也可以使用接口的方式来定义一个函数需要符合的结构

1
2
3
4
5
6
7
interface SearchFunc {
(source: string, subString: string): boolean;
}

let mySearch: SearchFunc = function (source: string, subString: string): boolean {
return source.search(subString) !== -1
}

采用函数表达式定义函数的方式时,对等号左侧进行类型限制,可以保证以后对函数名赋值时保证参数个数、参数类型、返回值类型不变

可选参数

与接口中的可选属性类似,我们用 ? 来表示可选的参数

1
2
3
4
5
6
7
8
9
10
11
function buildName(firstName: string, lastName?: string) {
if (lastName) {
return firstName + ' ' + lastName
} else {
return firstName
}
}

let man1 = buildName('zhangsan', 'lisi')

let man2 = buildName('zhangsan')

不过需要注意的是,可选参数必须接在必需参数后面,也就是说可选参数后面不允许再出现必需参数了

1
2
3
4
5
6
7
8
// ❌
function buildName(firstName?: string, lastName: string) {
if (lastName) {
return firstName + ' ' + lastName
} else {
return firstName
}
}

默认值

ES6 中,我们允许给函数的参数添加默认值,TypeScript 会将添加了默认值的参数识别为可选参数

1
2
3
4
5
6
7
function buildName(firstName: string, lastName: string = 'lisi') {
return firstName + ' ' + lastName
}

let man1 = buildName('zhangsan', 'wangwu')

let man2 = buildName('zhangsan')

但是需要注意一种情况,那就是我们在解构一个函数的时候,即给变量声明类型的同时又给变量设置默认值的情况,如下

1
2
3
4
// ❌
function f({ x: number }) {
console.log(x)
}

如上,在这种情况下,编辑器会提示我们找不到名称 x,针对于这种情况,我们可以像下面这样来进行处理

1
2
3
4
// ✅
function f({ x }: { x: number } = { x: 0 }) {
console.log(x)
}

剩余参数

ES6 中,我们可以使用 ...rest 的方式获取函数中的剩余参数(rest 参数)

1
2
3
4
5
6
7
8
9
function push(array, ...items) {
items.forEach(function (item) {
array.push(item)
})
}

let arr = []

push(arr, 1, 2, 3)

事实上,items 是一个数组,所以我们可以用数组的类型来定义它

1
2
3
4
5
6
7
8
9
function push(array: number[], ...items: number[]) {
items.forEach(function (item) {
array.push(item)
})
}

let arr = []

push(arr, 1, 2, 3)

这里需要注意的是,rest 参数同可选参数一样,只能是最后一个参数

重载

Java 等面向对象语言当中的函数重载,指的是两个或者两个以上的同名函数,参数类型不同或者参数个数不同,它的好处是不需要为功能相似的函数起不同的名称,而在 TypeScript 当中,表现为给同一个函数提供多个函数类型定义,适用于接收不同的参数和返回不同结果的情况

TypeScript 在实现函数重载的时候,要求定义一系列的函数声明,在类型最宽泛的版本中实现重载(前面的是函数声明,目的是约束参数类型和个数,最后的函数实现是重载,表示要遵循前面的函数声明,一般在最后的函数实现时用 any 类型),不过函数重载在实际应用中使用的比较少,一般会用联合类型或泛型代替,并且函数重载的声明只用于类型检查阶段,在编译后会被删除

TypeScript 编译器在处理重载的时候,会去查询函数申明列表,从上至下直到匹配成功为止,所以要把最容易匹配的类型写到最前面

1
2
3
4
5
6
7
8
9
10
11
12
13
function attr(val: string): string
function attr(val: number): number
// 前面两行是函数申明,这一行是实现函数重载
function attr(val: any): any {
if (typeof val === 'string') {
return val
} else if (typeof val === 'number') {
return val
}
}

attr('aaa')
attr(666)

上面的写法声明完函数后,必须实现函数重载,也可以『只声明函数』

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 后写的接口中的函数声明优先级高
interface Cloner111 {
clone(animal: Animal): Animal
}

interface Cloner111 {
clone(animal: Sheep): Sheep
}

interface Cloner111 {
clone(animal: Dog): Dog
clone(animal: Cat): Cat
}

// ==> 同名接口会合并
// 后写的接口中的函数声明优先级高
interface Cloner111 {
clone(animal: Dog): Dog
clone(animal: Cat): Cat
clone(animal: Sheep): Sheep
clone(animal: Animal): Animal
}

interface Cloner222 {
// 接口内部按书写的顺序来排,先写的优先级高
clone(animal: Dog): Dog
clone(animal: Cat): Cat
clone(animal: Sheep): Sheep
clone(animal: Animal): Animal
}

声明合并

这里既然提及到了同名接口合并,我们就再来扩展一些,其实这也就是所谓的『声明合并』,不光是函数,在接口当中也是可以进行合并的,如果定义了两个相同名字的函数、接口或类,那么它们将会合并成一个类型,如下

1
2
3
4
5
6
7
interface Alarm {
price: number;
}

interface Alarm {
weight: number;
}

上面的示例相当于

1
2
3
4
interface Alarm {
price: number;
weight: number;
}

但是需要注意的是,合并的属性的类型必须是唯一的

1
2
3
4
5
6
7
8
interface Alarm {
price: number;
}

interface Alarm {
price: number;
weight: number;
}

上面示例当中虽然字段 price 重复了,但是类型都是 number,所以不会报错,而下面这个示例则会编译错误

1
2
3
4
5
6
7
8
interface Alarm {
price: number;
}

interface Alarm {
price: string; // ❌
weight: number;
}

因为上面示例当中需要合并的类型不一致,所以报错了,另外接口中方法的合并,与函数的合并一样

1
2
3
4
5
6
7
8
9
interface Alarm {
price: number;
alert(s: string): string;
}

interface Alarm {
weight: number;
alert(s: string, n: number): string;
}

相当于

1
2
3
4
5
6
interface Alarm {
price: number;
weight: number;
alert(s: string): string;
alert(s: string, n: number): string;
}

重载与重写

最后我们再来看一个可能会与重载(overload)弄混淆的概念,那就是重写(override),这里需要注意区分两者之间的差异

  • 重写是指子类重写『继承』自父类中的方法,虽然 TypeScriptJava 相似,但是 TypeScript 中的继承本质上还是 JavaScript 中的『继承』机制(也就是原型链机制)
  • 而重载是指为同一个函数提供多个类型定义
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Animal {
speak(word: string): string {
return '动作叫' + word
}
}

class Cat extends Animal {
speak(word: string): string {
return '猫叫' + word
}
}

let cat = new Cat()

cat.speak('hello')
1
2
3
4
5
6
7
8
9
10
11
12
function double(val: number): number
function double(val: string): string
function double(val: any): any {
if (typeof val == 'number') {
return val * 2
}
return val + val
}

let r = double(1)

console.log(r)

继承与多态

既然提到了继承,那我们就再来看一个与其十分类似的概念,那就是多态

  • 继承,子类继承父类,子类除了拥有父类的所有特性外,还有一些更具体的特性
  • 多态,由继承而产生了相关的不同的类,对同一个方法可以有不同的响应
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Animal {
speak(word: string): string {
return 'Animal: ' + word
}
}

class Cat extends Animal {
speak(word: string): string {
return 'Cat:' + word
}
}

class Dog extends Animal {
speak(word: string): string {
return 'Dog:' + word
}
}

let cat = new Cat()
cat.speak('hello')

let dog = new Dog()
dog.speak('hello')

运算符

我们在上面的章节当中已经简单介绍过了可选属性和可选参数相关内容,在本小节当中我们再来稍微深入一些,来了解一些 TypeScript 当中的运算符

?. 运算符

TypeScript 3.7 实现了『可选链』(Optional Chaining)的功能,有了可选链后,我们编写代码时如果遇到 nullundefined 就可以立即停止某些表达式的运行,可选链的核心是新的 ?. 运算符

1
2
3
4
5
6
7
8
9
10
11
12
a?.b
// 相当于 a == null ? undefined : a.b
// 如果 a 是 null/undefined,那么返回 undefined,否则返回 a.b 的值

a?.[x]
// 相当于 a == null ? undefined : a[x]
// 如果 a 是 null/undefined,那么返回 undefined,否则返回 a[x] 的值

a?.b()
// 相当于a == null ? undefined : a.b()
// 如果 a 是 null/undefined,那么返回 undefined
// 如果 a.b 不是函数的话,会抛类型错误异常,否则计算 a.b() 的结果

下面我们通过一个可选的属性访问的详细示例例子来进行了解

1
const val = a?.b

为了更好的理解,我们可以看一下上面示例对应编译生成的 ES5 代码

1
var val = a === null || a === void 0 ? void 0 : a.b

上述的代码会自动检查对象 a 是否为 nullundefined,如果是的话就立即返回 undefined,这样就可以立即停止某些表达式的运行,所以我们可以利用 ?. 来替代很多使用 && 执行的空检查代码

1
2
3
4
5
if (a && a.b) { }

// 等同于 ==>

if (a?.b) { }

if (a?.b) { } 编译后的 ES 代码是下面这样的

1
2
3
4
if (
a === null || a === void 0
? void 0 : a.b) {
}

但需要注意的是,?.&& 运算符行为略有不同,&& 专门用于检测 falsy 值,比如空字符串、0NaNnullfalse 等,而 ?. 只会验证对象是否为 nullundefined,对于 0 或空字符串来说,并不会出现所谓的『短路』

可选链除了支持可选属性的访问之外,它还支持可选元素的访问,它的行为类似于可选属性的访问,只是可选元素的访问允许我们访问非标识符的属性,比如任意字符串、数字索引和 Symbol

1
2
3
function tryGetArrayElement<T>(arr?: T[], index: number = 0) {
return arr?.[index]
}

以上代码经过编译后会生成以下 ES5 代码

1
2
3
4
5
'use strict'
function tryGetArrayElement(arr, index) {
if (index === void 0) { index = 0 }
return arr === null || arr === void 0 ? void 0 : arr[index]
}

通过观察生成的 ES5 代码,很明显在 tryGetArrayElement 方法中会自动检测输入参数 arr 的值是否为 nullundefined,从而保证了我们代码的健壮性

另外,当我们尝试调用一个可能不存在的方法时也可以使用可选链,使用可选链可以使表达式自动返回 undefined 而不是抛出一个异常,比如

1
let result = obj.customMethod?.()

TypeScript 代码编译生成的 ES5 代码如下

1
2
var result = (_a = obj.customMethod) === null
|| _a === void 0 ? void 0 : _a.call(obj)

另外在使用可选调用的时候,我们要注意以下两个注意事项

  • 如果存在一个属性名且该属性名对应的值不是函数类型使用 ?. 仍然会产生一个 TypeError 异常
  • 可选链的运算行为被局限在属性的访问、调用以及元素的访问,因为它不会沿伸到后续的表达式中,也就是说可选调用不会阻止 a?.b / someMethod() 表达式中的除法运算或 someMethod 的方法调用

?? 运算符

TypeScript 3.7 版本中除了引入了前面介绍的可选链 ?. 之外,也引入了一个新的逻辑运算符,那就是『空值合并运算符(??)』,当左侧操作数为 nullundefined 时,其返回右侧的操作数,否则返回左侧的操作数

与逻辑或 || 运算符不同,逻辑或会在左操作数为 falsy 值时返回右侧操作数,也就是说如果你使用 || 来为某些变量设置默认的值时,你可能会遇到意料之外的行为,比如为 falsy 值(''NaN0)时,下面来看一个具体的示例

1
2
3
4
5
const foo = null ?? 'default string'
console.log(foo) // => default string

const baz = 0 ?? 42
console.log(baz) // => 0

以上 TypeScript 代码经过编译后,会生成以下 ES5 代码

1
2
3
4
5
6
7
'use strict'
var _a, _b
var foo = (_a = null) !== null && _a !== void 0 ? _a : 'default string'
console.log(foo) // => default string

var baz = (_b = 0) !== null && _b !== void 0 ? _b : 42
console.log(baz) // => 0

通过观察以上代码,我们更加直观的了解到,空值合并运算符是如何解决前面 || 运算符存在的潜在问题,下面我们来看看空值合并运算符的特性和使用时的一些注意事项

  • 短路

当空值合并运算符的左表达式不为 nullundefined 时,不会对右表达式进行求值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function A() {
console.log('A was called')
return undefined
}

function B() {
console.log('B was called')
return false
}

function C() {
console.log('C was called')
return 'foo'
}

A() ?? C()
B() ?? C()

上述代码运行后,控制台会输出以下结果

1
2
3
4
5
A was called 
C was called
foo
B was called
false
  • 不能与 &&|| 操作符共用

若空值合并运算符 ?? 直接与 AND&&)和 OR||)操作符组合使用 ?? 是不行的,这种情况下会抛出 SyntaxError

1
2
3
4
5
// '||' and '??' operations cannot be mixed without parentheses.(5076)
null || undefined ?? 'foo' // raises a SyntaxError

// '&&' and '??' operations cannot be mixed without parentheses.(5076)
true && undefined ?? 'foo' // raises a SyntaxError

但当使用括号来显式表明优先级时是可行的

1
(null || undefined) ?? 'foo' // => foo
  • 与可选链操作符 ?. 的关系

空值合并运算符针对 undefinednull 这两个值,可选链式操作符 ?. 也是如此,可选链式操作符对于访问属性可能为 undefinednull 的对象时非常有用

1
2
3
4
5
6
7
8
9
10
11
12
interface Customer {
name: string
city?: string
}

let customer: Customer = {
name: 'zhangsan'
}

let customerCity = customer?.city ?? 'Unknown city'

customerCity // => Unknown city

前面我们已经介绍了空值合并运算符的应用场景和使用时的一些注意事项,该运算符不仅可以在 TypeScript 3.7 以上版本中使用,当然也可以借助 Babel 来在 JavaScript 的环境中使用它,Babel 7.8.0 版本开始支持空值合并运算符

总结

以上就是我们梳理的一些 TypeScript 当中的基础内容,在下一章 深入 TypeScript 当中我们会接着来了解一些 TypeScript 当中的进阶内容,比如元祖,枚举,类等

参考

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×