Typescript学习(四) 函数和类

89 阅读10分钟

在前面的章节中, 我们说的类型其实都是一个变量的值的类型, 比如, 一个变量str的值是字符串, 那它的类型自然就是string; 而在JavaScript中, 我们不可能只处理这些简单的数据, 还有些比较复杂的数据类型, 比如说, 函数和类, 它们的类型, 则要更加的复杂些;

函数

声明方式

在Typescript中, 我们说一个函数的类型, 说白了主要是指一个函数的入参和返回值的类型, 而函数在Javascript中有2中声明方式: 函数声明(Function Declaration)和函数表达式(Function Expression), 同样的, 这两种方式在Typescript中也存在, 因此, 函数的类型标注, 也是多样的:

// 函数声明
function getNumber (params: number): number {
  return params
}
// 函数表达式
let getNumber: (params: number) => number = function (params) {
  return params
}

注意, 在函数表达式的声明方式中, 我们, 我们直接给一个变量标注了一个完整的类型, 而不是在函数的参数和返回值位置上分别标出, 这种写法的代码可读性其实不高, 毕竟这个类型标注太像箭头函数了, 如果我们的函数也是箭头函数, 那就会比较难看:

let getNumber: (params: number) => number = (params) => params

这还只是一个比较简单的函数, 如果遇到复杂的箭头函数, 那就更加不易阅读了, 如果非要这么写, 也可以把函数类型标注用类型别名直接设定为一个类型

type NumberFnType = (params: number) => number
let getNumber: NumberFnType = (params) => params

其实也可以使用interface进行类型声明, 这种interface被称作: Callable Interface, 也就是只包含一个函数标注的接口

interface NumberType {
  (params: number):number;
}
let getNumber: NumberType = (params) => params

当然, 接口里不止一个函数的类型标注, 也不会出错, 类型校验也能生效

interface NumberType {
  (params: number):number;
  name: string
}
let getNumber: NumberType = (params) => params
getNumber.name = '名字'

getNumber(1)

返回值

既然函数的类型主要看参数和返回值, 我们就从这两方面入手, 先看看返回值, 返回值这块, 要注意的就是void类型, 前面已经介绍过了void类型, 这个类型甚至可以看作是函数的'专属类型', 它表示一个空的, 没有意义的类型, 表示函数没有明确返回任何东西

function fn ():void {
  console.log('this is void')
}

参数

说完了返回值, 再来看看参数部分, 参数的类型上面, 主要需要注意的是可选和默认参数;

可选参数

先说说可选参数, 在Javascript中, 是没什么可选不可选的概念的, 你定义一堆参数, 执行的时候一个都不传, 在编译阶段也没啥问题, 到了运行时, 可能就是一堆错误, 而在Typescript中, 可选就是可选, 必选就是必选,必须写清楚

function getAge (age: number, gender?: string) {
  return age
}

getAge(12)

注意, 可选参数, 必须写在必选参数后面, 理由, Javascript和Typescript都是根据参数的位置来确定参数的, 即形参, 而非名参. 如果在必选参数前面有一个可选参数, 那么就意味着, 后面的参数代表的含义, 完全取决于前面的可选参数是否存在, 因为可选参数存在与否, 直接影响后面参数的位置!

// 第二个参数(age)的含义完全取决于gender是否存在, 存在, 它就代表年龄, 不存在, 它就代表性别!
// 这显然是很荒唐的逻辑!
function getAge (gender?: string, age: number) {
  return age
}

默认参数

如果有一个参数的值我们想有一个默认值, 而不是每次都去重复填写, 那怎么办 此时就要用到参数的默认值! 注意, 既然是有默认值, 那肯定就是可选参数了, 此时无需使用?, 直接用=, 就行了

function getAge (age:number, gender:string='male') {
  return age
}

此时也可以省略掉gender的类型标注, 因为参数的默认值的类型推导出这个值的类型! 比如, 上面的gender的string标注, 其实是可以去掉的, gender的类型依然会被推断为string类型, 当然, 如果是联合类型, 那么, 还是使用类型标注为好!

不过还是要注意一点, 虽然说具有默认值的参数, 可以看作是'可选'的, 但是, 它和真正的可选参数其实是有差异的, 真正的可选参数其实是具体类型和undefined组成的联合类型, 而具有默认值的参数的类型就是默认值的类型!

// age类型为number
function getDetail (name: string, age = 17) {
}

// age类型为number|undefined
function getInfo (name: string, age?:number) {
  if (typeof age !=='undefined') {
    return age
  } else {
    return name
  }
}

rest参数

在es6中, 有...rest这种参数的表达方式, 那么它的类型如何标注呢?

// 默认情况下, rest的类型为any[],当然, 最好别偷懒, 还是写上类型标注
// 因为noImplicitAny: true配置下会提示Rest参数隐式具有any[]类型!
function getDetail (id: number, ...options: any[]) {
  options.forEach(item => {
    console.log(item)
  })
  return id
}

如果觉得这么标注过于随便了, 也可以使用数组或者元组来标注

function getDetail (id: number, ...options: [string, number]) {
  options.forEach(item => {
    console.log(item)
  })
  return id
}

重载

我们来看看以下这个函数

function getData (data: number, isToBeString?:boolean):string|number {
  if (isToBeString) {
    return String(data)
  } else {
    return data
  }
}

let stringData = getData(12, true)
let numberData = getData(12, false)

在这个函数中, 我们获取了一个data, 是number类型, 但是这个data是否需要转为string, 取决于第二个参数isToBeString, 但是在类型标注上, 显然没有体现出这一点, 我们只知道返回值是一个联合类型; 而这个方法的第二个入参是一个可选参数, 它们之间有无关联, 不知道, 必须要通过阅读函数体内的逻辑才能知道; 所以执行getData方法之后, 其结果会被自动推导为string|number联合类型

如果能够在isToBeString为true的时候, 自动推导结果为string类型, 反之则为number类型就好了

这种情况下, 我们就需要函数重载来解决问题

// 函数重载
function getData (data:number, isToBeString:true):string
function getData (data:number, isToBeString?:false):number
function getData (data: number, isToBeString?:boolean):string|number {
  if (isToBeString) {
    return String(data)
  } else {
    return data
  }
}

let stringData = getData(12, true)
let numberData = getData(12, false)

所谓的函数重载, 其实就是将每一种情况都列出来, isToBeString为true, 则其返回值就是string, 反之则为number, 这样有俩好处:

  1. 接手代码的人, 可以很清晰了解函数的参数和返回值的关系, 而无需去阅读里面具体的逻辑;
  2. 函数执行的结果能够得到更加精确的类型, 而不是模棱两可的联合类型;

属性和方法

了解了函数的类型标注, 类, 也就顺理成章容易理解了, 毕竟, 类属性的类型标注, 和变量的类型标注一致; 而方法的类型标注和函数的类型标注一致; 方法同样可以使用重载

class Person {
  name:string = ''
  age:number = 12
  gender: 'male' | 'female' = 'male'
  constructor(name:string) {
    this.name = name
  }
  // setter必须给参数设置类型
  set setName(name:string) {
    this.name = name
  }
  // getter必须给返回值设置类型
  get getName ():string {
    return this.name
  }
  // 方法的重载
  getInfo (needItem: 'name'): string
  getInfo (needItem: 'age'): number
  getInfo (needItem: 'gender'): 'male' | 'female'
  getInfo (needItem: 'name' | 'age' | 'gender') {
    if (needItem === 'name') {
      return this.name
    } else if (needItem === 'age'){
      return this.age
    } else {
      return this.gender
    }
  }
}

注意, setter方法不能设置返回类型, 哪怕void也不行; getter不能设置参数以及类型

访问修饰符

说完了类的属性和方法, 我们再来看看类的访问修饰符, 所谓访问修饰符, 其实是对类的属性

class Animal {
  // 自己/子类/实例,都可以访问
  public publicData:string = 'Animal Public'
  // 只有自己能访问
  private privateData:number = 0
  // 自己/子类能访问
  protected protectedData:boolean = false
  constructor () {}
  run (name:string) {
    console.log(name + 'can run')
  }
}

class Dog extends Animal {
  public color:string = 'black'
  // 我们其实还可以在构造函数的参数中直接定义一个访问修饰符
  // 这样, 就相当于定义了一个公共属性
  constructor (public name:string, color:string) {
    // 派生类的构造函数必须有super调用
    super()
    // this只能访问到publicData、protectedData
    this.name = name
    this.color = color
  }
  run () {
    super.run(this.name)
  }
}

let dog = new Dog('柯基', 'yellow')
// dog.privateData // 只能在Animal中被访问
// dog.protectedData // 只能在Animal及其子类中被访问
console.log(dog.name) // 柯基
console.log(dog.color) // yellow
console.log(dog.publicData) // Animal Public
dog.run() // 柯基can run

关于访问修饰符, 有几个最基本的点需要记住:

  1. public , 被public修饰的成员, 其在类/子类/实例中均可被访问
  2. protected, 被protected修饰的成员, 其在类/子类中均可被访问
  3. private, 被private修饰的成员, 只能在类中被访问

静态成员

刚才我们在案例中使用的成员, 其实都是实例成员, 也就是通过this.xx访问, 实例可以访问的成员, 现在我们要介绍的是静态成员, 使用static关键字标识:

class WebSocet {
  static connectCount:number = 0
  constructor() {}
  connect () {}
  disconnect () {
    if (WebSocet.connectCount > 199) {
      console.log('连接数超标!')
    }
  }
}

静态成员在class中必须使用类加点操作符的方式进行访问, 而非this! 虽然不能被实例访问, 但是, 它却适合用来记录一些全局性的东西, 例如上面例子中, websocket的连接数量, 可以看看它编译为es5后的样子

"use strict";
var WebSocet = /** @class */ (function () {
    function WebSocet() {
    }
    WebSocet.prototype.connect = function () { };
    WebSocet.prototype.disconnect = function () {
        if (WebSocet.connectCount > 199) {
            console.log('连接数超标!');
        }
    };
    WebSocet.connectCount = 0;
    return WebSocet;
}());

由此可见, 静态成员说白了就是挂载在了函数体上; 静态方法在很多优秀的框架中都能找到, 例如, Vue中的Vue.util, 实际上就是Vue这个类的静态方法

// Vue源码, /src/core/global-api/index.ts 第36行
Vue.util = {
  warn,
  extend,
  mergeOptions,
  defineReactive
}

小节:

  1. 静态成员不能被实例继承, 实例成员可以;
  2. 静态成员挂载在函数体/类上, 实例成员挂载在原型对象/实例上;
  3. 静态成员无法用this访问, 实例成员可以;
  4. 静态成员主要用于处理全局性的任务, 实例成员则只属于本实例;

继承

关于继承, 很好理解, 在js中, 我们会使用extends关键字来实现类的继承; 被继承的类成为基类, 或者父类, 与之相对应的, 就是派生类, 或者说是子类

我们前面的的一个案例中, Dog类, 继承了Animal类, 并重写了其run方法, 但是, 这里有些问题:

  1. 如果我们的方法越来越多, 我们怎么知道哪些是父类有的方法, 当前正在重写父类方法;
  2. 万一父类的方法是runs, 我们写错了, 然后自以为成功重写了父类方法, 这可能会引起一些错误;

所以, 我们需要使用override关键字来标注派生类哪些成员是基类有的

class Animal {
  move(distanceInMeters: number = 0) {
    console.log(`Animal moved ${distanceInMeters}m.`);
  }
}
 class Cat extends Animal {
  override move(distanceInMeters = 5) {
    console.log('Jumping...');
    super.move(distanceInMeters);
  }
}
 const cat = new Cat();
cat.move();

如果我们不小心写错了, 把move写成了more, 那么, 在有override关键字的情况下, 就会告知, 父类中不存在该方法!

抽象类

还有一种类, 它不会实现每一个具体的逻辑, 只会提供一个大体的'结构', 我们称之为抽象类, 它有以下特点:

  1. 使用abstract修饰符声明整个类, 其内部的抽象方法也是由abstract来声明;
  2. 子类必须实现抽象基类中的抽象方法, 前面的例子中, 普通类的子类可以重写父类的方法, 也可以不重写, 但是抽象类的子类, 必须实现其父类的抽象方法;
  3. 抽象类不能被实例化;
  4. 访问修饰符必须位于abstract修饰符之前;
  5. 抽象类中不得声明静态方法;
abstract class Animal {
  abstract move (distanceInMeters: number):void
  name:string = ''
}

class Dog extends Animal {
  // 必须实现父类的抽象方法
  move (distanceInMeters:number) {
    console.log('I can move' + distanceInMeters)
  }
}

newable interface

既然interface能够用来表示对象的类型, 那么, 肯定也能用来表示类的类型, 这里就要介绍Newable Interface,Callable Interface类似, 其只包含一个构造函数的类型描述:

// 接上面的案例
interface DogConstructor {
  new (name:string): Dog
}

let DogFactory:DogConstructor = Dog
let dog = new DogFactory('二哈')

dog.move(1800)

Callable Interface其实只是说明了一个类(Dog), 实例化时入参的类型(name:string)