MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

TypeScript类既声明值也声明类型的原理

2023-03-293.7k 阅读

TypeScript 基础概念回顾

在深入探讨 TypeScript 类既声明值也声明类型的原理之前,我们先来回顾一些 TypeScript 的基础概念。

类型系统基础

TypeScript 是 JavaScript 的超集,它为 JavaScript 添加了静态类型系统。类型系统的主要目的是在编译时检测错误,提高代码的可靠性和可维护性。在 TypeScript 中,我们可以为变量、函数参数和返回值等指定类型。例如:

let num: number = 10;
function add(a: number, b: number): number {
    return a + b;
}

这里,我们声明了变量 num 的类型为 number,函数 add 接受两个 number 类型的参数并返回一个 number 类型的值。

接口(Interface)

接口是 TypeScript 中用于定义对象形状的一种方式。它可以描述对象拥有哪些属性以及这些属性的类型。例如:

interface Person {
    name: string;
    age: number;
}

let tom: Person = { name: 'Tom', age: 25 };

接口只在类型层面存在,在编译后的 JavaScript 代码中不会生成任何实际的代码。

类型别名(Type Alias)

类型别名和接口类似,也用于定义类型。不过类型别名可以用于定义更复杂的类型,比如联合类型、交叉类型等。例如:

type ID = number | string;
let userId: ID = 123;
userId = '456';

type UserInfo = { name: string } & { age: number };
let user: UserInfo = { name: 'Jack', age: 30 };

TypeScript 类的基本定义与使用

类的定义

在 TypeScript 中,类是一种面向对象编程的基本结构,用于封装数据和行为。类的定义包含属性、方法和构造函数等部分。例如:

class Animal {
    name: string;
    constructor(name: string) {
        this.name = name;
    }
    speak() {
        console.log(`${this.name} makes a sound.`);
    }
}

在这个例子中,Animal 类有一个 name 属性,一个构造函数用于初始化 name 属性,还有一个 speak 方法。

类的实例化

定义好类之后,我们可以通过 new 关键字来创建类的实例。例如:

let dog = new Animal('Buddy');
dog.speak();

这里,dogAnimal 类的一个实例,通过 new Animal('Buddy') 创建,然后调用 speak 方法输出相应的信息。

类作为值的声明

实例化与内存分配

当我们使用 new 关键字实例化一个类时,实际上是在内存中分配了一块空间来存储这个实例的属性和方法。以之前的 Animal 类为例:

class Animal {
    name: string;
    constructor(name: string) {
        this.name = name;
    }
    speak() {
        console.log(`${this.name} makes a sound.`);
    }
}

let dog = new Animal('Buddy');

在内存中,dog 这个实例会有自己独立的空间来存储 name 属性的值 Buddy,并且 speak 方法也会被绑定到这个实例上。从底层原理来看,JavaScript(TypeScript 编译后就是 JavaScript)的对象是基于原型链的。每个实例都有一个指向其构造函数原型对象的 [[Prototype]] 属性。当我们调用 dog.speak() 时,JavaScript 引擎首先在 dog 实例自身查找 speak 方法,如果找不到,就会沿着 [[Prototype]] 链向上查找,直到找到 speak 方法或者到达原型链的顶端(null)。

类作为函数的返回值

类的实例也可以作为函数的返回值。例如:

class Animal {
    name: string;
    constructor(name: string) {
        this.name = name;
    }
    speak() {
        console.log(`${this.name} makes a sound.`);
    }
}

function createAnimal(name: string): Animal {
    return new Animal(name);
}

let cat = createAnimal('Whiskers');
cat.speak();

在这个例子中,createAnimal 函数接受一个字符串参数 name,并返回一个 Animal 类的实例。这里类的实例作为函数返回值,进一步体现了类作为值的特性。

类作为类型的声明

类型检查与兼容性

当我们定义一个类时,它同时也定义了一种类型。例如,我们可以使用 Animal 类来声明变量的类型:

class Animal {
    name: string;
    constructor(name: string) {
        this.name = name;
    }
    speak() {
        console.log(`${this.name} makes a sound.`);
    }
}

let myPet: Animal;
myPet = new Animal('Max');

这里,myPet 被声明为 Animal 类型,所以只能赋值为 Animal 类的实例或者其子类的实例。TypeScript 的类型检查机制会确保这种类型兼容性。如果我们尝试将一个非 Animal 类型的值赋给 myPet,就会报错。例如:

class Animal {
    name: string;
    constructor(name: string) {
        this.name = name;
    }
    speak() {
        console.log(`${this.name} makes a sound.`);
    }
}

let myPet: Animal;
myPet = { name: 'Invalid', age: 5 }; // 报错:类型 '{ name: string; age: number; }' 中缺少属性'speak',但类型 'Animal' 中需要该属性。

这种类型检查在编译时进行,有助于提前发现代码中的错误。

类类型与接口类型的比较

类类型和接口类型有一些相似之处,但也有重要的区别。接口只描述对象的形状,而类不仅描述形状,还包含实现。例如:

interface Shape {
    area(): number;
}

class Circle implements Shape {
    radius: number;
    constructor(radius: number) {
        this.radius = radius;
    }
    area(): number {
        return Math.PI * this.radius * this.radius;
    }
}

这里,Circle 类实现了 Shape 接口,满足了 Shape 接口定义的形状,即拥有 area 方法。但 Circle 类还包含了 radius 属性和构造函数等实现细节。类类型可以作为接口类型使用,因为类已经定义了相应的形状。例如:

let shape: Shape;
let circle = new Circle(5);
shape = circle;

然而,接口类型不能作为类类型使用,因为接口没有实现。

类既声明值也声明类型的底层原理

类型擦除(Type Erasure)

在 TypeScript 编译过程中,类型信息会被擦除,最终生成的 JavaScript 代码只包含运行时需要的实际逻辑。例如,我们之前定义的 Animal 类:

class Animal {
    name: string;
    constructor(name: string) {
        this.name = name;
    }
    speak() {
        console.log(`${this.name} makes a sound.`);
    }
}

let dog = new Animal('Buddy');

编译后的 JavaScript 代码如下:

var Animal = /** @class */ (function () {
    function Animal(name) {
        this.name = name;
    }
    Animal.prototype.speak = function () {
        console.log(this.name + " makes a sound.");
    };
    return Animal;
})();
var dog = new Animal('Buddy');

可以看到,编译后的代码中,类型声明(如 name: string)都被移除了,只保留了实际的代码逻辑。这就是类型擦除。但在编译时,TypeScript 利用类型信息进行类型检查,确保代码的类型安全。

内部类型表示

在 TypeScript 的编译器内部,类有其独特的类型表示。类的类型既包含实例类型,也包含构造函数类型。以 Animal 类为例,其实例类型包含 name 属性和 speak 方法的类型信息,构造函数类型包含接受一个 string 类型参数的信息。例如:

class Animal {
    name: string;
    constructor(name: string) {
        this.name = name;
    }
    speak() {
        console.log(`${this.name} makes a sound.`);
    }
}

从类型角度来看,Animal 类的实例类型可以表示为:

{
    name: string;
    speak(): void;
}

而构造函数类型可以表示为:

{
    new (name: string): Animal;
}

这种内部类型表示使得 TypeScript 能够准确地进行类型检查和推断。当我们声明一个变量为 Animal 类型时,TypeScript 会根据这些内部类型表示来检查赋值是否符合类型要求。

类型推导与上下文类型

TypeScript 具有类型推导和上下文类型的机制,这与类既声明值也声明类型的原理密切相关。例如:

class Animal {
    name: string;
    constructor(name: string) {
        this.name = name;
    }
    speak() {
        console.log(`${this.name} makes a sound.`);
    }
}

function handleAnimal(animal: Animal) {
    animal.speak();
}

handleAnimal({ name: 'Leo', speak: function () { console.log('Roar!'); } });

在这个例子中,handleAnimal 函数接受一个 Animal 类型的参数。当我们调用 handleAnimal 并传入一个对象字面量时,TypeScript 会根据函数参数的类型要求(Animal 类型)来推导传入对象字面量的类型。只要对象字面量包含 name 属性且类型为 string,以及 speak 方法且返回值类型为 void,就符合 Animal 类型的要求。这种上下文类型的机制使得代码编写更加灵活,同时也利用了类声明的类型信息进行类型检查。

类的继承与类型关系

继承的基本概念

在 TypeScript 中,类可以通过 extends 关键字实现继承。例如:

class Animal {
    name: string;
    constructor(name: string) {
        this.name = name;
    }
    speak() {
        console.log(`${this.name} makes a sound.`);
    }
}

class Dog extends Animal {
    breed: string;
    constructor(name: string, breed: string) {
        super(name);
        this.breed = breed;
    }
    bark() {
        console.log(`${this.name} barks.`);
    }
}

这里,Dog 类继承自 Animal 类,它继承了 Animal 类的 name 属性和 speak 方法,并且添加了自己的 breed 属性和 bark 方法。

子类型关系

在类型层面,Dog 类是 Animal 类的子类型。这意味着 Dog 类的实例可以赋值给 Animal 类型的变量。例如:

class Animal {
    name: string;
    constructor(name: string) {
        this.name = name;
    }
    speak() {
        console.log(`${this.name} makes a sound.`);
    }
}

class Dog extends Animal {
    breed: string;
    constructor(name: string, breed: string) {
        super(name);
        this.breed = breed;
    }
    bark() {
        console.log(`${this.name} barks.`);
    }
}

let animal: Animal;
let dog = new Dog('Max', 'Golden Retriever');
animal = dog;

这种子类型关系是基于类的继承结构。TypeScript 的类型系统会根据类的继承关系来判断类型兼容性。如果一个地方需要 Animal 类型,那么 Dog 类型的实例是可以接受的,因为 Dog 类拥有 Animal 类的所有属性和方法,并且可能还有额外的属性和方法。

重写方法与类型兼容性

在子类中重写父类的方法时,需要注意方法的类型兼容性。例如:

class Animal {
    speak() {
        console.log('Animal speaks.');
    }
}

class Dog extends Animal {
    speak() {
        console.log('Dog barks.');
    }
}

这里,Dog 类重写了 Animal 类的 speak 方法。由于重写的方法返回值类型和参数类型与父类方法相同(在这个例子中,speak 方法都没有参数且返回 void),所以是类型兼容的。如果子类重写方法的参数类型或返回值类型与父类不兼容,TypeScript 会报错。例如:

class Animal {
    speak(): string {
        return 'Animal speaks.';
    }
}

class Dog extends Animal {
    speak(): number { // 报错:返回类型 'number' 与父类方法的返回类型'string' 不兼容
        return 42;
    }
}

这种对重写方法的类型检查确保了在继承体系中类型的一致性和安全性。

类与泛型

泛型类的定义

泛型是 TypeScript 中一个强大的特性,它允许我们在定义类、函数等时使用类型参数。泛型类可以在实例化时指定具体的类型。例如:

class Box<T> {
    content: T;
    constructor(content: T) {
        this.content = content;
    }
    getContent(): T {
        return this.content;
    }
}

在这个例子中,Box 类是一个泛型类,T 是类型参数。我们可以在实例化 Box 类时指定 T 的具体类型。

泛型类的实例化与类型推断

当我们实例化泛型类时,可以明确指定类型参数,也可以让 TypeScript 进行类型推断。例如:

class Box<T> {
    content: T;
    constructor(content: T) {
        this.content = content;
    }
    getContent(): T {
        return this.content;
    }
}

let numberBox = new Box<number>(10);
let stringBox = new Box('Hello'); // TypeScript 推断出类型为 string

numberBox 的实例化中,我们明确指定了类型参数为 number。而在 stringBox 的实例化中,TypeScript 根据传入的参数类型推断出类型参数为 string

泛型类与类型兼容性

泛型类在类型兼容性方面有其特点。例如:

class Box<T> {
    content: T;
    constructor(content: T) {
        this.content = content;
    }
    getContent(): T {
        return this.content;
    }
}

let box1: Box<number>;
let box2: Box<number | string>;
box1 = box2; // 报错:类型 'Box<number | string>' 不能赋值给类型 'Box<number>'。类型 'number | string' 不能赋值给类型 'number'。

这里,虽然 numbernumber | string 的子类型,但 Box<number>Box<number | string> 是不兼容的。这是因为泛型类的类型兼容性是基于类型参数的精确匹配,除非类型参数是相同的,否则它们不兼容。这种严格的类型兼容性规则有助于避免在使用泛型类时出现类型错误。

类在模块中的使用与类型管理

模块的基本概念

在 TypeScript 中,模块是一种将代码组织成独立单元的方式。模块可以包含类、函数、变量等。例如,我们可以将 Animal 类定义在一个模块中:

// animal.ts
export class Animal {
    name: string;
    constructor(name: string) {
        this.name = name;
    }
    speak() {
        console.log(`${this.name} makes a sound.`);
    }
}

然后在另一个模块中导入并使用这个类:

// main.ts
import { Animal } from './animal';

let dog = new Animal('Buddy');
dog.speak();

这里,通过 export 关键字将 Animal 类导出,通过 import 关键字导入并使用。

模块中的类型作用域

在模块中,类的类型作用域被限制在模块内部,除非通过 export 导出。例如,如果我们在 animal.ts 模块中定义了一个内部类:

// animal.ts
class InternalAnimal {
    name: string;
    constructor(name: string) {
        this.name = name;
    }
    speak() {
        console.log(`${this.name} makes a sound.`);
    }
}

export class Animal {
    name: string;
    constructor(name: string) {
        this.name = name;
    }
    speak() {
        console.log(`${this.name} makes a sound.`);
    }
}

InternalAnimal 类在模块外部是不可见的,其类型也不能在模块外部使用。只有导出的 Animal 类及其类型可以在其他模块中导入和使用。

模块间的类型依赖与共享

当一个模块依赖于另一个模块中的类时,需要正确处理类型依赖。例如,如果我们有一个 Zoo 类依赖于 Animal 类:

// zoo.ts
import { Animal } from './animal';

export class Zoo {
    animals: Animal[];
    constructor() {
        this.animals = [];
    }
    addAnimal(animal: Animal) {
        this.animals.push(animal);
    }
}

这里,Zoo 类的 animals 属性和 addAnimal 方法都依赖于 Animal 类的类型。通过正确的导入和使用,确保了模块间的类型共享和一致性。同时,TypeScript 的模块系统会在编译时处理这些类型依赖,保证整个项目的类型安全。

通过以上各个方面的深入探讨,我们全面了解了 TypeScript 类既声明值也声明类型的原理,这对于编写高质量、可维护的 TypeScript 代码至关重要。无论是在小型项目还是大型企业级应用中,深入理解这些原理都能帮助开发者更好地利用 TypeScript 的强大功能。