下一代前端开发语言 TypeScript从零到实践(6)

泛型

软件工程中,我们不仅要创建定义良好且一致的 API,同时也要考虑可重用性。 组件不仅能够支持当前的数据类型,同时也能支持未来的数据类型,这在创建大型系统时为你提供了十分灵活的功能。

在像 C# 和 Java 这样的语言中,可以使用泛型来创建可重用的组件,一个组件可以支持多种类型的数据。 这样用户就可以以自己的数据类型来使用组件。

基础示例

下面来创建第一个使用泛型的例子:identity 函数。 这个函数会返回任何传入它的值。 你可以把这个函数当成是 echo 命令。

不用泛型的话,这个函数可能是下面这样:

function identity(arg: number): number {
  return arg
}

或者,我们使用 any 类型来定义函数:

function identity(arg: any): any {
  return arg
}

使用 any 类型会导致这个函数可以接收任何类型的 arg 参数,但是这样就丢失了一些信息:传入的类型与返回的类型应该是相同的。如果我们传入一个数字,我们只知道任何类型的值都有可能被返回。

因此,我们需要一种方法使返回值的类型与传入参数的类型是相同的。这里,我们使用了类型变量,它是一种特殊的变量,只用于表示类型而不是值。

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

我们给 identity 添加了类型变量 TT 帮助我们捕获用户传入的类型(比如:number),之后我们就可以使用这个类型。 之后我们再次使用了 T 当做返回值类型。现在我们可以知道参数类型与返回值类型是相同的了。这允许我们跟踪函数里使用的类型的信息。

我们把这个版本的 identity 函数叫做泛型,因为它可以适用于多个类型。 不同于使用 any,它不会丢失信息,像第一个例子那像保持准确性,传入数值类型并返回数值类型。

我们定义了泛型函数后,可以用两种方法使用。 第一种是,传入所有的参数,包含类型参数:

let output = identity<string>('myString')

这里我们明确的指定了 Tstring 类型,并做为一个参数传给函数,使用了 <> 括起来而不是 ()

第二种方法更普遍。利用了类型推论 -- 即编译器会根据传入的参数自动地帮助我们确定 T 的类型:

let output = identity('myString')

注意我们没必要使用尖括号(<>)来明确地传入类型;编译器可以查看 myString 的值,然后把 T 设置为它的类型。 类型推论帮助我们保持代码精简和高可读性。如果编译器不能够自动地推断出类型的话,只能像上面那样明确的传入 T 的类型,在一些复杂的情况下,这是可能出现的。

使用泛型变量

使用泛型创建像 identity 这样的泛型函数时,编译器要求你在函数体必须正确的使用这个通用的类型。 换句话说,你必须把这些参数当做是任意或所有类型。

看下之前 identity 例子:

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

如果我们想打印出 arg 的长度。 我们很可能会这样做:

function loggingIdentity<T>(arg: T): T {
  console.log(arg.length)
  return arg
}

如果这么做,编译器会报错说我们使用了 arg.length 属性,但是没有地方指明 arg 具有这个属性。记住,这些类型变量代表的是任意类型,所以使用这个函数的人可能传入的是个数字,而数字是没有 .length 属性的。

现在假设我们想操作 T 类型的数组而不直接是 T。由于我们操作的是数组,所以 .length 属性是应该存在的。我们可以像创建其它数组一样创建这个数组:

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

你可以这样理解 loggingIdentity 的类型:泛型函数 loggingIdentity,接收类型参数 T 和参数 arg,它是个元素类型是 T 的数组,并返回元素类型是T 的数组。 如果我们传入数字数组,将返回一个数字数组,因为此时 T 的的类型为 number。 这可以让我们把泛型变量 T 当做类型的一部分使用,而不是整个类型,增加了灵活性。

泛型类型

上一节,我们创建了 identity 通用函数,可以适用于不同的类型。 在这节,我们研究一下函数本身的类型,以及如何创建泛型接口。

泛型函数的类型与非泛型函数的类型没什么不同,只是有一个类型参数在最前面,像函数声明一样:

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

let myIdentity: <T>(arg: T) => T = identity

我们也可以使用不同的泛型参数名,只要在数量上和使用方式上能对应上就可以。

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

let myIdentity: <U>(arg: U) => U = identity

我们还可以使用带有调用签名的对象字面量来定义泛型函数:

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

let myIdentity: {<T>(arg: T): T} = identity

这引导我们去写第一个泛型接口了。我们把上面例子里的对象字面量拿出来做为一个接口:

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

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

let myIdentity: GenericIdentityFn = identity

我们甚至可以把泛型参数当作整个接口的一个参数。 这样我们就能清楚的知道使用的具体是哪个泛型类型(比如: Dictionary<string> 而不只是Dictionary)。这样接口里的其它成员也能知道这个参数的类型了。

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

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

let myIdentity: GenericIdentityFn<number> = identity

注意,我们的示例做了少许改动。 不再描述泛型函数,而是把非泛型函数签名作为泛型类型一部分。 当我们使用 GenericIdentityFn 的时候,还得传入一个类型参数来指定泛型类型(这里是:number),锁定了之后代码里使用的类型。对于描述哪部分类型属于泛型部分来说,理解何时把参数放在调用签名里和何时放在接口上是很有帮助的。

除了泛型接口,我们还可以创建泛型类。 注意,无法创建泛型枚举和泛型命名空间。

泛型类

泛型类看上去与泛型接口差不多。 泛型类使用( <>)括起泛型类型,跟在类名后面。

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

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

GenericNumber 类的使用是十分直观的,并且你可能已经注意到了,没有什么去限制它只能使用 number 类型。 也可以使用字符串或其它更复杂的类型。

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

console.log(stringNumeric.add(stringNumeric.zeroValue, 'test'))

与接口一样,直接把泛型类型放在类后面,可以帮助我们确认类的所有属性都在使用相同的类型。

我们在那节说过,类有两部分:静态部分和实例部分。 泛型类指的是实例部分的类型,所以类的静态属性不能使用这个泛型类型。

泛型约束

我们有时候想操作某类型的一组值,并且我们知道这组值具有什么样的属性。在 loggingIdentity 例子中,我们想访问 arglength 属性,但是编译器并不能证明每种类型都有 length 属性,所以就报错了。

function loggingIdentity<T>(arg: T): T {
  console.log(arg.length)
  return arg
}

相比于操作 any 所有类型,我们想要限制函数去处理任意带有 .length 属性的所有类型。 只要传入的类型有这个属性,我们就允许,就是说至少包含这一属性。为此,我们需要列出对于 T 的约束要求。

我们定义一个接口来描述约束条件,创建一个包含 .length 属性的接口,使用这个接口和 extends 关键字来实现约束:

interface Lengthwise {
  length: number
}

function loggingIdentity<T extends Lengthwise>(arg: T): T {
  console.log(arg.length) // OK
  return arg
}

现在这个泛型函数被定义了约束,因此它不再是适用于任意类型:

loggingIdentity(3);  // Error

我们需要传入符合约束类型的值,必须包含必须的属性:

loggingIdentity({length: 10, value: 3}) // OK

在泛型约束中使用类型参数

你可以声明一个类型参数,且它被另一个类型参数所约束。 比如,现在我们想要用属性名从对象里获取这个属性。 并且我们想要确保这个属性存在于对象 obj 上,因此我们需要在这两个类型之间使用约束。

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') // okay
getProperty(x, 'm') // error

高级类型

交叉类型

交叉类型是将多个类型合并为一个类型。 这让我们可以把现有的多种类型叠加到一起成为一种类型,它包含了所需的所有类型的特性。 例如,Person & Loggable 同时是 PersonLoggable。 就是说这个类型的对象同时拥有了这两种类型的成员。

我们大多是在混入(mixins)或其它不适合典型面向对象模型的地方看到交叉类型的使用。 (在 JavaScript 里发生这种情况的场合很多!) 下面是如何创建混入的一个简单例子:

function extend<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
}

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

interface Loggable {
  log (): void
}

class ConsoleLogger implements Loggable {
  log () {
    // ...
  }
}

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

联合类型

联合类型与交叉类型很有关联,但是使用上却完全不同。 偶尔你会遇到这种情况,一个代码库希望传入 numberstring 类型的参数。 例如下面的函数:

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(`Expected string or number, got '${padding}'.`)
}

padLeft('Hello world', 4) // returns "    Hello world"

padLeft 存在一个问题,padding 参数的类型指定成了 any。 这就是说我们可以传入一个既不是 number 也不是 string 类型的参数,但是 TypeScript 却不报错。

let indentedString = padLeft('Hello world', true) // 编译阶段通过,运行时报错

为了解决这个问题,我们可以使用 联合类型做为 padding 的参数:

function padLeft(value: string, padding: string | number) {
  // ...
}

let indentedString = padLeft('Hello world', true) // 编译阶段报错

联合类型表示一个值可以是几种类型之一。我们用竖线(|)分隔每个类型,所以 number | string 表示一个值可以是 numberstring

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

interface Bird {
  fly()
  layEggs()
}

interface Fish {
  swim()
  layEggs()
}

function getSmallPet(): Fish | Bird {
  // ...
}

let pet = getSmallPet()
pet.layEggs() // okay
pet.swim()    // error

这里的联合类型可能有点复杂:如果一个值的类型是 A | B,我们能够确定的是它包含了 AB 中共有的成员。这个例子里,Fish 具有一个 swim 方法,我们不能确定一个 Bird | Fish 类型的变量是否有 swim方法。 如果变量在运行时是 Bird 类型,那么调用 pet.swim() 就出错了。

类型保护

联合类型适合于那些值可以为不同类型的情况。 但当我们想确切地了解是否为 Fish 或者是 Bird 时怎么办? JavaScript 里常用来区分这 2 个可能值的方法是检查成员是否存在。如之前提及的,我们只能访问联合类型中共同拥有的成员。

let pet = getSmallPet()

// 每一个成员访问都会报错
if (pet.swim) {
  pet.swim()
} else if (pet.fly) {
  pet.fly()
}

为了让这段代码工作,我们要使用类型断言:

let pet = getSmallPet()

if ((pet as Fish).swim) {
  (pet as Fish).swim()
} else {
  (pet as Bird).fly()
}

用户自定义的类型保护

这里可以注意到我们不得不多次使用类型断言。如果我们一旦检查过类型,就能在之后的每个分支里清楚地知道 pet 的类型的话就好了。

TypeScript 里的类型保护机制让它成为了现实。 类型保护就是一些表达式,它们会在运行时检查以确保在某个作用域里的类型。定义一个类型保护,我们只要简单地定义一个函数,它的返回值是一个类型谓词

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

在这个例子里,pet is Fish 就是类型谓词。谓词为 parameterName is Type 这种形式, parameterName 必须是来自于当前函数签名里的一个参数名。

每当使用一些变量调用 isFish 时,TypeScript 会将变量缩减为那个具体的类型。

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

注意 TypeScript 不仅知道在 if 分支里 petFish 类型;它还清楚在 else 分支里,一定不是 Fish类型而是 Bird 类型。

typeof 类型保护

现在我们回过头来看看怎么使用联合类型书写 padLeft 代码。我们可以像下面这样利用类型断言来写:

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

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

function padLeft (value: string, padding: string | number) {
  if (isNumber(padding)) {
    return Array(padding + 1).join(' ') + value
  }
  if (isString(padding)) {
    return padding + value
  }
  throw new Error(`Expected string or number, got '${padding}'.`)
}

然而,你必须要定义一个函数来判断类型是否是原始类型,但这并不必要。其实我们不必将 typeof x === 'number'抽象成一个函数,因为 TypeScript 可以将它识别为一个类型保护。 也就是说我们可以直接在代码里检查类型了。

function padLeft (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(`Expected string or number, got '${padding}'.`)
}

这些 typeof 类型保护只有两种形式能被识别:typeof v === "typename"typeof v !== "typename""typename"必须是 "number""string""boolean""symbol"。 但是 TypeScript 并不会阻止你与其它字符串比较,只是 TypeScript 不会把那些表达式识别为类型保护。

instanceof 类型保护

如果你已经阅读了 typeof 类型保护并且对 JavaScript 里的 instanceof 操作符熟悉的话,你可能已经猜到了这节要讲的内容。

instanceof 类型保护是通过构造函数来细化类型的一种方式。我们把之前的例子做一个小小的改造:

class Bird {
  fly () {
    console.log('bird fly')
  }

  layEggs () {
    console.log('bird lay eggs')
  }
}

class Fish {
  swim () {
    console.log('fish swim')
  }

  layEggs () {
    console.log('fish lay eggs')
  }
}

function getRandomPet () {
  return Math.random() > 0.5 ? new Bird() : new Fish()
}

let pet = getRandomPet()

if (pet instanceof Bird) {
  pet.fly()
}
if (pet instanceof Fish) {
  pet.swim()
}

可以为 null 的类型

TypeScript 具有两种特殊的类型,nullundefined,它们分别具有值 nullundefined。我们在基础类型一节里已经做过简要说明。 默认情况下,类型检查器认为 nullundefined 可以赋值给任何类型。 nullundefined 是所有其它类型的一个有效值。 这也意味着,你阻止不了将它们赋值给其它类型,就算是你想要阻止这种情况也不行。null的发明者,Tony Hoare,称它为价值亿万美金的错误

--strictNullChecks 标记可以解决此错误:当你声明一个变量时,它不会自动地包含 nullundefined。 你可以使用联合类型明确的包含它们:

let s = 'foo'
s = null // 错误, 'null'不能赋值给'string'
let sn: string | null = 'bar'
sn = null // 可以

sn = undefined // error, 'undefined'不能赋值给'string | null'

注意,按照 JavaScript 的语义,TypeScript 会把 nullundefined 区别对待。string | nullstring | undefinedstring | undefined | null 是不同的类型。

可选参数和可选属性

使用了 --strictNullChecks,可选参数会被自动地加上 | undefined:

function f(x: number, y?: number) {
  return x + (y || 0)
}
f(1, 2)
f(1)
f(1, undefined)
f(1, null) // error, 'null' 不能赋值给 'number | undefined'

可选属性也会有同样的处理:

class C {
  a: number
  b?: number
}
let c = new C()
c.a = 12
c.a = undefined // error, 'undefined' 不能赋值给 'number'
c.b = 13
c.b = undefined // ok
c.b = null // error, 'null' 不能赋值给 'number | undefined'

类型保护和类型断言

由于可以为 null 的类型能和其它类型定义为联合类型,那么你需要使用类型保护来去除 null。幸运地是这与在 JavaScript 里写的代码一致:

function f(sn: string | null): string {
  if (sn === null) {
    return 'default'
  } else {
    return sn
  }
}

这里很明显地去除了 null,你也可以使用短路运算符:

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

如果编译器不能够去除 nullundefined,你可以使用类型断言手动去除。语法是添加 ! 后缀: identifier!identifier 的类型里去除了 nullundefined

function broken(name: string | null): string {
  function postfix(epithet: string) {
    return name.charAt(0) + '.  the ' + epithet // error, 'name' 可能为 null
  }
  name = name || 'Bob'
  return postfix('great')
}

function fixed(name: string | null): string {
  function postfix(epithet: string) {
    return name!.charAt(0) + '.  the ' + epithet // ok
  }
  name = name || 'Bob'
  return postfix('great')
}

broken(null)

本例使用了嵌套函数,因为编译器无法去除嵌套函数的 null(除非是立即调用的函数表达式)。因为它无法跟踪所有对嵌套函数的调用,尤其是你将内层函数做为外层函数的返回值。如果无法知道函数在哪里被调用,就无法知道调用时 name 的类型。

字符串字面量类型

字符串字面量类型允许你指定字符串必须具有的确切值。在实际应用中,字符串字面量类型可以与联合类型,类型保护很好的配合。通过结合使用这些特性,你可以实现类似枚举类型的字符串。

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 {
      // error! 不能传入 null 或者 undefined.
    }
  }
}

let button = new UIElement()
button.animate(0, 0, 'ease-in')
button.animate(0, 0, 'uneasy') // error

你只能从三种允许的字符中选择其一来做为参数传递,传入其它值则会产生错误。

Argument of type '"uneasy"' is not assignable to parameter of type '"ease-in" | "ease-out" | "ease-in-out"'

总结

那么到这里,我们的 TypeScript 常用语法学习就告一段落了,当然 TypeScript 还有其他的语法我们并没有讲,我们只是讲了 TypeScript 的一些常用语法,你们把这些知识学会已经足以开发一般的应用了。如果你在使用 TypeScript 开发项目中遇到了其他的 TypeScript 语法知识,你可以通过 TypeScript 的官网文档学习。因为学基础最好的方法还是去阅读它的官网文档,敲上面的小例子。其实我们课程的基础知识结构也是大部分参考了官网文档,要记住学习一门技术的基础官网文档永远是最好的第一手资料。

但是 TypeScript 的学习不能仅仅靠看官网文档,你还需要动手实践,在实践中你才能真正掌握 TypeScript。相信很多同学学习到这里已经迫不及待想要大展身手了。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,258评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,335评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,225评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,126评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,140评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,098评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,018评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,857评论 0 273
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,298评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,518评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,678评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,400评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,993评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,638评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,801评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,661评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,558评论 2 352

推荐阅读更多精彩内容