TypeScript 语法

目录

  • 前言
  • 一、TypeScript 数据类型
    • 1、七种原始数据类型
      • (1)、布尔——boolean
      • (2)、数字——number
      • (3)、字符串——string
      • (4)、对象值缺失——null 和 未定义的值——undefined
        • ①、如何判定一个变量不为 null 或 undefined 呢?
      • (5)、symbol
        • ①、定义一个 symbol
        • ②、symbol 的用途
        • ③、内置的 Symbols 属性和方法
      • (6)、bigInt
    • 2、非原始数据类型
      • (1)、任意类型—any
      • (2)、没有任何类型——void
        • ①、指定函数的返回值类型为 void
        • ②、定义一个 void 类型的变量
      • (3)、永远不会有返回值的类型——never
      • (4)、数组
      • (5)、元组
      • (6)、对象
        • ①、ts 中创建一个对象
        • ②、在函数中使用 object 类型制定参数类型或返回值类型
      • (7)、枚举——enum
        • ①、数字枚举 和 字符串枚举
          • I、数字枚举
          • II、字符串枚举
        • ②、常量枚举
        • ③、外部枚举
        • ④、枚举的实现原理
      • (8)、字符串字面量类型
  • 二、TypeScript 类型高阶
    • 1、类型推断
      • (1)、类型推断基础
      • (2)、最佳通用类型
      • (3)、上下文类型
        • ①、认识 ts 的上下文类型
        • ②、解决 上下文类型 的报错
        • ③、用 上下文类型 作为 最佳通用类型
    • 2、类型断言
      • (1)、类型断言 的语法
      • (2)、类型断言的用途
        • ①、联合类型可以被断言为其中一个类型
        • ②、父类可以被断言为子类
        • ③、任何类型都可以被断言为 any(非必要不如此)
        • ④、any 可以被断言为任何类
      • (3)、类型断言的限制
      • (4)、双重断言
      • (5)、类型断言 与 类型转换
      • (6)、类型断言 与 类型声明
      • (7)、类型断言 与 泛型
    • 3、交叉类型
      • (1)、认识 交叉类型
      • (2)、在使用 交叉类型 时一个常见的报错及及其解决案例
    • 4、联合类型
      • (1)、认识 联合类型
      • (2)、联合类型 与 交叉类型 的区别
      • (3)、联合类型 的使用
      • (4)、只能访问这个联合类型的所有类型的共有成员
    • 5、类型保护
      • (1)、为什么需要做 类型保护?
      • (2)、TS 提供的类型保护机制
        • ①、类型谓词——`is`
        • ②、类型判断——`typeof`
        • ③、实例判断——`instanceof`
      • (3)、可为 null 的类型
        • ①、严格空值检查模式(--strictNullChecks)下的 null 和 undefined
        • ②、--strictNullChecks 在 可选参数 上的应用
        • ③、--strictNullChecks 在 可选属性 上的应用
        • ④、null 的类型保护 和 类型断言
      • (4)、字符串字面量类型
    • 4、类型别名
      • (1)、直接给类型起别名
      • (2)、用 type 关键字给类型起别名
  • 三、变量与常量
    • 1、变量的命名规则
    • 2、声明一个变量或常量
  • 四、声明文件
  • 五、接口
    • 1、定义一个接口
      • (1)、接口的 可选属性 和 只读属性
      • (2)、只读数组
      • (3)、额外的属性检查
        • ①、类型断言:as
        • ②、将“对象字面量”入参转换为一个“变量”
        • ③、添加一个 字符串的“签名索引”
    • 2、用接口描述函数
    • 3、可索引的类型
      • (1)、同时使用 字符串签名 和 数字签名
      • (2)、字符串的索引签名的 Dictionary 模式
      • (3)、只读的索引签名
    • 4、类类型——类式实现接口(`implements`)
      • (1)、用类实现一个接口
      • (2)、接口只描述了类的公共部分,不会检查类的私有成员
      • (3)、类的 实例接口 和 构造器接口
    • 5、继承接口
    • 6、混合类型
    • 7、接口继承类
  • 六、 类
    • 1、类的基本示例
    • 2、类的继承
      • (1)、简单的类的继承
      • (2)、复杂的类的继承
      • (3)、多重继承
    • 3、类的多态——重写类
    • 4、类的修饰符——公共的、私有的 和 受保护的修饰符 + readonly 修饰符
      • (1)、公共的、私有的 和 受保护的修饰符
        • ①、public 修饰符
        • ②、private 修饰符
        • ③、protected 修饰符
      • (2)、readonly 修饰符
      • (3)、参数属性
    • 5、存取器(getters 和 setters)
    • 6、静态属性
    • 7、抽象类
    • 8、类的高级技巧
      • (1)、快速创建一个类的副本
      • (2)、类可以作为接口使用
      • (3)、类类型——类式实现接口(`implements`)
  • 七、TS 函数
    • 1、函数的基本示例
    • 2、函数类型
      • (1)、参数类型的 2 种实现形式
        • ①、左边不写类型,右边写类型
        • ②、左边写类型,右边不写类型
      • (2)、可选参数 + 默认参数 + 剩余参数
        • ①、可选参数
        • ②、默认参数
        • ③、剩余参数
      • (3)、函数类型的推断类型
    • 3、this
      • (1)、js 中 this 的使用
      • (2)、this 参数
        • ①、用 ts 重构上面的 deck 案例
        • ②、this 参数在回调函数里面
    • 4、重载
      • (1)、函数重载的 3 种形式
        • ①、形参类型不同的函数重载
        • ②、形参数量不同的函数重载
        • ③、形参类型顺序不同的函数重载
      • (2)、函数重载的特点与技巧
      • (3)、函数重载的案例
        • ①、案例一
        • ②、案例二
    • 5、在给函数传参时应不应该“绕过”额外的属性的类型检查?怎么做?
  • 八、泛型()
    • 1、基本示例
      • (1)、什么是泛型?
      • (2)、泛型函数的 2 种使用方式
    • 2、使用泛型变量
    • 3、泛型类型
      • (1)、定义 泛型类型 的 2 种方式
      • (2)、创建泛型接口
    • 4、泛型类
    • 5、泛型约束
      • (1)、用接口约束泛型函数的类型变量
      • (2)、在泛型约束中使用其他的类型参数
      • (3)、用 类 约束泛型函数的类型变量——泛型中使用类类型
  • 九、JSX
    • 1、jsx 所需的配置
    • 2、as 操作符
  • 十、Mixins(不利于代码维护管理,不推荐使用)

前言

搭建 TypeScript 环境 & TSC 命令的使用 & 配置 tsconfig 文件
如何进阶TypeScript功底?一文带你理解TS中各种高级语法

TypeScript 是安德斯·海尔斯伯格(C#的首席架构师)于 2012 年 10 月发布。

TS 的特点:

  • TS 完全支持 JS,另外,TS 也有自己的特性,比如:接口、强制类型、泛型等等。
  • TS 可以编译出纯净的、简洁的 JS 代码,并可以运行在任何浏览器上,以及 node.js 环境中 和 任何支持 ECMAScript 3+ 的 JS 引擎中。
  • TS 提供了静态代码分析能力,结合它的强制类型约束特性,就能大大降低代码出错的概率,提高代码的质量,方便代码的检查、运维和重构。相较而言,js 是若类型语言,所以对类型不敏感,可能会存在一些潜在的问题。
  • TS 完全支持 ES 6+ 新的语法功能。
  • TS 完全支持面向对象特性,比如:类、接口、封装、继承、多态等。

TypeScript 编译器:tsc。

【注意】目前大多数浏览器不支持 TS,需要编译成 JS 后,才能在浏览器上运行。


一、TypeScript 数据类型

JavaScript 的类型分为两种:

  • 原始数据类型(基本类型):Boolean、Number、String、Null、Undefined、Symbol(ES6) 和 BigInt(ES10)。
  • 对象类型(引用类型):Object、Array、Funtion、RegExp 和 Date。
  • 内置对象:Global、Math(数学计算)。

TypeScript 数据类型有 14 种:

  • 基本类型(原始类型):
    • boolean
    • number
    • string
    • null
    • undefined
    • symbol
    • bigInt
  • 引用类型(非原始类型):
    • array:数组
    • tuple:元组
    • object:对象
    • enum:枚举
    • any:任意类型
    • never:永远不会有返回值的类型
    • void:没有任何返回值的类型

1、七种原始数据类型

(1)、布尔——boolean

表示逻辑值:true 和 false。

let isDone: boolean = false;

(2)、数字——number

支持二进制、八进制、十进制和十六进制字面量。

let decLiteral: number = 6;

【注意】TypeScript 和 JavaScript 都没有整数类型,都是浮点数。

(3)、字符串——string

可以使用 双引号、单引号 或 模版字符串 来表示字符串。

let name: string = "bob";

(4)、对象值缺失——null 和 未定义的值——undefined

  • null:表示对象值缺失。
  • undefined:用于初始化变量为一个未定义的值。

ts 中的 null 类型 和 undefined 类型的特点:

  • 我们可以为一个变量声明为 undefined 或 null。
  • 声明为 undefined 或 null 的变量不能被赋值为任何其他数据类型的值,只能被赋值为它本身。
  • 在 ts 官方文档中规定:“undefined 和 null 可以是任何类型的子类型”。这说明它可以被赋值给其他类型。
let un: undefined = undefined
let nu: null = null
// un = 'a' // 报错:不能将类型“"a"”分配给类型“undefined”。
// nu = 1 // 报错:不能将类型“"1"”分配给类型“null”。

// 可以将其他类型的值赋值给 undefined 或 null 类型的变量
let test: number | undefined | null = 1
test = undefined
test = null

【注意】
当你指定了 --strictNullChecks 标记,null 和 undefined 只能赋值给 void 和 它们自身。
--strictNullChecks 标记:等价于在 tsconfig.json 文件中的 compilerOptions 对象里设置了 strict: 'true',从而开启了严格模式。

①、如何判定一个变量不为 null 或 undefined 呢?

在变量后使用“ ! ”即可:变量名!

例如:

export interface DataConfig {
  url?: string
  params?: any
  type?: string
}

function transformData(config: DataConfig): string {
    const { url, params } = config1⃣️
    return buildUrl(url!, params)
}

(5)、symbol

Symbol 是 ES2015 新增类型,它的功能类似于一种唯一标识的 ID。

symbol 类型的特点:

  • symbol 类型的值是通过 Symbol 构造函数创建的。
  • symbols 类型的值具有唯一且不可变的特点。

①、定义一个 symbol

let s1: symbol = Symbol() // 显示声明一个symbol
let s2 = Symbol()

console.log('比较symbol的变量:', s1 === s2); // false。因为 symbol 是唯一的。

②、symbol 的用途

–> 像字符串一样,symbols 可以被用做——对象属性的键

let sym = Symbol();

let obj = {
    [sym]: "value"
};

console.log(obj[sym]); // "value"

–> Symbols 也可以与 计算出的属性名声明 相结合来——声明对象的属性 和 类成员

const getClassNameSymbol = Symbol();

class C {
    [getClassNameSymbol](){
       return "C";
    }
}

let c = new C();
let className = c[getClassNameSymbol](); // "C"

③、内置的 Symbols 属性和方法

属性 描述
Symbol.hasInstance 方法,会被instanceof运算符调用。构造器对象用来识别一个对象是否是其实例。
Symbol.isConcatSpreadable 布尔值,表示当在一个对象上调用Array.prototype.concat时,这个对象的数组元素是否可展开。
Symbol.iterator 方法,被for-of语句调用。返回对象的默认迭代器。
Symbol.match 方法,被String.prototype.match调用。正则表达式用来匹配字符串。
Symbol.replace 方法,被String.prototype.replace调用。正则表达式用来替换字符串中匹配的子串。
Symbol.search 方法,被String.prototype.search调用。正则表达式返回被匹配部分在字符串中的索引。
Symbol.species 函数值,为一个构造函数。用来创建派生对象。
Symbol.split 方法,被String.prototype.split调用。正则表达式来用分割字符串。
Symbol.toPrimitive 方法,被ToPrimitive抽象操作调用。把对象转换为相应的原始值。
Symbol.toStringTag 方法,被内置方法Object.prototype.toString调用。返回创建对象时默认的字符串描述。
Symbol.unscopables 对象,它自己拥有的属性会被with作用域排除在外。

(6)、bigInt

BigInt 是ES2020新增数据类型,用于支持比Number数据类型支持的范围更大的整数值。使用BigInt,整数溢出的问题将不复存在。

// bigint 数值,需要在数字后面加字母n
const bigint1: bigint = 999999999999999999n
// 也可以使用 bigint 构造函数来表示
const bigint2: bigint = BigInt('9999999999999')

2、非原始数据类型

(1)、任意类型—any

在 ts 中,如果我们不指定一个变量的类型,那么默认这个变量的类型是any类型。我们可以任意给它赋值。所以,如果不是特殊情况,我们不建议将变量定义为 any 类型。否则,就没必要使用 ts 了。

any 表示:任意类型。

例如:

let notSure: any = 4;
notSure = "qwert";
notSure = true;

当一个数组中要存储多个数据,可数据的 个数 或 类型 不确定时,就需要使用 any 类型来定义数组。不过,其弊端是,有时候在代码中不会有类型的错误提示信息(代码编译竟然能通过),幸亏在浏览器上会正常抛出此错误。

let arr: any[] = [123, 'qwert', true]
// 若像上面这样定一个不确定个数和类型的数组,
console.log(arr[0].split('')); // 数字类型的数据是没有split方法的,所以此处应该报错,但是代码编译通过了,幸亏浏览器能正常捕获此错误。

(2)、没有任何类型——void

在 js 中,void 是一种操作符,它可以让任表达式返回 undefined,比如:返回 undefined 最便捷的一个方法是执行代码——void 0;
在 ts 中,void 表示:没有任何返回值的类型。比如:一个没有任何返回值的函数,它的类型就是 void 类型。

void 类型与 any 类型相反,它表示:没有任何类型。

void 的使用:

  • 指定函数的返回值类型为 void。
  • 定义一个 void 类型的变量。

①、指定函数的返回值类型为 void

当一个函数没有返回值时,可以指定其返回值类型为 void。

例如:

function showMsg(): void {
    //
}
console.log(showMsg()); // undefined

在指定了一个函数的返回值类型为 void 后,若该函数体中使用了 return 关键字返回了任意类型的值,都可以正常返回。

function showMsg(): void {
    return 123
}
console.log(showMsg()); // 123

②、定义一个 void 类型的变量

可以定义一个 void 类型的变量,不过意义不大,因为它只能等于 undefined 或 null。

例如:

let vd: void = undefined;
let vd: void = null;

(3)、永远不会有返回值的类型——never

nerver 类型表示:永远不会有返回值的类型。

nerver 类型的特点:

  • never 仅表示类型,是任何类型的子类型,但不可以作为值使用。
  • 没有类型是 never 类型的子类型,也就是说,不能把任何类型的值赋值给 never 类型, 即使 any 类型的值也不可以赋值给 never 类型的变量。

有两种情况 永远不会有返回值的类型:

// 1、一个函数抛出了异常,那么这个函数永远不会有返回值,那么它的类型就是 never。
let error = () => {
  throw new Error('error')
}

// 2、死循环函数永远不会有返回值,那么它的类型也是 never。
let endless = () => {
  while(true) {}
}

(4)、数组

定义一个 TypeScript 数组的两种方式:

  • 使用 : 数据类型[] 定义一个数组。
  • 使用数组泛型 : Array<数据类型> 定义一个数组。

在 TypeScript 里,数组定义后,里面的数据的类型必须和定义数组的时候的类型一致,否则会有错误提示,编译失败。

let list1: number[] = [1, 2, 3];
let list2: Array<number> = [1, 2, 3]; // 数组泛型

【注意】
在 TypeScript 里,若要存储不同类型的数据,可以但不建议使用数组(建议用元组):

// 可以但不建议使用数组存储不同类型的数据
const arr: (string | number | boolean)[] = [1, '2', true];

(5)、元组

元组本身就是一个数组,它更像是 JavaScript 里的数组,与 typescript 里的数组不同的是:各元素的类型不必相同。

在使用元组类型时,数据的 类型、个数 和 位置,必须与定义元组时限定的数据的 类型、个数 和 位置 一一对应

// 声明一个元组类型
let x: [string, number] = ['qwer', 123, true];

建议: 数组(Array)用于保存相同类型元素集合,元组(Tuple)用于保存不同类型元素集合

【拓展】元组越界问题

let tuple: [number, string] = [1, 'a']
tuple.push(3)

console.log('为元组push一个新成员: ', tuple);
// [1, 'a', 3] 这歌结果说明:ts允许我们往元组中push新的元素,但是访问呢?

console.log('访问push进元组的数据:', tuple1[2]);
// 报错提示:“长度为 "2" 的元组类型 "[number, string]" 在索引 "2" 处没有元素”。由此可见,无法访问。

由上述代码推论可知:我们可以通过 push 方法为元组添加新元素,但是仍然不能进行越界访问。实际开发中强烈不建议这样使用元组。

(6)、对象

object 是包含一组键值对的实例。

①、ts 中创建一个对象

// 直接创建一个对象
const obj = {};
// 使用接口(Interfaces)来定义对象
interface Person {
    name : string ,
    age: number
}

【注意】
与 js 不同,在 ts 中,不允许通过 “对象.属性名” 的方式直接修改对象里属性的值。

let obj: object = { x: 1, y: 2 }

因为我们只是简单的定义 obj 是一个 object,但并未定义 obj 应该包含的属性的数据类型。所以,正确的方法如下:

let obj2: {x: number, y: number} = { x: 1, y: 2 }
obj2.x = 3

②、在函数中使用 object 类型制定参数类型或返回值类型

function getObj (obj: object): object {
  return {}
}

(7)、枚举——enum

TypeScript 中的枚举是:一组有名字的常量集合。

定义枚举类型的语法:

enum 枚举名 {
  成员一,
  成员二 = 成员二的值 // 成员二的值包括:数字(常量 或 计算)和 字符串
};

枚举的特性:

  • 枚举按数据类型可分为:数字枚举 和 字符串枚举。
  • const 声明的枚举是常量枚举。
  • declare 声明的枚举是外部枚举。
  • 枚举成员的值,只有定义时可写,之后仅可读。

①、数字枚举 和 字符串枚举

I、数字枚举
  • 一个不赋值的枚举,默认是数字枚举。
  • 数字枚举里,默认第一个成员的默认值是 0,之后的成员的默认值依次递增。也可以手动指定某个成员的默认值,其后的成员的默认值以修改后的值为基础依次递增。
  • 枚举成员的值定义后仅可读,所以,可以通过枚举成员的值获取其对应的成员,也可以通过枚举成员获取其对应的枚举成员的值。
  • 数字枚举成员可以是:常量成员 或 计算成员。
    • 常量成员(常量枚举表达式):
      • 枚举表达式字面量,包括:字符串字面量 或 数字字面量。比如:a, b = 1, c = ‘qwer’。
      • 对已有枚举成员的引用(可以引用其他枚举中的成员)。
      • 通过一元和二元计算得来的 常数枚举表达式:
        • 进行一元运算:+, -, ~。例如: -1, -100。
        • 进行二元运算: +, -, *, /, %, <<, >>, >>>, &, |, ^。例如:1 + 3。
        • 若常数枚举表达式求值后为 NaN 或 Infinity,编译时会报错。
    • 计算成员:
      • 一些非常量的表达式,比如:‘qwer’.length。
    • 紧跟在计算成员后的第一个常量成员,必须手动赋值,否则会报错。
    • 常量成员的值,会在编译时赋给该成员。计算成员的值,会在执行时计算,编译时会以表达式的形式赋给该成员。
  • 数字枚举在编译为 js 代码时,是可以双向映射的。

例如:

// 定义一个枚举
enum Char {
  // 常量成员
  a, // 默认是 0
  b, // 默认是 1
  c = 100,
  d = Char.a + 3,
  
  // 计算成员
  e = Math.random(),
  f = 'qwer'.length,
  g = 1, // 紧跟在计算成员后的第一个常量成员,必须手动赋值,否则会报错。
};
II、字符串枚举
  • 字符串枚举在编译为 js 代码时,只能正向映射,而不能反向映射。

例如:

enum Direction {
    Up = "UP",
    Down = "DOWN",
    Left = "LEFT",
    Right = "RIGHT",
}

【拓展】

  • 异构枚举是:既包含数字,也包含字符串的枚举。可以但不建议
  • 枚举中的元素可以是中文,但不推荐。

例如:

enum BooleanLikeHeterogeneousEnum {
    No = 0,
    Yes = "YES",
    other = "日月"
}

②、常量枚举

const 声明的枚举是常量枚举。

  • 常量枚举里只能使用常量成员,不能使用计算成员。
  • 常量枚举的成员会在编译阶段被移除。
  • 常量枚举的作用:当我们不需要一个对象,而需要对象的值的时候,就可以使用常量枚举。这样会减少在编译环境的代码。
const enum Enum {
    A = 1,
    B = A * 2
}

③、外部枚举

declare 声明的枚举是外部枚举。

  • 外部枚举的成员会在编译阶段被移除。
  • 外部枚举用来描述已经存在的枚举类型的形状。
  • 与正常枚举相比,外部枚举里,没有初始化方法的成员会被当作 计算成员,在编译时不会被执行。
declare enum Enum {
    A = 1,
    B,
    C = 2
}

【注意】以 declare 声明的变量和模块后,其他地方不需要引入,就可以直接使用了。

④、枚举的实现原理

枚举的实现原理是:双向映射。

例如:有这样一个枚举

enum Direction {
    Up,
    Down,
    Left,
    Right
}

将其编译为 js:

var Direction;
(function (Direction) {
    Direction[Direction["Up"] = 0] = "Up";
    Direction[Direction["Down"] = 1] = "Down";
    Direction[Direction["Left"] = 2] = "Left";
    Direction[Direction["Right"] = 3] = "Right";
})(Direction || (Direction = {}));

可见,它先以键取值(正向映射),又以值取键(反向映射)。

不过,字符串枚举 是不能反向映射的。

(8)、字符串字面量类型

字符串字面量类型用来约束取值只能是某几个字符串中的一个。

可以用 type 关键字来定义字符串字面量类型。

例如:

type EventNames = 'click' | 'scroll' | 'mousemove';

function handleEvent(ele: Element, event: EventNames) {
    // do something
}

handleEvent(document.getElementById('hello'), 'scroll');  // 没问题
handleEvent(document.getElementById('world'), 'dblclick'); // 报错,event 不能为 'dblclick'
// index.ts(7,47): error TS2345: Argument of type '"dblclick"' is not assignable to parameter of type 'EventNames'.

上例中,我们使用 type 定了一个字符串字面量类型 EventNames,它只能取三种字符串中的一种。


二、TypeScript 类型高阶

1、类型推断

(1)、类型推断基础

在 ts 中,在有些没有明确指出类型的地方,tsc 类型推断就会帮助我们提供类型。

let x1 = 3//此时,x1 变量的类型就会默认推断为 number。

这种推断,除了发生在变量中,还可以发生在初始化变量的时候,或者是设置默认参数、决定参数返回值的时候。

(2)、最佳通用类型

最佳通用类型:有时我们需要从几个表达式中推断类型,那么我们会使用这些表达式类型来推断出一个最合适的通用类型。

例如:

let x2 = [0, 1, null]

为了推断 x2 的类型,必须考虑所有元素的类型,这里我们两种选择,一个是 number,一个是 null。推断通用类型就会考虑到所有的候选类型,然后给出一个兼容所有候选类型的一个类型。x2 的候选类型是 number 和 null 这样的一个联合类型。

最终的通用类型取自于候选类型。有些时候,候选类型是共享一个公共结构的,但他们却没有一个类型能作为所有候选类的超级类型。

例如:

class Animal {
    numLegs: number
}

class Bee extends Animal {}

class Lion extends Animal {}

let zoo = [new Bee(), new Lion()]

这里,我们想要 zoo 被推断为一个 Animal 数组类型。但是这个数组中没有一个对象明确是 Animal 类型的。因此,它是不能被推断出这个结果的。此时,zoo 找不到最佳的通用类型,就会被推断为 Bee 和 Lion 的一个联合数组类型。所以,有些时候,我们为了更正它,我们可以明确声明我们期望的类型。比如:

let zoo2: Animal[] = [new Bee(), new Lion()]

(3)、上下文类型

①、认识 ts 的上下文类型

有时候 TS 的类型推断会按照另外一种方式进行——上下文类型。

上下文类型的出现,和表达式的类型,以及它所处的位置是相关的。

比如:

window.onmousedown = function (mouseEvent) {
    console.log(mouseEvent.clickTime);
}

这个列子,我们会得到一个错误:clickTime is undefined。

这是因为:TS 类型检查器,会使用 window.onmousedown 的函数类型,来推断右边函数表达式的类型。那么我们就可以推断出这个参数的类型。但是,mouseEvent 是 event 类型,它显然是没有 clickTime 属性的,访问了一个不存在的属性,所以会报 undefined。我们可以通过给 上下文表达式 指定一个明确的类型,来解决这个问题。

②、解决 上下文类型 的报错

如果上下文表达式包含明确的类型解析,那么这个上下文类型就会被忽略。

比如:

window.onmousedown = function (mouseEvent: any) {
    console.log(mouseEvent.clickTime);
}

这样明确指明类型为 any 后,这个上下文类型就被忽略了,就不报错了。

上下文类型是一个非常有用的一个类型推断,它在很多情况下都会用到,通常包括函数的参数、赋值表达式右边、类型断言、对象成员、函数的 字面量 和 返回值语句。

③、用 上下文类型 作为 最佳通用类型

上下文类型也可以作为 最佳通用类型的一个候选类型。

例如:

class Animal2 {
    numLegs: number
}

class Bee2 extends Animal2 {}

class Lion2 extends Animal2 {}

function createZoo (): Animal2[] {
    return [new Bee2(), new Lion2()];
}

此时,最佳通用类型有 3 个候选类型: Animal2、Bee2 和 Lion2。这里 Animal2 就会作为最佳通用类型。这也是上下文类型的一个应用。

2、类型断言

“类型断言”:可以用来手动指定一个值的类型。

类型断言 不是 强制类型转换。

(1)、类型断言 的语法

语法:值 as 类型<类型>值

例如:

let someValue: any = "this is a string";

let strLength1: number = (<string>someValue).length;
let strLength2: number = (someValue as string).length;

【注意】:在 tsx 语法(React 的 jsx 语法的 ts 版)中必须使用前者——故建议大家统一使用 as 的语法做类型断言。

(2)、类型断言的用途

  • 联合类型可以被断言为其中一个类型
  • 父类可以被断言为子类
  • 任何类型都可以被断言为 any(非必要不如此)
  • any 可以被断言为任何类型

①、联合类型可以被断言为其中一个类型

interface Cat {
    name: string;
    run(): void;
}
interface Fish {
    name: string;
    swim(): void;
}

function getName(animal: Cat | Fish) {
    return animal.name;
}

而有时候,我们确实需要在还不确定类型的时候就访问其中一个类型特有的属性或方法,比如:

interface Cat {
    name: string;
    run(): void;
}
interface Fish {
    name: string;
    swim(): void;
}

function isFish(animal: Cat | Fish) {
    if (typeof animal.swim === 'function') {
        return true;
    }
    return false;
}

// index.ts:11:23 - error TS2339: Property 'swim' does not exist on type 'Cat | Fish'.
//   Property 'swim' does not exist on type 'Cat'.

上面的例子中,获取 animal.swim 的时候会报错。

此时可以使用类型断言,将 animal 断言成 Fish:

interface Cat {
    name: string;
    run(): void;
}
interface Fish {
    name: string;
    swim(): void;
}

function isFish(animal: Cat | Fish) {
    if (typeof (animal as Fish).swim === 'function') {
        return true;
    }
    return false;
}

这样就可以解决访问 animal.swim 时报错的问题了。

需要注意的是,类型断言只能够 “欺骗” TypeScript 编译器,无法避免运行时的错误,反而滥用类型断言可能会导致运行时错误:

interface Cat {
    name: string;
    run(): void;
}
interface Fish {
    name: string;
    swim(): void;
}

function swim(animal: Cat | Fish) {
    (animal as Fish).swim();
}

const tom: Cat = {
    name: 'Tom',
    run() { console.log('run') }
};
swim(tom);
// Uncaught TypeError: animal.swim is not a function`

上面的例子编译时不会报错,但在运行时会报错。原因是 (animal as Fish).swim() 这段代码隐藏了 animal 可能为 Cat 的情况,将 animal 直接断言为 Fish 了,而 TypeScript 编译器信任了我们的断言,故在调用 swim() 时没有编译错误。可是 swim 函数接受的参数是 Cat | Fish,一旦传入的参数是 Cat 类型的变量,由于 Cat 上没有 swim 方法,就会导致运行时错误了。

②、父类可以被断言为子类

当类之间有继承关系时,类型断言也是很常见的:

class ApiError extends Error {
    code: number = 0;
}
class HttpError extends Error {
    statusCode: number = 200;
}

function isApiError(error: Error) {
    if (typeof (error as ApiError).code === 'number') {
        return true;
    }
    return false;
}

上面的例子中,我们声明了函数 isApiError,它用来判断传入的参数是不是 ApiError 类型,为了实现这样一个函数,它的参数的类型肯定得是比较抽象的父类 Error,这样的话这个函数就能接受 Error 或它的子类作为参数了。

但是由于父类 Error 中没有 code 属性,故直接获取 error.code 会报错,需要使用类型断言获取 (error as ApiError).code。

大家可能会注意到,在这个例子中有一个更合适的方式来判断是不是 ApiError,那就是使用 instanceof:

class ApiError extends Error {
    code: number = 0;
}
class HttpError extends Error {
    statusCode: number = 200;
}

function isApiError(error: Error) {
    if (error instanceof ApiError) {
        return true;
    }
    return false;
}

上面的例子中,确实使用 instanceof 更加合适,因为 ApiError 是一个 JavaScript 的类,能够通过 instanceof 来判断 error 是否是它的实例。

但是有的情况下 ApiError 和 HttpError 不是一个真正的类,而只是一个 TypeScript 的接口(interface),接口是一个类型,不是一个真正的值,它在编译结果中会被删除,当然就无法使用 instanceof 来做运行时判断了:

interface ApiError extends Error {
    code: number;
}
interface HttpError extends Error {
    statusCode: number;
}

function isApiError(error: Error) {
    if (error instanceof ApiError) {
        return true;
    }
    return false;
}
// index.ts:9:26 - error TS2693: 'ApiError' only refers to a type, but is being used as a value here.

此时就只能用类型断言,通过判断是否存在 code 属性,来判断传入的参数是不是 ApiError 了:

interface ApiError extends Error {
    code: number;
}
interface HttpError extends Error {
    statusCode: number;
}

function isApiError(error: Error) {
    if (typeof (error as ApiError).code === 'number') {
        return true;
    }
    return false;
}

③、任何类型都可以被断言为 any(非必要不如此)

理想情况下,TypeScript 的类型系统运转良好,每个值的类型都具体而精确。

当我们引用一个在此类型上不存在的属性或方法时,就会报错:

const foo: number = 1;
foo.length = 1;

// index.ts:2:5 - error TS2339: Property 'length' does not exist on type 'number'.

上面的例子中,数字类型的变量 foo 上是没有 length 属性的,故 TypeScript 给出了相应的错误提示。

这种错误提示显然是非常有用的。

但有的时候,我们非常确定这段代码不会出错,比如下面这个例子:

window.foo = 1;

// index.ts:1:8 - error TS2339: Property 'foo' does not exist on type 'Window & typeof globalThis'.

上面的例子中,我们需要将 window 上添加一个属性 foo,但 TypeScript 编译时会报错,提示我们 window 上不存在 foo 属性。

此时我们可以使用 as any 临时将 window 断言为 any 类型:

(window as any).foo = 1;

在 any 类型的变量上,访问任何属性都是允许的。

需要注意的是,将一个变量断言为 any 可以说是解决 TypeScript 中类型问题的最后一个手段。

它极有可能掩盖了真正的类型错误,所以:如果不是非常确定,就不要使用 as any。

④、any 可以被断言为任何类

在日常的开发中,我们不可避免的需要处理 any 类型的变量,它们可能是由于第三方库未能定义好自己的类型,也有可能是历史遗留的或其他人编写的烂代码,还可能是受到 TypeScript 类型系统的限制而无法精确定义类型的场景。

遇到 any 类型的变量时,我们可以选择无视它,任由它滋生更多的 any。

我们也可以选择改进它,通过类型断言及时的把 any 断言为精确的类型,亡羊补牢,使我们的代码向着高可维护性的目标发展。

举例来说,历史遗留的代码中有个 getCacheData,它的返回值是 any:

function getCacheData(key: string): any {
    return (window as any).cache[key];
}

那么我们在使用它时,最好能够将调用了它之后的返回值断言成一个精确的类型,这样就方便了后续的操作:

function getCacheData(key: string): any {
    return (window as any).cache[key];
}

interface Cat {
    name: string;
    run(): void;
}

const tom = getCacheData('tom') as Cat;
tom.run();

上面的例子中,我们调用完 getCacheData 之后,立即将它断言为 Cat 类型。这样的话明确了 tom 的类型,后续对 tom 的访问时就有了代码补全,提高了代码的可维护性。

(3)、类型断言的限制

并不是任何一个类型都可以被断言为任何另一个类型——具体来说,若 A 兼容 B,那么 A 能够被断言为 B,B 也能被断言为 A。

例如:

interface Animal {
    name: string;
}
interface Cat {
    name: string;
    run(): void;
}

let tom: Cat = {
    name: 'Tom',
    run: () => { console.log('run') }
};
let animal: Animal = tom;

在上述案例中,Cat 包含了 Animal 中的所有属性,除此之外,它还有一个额外的方法 run。由于 TypeScript 是结构类型系统,类型之间的对比只会比较它们最终的结构,而会忽略它们定义时的关系。 所以,TypeScript 并不关心 Cat 和 Animal 之间定义时是什么关系,而只会看它们最终的结构有什么关系——所以它与 Cat extends Animal 是等价的:

interface Animal {
    name: string;
}
interface Cat extends Animal {
    run(): void;
}

那么也不难理解为什么 Cat 类型的 tom 可以赋值给 Animal 类型的 animal 了——就像面向对象编程中我们可以将子类的实例赋值给类型为父类的变量。

我们把它换成 TypeScript 中更专业的说法,即:Animal 兼容 Cat。

当 Animal 兼容 Cat 时,它们就可以互相进行类型断言了:

interface Animal {
    name: string;
}
interface Cat {
    name: string;
    run(): void;
}

function testAnimal(animal: Animal) {
    return (animal as Cat);
}
function testCat(cat: Cat) {
    return (cat as Animal);
}

这样的设计其实也很容易就能理解:

  • 允许 animal as Cat 是因为“父类可以被断言为子类”。
  • 允许 cat as Animal 是因为既然子类拥有父类的属性和方法,那么被断言为父类,获取父类的属性、调用父类的方法,就不会有任何问题,故“子类可以被断言为父类”。

(4)、双重断言

使用双重断言可以将任何一个类型断言为任何另一个类型。

比如:

interface Cat {
    run(): void;
}
interface Fish {
    swim(): void;
}

function testCat(cat: Cat) {
    return (cat as any as Fish);
}

在上面的例子中,若直接使用 cat as Fish 肯定会报错,因为 Cat 和 Fish 互相都不兼容。

但是若使用双重断言,则可以打破「要使得 A 能够被断言为 B,只需要 A 兼容 B 或 B 兼容 A 即可」的限制,将任何一个类型断言为任何另一个类型。

若你使用了这种双重断言,那么十有八九是非常错误的,它很可能会导致运行时错误。除非迫不得已,千万别用双重断言。

(5)、类型断言 与 类型转换

类型断言只会影响 TypeScript 编译时的类型,类型断言语句在编译结果中会被删除:

function toBoolean(something: any): boolean {
    return something as boolean;
}

toBoolean(1);
// 返回值为 1

在上面的例子中,将 something 断言为 boolean 虽然可以通过编译,但是并没有什么用,代码在编译后会变成:

function toBoolean(something) {
    return something;
}

toBoolean(1);
// 返回值为 1

所以类型断言不是类型转换,它不会真的影响到变量的类型。

若要进行类型转换,需要直接调用类型转换的方法:

function toBoolean(something: any): boolean {
    return Boolean(something);
}

toBoolean(1);
// 返回值为 true

(6)、类型断言 与 类型声明

在这个例子中:

function getCacheData(key: string): any {
    return (window as any).cache[key];
}

interface Cat {
    name: string;
    run(): void;
}

const tom = getCacheData('tom') as Cat;
tom.run();

我们使用 as Cat 将 any 类型断言为了 Cat 类型。

但实际上还有其他方式可以解决这个问题:

function getCacheData(key: string): any {
    return (window as any).cache[key];
}

interface Cat {
    name: string;
    run(): void;
}

const tom: Cat = getCacheData('tom');
tom.run();

上面的例子中,我们通过类型声明的方式,将 tom 声明为 Cat,然后再将 any 类型的 getCacheData(‘tom’) 赋值给 Cat 类型的 tom。

这和类型断言是非常相似的,而且产生的结果也几乎是一样的——tom 在接下来的代码中都变成了 Cat 类型。

它们的区别,可以通过这个例子来理解:

interface Animal {
    name: string;
}
interface Cat {
    name: string;
    run(): void;
}

const animal: Animal = {
    name: 'tom'
};
let tom = animal as Cat;

在上面的例子中,由于 Animal 兼容 Cat,故可以将 animal 断言为 Cat 赋值给 tom。

但是若直接声明 tom 为 Cat 类型:

interface Animal {
    name: string;
}
interface Cat {
    name: string;
    run(): void;
}

const animal: Animal = {
    name: 'tom'
};
let tom: Cat = animal;

// index.ts:12:5 - error TS2741: Property 'run' is missing in type 'Animal' but required in type 'Cat'.

则会报错,不允许将 animal 赋值为 Cat 类型的 tom。

这很容易理解,Animal 可以看作是 Cat 的父类,当然不能将父类的实例赋值给类型为子类的变量。

深入的讲,它们的核心区别就在于:

animal 断言为 Cat,只需要满足 Animal 兼容 Cat 或 Cat 兼容 Animal 即可
animal 赋值给 tom,需要满足 Cat 兼容 Animal 才行
但是 Cat 并不兼容 Animal。

而在前一个例子中,由于 getCacheData(‘tom’) 是 any 类型,any 兼容 Cat,Cat 也兼容 any,故

const tom = getCacheData('tom') as Cat;
//等价于
const tom: Cat = getCacheData('tom');

知道了它们的核心区别,就知道了:类型声明是比类型断言更加严格的。

所以为了增加代码的质量,我们最好优先使用类型声明,这也比类型断言的 as 语法更加优雅。

(7)、类型断言 与 泛型

还是这个例子:

function getCacheData(key: string): any {
    return (window as any).cache[key];
}

interface Cat {
    name: string;
    run(): void;
}

const tom = getCacheData('tom') as Cat;
tom.run();

我们还有第三种方式可以解决这个问题,那就是泛型:

function getCacheData<T>(key: string): T {
    return (window as any).cache[key];
}

interface Cat {
    name: string;
    run(): void;
}

const tom = getCacheData<Cat>('tom');
tom.run();

通过给 getCacheData 函数添加了一个泛型 ,我们可以更加规范的实现对 getCacheData 返回值的约束,这也同时去除掉了代码中的 any,是最优的一个解决方案。

3、交叉类型

(1)、认识 交叉类型

将多个类型合并成一个类型。将现有的多种类型叠加到一起,合并成一个新的类型。大多数是在其他 混入 或者 不适合典型面向对象模型 的地方可以看到交叉类型的使用。

使用 & 管道符 可以定义 交叉类型:

let val: string & number 
let arr: number[] & string[]

(2)、在使用 交叉类型 时一个常见的报错及及其解决案例

问题再现:

interface T{}

interface U{}

function extend<T, U>(first: T, second: U): T & U {
    let result = {} as T & U

    for(let id in first) {
        // result[id] = first[id]
        //报错了:不能将类型“T[Extract]”分配给类型“(T & U)[Extract]”。
        //    不能将类型“T”分配给类型“T & U”。
        //    不能将类型“T”分配给类型“U”。
        //    “U”可以使用与“T”无关的任意类型进行实例化。
    }

    for(let id in second) {
        if(!result.hasOwnProperty(id)){
            // result[id] = second[id]
            //报错了:不能将类型“U[Extract]”分配给类型“(T & U)[Extract]”。
            //    不能将类型“U”分配给类型“T & U”。
            //    不能将类型“U”分配给类型“T”。
            //    “T”可以使用与“U”无关的任意类型进行实例化。
        }
    }
    
    return result
}

上述案例中,“T & U”这个类型就是交叉类型。

问题的解决:

此时,为了让这段代码编译通过,我们可以在复制的时候 给它指定 一个明确的 any 类型。

function extend2<T, U>(first: T, second: U): T & U {
    let result = {} as T & U

    for(let id in first) {
        result[id] = first[id] as any
    }

    for(let id in second) {
        if(!result.hasOwnProperty(id)){
            result[id] = second[id] as any
        }
    }
    
    return result
}

这样编译就通过了。

接下来,我们去使用这个 extend2 函数

class Person {
    constructor(public name: string) {}
}

interface Loggable {
    log(): void
}

class ConsoleLogger implements Loggable {
    log() {}
}

var jim = extend(new Person('jim'), new ConsoleLogger())
jim.name
jim.log()

这里,把 Person 实例和 ConsoleLogger 实例通过 extend 扩展到一起,使他成为一个交叉类型。那么,jim 实际上就是 Person 类和 ConsoleLogger 类返回结果的一个交叉。所以,jim 既可以访问 name 属性,又可以调 log 方法。

4、联合类型

(1)、认识 联合类型

使用 | 管道符 可以定义 联合类型:

let val: string | number 
let arr: number[] | string[]

(2)、联合类型 与 交叉类型 的区别

  • 联合类型:一个值可以是几种类型之一。用 | 间隔多个类型。
  • 交叉类型:一个值可以是几种类型之和。用 & 间隔多个类型。

(3)、联合类型 的使用

/**
 * @param value string
 * @param padding number | string
 */
function padLeft(value: string, padding: any) {
    if(typeof padding === 'number') {
        return Array(padding + 1).join(' ') + value
    }
    if(typeof padding === 'string') {
        return padding + value
    }
    throw new Error(`${padding}异常`)
}

padLeft('hello world', 4)
padLeft('hello world', true)

其实上述 padLeft 函数存在一个问题,因为 padding 的类型是 any,所以,实际上可以传入的值可以既不是number也不是string类型的其他类型的值。此时,ts 不会报错,只有在执行 padLeft 函数后才会抛出 new Error 里定义的错误。那么如何在书写代码时,让 ts 推断出类型的错误呢?可以使用联合类型。

function padLeft2(value: string, padding: string | number) {
    if(typeof padding === 'number') {
        return Array(padding + 1).join(' ') + value
    }
    if(typeof padding === 'string') {
        return padding + value
    }
    throw new Error(`${padding}异常`)
}

padLeft2('hello world', 4)
// padLeft2('hello world', true)
//报错了:类型“boolean”的参数不能赋给类型“string | number”的参数。

(4)、只能访问这个联合类型的所有类型的共有成员

如果一个值是联合类型,那么我们只能访问这个联合类型的所有类型的共有成员。

比如:

interface Bird {
    fly()
    layEggs()
}

interface Fish {
    swim()
    layEggs()
}

function getSmallPet(): Fish | Bird {
    //为了将问题简明的表达出来,故此处请忽略 没有返回值 的报错提示
    return {} as Fish | Bird
}

let pet = getSmallPet()
pet.layEggs()
// pet.swim()
//报错了:类型“Bird | Fish”上不存在属性“swim”。类型“Bird”上不存在属性“swim”。

这个报错是因为:swim 不是联合类型 “Fish | Bird” 的共有成员,所以会报错。而 layEggs 是联合类型 “Fish | Bird” 的共有成员,就不会报错。

5、类型保护

(1)、为什么需要做 类型保护?

举个例子:

interface Bird2 {
    fly()
    layEggs()
}

interface Fish2 {
    swim()
    layEggs()
}

function getSmallPet2(): Fish2 | Bird2 {
    //为了将问题简明的表达出来,故此处请忽略 没有返回值 的报错提示
    return {} as Fish | Bird
}

let pet2 = getSmallPet2()

在上述案例中,如果想要访问 swim,在 js 中,区分多个可能值的方法是检测成员是否存在:

if(pet2.swim) {
    pet2.swim()
}else if(pet2.fly){
    pet2.fly()
}

但是在 ts 里,我们每次访问这个成员的时候都会报错,为了让这段代码工作,我们可以使用类型断言将 pet2 强行断言成我们想要的类型。

if((pet2 as Fish2).swim) {
    (pet2 as Fish2).swim()
}else if((pet2 as Bird2).fly){
    (pet2 as Bird2).fly()
}

可见,通过断言是可以的,但是我们需要多次使用断言。那么有没有更简洁优雅的方式呢?ts 提供了 类型保护机制。

(2)、TS 提供的类型保护机制

TS 提供的类型保护机制包括:

  • 用户自定义的类型保护——类型谓词
  • typeof 类型保护——针对原始类型
  • instanceof 类型保护

①、类型谓词——is

认识类型谓词:

function isFish(pet: Fish2 | Bird2):pet is Fish2 {
    return (pet as Fish2).swim !== undefined
}

上述代码中,isFish 函数的返回值类型 “pet is Fish2” 的 is 就是类型谓词。

改写类型保护的案例:

if(isFish(pet2)) {
    pet2.swim()//一旦使用了类型谓词,ts会默认推断出 isFish 匹配的类型
} else {
    pet2.fly()//一旦使用了类型谓词,ts会默认推断出剩余的类型的属性
}

②、类型判断——typeof

在研究 typeof 之前,我们先用 类型谓词 改写下 padLeft:

function isNumber (x: any): x is number {
    return typeof x === 'number'
}

function isString (x: any): x is string {
    return typeof x === 'string'
}

function padLeft3(value: string, padding: string | number) {
    if(isNumber(padding)) {
        return Array(padding + 1).join(' ') + value
    }
    if(isString(padding)) {
        return padding + value
    }
    throw new Error(`${padding}异常`)
}

这样改写是可以的,但是,这里每个类型都去定义一个函数去它是否是指定的一个原始类型,其实判断“原始类型”时,我们没必要去额外定义函数,ts 提供了直接使用 typeof 来做类型保护的方式:

function padLeft4(value: string, padding: string | number) {
    if(typeof padding === 'number') {
        return Array(padding + 1).join(' ') + value
    }
    if(typeof padding === 'string') {
        return padding + value
    }
    throw new Error(`${padding}异常`)
}

建议:typeof 可以对 “原始类型” 做 类型保护,非原始类型可以使用 谓词 做类型保护。

③、实例判断——instanceof

instanceof 类型保护 是通过构造函数来细化类型的一种方式。用于判断一个对象是不是某一个类的实例对象。instanceof 的右边必须是一个构造函数。

例如:

class Birds {
    fly() {
        console.log('birds fly');
        
    }
    layEggs() {
        console.log('birds layEggs');
    }
}

class Fishs {
    swim() {
        console.log('Fishs swim');
    }
    layEggs() {
        console.log('Fishs layEggs');
    }
}

function getRandomPet(): Fishs | Birds {
    return Math.random() > 0.5 ? new Birds() : new Fishs()
}

let pets = getRandomPet()

//判断pets是不是Birds的实例对象,如果中间有其他值覆盖了,会出现问题
if(pets instanceof Birds) {
    pets.fly()
}
//判断pets是不是Fishs的实例对象,如果中间有其他值覆盖了,会出现问题
if(pets instanceof Fishs) {
    pets.swim()
}

(3)、可为 null 的类型

①、严格空值检查模式(–strictNullChecks)下的 null 和 undefined

ts 中具有两种特殊类型:null 和 undefined,他们分别具有值 null 和 undefied。也就是说,null 和 undefined 既可以作为类型也可以作为值。

默认情况下 null 和 undefined 是可以赋值给任何类型的,而且 null 和 undefined 是所有其他类型的一个有效值。

比如:

let s = 'f00'
// s = null
//在 tsc 开启 --strictNullChecks 的编译模式下,会报错:不能将类型“null”分配给类型“string”。

可以用联合类型来解决这个报错:

let sn: string | null = 'bar'
sn = null
// sn = undefined
//在 tsc 开启 --strictNullChecks 的编译模式下,会报错:不能将类型“undefined”分配给类型“string | null”。

这是因为:在严格空值检查模式下,null 和 undefined 无法赋值给其他类型的变量。

【拓展】--strictNullChecks 标记是“开启空值的严格检查”的,平时开发是要开启它的,它能帮助定位并解决一些问题。

②、–strictNullChecks 在 可选参数 上的应用

function f(x: number, y?: number) {
    return x + (y || 0)
}

f(1, 2)
f(1)
f(1, undefined)
//之所以能够传入 undefined,是因为:当 tsc 开启 --strictNullChecks 的时候,可选参数 y 实际上是 number 或者 undefined 的联合类型。

// f(1, null)
//在 tsc 开启 --strictNullChecks 的编译模式下,会报错:类型“null”的参数不能赋给类型“number | undefined”的参数。
//这是因为:在严格空值检查模式下,null 和 undefined 无法赋值给其他类型的变量。

③、–strictNullChecks 在 可选属性 上的应用

class C {
    a: number
    b?: number
}

let c = new C()
c.a = 1
c.b = 2
c.b = undefined
//之所以能够传入 undefined,是因为:当 tsc 开启 --strictNullChecks 的时候,可选属性 b 实际上是 number 或者 undefined 的联合类型。

// c.b = null
//在 tsc 开启 --strictNullChecks 的编译模式下,会报错:不能将类型“null”分配给类型“number | undefined”。
//这是因为:在严格空值检查模式下,null 和 undefined 无法赋值给其他类型的变量。

④、null 的类型保护 和 类型断言

由于 null 作为 可选属性可选参数 时,ts 默认会将其推断为一个 null | undefined 的联合类型。而且,只有开启 --strictNullChecks 严格空值检查模式时,ts 在编译时才可以捕获其错误。所以,有的时候 我们需要做 null 类型保护——去除 null

比如:

function f1(sn: string | null): string {
    if(sn === null) {
        return 'default'
    } else {
        return sn
    }
}
//还可以简写为
function f2(sn: string | null): string {
    return sn || 'default'
}

但是,有些时候编译器是无法去除 null 和 undefined。此时可以“用 类型断言 去手动去除”。

用 类型断言 去手动去除 null 的语法:在变量后添加 ! ——表示此变量不为 null。

function f3(sn: string | null): string {
    return sn! || 'default'
}

来看一个具体的示例:输入一个名字返回其绰号。

function broken(name: string|null): string {
    function postfix(epithet: string) {
        // return name.charAt(0) + ". the" + epithet
        //在 tsc 开启 --strictNullChecks 的编译模式下,会报错::“name”可能为 “null”。
        //这是因为:我们此处使用了一个嵌套函数 postfix,编译器是无法对嵌套函数 postfix 的 null 识别的。
        return name!.charAt(0) + ". the" + epithet
    }
    name = name || 'Bob'
    return postfix(name)
}

(4)、字符串字面量类型

字符串字面量类型 允许我们指定的 字符串 必须具有确切的值。

在实际应用中,字符串字面量类型 是可以和 联合类型、类型保护 配合着使用的。

通过结合使用它们的特性,可以实现类似于 枚举 类型的字符串。

比如:

type Easing = 'ease-in' | 'ease-out' | 'ease-in-out'

class UIElement {
    animate(dx: number, dy: number, easing: Easing) {
        if (easing === 'ease-in') {} 
        else if (easing === 'ease-out') {} 
        else if (easing === 'ease-in-out') {} 
        else {}
    }
}

let button = new UIElement()

button.animate(0, 0, 'ease-in')
// button.animate(0, 0, 'unease')
//报错了:类型“"unease"”的参数不能赋给类型“Easing”的参数。

4、类型别名

定义类型别名的方式:

  • 通过 别名: 原名 语法定义一个类型别名;
  • 通过 type 关键字定义 类型别名。

(1)、直接给类型起别名

通过 别名: 原名 语法可以直接给类型起别名。

例如:

interface Person {
    name: string
}
// 给接口 Person 起个别名 P
const P: Person = {
    name: 'Tom'
}

(2)、用 type 关键字给类型起别名

例如:

type Name = string; // 给 string 起个别名为 Name
type NameResolver = () => string; // 给 `() => string` 起个别名为 NameResolver
type NameOrResolver = Name | NameResolver; // 给 `Name | NameResolver` 起个别名为 NameOrResolver
// 给 NameOrResolver 起个别名为 n
function getName(n: NameOrResolver): Name {
    if (typeof n === 'string') {
        return n;
    } else {
        return n();
    }
}

【拓展】 type 关键字除了可以定义 类型别名,还可以定义 一个新的类型,包括:原始值、联合类型、元组以及其它任何你需要手写的类型——比如:字符串字面量类型。


三、变量与常量

1、变量的命名规则

  • 变量名称可以包含数字和字母。
  • 除了下划线 _ 和美元 $ 符号外,不能包含其他特殊字符,包括空格。
  • 变量名不能以数字开头。

2、声明一个变量或常量

变量,可改其值。常量,不可改其值。

变量使用前必须先声明,我们可以:

  • var 和 let 可以声明一个变量。
    • 建议使用 let 而不是 var 来声明一个变量,因为 var 声明的变量存在声明提升,并且用 var 多次声明同一个变量不会报错。
  • const 既可以声明一个变量,也可以来声明一个常量:
    • 常量:基本类型;
    • 变量:引用类型,如:数组、元组、对象、函数等。

常见的声明一个变量的方式:

// 使用 js 语法定义一个变量(ts 兼容所有 js 的语法)
let [变量名]; // 定义一个变量但未实现,默认值是 undefined。
let [变量名] =; // 定义并赋值一个变量

// 使用 ts 愈发定义一个变量
let [变量名] : [类型]; // 定义一个变量但未实现,默认值是 undefined。
let [变量名] : [类型] =; // 定义并赋值一个变量

例如:

let uname;
let uname = "marry";

let uname: string;
let uname: string = "marry";

【注意】变量不要使用 name 否则会与 DOM 中的全局 window 对象下的 name 属性出现了重名。


四、声明文件

当使用第三方库时,我们需要引用它的声明文件,才能获得对应的代码补全、接口提示等功能。

  • declare var 声明全局变量
  • declare function 声明全局方法
  • declare class 声明全局类
  • declare enum 声明全局枚举类型
  • declare namespace 声明(含有子属性的)全局对象
  • interface 和 type 声明全局类型
  • export 导出变量
  • export namespace 导出(含有子属性的)对象
  • export default ES6 默认导出
  • export = commonjs 导出模块
  • export as namespace UMD 库声明全局变量
  • declare global 扩展全局变量
  • declare module 扩展模块
  • /// 三斜线指令

具体请参阅:这里。


五、接口

TypeScript 通过 interface 关键字来定义接口。

广义上讲,一切皆对象,所以接口是对对象的状态(属性)和行为(方法)的抽象(描述)——为对象的属性和方法定义约束。

狭义上讲,接口可以用来约束对象、函数、类的结构 和 类型

接口的唯一一个原则:当类型检查器检查时,只要相应的属性存在且类型也匹配即可,其他皆无所谓。

当不确定一个接口中属性的个数或属性的类型时,就可以定义使用 可索引类型的接口。

1、定义一个接口

// 定义一个接口
interface RouteType {
  info: any;
  type: string;
}
// 声明一个函数,并对函数的形参进行约束
const setRouteModuleList = (list: RouteType[]) => {
  list.forEach(item => {
    // ...
  });
}
// 按照函数形参的约束来定义实参
let params = [
  {
    type: 'amy',
    info: amyModules
  }
]
// 调用这个函数,并传入实参
setRouteModuleList(params)

(1)、接口的 可选属性 和 只读属性

接口中的属性包括:

  • 必选属性:参数: 类型
  • 可选属性:参数?: 类型
  • 只读属性:readonly 参数: 类型
  • 额外属性:[剩余参数: 类型]: 数量,其中表示任意数量的数量是 any

例如:

interface LabelledValue {
  label: string;
  color?: string;
  readonly x: number;
  [propName: string]: any;
}

【拓展】Readonly 与 const 的区别:

  • Readonly:声明一个只读“属性”。
  • const:声明一个只读“变量”。

(2)、只读数组

通过 ReadonlyArray (T 表示类型)关键字可以定义一个只读数组。

let a: number[] = [1, 2, 3, 4]
let ro: ReadonlyArray<number> = a
// ro[0] = 100;			//报错:类型“readonly number[]”中的索引签名仅允许读取。
// ro.length = 10;		//报错:无法为“length”赋值,因为它是只读属性。
// a = ro;				//报错:类型 "readonly number[]" 为 "readonly",不能分配给可变类型 "number[]"。
a = ro as number[]		//利用“类型断言”是可以:将一个只读属性赋值给一个变量的。这相当于,在赋值之前将只对属性 ReadonlyArray 强转成了 number[] 类型了。

(3)、额外的属性检查

当传入的参数是对象字面量时,TS 会自动对其进行“额外的属性检查”。

若有额外的属性传入就会报错,那么怎么解决这个报错呢?

  • 类型断言:as。(不推荐,因为我们不应该绕过 ts 的额外属性检查)
  • 将“对象字面量”入参转换为一个“变量”。(不推荐,因为我们不应该绕过 ts 的额外属性检查)
  • 添加一个 字符串的“签名索引”。前提是你能确定这个对象可能具有某些额外的属性。(推荐)
interface SquareConfig2 {
    color?: string,
    width?: number,
}

function createSquare2 (config: SquareConfig2): Square {
    let newSquare = { color: 'white', area: 100 }
    if (config.color) {
        newSquare.color = config.color
    }
    if (config.width) {
        newSquare.area = config.width * config.width
    }
    return newSquare
}

//如下,显然我们误将 color 拼错成了 colorrr,此时就会触发 TS 的“额外的属性检查”。
// let mySquare2 = createSquare2({ colorrr: 'black', width: 50 });

我们分别试试不同的解决方案:

①、类型断言:as

let mySquare21 = createSquare2({ colorrr: 'black', width: 50 } as SquareConfig2);

②、将“对象字面量”入参转换为一个“变量”

const squareOption = { colorrr: 'black', width: 50 }
let mySquare22 = createSquare2(squareOption);

③、添加一个 字符串的“签名索引”

interface SquareConfig23 {
    color?: string,
    width?: number,
    [propName: string]: any//索引的属性名是一个字符串类,索引的值是任意类型。
}
function createSquare23 (config: SquareConfig23): Square {
    let newSquare = { color: 'white', area: 100 }
    if (config.color) {
        newSquare.color = config.color
    }
    if (config.width) {
        newSquare.area = config.width * config.width
    }
    return newSquare
}
let mySquare23 = createSquare23({ colorrr: 'black', width: 50 });

2、用接口描述函数

接口能够描述带有属性的普通对象,也能够描述一个函数类型。

用接口描述函数的好处是:不必显示的约束函数的形参类型和返回值类型了,TS 可以通过签名自动推断出参数和返回值的类型。

interface SerachFunc {
    (source: string, subString: string): boolean,
}

let mySearch: SerachFunc;

mySearch = function (source: string, subString: string): boolean {
    let result = source.search(subString);
    return result > -1;
}

函数的入参 与 接口中函数签名的参数 的命名可以不一致,只需要保证他们对应参数的 类型一致 就可以了。

mySearch = function (src: string, sub: string): boolean {
    let result = src.search(sub);
    return result > -1;
}

我们也可以完全不写类型(就变成 js 的函数了),ts也能够自己进行类型推断。

mySearch = function (src, sub) {
    let result = src.search(sub);
    return result > -1;
}

3、可索引的类型

接口还能够描述:那些能够通过索引得到的一个类型。

TS 支持 2 种索引类型:

  • 字符串。
  • 数字。

以 数字签名的索引 为例:

interface StringArray {
    [index: number]: string
    //该索引的类型是 数字签名的索引。它表示:当用number来索引StringArray数组的时候,会得到一个string类型的返回值。
}

let myArray: StringArray;
myArray = ['Bob', 'Fred'];

//通过数字索引,来得到一个值
let myStr: string = myArray[0];

(1)、同时使用 字符串签名 和 数字签名

**字符串签名 和 数字签名 同时使用时,数字签名索引的返回值必须是 字符串签名索引的返回值 的 子类型。**因为同时使用时,会把数字索引转化为字符串,比如:会把 myArray[0] 中的 0 默认转化为字符串类型的 “0”。

所以,我们必须让两者的返回结果保持一致,他们必须是兼容的。来看下面一个例子。

class Animal {
    name: string
}

class Dog extends Animal {
    breed: string
}

// interface NotOkay {
//     [x: number]: Animal
//     [x: string]: Dog
// }

上述代码中,NotOkay 报错:“number”索引类型“Animal”不能分配给“string”索引类型“Dog”。

这是由于:其 数字签名索引的返回值 并不是 字符串签名索引返回值 的 子类型。违背了“字符串签名 和 数字签名 同时使用时,数字签名索引的返回值 必须是 字符串签名索引的返回值 的 子类型。”这一原则。怎么修改呢?将二者的 返回值调换一下 就 ok 了。

interface NotOkay2 {
    [x: number]: Dog,
    [x: string]: Animal
}

(2)、字符串的索引签名的 Dictionary 模式

字符串的索引签名实际上是一个自定义模式(dictionary),能够确保所有属性与 “字符串的索引签名” 的返回值的类型匹配。

interface NumberDictionary {
    [index: string]: number,
    length: number,
    // name: string
}

上述接口中,“name: string” 报错了:类型“string”的属性“name”不能赋给“string”索引类型“number”。
这是因为:在定义了 字符串索引签名 后,这就要求:其后的所有属性的返回值的类型必须与 字符串索引的返回值类型 保持一致,否则就会报错。

(3)、只读的索引签名

interface ReadonlyStringArray {
    readonly [index: number]: string
}

let myArr: ReadonlyStringArray = ['alice', 'Bob'];
// myArr[2] = 'Mallory';
//报错:类型“ReadonlyStringArray”中的索引签名仅允许读取。

可索引类型具有一个 索引签名,该索引签名可以是 stringnumber类型。它描述了对象索引的类型,还有相应的索引返回值类型。

4、类类型——类式实现接口(implements

在 TS 中,类类型 可以是 基于接口来实现(也叫:类式实现接口)。

类式实现接口的特点:

  • 接口中定义的属性和方法,在类中都要实现。
  • 定义的类也具有类的一般特性,比如:每个类默认都有自己的构造函数。

类类型的实质:用类实现一个接口,然后把当前接口看作是这个类的类型。

(1)、用类实现一个接口

可以定义一个类,并通过 implements 关键字来实现一个接口。

interface ClockInterface {
    currentTime: Date,
    setTime(d: Date)
}

// implements:用来实现一个接口。
class Clock implements ClockInterface {
    currentTime: Date;
    constructor(h: number, m: number) {}
    
    setTime(d: Date) {
        this.currentTime = d
    }
}

(2)、接口只描述了类的公共部分,不会检查类的私有成员

类实现接口,接口实际上只描述了类的公共部分。不会检查类的私有成员的。

操作类的接口时,类有两种类型:

  • 实例类型:比如上面的案例中的 interface 声明的 currentTime 和 setTime 就是实例类型。
  • 静态类型:比如 构造器 constructor。

用 类 实现一个 “构造器的接口” 会报错,比如:

interface ClockConstructor {
    currentTime: Date
    new(hour: number, minute: number)
    setTime(d: Date)
}

// class Clock1 implements ClockConstructor {
//     currentTime: Date;
//     constructor(h: number, m: number) {}
//     setTime(d: Date) {
//         this.currentTime = d
//     }
// }

上述代码会报错:类“Clock1”错误实现接口“ClockConstructor”。类型“Clock1”提供的内容与签名“new (hour: number, minute: number): any”不匹配。

这是因为:类实现接口时,实际上会对实例部分(比如:currentTime 和 setTime)做类型检查。而构造器存在于类的静态部分,所以是不会做检查的。

(3)、类的 实例接口 和 构造器接口

类相关的接口包括:类的 实例接口 和 构造器接口。

那么对于类,什么时候该用实例接口?什么时候该用构造器接口呢?

看个例子:

//实例接口
interface ClockInterface2 {
    tick()
}

//构造器接口
interface ClockConstructor2 {
    new(hour: number, minute: number): ClockInterface2
}

//工厂模式
function createClock(ctor: ClockConstructor2, hour: number, minute: number): ClockInterface2 {
    return new ctor(hour, minute)
}

/**定义 类 去 实现 实例接口 */
//实例类1--数字时钟
class DigitalClock implements ClockInterface2 {
    constructor(h: number, m: number) {}
    
    tick() {
        console.log('beep beep');  
    }
}

//实例类2--指针时钟
class AnalogClock implements ClockInterface2 {
    constructor(h: number, m: number) {}
    
    tick() {
        console.log('tick toc');
    }
}

let digital = createClock(DigitalClock, 12, 17);
let analog = createClock(AnalogClock, 7, 32);

5、继承接口

与类一样,接口也是可以继承的。接口可以通过 extends 关键字来 继承 其他的接口。

interface Shap {
    color: string
}

interface PenStroke {
    penWidth: number
}

//一个接口继承另一个接口
interface Square1 extends Shap {
    sideLength: number
}

let square = {} as Square1
square.color = 'blue'
square.sideLength = 10

//一个接口继承多个接口
interface Square2 extends Shap, PenStroke {
    sideLength: number
}

let square2 = {} as Square2
square2.color = 'blue'
square2.sideLength = 10
square2.penWidth = 5.0

6、混合类型

接口可以用来描述 JS 中丰富的类型,JS 具有动态灵活的特点,有时候我们希望:一个对象中同时具有多种类型。

interface Counter {
    (state: number): string //函数
    interval: number        //对象的属性
    reset(): void           //对象的方法
}

function getCounter(): Counter {
    let counter = (function (star: number) {}) as Counter

    counter.interval = 123
    counter.reset = function () {}

    return counter
}

let c = getCounter()
c(10)
c.reset()
c.interval = 5.0

7、接口继承类

(见类->接口继承类)

接口继承类指的是:一个 接口 继承一个 类的类型。

接口继承类的特性:

  • 接口继承类时,会继承这个类的成员,但不包括它的实现。就好像,这个接口声明了所有类存在的成员,但并没有提供具体实现一样。
  • 接口继承类时,接口的类型只能被该类或其子类所实现。这是因为,接口同样会继承到类的一些 private(私有) 和 protected(受保护) 成员。

举个例子:

class Control {
    private state: any
}

interface SelectableControl extends Control {
    select()
}

class button extends Control implements SelectableControl {
    select () {}
}
// button 正常

class TextBox extends Control {
    select () {}
}

// class ImageC implements SelectableControl {
//     select() {}
// }

上述代码中,ImageC 类报错:类“ImageC”错误实现接口“SelectableControl”。类型 “ImageC” 中缺少属性 “state”,但类型 “SelectableControl” 中需要该属性。

这是因为:ImageC 并没有继承 Control,它去实现 SelectableControl 的时候是缺少 state 属性的。接口去继承类的时候,实际上就会继承这个类的一些私有成员,既然继承了这个类的私有成员,你去定义这个类的时候就要实现这个私有成员。但是 ImageC 不是 Control 的子类,所以没法实现这个私有成员,所以它就缺少这个 state 属性,这样是不可以的。

所以说,当一个 接口 继承一个 类的类型 的时候,只有这个类或其子类才可以实现这个接口。像 Button 继承了 Control,它就可以实现 SelectableControl 接口。


六、 类

ES6 Class 的相关概念与特性

类的核心是“面向对象”的思想,面向对象的三大特性:封装、继承 和 多态。

TS 类中可用的关键字:

  • 访问控制的关键字:描述类中成员的可访问性。
    • public:(默认)公有 父类(自身)以及子类和外部都能访问。
    • protected:受保护 父类(自身)以及子类都能访问 但是外部不能访问。
    • private:私有的 父类(自身)可以访问 但是子类和外部不能访问。
  • super 关键字:用于对父类的直接引用,该关键字可以引用父类的属性和方法。
  • static 关键字:用于定义类的数据成员(属性和方法)为静态的,静态成员可以直接通过类名调用。
  • implements 关键字:类使用该关键字可以实现接口,并将 interest 字段作为类的属性使用。
  • type 关键字:用于定义 类型别名 或 一个新的类型(包括:原始值、联合类型、元组以及其它任何你需要手写的类型——比如:字符串字面量类型)。
  • abstract 关键字: 用于定义抽象类和在抽象类内部定义抽象方法抽象成员。
  • readonly 关键字:只读属性。使用此关键字定义的属性,在类的外部以及类内部的实例方法里是不可以被修改的,在类的构造函数中才可以被修改。
  • instanceof 运算符:用于判断对象是否是指定的类型,如果是返回 true,否则返回 false。

1、类的基本示例

类有 3 个成员:

  • 属性:属性是类里面声明的变量。属性表示对象的有关数据。
  • 构造函数(constructor):类实例化时调用,可以为类的对象分配内存。
    • constructor 方法是类的默认方法,类实例化时自动调用,可以为类的对象分配内存。
    • 一个类必须有 constructor 方法,如果没有显式定义,一个空的 constructor 方法会被默认添加。
  • 方法:方法为对象要执行的操作。

定义一个类时,会创建两个东西:

  • 类的实例类型;
  • 一个构造函数。
class Greeter {
    greeting: string

    constructor(message: string) {
        this.greeting = message
    }

    greet() {
        return 'Hello' + this.greeting
    }
}

let greeter = new Greeter('wolrd')
greeter.greet()

2、类的继承

基类、父类、超类:被继承的类。
派生类、子类:继承而来的类。

类使用 extends 关键字继承,类中 this 表示当前对象本身,super 表父类对象。

类继承的实质:

  • ES5:先创造 子类的实例对象 this,然后再将父类的方法添加到 this 上面 Parent.apply(this)
  • ES6:先将 父类实例对象的属性和方法加到 this 上面,所以必须先调用 super 方法,然后再用子类的构造函数修改 this。因为子类自己的 this 对象,必须先通过父类的构造函数完成塑造,得到与父类同样的实例属性和方法,然后再对其进行加工,加上子类自己的实例属性和方法。如果不调用 super 方法,子类就得不到 this 对象。

类的继承的特点:

  • 子类的构造函数中,必须先用 super() 方法来调用父类中的 构造函数,然后再进行子类的进一步初始化
  • 在 TS 中,子类只能继承一个父类,不支持继承多个类,但支持多重继承。

(1)、简单的类的继承

//定义一个基类(超类)
class Animal {
    move(distance: number = 0) {
        console.log('Animal moved: ', distance);
    }
}

//定义一个子类(派生类),继承自超类(基类)
class Dog extends Animal {
    bark() {
        console.log("Woof! Woof!");
        
    }
}

const dog = new Dog()
dog.bark()
dog.move(10)

(2)、复杂的类的继承

class Animal2 {
    name: string

    constructor(name: string) {
        this.name = name
    }

    move(distance: number = 0) {
        console.log(`${this.name} moved ${distance}m`);
        
    }
}

//蛇
class Snake extends Animal2 {
    constructor(name: string) {
        super(name)//调用父类的构造方法
    }

    move(distance: number = 5) {
        console.log('Slithering...');
        super.move(distance)//调用父类的 move 方法
    }
}

//马
class Horse extends Animal2 {
    constructor(name: string) {
        super(name)//调用父类的构造方法
    }

    move(distance: number = 45) {
        console.log('Galloping...');
        super.move(distance)//调用父类的 move 方法
    }
}

let sam = new Snake('Sammy')
let tom: Animal = new Horse('Tommy')
//可以把 tom 指定为 Animal 类型,它虽然是 Animal 类型,但它实例化出来的值仍然是一个 Horse 子类的实例。

sam.move()
tom.move(20)

(3)、多重继承

class Root { 
   str:string; 
} 
 
class Child extends Root {} 
class Leaf extends Child {} // 多重继承,继承了 Child 和 Root 类
 
const obj = new Leaf(); 
obj.str ="hello" 
console.log(obj.str)

【总结】类的继承的核心要点:

  • 在派生类中,必须在其构造函数中用 super 关键字调用基类的构造函数。
  • 如果我们在派生类的构造函数中要访问 当前类的一些成员的时候,我们需要把这些放在 “super 调用基类的构造函数” 后面。
  • 派生类可以通过重写基类里的方法来实现自己的逻辑,还可以通过 super 关键字调用基类的方法。

3、类的多态——重写类

父类定义一个方法不去实现,让继承它的子类去实现,每一个子类可以有不同的表现——子类可以对父类的方法重新定义,这个过程称之为方法的重写,也是对类的多态的体现。

多态的特点:

  • 父类型的引用指向子类型的对象。
  • 不同类型的对象针对相同的方法产生了不同的行为。
class PrinterClass { 
   doPrint():void {
      console.log("父类的 doPrint() 方法。") 
   } 
} 
 
class StringPrinter extends PrinterClass { 
   doPrint():void { 
      super.doPrint() // 调用父类的函数
      console.log("子类的 doPrint()方法。")
   } 
}

4、类的修饰符——公共的、私有的 和 受保护的修饰符 + readonly 修饰符

(1)、公共的、私有的 和 受保护的修饰符

①、public 修饰符

public 修饰符:可以在类的内部外部任意使用,没有限制。

类中的属性和方法默认都是 public 的,可省略不写 public 修饰符。

class Animal3 {
    public name: string

    public constructor(name: string) {
        this.name = name
    }

    public move(distance: number) {
        console.log(`${this.name} moved ${distance}m`);
        
    }
}

②、private 修饰符

private 修饰符:可以在类的内部使用,但不能在类的外部使用。

class Animal4 {
    private name: string

    public constructor(name: string) {
        this.name = name
    }

    public move(distance: number) {
        console.log(`${this.name} moved ${distance}m`);
        
    }
}

// new Animal4('Cat').name//报错了:属性“name”为私有属性,只能在类“Animal4”中访问。

TS 是结构类型的语言,当比较两种不同类型的类型的时候,并不在乎他们是从哪里来的,只要他们所有的成员的类型兼容的话,就认为他们是类型兼容的。但是,我们比较 private 和 protected 的成员的类型的时候,情况就不同了:

“如果其中一个类型包含一个 private 成员,那么只有另外一个类型也包含一个 private 成员,并且他们都是来自同一处声明的时候,我们才认为这两个是类型兼容的。”

而且,对于 protected 成员也适用这个规则。

以 private 为例:

class Animal5 {
    private name: string

    constructor(name: string) {
        this.name = name
    }

    move(distance: number) {
        console.log(`${this.name} moved ${distance}m`);
        
    }
}

//河马
class Hippo extends Animal5 {
    constructor() {
        super('Hippo')
    }
}

//大象
class Elephant {
    private name: string

    constructor(name: string) {
        this.name = name        
    }
}

let animal = new Animal5('Goat')
let hippo = new Hippo()
let employee = new Elephant('Bob')

animal = hippo
// animal = employee//报错了:类型 "Employee" 中缺少属性 "move",但类型 "Animal" 中需要该属性。

为什么把 hippo 赋值给 animal 正常,把 employee 赋值给 animal 就报错了呢?

这是因为:
首先,要明确:hippo 是 Hippo 的实例, animal 是 Animal5 的实例,employee 是 Employee 的实例。
其次,Hippo 是 Animal5 的子类,Employee 不是 Animal5 的子类。Employee 里的 name 是其私有的成员,Animal5 里的 name 是其私有的成员,他们并不是同一个来源的 name。
但是,Hippo 和 Animal5 实际上是共享了 Animal5 的 name,他们的 name 来源是一致的。
所以,Hippo 和 Animal5 之间是兼容的,而 Employee 和 Animal5 是不兼容的。
毕竟,不同类的私有成员的来源是不一样的。

③、protected 修饰符

protected 修饰符:可以在类的内部使用,也可以在其派生类里使用,其他均不能使用。

//人类
class Person {
    protected name: string

    constructor(name: string) {
        this.name = name
    }

    move(distance: number) {
        console.log(`${this.name} moved ${distance}m`);
        
    }
}

//成员
class Employee extends Person  {
    private department: string

    constructor(name: string, department: string) {
        super(name)
        this.department = department       
    }

    getElevatorPitch() {
        return `hello my name is ${this.name} and I work in ${this.department}`
    }
}

let howard = new Employee('Howard', 'Sales')
console.log(howard.getElevatorPitch());
// console.log(howard.name));//报错了:属性“name”受保护,只能在类“Person”及其子类中访问。

protected 还有一个用处:它可以给构造函数也标记为 protected。也就是说,我们为了防止外面的使用者去 new 这个 Person,可以把 Person 的构造函数也标记为 protected。

例如:

//人类
class Person2 {
    protected name: string

    protected constructor(name: string) {
        this.name = name
    }

    move(distance: number) {
        console.log(`${this.name} moved ${distance}m`);
        
    }
}

//成员
class Employee2 extends Person  {
    private department: string

    constructor(name: string, department: string) {
        super(name)
        this.department = department       
    }

    getElevatorPitch() {
        return `hello my name is ${this.name} and I work in ${this.department}`
    }
}

let howard2 = new Employee2('Howard', 'Sales')
// let john = new Person2('John')//报错了:类“Person2”的构造函数是受保护的,仅可在类声明中访问。

(2)、readonly 修饰符

class Person3 {
    readonly name: string

    constructor(name: string) {
        this.name = name
    }
}

let john2 = new Person3('John')
// john2.name = ''//报错了:属性“name”受保护,只能在类“Person”及其子类中访问。

(3)、参数属性

参数属性:给 构造函数的参数 的“前面”添加 访问限定修饰符。

常见的“参数属性”包括: readonly、private、protected、public。

参数属性实际上是一种简写:在一个地方定义并初始化一个成员。

class Person4 {
    constructor(readonly name: string) {}
}

let john3 = new Person4('John')
console.log(john3.name);
// john2.name = ''//报错了:无法为“name”赋值,因为它是只读属性。

虽然参数属性可以简化我们的书写,但是实际上我还是比较愿意在类的构造函数外面声明好属性,然后在构造函数里赋值,因为这样代码看起来更加清晰。

所以说,参数属性,只是在类中定义属性时写法的一个简化。

5、存取器(getters 和 setters)

TS 支持 getters 和 setters 来对对象成员的访问的。

下面我们看看:如何把一个简单的类改写成 getter 和 setter 的形式?

先看一个没有使用存取器的例子:

class Employee3 {
    fullName: string
}

let employee3 = new Employee3()
employee3.fullName = 'Bob Smith'
if(employee3.fullName) {
    console.log(employee3.fullName);
}

有时候,我们想在设置 fullName 的时候触发一些额外的逻辑,这时候 存取器 就派上用场了。

额外的逻辑是:当我们去设置用户名的时候,我们想检测一下他的密码是否正确。密码匹配才可以设置这个用户名。

const passcode = 'secret passcode'

class Employee4 {
    private _fullName: string
    //访问
    get fullName(): string {
        return this._fullName
    }
    //修改
    set fullName(newName: string) {
        if(passcode && passcode === 'secret passcode') {
            this._fullName = newName
        } else {
            console.log('Error: 未经授权更新员工!');
            
        }
    }
    /**
     * fullName 会报错:Accessors are only available when targeting ECMAScript 5 and higher.(访问器仅在以ECMAScript 5及更高版本为目标时可用。)
     * 报错原因:tsc 默认编译的是 es3 的代码,而这里要求必须是 es5 以上。
     * 解决方案:
     *     在编译代码时,需要给 tsc 指定 es5 的编译版本:tsc exemples\03ts常用语法\03-6类\03-6-1类.ts --target es5
     */
}

let employee4 = new Employee4()
employee4.fullName = 'Bob Smith'
if(employee4.fullName) {
    console.log(employee4.fullName);
}

6、静态属性

类是由 实例部分 和 静态部分 两个部分组成的。到目前为止,我们只讨论了类的实例成员,但是我们也可以创建一些类的静态成员。

静态成员:属性存在于类的本身,而不是类的实例上。

TS 类中完全支持了 静态方法(ES6)和 静态属性(ES7)(注意:static 不能修饰构造函数)。

TS 通过在属性和方法前面加上 static 关键字来定义静态的属性和方法。

静态成员的特点:

  • 静态属性不能与内置属性重名,所以不能定义名为 name 的静态属性。
  • 通过类名可以调用静态的 属性 和 方法,其中静态属性可读可写。

案例需求:定义一个网格类,计算一个坐标点距离原点的距离。

class Grid {
    //原点是可以被网格共享的,所以给它设置为静态属性
    static origin = { x: 0, y: 0 }

    //成员属性
    scale: number

    constructor(scale: number) {
        this.scale = scale
    }

    calculateDistanceFromOrigin(point: {x:number, y:number}) {
        //静态属性需要用类去访问
        let xDist = point.x - Grid.origin.x
        let yDist = point.y - Grid.origin.y
        //勾股定理
        return Math.sqrt(xDist * xDist + yDist * yDist) * this.scale
    }
}

let grid1 = new Grid(1.0)
let grid2 = new Grid(5.0)

console.log(grid1.calculateDistanceFromOrigin({x:3, y:4}));
console.log(grid2.calculateDistanceFromOrigin({x:3, y:4}));

7、抽象类

abstract 关键字标识的类是抽象类。

抽象类的用途(特点):

  • 抽象类一般作为其他派生类的基类使用。
  • 抽象类不能直接被实例化。

抽象类除了包含普通的成员,还包含抽象方法,用 abstract 关键字标识的方法是抽象方法。

抽象方法的特点:

  • 抽象方法是不能直接被实现的,必须在他的派生类中实现。
  • 抽象方法是必须包含 abstract 关键字的。
  • 抽象方法是可以包含一些访问修饰符的。
  • 一个类里若有抽象属性或方法,则必须声明为抽象类。
abstract class Department2 {
    name: string

    constructor(name: string) {
        this.name = name
    }

    printName(): void {
        console.log('Department name' + this.name);
        
    }

    abstract printMeeting(): void
}

class AccountingDepartment extends Department2 {
    constructor() {
        super('Accounting add Auditing')
    }

    printMeeting(): void {
        console.log('The Accounting Department meets each Monday at 10am')
    }

    genterateReports(): void {
        console.log('Generating accounting reports...');
        
    }
}   

let department2: Department2
// department2 = new Department2()
//报错了:无法创建抽象类的实例。

//抽象类不能被实例化,但是其派生类是可以的。
department2 = new AccountingDepartment()
department2.printName()
department2.printMeeting()
// department2.genterateReports()
//报错了:类型“Department2”上不存在属性“genterateReports”。

这里就有一个疑问:话说 department2 是 AccountingDepartment 的一个实例,怎么还不能用 AccountingDepartment 里面的 genterateReports 方法了呢?

这是因为:我们事先已经将 department2 声明为抽象类 Department2 这样的一个类型了。抽象类 Department2 中是没有定义这个 genterateReports 方法的,所以才会报这个错误的。

8、类的高级技巧

(1)、快速创建一个类的副本

适用 typeof 关键字可以快速创建一个类的副本。

class Greeter3 {
    static standardGreeting= 'Hello, there'

    greeting: string

    constructor(message?: string) {
        this.greeting = message || ''
    }

    greet() {
        if(this.greeting) {
            return 'Hello, ' + this.greeting
        } else {
            return Greeter3.standardGreeting
        }
    }
}

let greeter3: Greeter3
greeter3 = new Greeter3()
console.log(greeter3.greet());

//给 Greeter3 类起别名(快速创建一个类的副本)
let greeterMaker: typeof Greeter3 = Greeter3
greeterMaker.standardGreeting = 'Hey, there'

let greeter3_2: Greeter3 = new greeterMaker()
console.log(greeter3_2.greet());

(2)、类可以作为接口使用

interface Point {
    x: number
    y: number
}

interface Point3d extends Point {
    z: number
}

当然,我们还可以把 Point 的 interface 换成 class,将类作为接口使用:

class Point2 {
    x: number
    y: number
}

interface Point3d2 extends Point2 {
    z: number
}

let point3d2: Point3d2 = {x: 1, y: 2, z: 3}

虽然我们可以将类作为接口使用,但是通常不建议这样去用。因为:如果它没有 “类具有的特性” 需要定义的话,只是单纯的包含一些签名属性,还是建议直接把它定义为接口。

(3)、类类型——类式实现接口(implements

详见本文的 “接口-类类型” 部分。


七、TS 函数

TypeScript 函数保留了所有 JavaScript 函数的功能,所以,在 TS 中,完全可以像 JS 那样去使用函数。不过,TS 为 JS 函数添加了额外的类型校验功能,这使得 TS 函数的功能更加强大。

1、函数的基本示例

//命名函数
function add(x, y) {
    return x + y
}
//匿名函数
let myAdd = function(x, y) {
    return x + y
}
//匿名函数也是可以定义函数名的
let myAdd2 = function add2 (x, y) {
    return x + y
}

2、函数类型

函数类型 包括 参数类型返回值类型 两部分。

  • 参数类型:
    • 参数:数据类型:可以指定函数的参数类型。
    • 参数?:数据类型:用来定义可选参数。
    • 参数=一个常量:用来定义参数的默认值。以防没有传入型参 a 对应的实参而产生未定义。
    • ...参数:参数类型[]:用来表示剩余参数。
  • 返回值类型:
    • 必须要指定函数返回值类型。(即使函数没有返回任何值,你也必须指定返回值类型为 void 而不能留空)。
    • 若指定的函数的返回类型是 anyvoid,那么可以没有返回值,否则该函数必须要返回值,即必须包含 return 语句。
    • TypeScript 能够根据返回体类型自动推断出返回值类型,因此我们通常省略它。

(1)、参数类型的 2 种实现形式

参数类型有两种实现形式:

  • 左边不写类型,右边写类型;
  • 左边写类型,右边不写类型。

①、左边不写类型,右边写类型

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

②、左边写类型,右边不写类型

let myAdd6: (baseValue: number, increment: number) => number = function(x, y) {
    return x + y
}
myAdd6(1, 2)

参数类型的重命名:只要参数个数、位置、类型是匹配的就可以,参数名(入参与形参)不一样没事。

例如:

let myAdd4: (baseValue: number, increment: number) => number = function(x: number, y: number): number {
    return x + y
}

(2)、可选参数 + 默认参数 + 剩余参数

在 js 中函数的每个参数都是可选的,不传的时候默认是 undefined。但在 ts 中函数的每个参数默认都是必须传的,不传就会报错。也就是传入的参数的个数必须和函数所期望的个数是一致的。

function builName(firstName: string, lastName: string): string {
    return firstName + ' ' + lastName
}

// let result1 = builName('Bob')// 少了——报错了:应有 2 个参数,但获得 1 个。
// let result2 = builName('Bob', 'Adams', 'Sr')// 多了——报错了:应有 2 个参数,但获得 3 个。
let result3 = builName('Bob', 'Adams')

①、可选参数

在 ts 中定义函数时,可以使用 ? 符号实现可选参数的功能。可选参数必须放在其他参数的后面。

function builName2(firstName: string, lastName?: string): string {
    if(lastName) {
        return firstName + ' ' + lastName
    } else {
        return firstName
    }
}

②、默认参数

为参数提供默认值,当用户不传的时候,它会得到这个默认值。

function builName3(firstName: string, lastName = 'Smith'): string {
    return firstName + ' ' + lastName
}

③、剩余参数

用剩余运算符(…)定义函数的剩余参数,可以把其余的参数均收进一个变量里。可以一个都没有,也可以是任意多个。

function builName4(firstName: string, ...restOfName: string[]): string {
    let [lastName] = restOfName
    if(lastName) {
        return firstName + ' ' + lastName
    } else {
        return firstName
    }
}

let result1_3 = builName4('Bob')
let result2_3 = builName4('Bob', 'Adams', 'Sr')
let result3_3 = builName4('Bob', 'Adams')

当然,剩余运算符(…)还可以在带有剩余参数的函数定义时使用到。

let builNameFn: (fname: string, ...rest: string[]) => string = builName4

(3)、函数类型的推断类型

TS 默认会自动推断出函数类型的。

let myAdd6: (baseValue: number, increment: number) => number = function(x, y) {
    return x + y
}
myAdd6(1, 2)
// myAdd6('', 2)//报错了:类型“string”的参数不能赋给类型“number”的参数。

3、this

(1)、js 中 this 的使用

let deck = {
    suits: ['红心', '黑桃', '草花', '方片'],
    cards: Array(52),
    createCardPicker: function() {
        return function() {
            let pickedCard = Math.floor(Math.random() * 52)
            let pickedSuit = Math.floor(pickedCard / 13)

            return {
                suit: this.suits[pickedSuit],
                card: pickedCard % 13
            }
        }
    }
}

let cardPicker = deck.createCardPicker()
// let pickedCard = cardPicker()

// console.log('Card: ' + pickedCard.card + ' of ' + pickedCard.suit);
//执行后报错:TypeError: Cannot read property '1' of undefined

这是因为:首先,cardPicker 是 deck.createCardPicker 函数,执行 cardPicker 函数时,this 指向的是全局的 global。 global 里面显然是没有 suits 这个属性的。所以 this.global 就是 undefined,再去访问它的下标就会报错了。为了解决这个问题,就要保证这个 this 的指向是正确的。在 es6 里我们可以使用箭头函数来解决这个问题。

let deck2 = {
    suits: ['红心', '黑桃', '草花', '方片'],
    cards: Array(52),
    createCardPicker: function() {
        //箭头函数:不会改变 this 指向
        return () => {
            let pickedCard = Math.floor(Math.random() * 52)
            let pickedSuit = Math.floor(pickedCard / 13)

            return {
                suit: this.suits[pickedSuit],
                card: pickedCard % 13
            }
        }
    }
}

let cardPicker2 = deck2.createCardPicker()
let pickedCard2 = cardPicker2()

// console.log('Card: ' + pickedCard2.card + ' of ' + pickedCard2.suit);
// 此时,cardPicker2 函数执行时,this 指向的是 deck2 这个对象了。

虽然 箭头函数 能够解决上述代码中 this 指向的问题,但是在 ts 中该对象字面量的这个 this 是被默认推断为 any 类型的,也就是说你访问 this.suits.[任意属性] 时都是可以通过的。这就会导致没有类型校验了,我们可以给函数提供一个显式的 this 参数。

(2)、this 参数

this 参数是一个假的参数,它是出现在参数列表最前面。它告诉你这个 this 是不可用的。

function f(this: void) {}

这样的话,他就能确保,这个 this 在这个独立函数汇总是一个空的、不可用的一个状态。

①、用 ts 重构上面的 deck 案例

interface Card {
    suit: string
    card: number
}

interface Deck {
    suits: string[]
    cards: number[]
    
    createCardPicker(this: Deck): () => Card
}

let deck3: Deck = {
    suits: ['红心', '黑桃', '草花', '方片'],
    cards: Array(52),
    createCardPicker: function(this: Deck) {
        //箭头函数:不会改变 this 指向
        return () => {
            let pickedCard = Math.floor(Math.random() * 52)
            let pickedSuit = Math.floor(pickedCard / 13)

            return {
                suit: this.suits[pickedSuit],
                card: pickedCard % 13
            }
        }
    }
}

let cardPicker3 = deck3.createCardPicker()
let pickedCard3 = cardPicker3()

console.log('Card: ' + pickedCard3.card + ' of ' + pickedCard3.suit);

②、this 参数在回调函数里面

有时我们在回调函数里面调用 this 会报错,特别是我们在使用一些第三方库的时候,那其实我们也以在第三方库中为这个 this 的回调函数指定 this 参数。

interface UIElement {
    addClickListener(onclick: (this: void, e: Event) => void): void
}

class Handler {
    type: string

    onClickBad(this: Handler, e: Event) {
        console.log('clicked');
        this.type = e.type
    }
}

let h = new Handler()

let uiElement: UIElement = {
    addClickListener() {

    }
}

// uiElement.addClickListener(h.onClickBad)
//报错了:
//    类型“(this: Handler, e: Event) => void”的参数不能赋给类型“(this: void, e: Event) => void”的参数。
//    每个签名的 "this" 类型不兼容。
//    不能将类型“void”分配给类型“Handler”。

这是因为:首先,uiElement 是 UIElement 的实例,那么,UIElement 定义的 addClickListener 回调函数的函数类型的 this 是 void,但是我们在 uiElement.addClickListener 给他传进去的函数对应的 this 是 Handler,显然我们不能把 Handler 类型赋值给 void 类型,类型就不匹配了。怎么办呢?我们可以显示的把类型改为 void,这样类型就一致了。

interface UIElement2 {
    addClickListener(onclick: (this: void, e: Event) => void): void
}

class Handler2 {
    type: string

    //显示的把类型改为 void,这样类型就匹配了
    onClickBad(this: void, e: Event) {
        console.log('clicked');
        // this.type = e.type
        //这里报错了:类型“void”上不存在属性“type”。
    }
}

let h2 = new Handler2()

let uiElement2: UIElement2 = {
    addClickListener() {

    }
}

uiElement2.addClickListener(h2.onClickBad)

上述代码报错是因为:onClickBad 的 this 的类型是 void,所以就不能访问这个 this.type,是访问不到的。但是有些情况我们又想去访问 this,又要保证类型是匹配的,那怎么办呢?我们可以用箭头函数来解决:

interface UIElement3 {
    addClickListener(onclick: (this: void, e: Event) => void): void
}

class Handler3 {
    type: string

    //显示的把类型改为 void,这样类型就匹配了
    onClickBad = (e: Event) => {
        console.log('clicked');
        this.type = e.type
    }
}

let h3 = new Handler3()

let uiElement3: UIElement3 = {
    addClickListener() {

    }
}

uiElement3.addClickListener(h3.onClickBad)

这样是用箭头函数,就能满足:我们又想去访问 this,又要保证类型是匹配的。

【总结】:

  • 通常,由 this 导致的问题,都建议使用箭头函数来尝试解决。
  • 以及,我们可以巧用 this 参数来明确:在一些函数中 this 对应的值到底是 void 还是某个对象。

4、重载

TS 函数重载指的是:函数的名字相同,函数形参的 类型、 个数 和 顺序 不同

函数重载用于约定:函数参数的类型和个数,以及函数返回值的类型。

函数重载,需要声明函数函数的签名,即:只定义而不实现这个函数。

(1)、函数重载的 3 种形式

①、形参类型不同的函数重载

function add (s: string): void; 
function add (n: number): void;

②、形参数量不同的函数重载

function add (n1: number): void; 
function add (x: number, y: number): void;

③、形参类型顺序不同的函数重载

function add (n: number, s: string): void; 
function add (s: string, n: number): void;

(2)、函数重载的特点与技巧

函数重载的特点:

  • 函数的名字必须相同,而形参的类型和个数可以不同;
  • 函数的返回类型,可以相同,也可以不同;
  • 每个重载的方法(或者构造函数)都必须有一个独一无二的 参数类型列表。

函数重载的技巧:

  • 如果形参类型不同,则形参类型应设置为 any。
  • 如果形参数量不同,则可以将不同的形参设为可选。

(3)、函数重载的案例

①、案例一

下面举个例子,先从一个 js 函数的场景切入:

js 是一个动态语言,js 函数会根据不同的参数返回不同的类型的数据。

let suits = ['红心', '黑桃', '草花', '方片']

function pickCard(x): any {
    if(Array.isArray(x)) {
        let pickedCard = Math.floor(Math.random() * x.length)
        return pickedCard
    } else if(typeof x == 'number') {
        let pickedSuit = Math.floor(x / 13)
        return { suit: suits[pickedSuit], card: x % 13 }
    }
}

let myDeck = [
    { suit: '方片', card: 2 },
    { suit: '黑桃', card: 10 },
    { suit: '红心', card: 4 },
]

let pickedCard1_1 = myDeck[pickCard(myDeck)]
console.log('card: ' + pickedCard1_1.card + ' of ' + pickedCard1_1.suit);

let pickedCard1_2 = pickCard(15)
console.log('card: ' + pickedCard1_2.card + ' of ' + pickedCard1_2.suit);

//这样写是有缺陷的:没有对 pickCard 做有效的类型检查。我们甚至传入一个字符串都没有报错的,因为 pickCard 没有做参数类型的约束。
// let pickedCard1_3 = myDeck[pickCard('sss')]

对于这种场景,我们要通过函数重载的方式来做约束。

let suits2 = ['红心', '黑桃', '草花', '方片']

//重载函数的声明
function pickCard2(x:{suit: string, card: number}[]): number
function pickCard2(x: number): {suit: string, card: number}
//重载函数时,可以重新自定义函数的参数和返回值。

//重载函数的实现
function pickCard2(x): any {
    if(Array.isArray(x)) {
        let pickedCard = Math.floor(Math.random() * x.length)
        return pickedCard
    } else if(typeof x == 'number') {
        let pickedSuit = Math.floor(x / 13)
        return { suit: suits[pickedSuit], card: x % 13 }
    }
}

let myDeck2 = [
    { suit: '方片', card: 2 },
    { suit: '黑桃', card: 10 },
    { suit: '红心', card: 4 },
]

let pickedCard2_1 = myDeck2[pickCard2(myDeck2)]
console.log('card: ' + pickedCard2_1.card + ' of ' + pickedCard2_1.suit);

let pickedCard2_2 = pickCard(15)
console.log('card: ' + pickedCard2_2.card + ' of ' + pickedCard2_2.suit);

// let pickedCard2_3 = myDeck2[pickCard2('sss')]
//报错了:没有与此调用匹配的重载。
//    第 1 个重载(共 2 个),“(x: { suit: string; card: number; }[]): number”,出现以下错误。
//    类型“string”的参数不能赋给类型“{ suit: string; card: number; }[]”的参数。
//    第 2 个重载(共 2 个),“(x: number): { suit: string; card: number; }”,出现以下错误。

可见,此时就可以做类型检查了:再传入一个字符串,就不满足重载后的函数的任何一种定义了。

【注意】:编译器对重载的处理是“依次重试匹配”的,所以我们在做重载定义的时候,要把最精确的放在前面。这是为了让编译器能够正确高效的做类型检查。

②、案例二

假设我们有一个需求:定义一个add函数,它可以接收两个都是string类型的参数,返回拼接后的结果;也可以接收两个都是number类型的参数,返回两数相加之和。

先看不用函数重载,而是通过联合类型,来实现:

// 通过联合类型声明函数
function add(a1: number | string, a2: number | string): string | number {
  if (typeof a1 === "number" && typeof a2 === "number") {
    return a1 + a2
  } else if (typeof a1 === "string" && typeof a2 === "string") {
    return a1 + a2
  }
}

// 调用这个函数
console.log(add('qwer', 'asdf')) // qwerasdf
console.log(add(123, 456)) // 579
console.log(add('qwer', 123)) // undefined

由上述代码可知,通过联合类型来实现这个需求,有两个缺点:

  • 要进行很多的逻辑判断。
  • 返回值的类型依然是不能确定的。因为,当实参的类型与函数的形参类型要求不符时,并没有报错,而是最终返回了 undefined。

而函数重载恰好能解决这两个问题:

// 通过函数重载声明函数的签名
function add(num1: number, num2: number): number;
function add(num1: string, num2: string): string;

// 实现这个函数
function add(num1: any, num2: any): any {
  return num1 + num2
}

// 调用这个函数
console.log(add('qwer', 'asdf')) // qwerasdf
console.log(add(123, 456)) // 579
// console.log(add('qwer', 123)) // 报错:没有与此调用匹配的重载

5、在给函数传参时应不应该“绕过”额外的属性的类型检查?怎么做?

详见上文的 “接口–>定义一个接口–>额外的属性检查” 小节。


八、泛型()

泛型指:在定义函数、接口或类时,无法预先确定要使用的数据的类型,而是在使用函数、接口或类时才能确定数据的类型。

泛型主要用来满足:函数能够支持多种类型的数据。这样用户就可以用自己的数据类型来使用函数。

泛型的核心思想是:类型变量

  • 类型变量 是一种特殊的变量,只用于表示类型而不是值。
  • 类型变量 确保了 返回值的类型 与 传入参数的类型 是相同。

1、基本示例

(1)、什么是泛型?

我们使用 any 来定义一个函数:

function fn(arg: any): any {
    return arg;
}

由于使用 any 类型会导致这个函数可以接收任何类型的参数,那么也就可能返回任何数据类型的值。这样就导致:可能会出现传入的参数类型与返回值的类型不匹配的情况。

比如:

function fn(arg: any): any {
    return arg + ''
}
fn(123)//"123"

此时,传入的是 number 类型的参数,返回的却是 string 类型的结果。

那么,此时如何保持参数类型与返回值类型是相同的呢?可以使用“类型变量”,来保持参数类型与返回值类型是相同的。

用 类型变量 重写 fn 函数:我们给 fn 添加了类型变量 T 来捕获用户传入的类型。

function fn<T>(arg: T): T {
    return arg;
}

这就是 泛型 的基本实现了。传入什么类型就返回什么类型。适用于多个类型。不同于 any,any 会丢失类型信息,而泛型不会丢失类型信息。

(2)、泛型函数的 2 种使用方式

function identity4<T>(arg: T): T {
    return arg
}

//使用方式一
let output = identity4<string>('myString')

//使用方式二:
let output2 = identity4('myString')//利用编译器帮我们自动进行类型推断

对于一些复杂的情况,编译器有时候是不能帮我们自动的类型推断的,此时就只能通过第一种方式来使用泛型了。

2、使用泛型变量

function identity5<T>(arg: T): T {
    return arg
}

function loggingIdentity<T>(arg: T): T {
    // console.log(arg.length);
    //报错了:类型“T”上不存在属性“length”。
    return arg
}

上述代码报错了,这是因为 arg 是任意类型的,如果我们传入的是一个数值,显然 number 类型是没有 length 属性的。

如果你想操作这个 T,而且这个 T 是有 length 的,那么可以把这个类型 T 作为一个类型 T 的数组。

function loggingIdentity2<T>(arg: T[]): T[] {
    console.log(arg.length);
    return arg
}

也就是说,这个泛型函数,可以接收类型参数 T 和参数 arg,参数 arg 的类型是 T[],并且这个泛型函数的返回值的类型也是 T[]

如果我们传入的是一个 number 类型的数组,它就会返回一个 number 类型的数组,所以 T 的类型就是一个 number,这样就可以:
把 泛型变量 T 当做类型的一部分使用,而不是作为整个类型使用,增加了灵活性。

3、泛型类型

我们已经知道,泛型函数只是在普通函数的基础上,在参数的前面增加了 类型参数 ,其他与普通函数没什么区别。

function identity6<T>(arg: T): T {
    return arg;
}

那么如何定义 泛型类型(泛型函数的类型参数) 呢?

(1)、定义 泛型类型 的 2 种方式

泛型类型 的特点:

  • 泛型类型 只要能和 参数类型 以及 返回值类型 对应上就行。
  • 泛型类型 的参数命名叫什么都可以,通常叫 T,因为 T 的语义性更强。

我们也可以:用一个 “带有 调用签名 的对象字面量” 来定义 “泛型类型”。

function identity6<T>(arg: T): T {
    return arg
}

//用带有 调用签名 的 箭头函数 定义 泛型类型
let myIdentity: <U>(arg: U) => U = identity6
//用带有 调用签名 的 对象字面量 定义 泛型类型
let myIdentity2: {<T>(arg: T): T} = identity6

(2)、创建泛型接口

我们通常基于 “用一个带有调用签名的对象字面量来定义泛型类型” 的实现方式,来定义泛型接口。

function identity6<T>(arg: T): T {
    return arg
}

interface FenericIdentityFn {
    <T>(arg: T): T
}

let myIdentity3: FenericIdentityFn = identity6

在定义泛型接口时,可以把 T 拿出来作为接口的参数。把参数拿出来之后,在使用该泛型接口时,就需要指明使用的是什么类型。传入什么类型就返回什么类型。

function identity6<T>(arg: T): T {
    return arg
}

interface FenericIdentityFn2<T> {
    (arg: T): T
}

let myIdentity4: FenericIdentityFn2<number> = identity6

这样做的好处是:我们就不用在这个泛型接口中描述这个泛型函数了。而是把这个非泛型函数签名,作为泛型类型的一部分。然后,我们使用 FenericIdentityFn2 泛型接口的时候,需要传入一个类型参数来指定这个泛型的类型。推荐这样使用泛型接口。

4、泛型类

泛型类要放在 类名的后面。用于:确认类中的所有属性都可以使用这个类型。

class GgenericNumber<T> {
    zeroValue: T
    add: (x: T, y: T) => T
}

let myGenericNumber = new GgenericNumber<number>()
myGenericNumber.zeroValue = 0
myGenericNumber.add = function (x, y) {
    return x + y
}

let stringNumberic = new GgenericNumber<string>()
stringNumberic.zeroValue = ''
stringNumberic.add = function (x, y) {
    return x + y
}

类包含两个部分:静态部分和实例部分。泛型类实际上指的是实例部分的一个类型,静态属性是不能使用泛型类的

5、泛型约束

(1)、用接口约束泛型函数的类型变量

function loggingIdentity3<T>(arg: T): T {
    // console.log(arg.length);
    //报错了:类型“T”上不存在属性“length”。
    return arg
}

回顾上面这个案例,当时给出的解决方案是,将 T 类型变成一个 T 类型的数组,因为数组是有 length 属性的。实际上还有另外一种解决方案:可以给类型变量 T 做 “类型约束”。

通过接口约束泛型函数的类型变量:

interface Lengthwise {
    length: number
}

function loggingIdentity4<T extends Lengthwise>(arg: T): T {
    console.log(arg.length);
    return arg
}

这样就不报错了。

此时,在使用该泛型函数时,若传入的参数的类型不符合签名的约束就会报错。

// loggingIdentity4(3)
//报错了:类型“number”的参数不能赋给类型“Lengthwise”的参数。
loggingIdentity4({length: 1})

这是因为,当我们调这个方法的时候,它会推断这个类型。把这个类型赋值给 T,T 实际上是被 Lengthwise 接口约束的。

(2)、在泛型约束中使用其他的类型参数

比如:现在有一个需求是查询对象的某一个值。对象用任意类型 T 约束,我们希望这个属性 key 是存在于这个对象中,所以也要对 key 进行约束。所以我们再定义一个类型参数 K 来约束属性 key,K extends keyof T,K 是被 T 的 key 所约束的,即 K 类型必须是 T 类型里的属性。

function getProperty<T, K extends keyof T>(obj: T, key: K) {
    return obj[key]
}

let x = {a: 1, b: 2, c: 3, d: 4}

getProperty(x, 'a')
// getProperty(x, 'm')
//报错了:类型“"m"”的参数不能赋给类型“"a" | "b" | "c" | "d"”的参数。

(3)、用 类 约束泛型函数的类型变量——泛型中使用类类型

比如:创建一个 Create 函数,传入一个类的实例类型 T。这个就是工厂函数的一个构造器,构造器的参数是一个构造器类型,所以它是一个 {new(): T}——构造器返回的是一个类的实例类型 T。这个工厂函数返回值类型也是一个类的实例类型 T。它的实现就是return new c()。这就是类类型在工厂函数中的一个应用。

function Create<T>(c: {new(): T}): T {
    return new c()
}

来看一个更高级的例子。比如:我们定义一些动物和动物管理员。

class BeeKeeper {
    hasMask: boolean
}

class LionKeeper {
    nametag: string
}

class Animal {
    numLengs: number
}

class Bee extends Animal {
    keeper: BeeKeeper
}

class Lion extends Animal {
    keeper: LionKeeper
}

function createInstance<T extends Animal>(c: new() => T) :T {
    return new c()
}

// createInstance(Lion).keeper.nametag
// createInstance(Bee).keeper.hasMask

利用泛型约束推导的一个属性,很容易推断出你的成员的一个类型是什么样子的——这就是泛型约束的好处。


九、JSX

TypeScript 支持内嵌,类型检查以及将 JSX 直接编译为 JavaScript。

1、jsx 所需的配置

使用 JSX 必须做两件事:

  • 给文件一个.tsx 扩展名
  • 启用 jsx 选项

TypeScript具有三种JSX模式:preserve、react 和 react-native。

模式 输入 输出 输出文件扩展名
preserve < div /> < div /> .jsx
react < div /> React.createElement(“div”) .js
react-native < div /> < div /> .js

你可以通过在命令行里使用 --jsx 标记 或 tsconfig.json 里的选项 来指定模式。

【注意】React标识符是写死的硬编码,所以你必须保证React(大写的R)是可用的。

2、as 操作符

as 操作符在 .ts 和 .tsx 里都可用,并且与尖括号类型断言行为是等价的。但是在 .tsx 里只可以使用 as 操作符来进行类型断言。

var foo = bar as foo;

其他请看官网的 JSX。

十、Mixins(不利于代码维护管理,不推荐使用)

除了传统的面向对象继承方式,还可以通过 Mixins 来重用组件。

例如:

// 一次性的 Mixin
class Disposable {
    isDisposed: boolean;
    dispose() {
        this.isDisposed = true;
    }

}

// 可激活的 Mixin
class Activatable {
    isActive: boolean;
    activate() {
        this.isActive = true;
    }
    deactivate() {
        this.isActive = false;
    }
}

class SmartObject implements Disposable, Activatable {
    constructor() {
        setInterval(() => console.log(this.isActive + " : " + this.isDisposed), 500);
    }

    interact() {
        this.activate();
    }

    // Disposable
    isDisposed: boolean = false;
    dispose: () => void;
    // Activatable
    isActive: boolean = false;
    activate: () => void;
    deactivate: () => void;
}

applyMixins(SmartObject, [Disposable, Activatable]);

let smartObj = new SmartObject();
setTimeout(() => smartObj.interact(), 1000);


// 在运行库的某个地方


function applyMixins(derivedCtor: any, baseCtors: any[]) {
    baseCtors.forEach(baseCtor => {
        Object.getOwnPropertyNames(baseCtor.prototype).forEach(name => {
            derivedCtor.prototype[name] = baseCtor.prototype[name];
        });
    });
}

代码里首先定义了 Disposable 和 Activatable 两个类,它们将做为 mixins。 然后创建一个 SmartObject 类,结合了这两个 mixins。首先应该注意到的是,没使用 extends 而是使用 implements。 把类当成了接口,仅使用 Disposable 和 Activatable 的类型而非其实现。 这意味着我们需要在类里面实现接口。 但是这是我们在用 mixin 时想避免的:

// 一次性的
isDisposed: boolean = false;
dispose: () => void;
// 可激活的
isActive: boolean = false;
activate: () => void;
deactivate: () => void;

最后,把mixins混入定义的类,完成全部实现部分。

applyMixins(SmartObject, [Disposable, Activatable]);

最后,创建这个帮助函数,帮我们做混入操作。 它会遍历mixins上的所有属性,并复制到目标上去,把之前的占位属性替换成真正的实现代码。

function applyMixins(derivedCtor: any, baseCtors: any[]) {
    baseCtors.forEach(baseCtor => {
        Object.getOwnPropertyNames(baseCtor.prototype).forEach(name => {
            derivedCtor.prototype[name] = baseCtor.prototype[name];
        })
    });
}

【推荐】
如何进阶TypeScript功底?一文带你理解TS中各种高级语法




【参考文章】
TypeScript 官方文档
TypeScript 教程
TypeScript 入门教程
深入理解 TypeScript
TypeScript语法总结+项目(Vue.js+TS)实战

你可能感兴趣的:(TypeScript,typescript)