阅读 872

TypeScript 简明教程:接口、函数与类

本文为系列文章《TypeScript 简明教程》中的一篇。

接口

TypeScript 中,我们使用接口来描述对象或类的具体结构。接口的概念在 TypeScript 中是至关重要的。它就像是你与程序签订的一个契约,定义一个接口就意味着你答应程序:未来的某个值(或者类)一定会符合契约中所规定的模样,如果不符合,TS 就会直接在编译时报错

感兴趣的同学可以了解一下 鸭子类型

举个例子:

interface Phone {
    model: string
    price: number
}

let newPhone: Phone = {
    model: 'iPhone XS',
    price: 8599,
}
复制代码

上面的例子中,我们定义了一个接口 Phone,它约定:任何类型为 Phone 的值,有且只能有两个属性:string 类型的 model 属性以及 number 类型的 price 属性。之后,我们声明了一个变量 newPhone,它的类型为 Phone,而且遵照契约,将 model 赋值为字符串,price 赋值为数值。

接口一般首字母大写。在某些编程语言会建议使用 I 作为前缀。关于是否要使用 I 前缀,tslint 有一条 专门的规则,请根据团队编码风格自行选择。

多一些属性和少一些属性都是不允许的。

let phoneA: Phone = {
    model: 'iPhone XS',
} // Error: Property 'price' is missing in type '{ model: string; }' but required in type 'Phone'

let phoneB: Phone = {
    model: 'iPhone XS',
    price: 8599,
    producer: 'Apple',
} // Error: Property 'producer' doesn't exist on type `Phone`.
复制代码

接口作为类型注解,只在编译时起作用,不会出现在最终输出的 JS 代码中。

可选属性

对于某个可能存在的属性,我们可以在该属性后加上 ?标记 表示这个属性是可选的。

interface Phone {
    model: string
    price: number
    producer?: string
}

let newPhone: Phone = {
    model: 'iPhone XS',
    price: 8599, // OK
}

let phoneB: Phone = {
    model: 'iPhone XS',
    price: 8599,
    producer: 'Apple', // OK
} 
复制代码

任意属性

某些情况下,我们可能只知道接口中部分属性及它们的类型。或者,我们希望能够在初始化后动态添加对象的属性。这时,我们可以使用下面这种写法。

interface Phone {
    model: string
    price: number
    producer?: string
    [propName: string]: any
}

let phoneB: Phone = {
    model: 'iPhone XS',
    price: 8599,
} 
phoneB.storage = '256GB' // OK
复制代码

上面,我们定义任意属性的签名为 string 类型,值为 any 类型。注意:任意属性的值类型必须包含所有已知属性的值类型。 上述例子中,any 包括 stringnumber 类型。

只读属性

接口中,我们可以使用 readonly 标记某个属性是只读的。当我们试图修改它时,TS 会提示报错。

interface Phone {
    readonly model: string
    price: number
}

let phoneA: Phone = {
    model: 'iPhone XS',
    price: 8599,
}
phoneA.model = 'iPhone Air' // Error: Cannot assign to 'model' because it is a read-only property.
复制代码

函数

呼,终于说到函数了。JavaScript 中有两种定义函数的方法。

// 命名函数
function add(x, y) {
    return x + y
}

// 匿名函数
const add = function(x, y) { return x + y }
复制代码

对于这两种方法,添加类型注释的方式大同小异。

// 命名函数
function add(x: number, y: number): number {
    return x + y
}

// 匿名函数
const add = function(x: number, y: number): number { 
    return x + y
}
复制代码

上面我们定义了 add 函数,它接受两个 number 类型的参数,并规定其返回值为 number 类型。

调用函数时,传入参数的类型和数量必须与定义时保持一致。

add(1, 2) // OK
add('1', 0) // Error
add(1, 2, 3) // Error
复制代码

可选参数

使用 ?标记 可以标识某个参数是可选的。可选参数必须放在必要参数后面。

function increment(x: number, step?: number): number {
    return x + (step || 1)
}

increment(10) // => 11
复制代码

参数默认值

ES6 允许我们为参数添加默认值。作为 JS 的超集,TS 自然也是支持参数默认值的。

function increment(x: number, step: number = 1): number {
    return x + step
}

increment(10) // => 11
复制代码

因为具有参数默认值的参数必然是可选参数,所以无需再使用 ? 标记该参数时可选的。

这里,step: number = 1 可以简写为 step = 1,TS 会根据类型推断自动推断出 step 应为 number 类型。

与可选参数不同的是,具有默认值的参数不必放在必要参数后面。下面的写法也是允许的,只是在调用时,必须明确地传入 undefined 来获取默认值。

function increment(step = 1, x: number): number {
    return x + step
}

increment(undefined, 10) // => 11
复制代码

剩余参数

ES6 允许我们使用剩余参数将一个不定数量的参数表示为一个数组。TypeScript 中我们可以这样写。

function sum(...args: number[]): number {
    return args.reduce((prev, cur) => prev + cur)
}

sum(1, 2, 3) // => 6
复制代码

注意与 arguments 对象进行 区分

接口中的方法

对于接口中的方法,我们可以使用如下方式去定义:

interface Animal {
    say(text: string): void
}

// 或者 
interface Animal {
    say: (text: string) => void
}
复制代码

这两种注解方法的效果是一致的。

函数重载

函数重载允许你针对不同的参数进行不同的处理,进而返回不同的数据。

因为 JavaScript 在语言层面并不支持重载,我们必须在函数体内自行判断参数进行针对性处理,从而模拟出函数重载。

function margin(all: number);
function margin(vertical: number, horizontal: number);
function margin(top: number, right: number, bottom: number, left: number);
function margin(a: number, b?: number, c?: number, d?: number) {
    if (b === undefined && c === undefined && d === undefined) {
        b = c = d = a
    } else if (c === undefined && d === undefined) {
        c = a
        d = b
    }

    return {
        top: a,
        right: b,
        bottom: c,
        left: d,
    }
}

console.log(margin(10))
// => { top: 10, right: 10, bottom: 10, left: 10 } 
console.log(margin(10, 20))
// => { top: 10, right: 20, bottom: 10, left: 20 }
console.log(margin(10, 20, 20, 20))
// => { top: 10, right: 20, bottom: 20, left: 20 }
console.log(margin(10, 20, 20))
// Error
复制代码

上述例子中,前面三个声明了三种函数定义,编译器会根据这个顺序来处理函数调用,最后一个为最终的函数实现。需要注意的是,最后的函数实现参数类型必须包含之前所有的参数类型定义。因此,在定义重载的时候,一定要把最精确的定义放在最前面。

以前,JavaScript 中并没有类的概念,我们使用原型来模拟类的继承,直到 ES6 的出现,引入了 class 关键字。如果你对 ES6 的 class 还不是很了解,建议阅读 ECMAScript 6 入门 - Class

TypeScript 除了实现了所有 ES6 中的类的功能以外,还添加了一些新的用法。

访问修饰符

TypeScript 中可以使用三种修饰符:publicprivateprotected

public 修饰符

表示属性或方法是公有的,在类内部、子类内部、类的实例中都能被访问。默认情况下,所有属性和方法都是 public 的。

class Animal {
    public name: string
    
    constructor(name) {
        this.name = name
    }
}
    
let cat = new Animal('Tom')
console.log(cat.name); // => Tom
复制代码

private 修饰符

表示属性或方法是私有的,只能在类内部访问。

class Animal {
    private name: string
    
    constructor(name) {
        this.name = name
    }
    
    greet() {
        return `Hello, my name is ${ this.name }.`
    }
}
    
let cat = new Animal('Tom')
console.log(cat.name); // Error: 属性“name”为私有属性,只能在类“Animal”中访问。
console.log(cat.greet()) // => Hello, my name is Tom. 
复制代码

protected 修饰符

表示属性或方法是受保护的,与 private 近似,不过被 protected 修饰的属性或方法也能被其子类访问。

class Animal {
    protected name: string
    
    constructor(name) {
        this.name = name
    }
}
    
class Cat extends Animal {
    constructor(name) {
        super(name)
    }
    
    greet() {
        return `Hello, I'm ${ this.name } the cat.`
    }
}
    
let cat = new Cat('Tom')
console.log(cat.name); // Error: 属性“name”受保护,只能在类“Animal”及其子类中访问。
console.log(cat.greet()) // => Hello, I'm Tom the cat. 
复制代码

注意,TypeScript 只做编译时检查,当你试图在类外部访问被 private 或者 protected 修饰的属性或方法时,TS 会报错,但是它并不能阻止你访问这些属性或方法。

目前有一个提案,建议在语言层面使用 # 前缀标记某个属性或方法为私有的,感兴趣的可以看 这里

抽象类

抽象类是某个类具体实现的抽象表述,作为其他类的基类使用。

它具有两个特点:

  1. 不能被实例化
  2. 其抽象方法必须被子类实现

TypeScript 中使用 abstract 关键字表示抽象类以及其内部的抽象方法。

继续使用上面的 Animal 类的例子:

abstract class Animal {
    public abstract makeSound(): void
    public move() {
        console.log('Roaming...')
    }
}

class Cat extends Animal {
    makeSound() {
        console.log('Meow~')
    }
}

let tom = new Cat()
tom.makeSound() // => 'Meow~'
tom.move() // => 'Roaming...'
复制代码

上述例子中,我们创建了一个抽象类 Animal,它定义了一个抽象方法 makeSound。然后,我们定义了一个 Cat 类,继承自 Animal。因为 Animal 定义了 makeSound 抽象类,所以我们必须在 Cat 类里面实现它。不然的话,TS 会报错。

// Error: 非抽象类“Cat”没有实现继承自“Animal”类的抽象成员“makeSound”。
class Cat extends Animal {
    meow() {
       console.log('Meow~')  
    }
}
复制代码

类与接口

类可以实现(implement)接口。通过接口,你可以强制地指明类遵守某个契约。你可以在接口中声明一个方法,然后要求类去具体实现它。

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

class Clock implements ClockInterface {
    currentTime: Date
    setTime(d: Date) {
        this.currentTime = d
    }
}
复制代码

接口与抽象类的区别

  1. 类可以实现(implement)多个接口,但只能扩展(extends)自一个抽象类。
  2. 抽象类中可以包含具体实现,接口不能。
  3. 抽象类在运行时是可见的,可以通过 instanceof 判断。接口则只在编译时起作用。
  4. 接口只能描述类的公共(public)部分,不会检查私有成员,而抽象类没有这样的限制。

小结

本篇主要介绍了 TypeScript 中的几个重要概念:接口、函数和类,知道了如何用接口去描述对象的结构,如何去描述函数的类型以及 TypeScript 中类的用法。

关注下面的标签,发现更多相似文章
评论