前端技术专家面试-Typescript

141 阅读8分钟

核心内容:对于Typescript的类型系统、类型推断、类型兼容的理解

=========

1. Typescript和Javascript的对比

  1. javascript是动态类型语言,运行的时候才知道具体的类型是什么,导致的问题

    1. 书写的时候无法确定类型导致容易出错,特别是在大型项目中。
    2. 运行的时候会影响性能,JavaScript引擎的优化已经大大减轻了这个问题。
    3. 一些解决方案:JSDoc注释、使用静态类型检查工具(如Flow)等。
  2. Typescript提供了静态类型检查。它在编译时进行类型检查。

    • TypeScript最终会编译成JavaScript,因此它的类型信息在运行时是不存在的。
    • TypeScript提供了渐进式类型系统,允许开发者逐步添加类型注解。

2. Typescript的3个核心概念

  1. 类型系统

    • 类型系统包括基本类型(如 numberstring)、复杂类型(如 interfaceclassenum)、泛型、联合类型和交叉类型等。
  2. 类型推断

    • TypeScript 具有强大的类型推断能力,可以在没有显式类型注释的情况下推断变量的类型。这减少了开发者的负担,同时仍然提供类型安全性。
  3. 类型兼容性

    • TypeScript 使用结构化类型系统(或称为“鸭子类型”),这意味着类型的兼容性是基于它们的结构而不是显式声明。这使得不同类型之间的交互更加灵活。

3. 通用语言的类型推断

  1. 语法分析(Parsing)

    • 词法分析(Lexical Analysis):将源代码分解成标记(tokens)。
    • 语法分析(Syntax Analysis):根据语言的语法规则将标记序列转换为抽象语法树(AST)。
  2. 符号表管理(Symbol Table Management)

    • 在语法分析过程中,编译器会构建符号表,用于存储变量、函数、类等的声明信息,包括它们的名称、作用域和类型信息。
    • 符号表是类型推断的基础数据结构,因为它提供了关于标识符的所有已知信息。
  3. 类型检查和推断(Type Checking and Inference)

    • 约束生成(Constraint Generation):编译器通过遍历 AST,生成类型约束。这些约束描述了程序中各个部分的类型关系。例如,如果变量 ( x ) 被赋值为一个整数,那么我们生成一个约束 ( \text{Type}(x) = \text{int} )。
    • 约束求解(Constraint Solving):编译器使用特定算法解决这些约束,推断出未知类型。常用的算法包括:
      • 合一算法(Unification Algorithm):用于在逻辑编程和类型推断中解决类型方程。这是一个递归算法,尝试通过合并类型变量来解决类型方程。
      • 类型合并(Type Merging):在处理联合类型或多态类型时,编译器需要合并多种可能的类型以找到一个“最佳通用类型”。
    • 控制流分析(Control Flow Analysis):通过分析程序的控制流图(CFG),编译器能够推断出变量在不同执行路径上的类型。这种分析对于处理条件语句和循环特别重要。
  4. 类型推断策略

    • 局部推断(Local Inference):在一个局部上下文中推断类型,通常针对函数体或单个表达式。
    • 全局推断(Global Inference):通过分析整个程序来推断类型,适用于更复杂的类型关系和依赖。
  5. 优化和错误报告

    • 在类型推断过程中,编译器还可能进行优化,例如消除冗余类型检查。
    • 如果类型推断失败,编译器会生成有意义的错误消息,帮助开发者定位和修复类型错误。

4. Typescript类型推断

  1. 生成抽象语法树(AST)
    • 当 TypeScript 编译器(tsc)处理 TypeScript 源代码时,它首先进行词法分析和语法分析,将代码解析为抽象语法树(AST)。AST 是源代码的结构化表示,包含了所有语法元素及其关系。
  2. 构建符号表
    • 在生成 AST 的同时,TypeScript 编译器构建符号表。符号表记录了每个标识符(如变量、函数、类等)的名称、作用域和声明位置等信息。
    • 符号表是类型推断的基础数据结构,因为它提供了关于标识符的所有已知信息
  3. 类型推断
    • TypeScript 使用上下文信息和类型推断规则来推断变量和表达式的类型。推断规则包括:
      • 直接推断:根据赋值语句或函数返回值推断类型。
      • 上下文推断:根据函数调用或对象属性访问的上下文推断类型。
      • 控制流分析:通过分析程序的控制流,推断变量在不同路径上的可能类型。
  4. 类型检查
    • 在推断出类型后,TypeScript 会进行类型检查,确保代码中的类型使用是合法的。这包括检查:
      • 类型兼容性:确保变量、函数参数和返回值的类型匹配。
      • 类型安全性:防止类型错误,如访问未定义的属性或调用不存在的方法。
  5. 错误报告
    • 如果在类型检查过程中发现类型不匹配或其他类型错误,TypeScript 编译器会生成详细的错误信息,帮助开发者理解和修复问题。

5. Typescript类型兼容

  1. Typescript类型兼容采用类型检查的部分

  2. 类型兼容有两种

    • 结构化类型系统(structural typing)

      • 类型的兼容性是基于它们的结构而非显式声明
      • 优点:灵活性高、简化代码、更容易进行代码重构
      • 缺点:类型安全性较低、难以表达意图、可能导致难以理解的错误
    • 名义类型系统(nominal typing)

      • Java是名义类型系统的经典例子。类和接口之间的关系必须通过显式的继承或实现来声明。
      • 优点:清晰的意图表达、类型安全性高、更容易进行静态分析
      • 缺点:灵活性较低 重构复杂 增加代码冗余
  3. TypeScript类型兼容性:如果一个类型的所有属性及其类型都可以在另一个类型中找到,则这两个类型是兼容的。

interface Point2D {
    x: number;
    y: number;
}

interface Point3D {
    x: number;
    y: number;
    z: number;
}

let point2D: Point2D = { x: 1, y: 2 };
let point3D: Point3D = { x: 1, y: 2, z: 3 };

// 这是允许的,因为Point3D包含Point2D的所有属性
point2D = point3D;

// 这是不允许的,因为Point2D缺少z属性
// point3D = point2D; // 错误

  1. 子类型关系:如果类型 A 的所有属性在类型 B 中都存在,并且这些属性的类型也兼容,则类型 A 是类型 B 的子类型。换句话说,类型 A 可以赋值给类型 B 的变量。
type Animal = {
    name: string;
}

type Dog = {
    name: string;
    breed: string;
}

let animal: Animal;
let dog: Dog = { name: "Buddy", breed: "Labrador" };

// Dog 是 Animal 的子类型,因为 Dog 包含 Animal 的所有属性
animal = dog; // 这是允许的

// 反过来不行,因为 Animal 缺少 breed 属性
// dog = animal; // 这会报错
  1. 宽松的类型匹配:TypeScript 允许多余的属性存在于一个类型中,只要目标类型的所有必需属性都存在且类型兼容。例如,一个对象可以有比接口要求更多的属性,只要它至少具有接口中定义的属性。
interface Person {
    name: string;
    age: number;
}

let person: Person = {
    name: "Alice",
    age: 30,
    occupation: "Engineer" // 额外的属性
}; // 这是允许的
  1. 函数类型兼容性:函数的类型兼容性不仅仅考虑参数和返回值的类型,还考虑参数的数量。TypeScript 允许函数有多余的参数,只要调用时所需的参数都存在并且类型兼容。
type SimpleFunction = (x: number) => void;

const detailedFunction = (x: number, y: string) => {
    console.log(x, y);
};

let simpleFunc: SimpleFunction = detailedFunction; // 这是允许的

simpleFunc(10); // 正常工作,y 参数被忽略
  1. 泛型兼容性:泛型类型的兼容性基于具体类型实例化后的结构。例如,Array 类型的兼容性取决于 T 的具体类型。
interface Box<T> {
    value: T;
}

let numberBox: Box<number> = { value: 10 };
let anyBox: Box<any> = numberBox; // 这是允许的
let stringBox: Box<string> = numberBox; // 错误:类型不兼容
  1. 协变与逆变:
    1. 协变(Covariant):

      • 简单来说,协变意味着"跟着一起变"。
      • 如果 A 是 B 的子类型,那么 Container<A> 也是 Container<B> 的子类型。
      • 在TypeScript中,返回值类型是协变的。
    2. 逆变(Contravariant):

      • 逆变意味着"反方向变"。
      • 如果 A 是 B 的子类型,那么 Container<B> 反而是 Container<A> 的子类型。
      • 在TypeScript中,参数类型是逆变的。
    3. 为什么这个设计:为了类型安全

      • 目的:复杂的类型关系中,可以安全地替换函数,同时保持最大的灵活性。它允许我们在需要更具体类型的地方使用更一般的函数,反之亦然。
      • 返回值类型的协变性允许我们返回更具体(更专门化)的类型。这是安全的,因为接收返回值的代码总是可以安全地处理更具体的类型。
      class Animal { eat() {} }
      class Dog extends Animal { bark() {} }
      
      type AnimalGetter = () => Animal;
      type DogGetter = () => Dog;
      
      let getAnimal: AnimalGetter;
      let getDog: DogGetter = () => new Dog();
      
      // 这是安全的
      getAnimal = getDog;
      
      • 参数类型的逆变性允许我们传入更一般(更抽象)的类型。这看起来可能有点反直觉,但实际上是为了保证类型安全。
      type AnimalConsumer = (a: Animal) => void;
      type DogConsumer = (d: Dog) => void;
      
      let feedAnimal: AnimalConsumer = (a) => { a.eat(); };
      let feedDog: DogConsumer;
      
      // 这是安全的
      feedDog = feedAnimal;