最近在项目里用TypeScript写了几个模块越用越觉得TypeScript确实是个好东西它在JavaScript的基础上加了类型系统能在编译阶段就发现很多潜在的bug大大提升了代码的可维护性和可靠性。
但是很多朋友刚接触TypeScript的时候,可能只知道它是加了类型的JavaScript会用一些基本的类型注解,但是对它的底层原理和机制了解得不多,比如TypeScript的类型系统到底是怎么工作的它的编译器是怎么把TypeScript代码编译成JavaScript的类型擦除是怎么回事接口和类型别名有什么区别泛型的底层原理是什么等等今天想从原理层面深入剖析一下TypeScript的底层机制帮助大家更好地理解和使用TypeScript。
一、TypeScript是什么
在深入原理之前,先简单回顾一下TypeScript是什么方便不太熟悉的朋友理解。
TypeScript是由微软开发的一种开源的编程语言它是JavaScript的超集,也就是说它在JavaScript的基础上扩展了一些新特性最核心的就是加了可选的静态类型系统TypeScript的代码不能直接在浏览器或Node.js里运行需要先编译成JavaScript代码,然后才能运行编译的过程中TypeScript编译器会做类型检查,如果发现类型错误就会报错提醒开发者修复这样就能在代码运行之前,就发现很多潜在的bug而不是等到运行时才报错这就是TypeScript最大的价值所在。
TypeScript的设计者是安德斯·海尔斯伯格(Anders Hejlsberg)他也是C#和Delphi的首席架构师是编译器和编程语言设计领域的大师,所以TypeScript的类型系统设计得非常精巧和强大既保持了和JavaScript的兼容性又提供了强大的类型检查能力这也是TypeScript能这么受欢迎的重要原因TypeScript第一个版本发布于2012年经过几年的发展现在已经非常成熟了被广泛应用在各种前端和Node.js项目中特别是大型项目用TypeScript的收益非常明显。
二、TypeScript编译器的工作原理
要理解TypeScript的底层机制首先,要理解它的编译器是怎么工作的TypeScript编译器(tsc)的工作过程大致可以分为几个阶段下面逐一介绍。
阶段1:扫描(Scanner)和词法分析(Lexer)
第一个阶段是词法分析也叫扫描TypeScript编译器首先,会把我们写的TypeScript源代码(字符串)读进来,然后一个字符一个字符地扫描把它们分解成一个个的词法单元(Token)比如关键字(let, const, function, interface, 等)标识符(变量名函数名等)运算符(+, -, *, /, 等)标点符号(括号分号逗号等)字面量(数字字符串布尔值等)等等这个过程就像我们读一句话的时候,把它拆成一个个的词语一样词法分析的输出是一个Token流也就是一串按顺序排列的词法单元。
举个简单的例子,比如下面这行TypeScript代码:
let name: string = "hello";词法分析后会分解成以下Token:
let(关键字)name(标识符):(标点符号)string(标识符/类型关键字)=(运算符)"hello"(字符串字面量);(标点符号)
这个过程看起来简单,但是实际上,有很多细节要处理,比如字符串里的转义字符注释的跳过正则表达式的识别等等TypeScript的Scanner都处理得很好。
阶段2:语法分析(Parser)和抽象语法树(AST)
第二个阶段是语法分析也叫解析Parser会接收Scanner输出的Token流,然后根据TypeScript的语法规则把它们组织成一个树形结构这就是抽象语法树(Abstract Syntax Tree简称AST)AST是源代码的一种结构化表示它用树的形式表达了代码的语法结构每个节点代表代码中的一个语法结构,比如变量声明函数声明表达式语句等等节点之间,有父子关系构成了一棵树。
还是用上面那个例子:
let name: string = "hello";语法分析后生成的AST大致是这样的(简化版):
VariableStatement
└── VariableDeclarationList
├── kind: let
└── declarations
└── VariableDeclaration
├── name: Identifier (name)
├── type: StringKeyword
└── initializer: StringLiteral ("hello")可以看到AST把这行代码的结构清晰地表达了出来它是一个变量声明语句用let声明了一个叫name的变量类型是string初始值是"hello"AST是编译器后续所有操作的基础,不管是类型检查还是代码生成都是基于AST来做的。
TypeScript的AST设计得非常完善它,不仅包含了JavaScript的所有语法结构还包含了TypeScript特有的类型相关的语法结构,比如类型注解接口泛型等等,而且每个节点都保存了它在源代码中的位置信息(起始和结束的行号列号)这样在报错的时候,就能准确地告诉开发者错误在哪一行哪一列非常方便。
阶段3:绑定(Binder)和符号(Symbol)
第三个阶段是绑定这个阶段TypeScript编译器会遍历AST为每个声明(变量函数类接口等)创建一个符号(Symbol)符号是TypeScript类型系统中的一个核心概念它代表了一个命名的实体,比如一个变量一个函数一个类等等符号保存了这个实体的各种信息,比如它的名字它的声明位置它的类型它的作用域等等Binder还会建立作用域链把每个符号和它所在的作用域关联起来这样在后续类型检查的时候,就能根据作用域链查找变量和类型了。
这个阶段的作用类似于我们读文章的时候,把每个名词和它的定义关联起来知道每个名字指代的是什么在哪个范围内有效这样后续理解文章内容的时候,就不会混淆了。
阶段4:类型检查(Type Checker)
第四个阶段也是TypeScript最核心的阶段就是类型检查Type Checker是TypeScript编译器中最复杂最重要的部分它会遍历AST结合Binder创建的符号和作用域对每个表达式和语句进行类型推断和类型检查看看有没有类型错误,如果有就生成诊断信息(Diagnostic)也就是我们平时看到的编译错误提示。
类型检查的过程大致是这样的:
- 对每个声明根据它的类型注解,或者初始值推断出它的类型。
- 对每个表达式根据它的操作数的类型和运算符的规则推断出表达式的类型。
- 对每个语句检查类型是否匹配,比如赋值语句左边的类型和右边的类型是否兼容函数调用的参数类型和函数定义的参数类型是否匹配等等。
- 如果发现类型不兼容,或者有其他类型错误就生成诊断信息记录错误位置和错误信息。
举个简单的例子:
let name: string = "hello";
name = 123; // 错误:不能将类型"number"分配给类型"string"类型检查器在检查第二行赋值语句的时候,会先看左边变量name的类型是string再看右边表达式123的类型是number然后检查number类型是否能赋值给string类型发现不能就生成一个错误提示"不能将类型'number'分配给类型'string'"这就是类型检查的基本过程。
当然实际的类型检查比这个复杂得多要处理各种复杂的类型,比如联合类型交叉类型泛型条件类型映射类型等等还要处理类型推断类型兼容类型收窄等各种复杂的情况TypeScript的Type Checker是一个非常庞大和复杂的系统也是TypeScript最有技术含量的部分。
阶段5:代码生成(Emitter)
第五个阶段是代码生成也叫发射(Emit)在类型检查完成后,如果没有错误(或者错误不影响代码生成取决于配置)TypeScript编译器就会遍历AST把它转换成JavaScript代码输出这个过程就是代码生成代码生成的时候TypeScript会把所有类型相关的代码都去掉(类型擦除)只保留JavaScript能运行的代码,同时也会根据配置做一些转换,比如把ES6+的语法转换成ES5的代码(降级编译)等等。
还是用上面那个例子:
let name: string = "hello";编译后生成的JavaScript代码是:
let name = "hello";可以看到类型注解: string被去掉了只保留了变量声明和初始值这就是类型擦除TypeScript的类型只在,编译阶段存在编译成JavaScript后就都消失了运行时是没有类型信息的这一点很重要后面会详细说。
代码生成阶段还会生成sourcemap(如果配置了的话)方便调试的时候,把编译后的JavaScript代码映射回原始的TypeScript代码还会生成声明文件(.d.ts如果配置了的话)也就是类型定义文件方便其他TypeScript项目引用这个模块的时候,能获得类型提示。
以上就是TypeScript编译器的五个主要阶段扫描语法分析绑定类型检查代码生成理解了这个过程就能对TypeScript的工作原理有一个整体的认识下面再深入剖析几个核心的底层机制。
三、类型擦除:TypeScript的类型只在编译期存在
这是理解TypeScript最重要的一个概念类型擦除(Type Erasure)前面提到了TypeScript的类型只在编译阶段存在编译成JavaScript后所有,类型相关的代码都会被擦除运行时是没有类型信息的这是TypeScript的一个核心设计决策也是它和其他一些带类型的语言(比如JavaC#)的重要区别。
为什么要类型擦除?
TypeScript设计为JavaScript的超集目标是编译成标准的JavaScript代码在任何JavaScript运行时都能运行不需要额外的运行时类型系统支持,如果TypeScript的类型要保留到运行时就需要在JavaScript运行时加一套类型系统这就不是纯JavaScript了会增加运行时开销也会和JavaScript生态不兼容,所以TypeScript选择了类型擦除的方案类型只在编译阶段做检查编译后就全部去掉生成纯JavaScript代码这样既享受了编译期类型检查的好处又保持了和JavaScript的完全兼容运行时零开销这是一个非常聪明的设计。
类型擦除的具体表现:
类型擦除具体表现在以下几个方面:
- 类型注解被擦除:变量函数参数函数返回值等的类型注解编译后都会被去掉。
// TypeScript
function add(a: number, b: number): number {
return a + b;
}
// 编译后的JavaScript
function add(a, b) {
return a + b;
}- 接口被擦除:接口(interface)只在编译阶段存在,编译后会完全消失不会生成任何JavaScript代码。
// TypeScript
interface Person {
name: string;
age: number;
}
function greet(person: Person) {
console.log(`Hello, ${person.name}`);
}
// 编译后的JavaScript
function greet(person) {
console.log(`Hello, ${person.name}`);
}可以看到Person接口完全消失了没有生成任何代码。
- 类型别名被擦除:和接口一样类型别名(type)也只在编译阶段存在编译后完全消失。
// TypeScript
type ID = string | number;
function processId(id: ID) {
console.log(id);
}
// 编译后的JavaScript
function processId(id) {
console.log(id);
}- 泛型被擦除:泛型(Generics)的类型参数编译后也会被擦除不会保留到运行时。
// TypeScript
function identity<T>(arg: T): T {
return arg;
}
// 编译后的JavaScript
function identity(arg) {
return arg;
}- 枚举不会被完全擦除:注意枚举(enum)是一个例外它不会被完全擦除而是会编译成一个JavaScript对象,因为枚举,不仅是类型也是一个运行时的值。
// TypeScript
enum Color {
Red,
Green,
Blue
}
// 编译后的JavaScript
var Color;
(function (Color) {
Color[Color["Red"] = 0] = "Red";
Color[Color["Green"] = 1] = "Green";
Color[Color["Blue"] = 2] = "Blue";
})(Color || (Color = {}));所以枚举是TypeScript中少数会影响运行时的特性之一使用的时候,要注意。
- 类不会被擦除:类(class)也不会被擦除,因为ES6已经有class语法了TypeScript的类会编译成对应的JavaScript类(或者ES5的构造函数原型模式)类的成员类型注解会被擦除,但是类本身会保留。
类型擦除带来的影响:
理解类型擦除对正确使用TypeScript非常重要它带来了一些影响需要注意:
- 不能在运行时检查类型:因为类型在运行时已经被擦除了,所以你不能用
typeof或instanceof来检查接口或类型别名,因为它们在运行时根本不存在。
interface Person {
name: string;
age: number;
}
let p: Person = { name: "Tom", age: 20 };
// 错误:Person只是一个类型运行时不存在不能用instanceof
if (p instanceof Person) {
console.log("是Person");
}这段代码会编译报错,因为Person只是一个类型运行时不存在不能用instanceof检查,如果需要在运行时检查对象的形状需要用类型守卫(Type Guard)比如检查对象是否有某个属性,或者用类(类在运行时存在可以用instanceof)。
- 泛型不能用于运行时判断:同样,因为泛型参数被擦除了,所以不能在运行时判断泛型的具体类型。
function process<T>(arg: T) {
// 错误:T只是类型参数运行时不存在
if (arg instanceof T) {
console.log("是T类型");
}
}如果需要根据泛型类型做不同处理需要把类型作为参数传进去,或者用类型守卫。
- 接口和类型别名没有运行时表示:接口和类型别名完全是编译期的概念运行时没有任何痕迹,所以不能在运行时动态创建接口或类型别名也不能遍历接口的属性等等。
理解了类型擦除就能避免很多常见的TypeScript使用误区也能更好地理解TypeScript的设计哲学它是在不改变JavaScript运行时的前提下给JavaScript加了一层编译期的类型检查这层检查在编译后就被擦除了不影响运行时的性能和兼容性这是一个非常优雅的设计。
四、TypeScript的类型系统:结构化类型系统
TypeScript的类型系统采用的是结构化类型系统(Structural Type System)也叫鸭子类型(Duck Typing)这和,JavaC#等语言的名义类型系统(Nominal Type System)不同理解这一点对正确使用TypeScript非常重要。
什么是结构化类型系统?
结构化类型系统的核心思想是两个类型是否兼容不取决于它们的名字或声明关系而取决于它们的结构(也就是成员)是否兼容简单说就是,如果一个类型的结构满足另一个类型的要求(有所有需要的成员且成员类型兼容)那么它们就是兼容的,不管它们的名字是什么有没有继承关系这就是"鸭子类型"的思想"如果它走起来像鸭子叫起来像鸭子那它就是鸭子"。
举个例子:
interface Point {
x: number;
y: number;
}
interface Vector2D {
x: number;
y: number;
}
let p: Point = { x: 1, y: 2 };
let v: Vector2D = p; // 没问题,因为Point和Vector2D结构相同在这个例子里Point和Vector2D是两个完全独立声明的接口没有任何继承关系,但是,因为它们的结构完全相同(都有x和y两个number类型的属性)所以它们是兼容的可以互相赋值这就是结构化类型系统的特点,如果是在Java或C#这样的名义类型系统里这两个类型是不兼容的,因为它们名字不同没有继承关系。
再看一个例子:
interface Person {
name: string;
age: number;
}
interface Student {
name: string;
age: number;
grade: number;
}
let s: Student = { name: "Tom", age: 20, grade: 90 };
let p: Person = s; // 没问题Student有Person需要的所有属性在这个例子里Student比Person多了一个grade属性,但是,因为Student有Person需要的所有属性(name和age)且类型匹配,所以Student类型的值可以赋值给Person类型的变量这就是结构化类型系统的子类型关系,只要结构满足就是子类型,不管名字和声明关系。
结构化类型系统的好处:
结构化类型系统有很多好处:
- 更灵活:不需要显式声明继承或实现关系,只要结构兼容就能用这让代码更灵活更容易组合和复用。
- 更符合JavaScript的习惯:JavaScript本身就是鸭子类型的不关心对象的类型名只关心对象有没有需要的属性和,方法TypeScript的结构化类型系统和JavaScript的习惯一致学习成本低也更容易给已有的JavaScript代码加类型。
- 方便第三方库的类型定义:因为是结构化类型,所以给第三方库写类型定义的时候,不需要修改库的代码,只要描述清楚结构就能和TypeScript代码配合使用。
结构化类型系统需要注意的地方:
结构化类型系统,虽然灵活,但是也有一些需要注意的地方:
- 多余属性检查:在直接传对象字面量的时候TypeScript会做多余属性检查(Excess Property Checks)如果对象字面量有目标类型不需要的属性会报错。
interface Person {
name: string;
age: number;
}
// 错误:对象字面量只能指定已知属性"grade"不在类型"Person"中
let p: Person = { name: "Tom", age: 20, grade: 90 };
// 但是这样没问题先赋值给一个变量再传
let s = { name: "Tom", age: 20, grade: 90 };
let p2: Person = s; // 没问题这是,因为对象字面量直接赋值的时候TypeScript认为你可能是写错了,所以做更严格的检查而通过变量间接赋值的时候,不做多余属性检查这是TypeScript的一个特殊设计需要注意。
- 名义类型的需求:有时候我们希望两个结构相同的类型不兼容,比如用户ID和订单ID都是string但是不希望把用户ID传给需要订单ID的地方这时候结构化类型系统就不能满足需求了需要用一些技巧来模拟名义类型,比如加一个私有属性或用品牌类型(Branded Type)的技巧这是TypeScript的一个进阶话题这里就不展开了。
理解了结构化类型系统就能更好地理解TypeScript的类型兼容规则避免很多困惑和,错误。
五、接口 vs 类型别名:有什么区别
很多刚学TypeScript的朋友都会困惑接口(interface)和类型别名(type alias)有,什么区别什么时候用接口什么时候用类型别名今天从原理层面来剖析一下它们的区别。
相同点:
首先,接口和类型别名有很多相同的地方:
- 都能描述对象形状:都能用来定义对象的类型描述对象有哪些属性属性是什么类型。
// 接口
interface Point1 {
x: number;
y: number;
}
// 类型别名
type Point2 = {
x: number;
y: number;
};这两个定义在使用上几乎没有区别。
- 都能扩展:都能扩展其他类型接口用extends类型别名用交叉类型(&)。
// 接口扩展
interface Animal1 {
name: string;
}
interface Dog1 extends Animal1 {
bark(): void;
}
// 类型别名扩展
type Animal2 = {
name: string;
};
type Dog2 = Animal2 & {
bark(): void;
};- 都能描述函数:都能用来定义函数类型。
// 接口
interface Add1 {
(a: number, b: number): number;
}
// 类型别名
type Add2 = (a: number, b: number) => number;- 都会被类型擦除:接口和类型别名都只在编译阶段存在编译后都会被完全擦除不会生成任何JavaScript代码运行时都不存在。
不同点:
虽然有很多相同点,但是接口和类型别名也有一些重要的区别:
- 接口可以声明合并类型别名不行:这是最重要的区别接口支持声明合并(Declaration Merging)也就是,说你可以多次声明同一个名字的接口它们会自动合并成一个接口而类型别名不行不能重复声明同一个名字的类型别名。
// 接口声明合并
interface User {
name: string;
}
interface User {
age: number;
}
// 合并后User有name和age两个属性
let u: User = { name: "Tom", age: 20 }; // 没问题
// 类型别名不能重复声明
type User2 = {
name: string;
};
type User2 = { // 错误:标识符"User2"重复
age: number;
};这个特性使得接口特别适合用来给第三方库或已有代码扩展类型,比如给Vue的组件实例扩展属性给Express的Request对象扩展属性等等都是用接口的声明合并来做的类型别名做不到这一点。
- 类型别名可以表示更多类型接口只能表示对象类型:类型别名更通用可以表示任何类型包括基本类型联合类型交叉类型元组等等而接口只能表示对象类型(包括函数类等对象形状)。
// 类型别名可以表示基本类型
type ID = string;
// 类型别名可以表示联合类型
type Status = "success" | "error" | "loading";
// 类型别名可以表示元组
type Point = [number, number];
// 这些都不能用接口表示所以,如果你需要定义的不是对象类型而是基本类型别名联合类型元组等就只能用类型别名不能用接口。
- 接口的extends更直观类型别名的交叉类型更灵活:接口用extends扩展语法更直观更像面向对象的继承而类型别名用交叉类型(&)扩展更灵活可以交叉多个任意类型。
另外在处理属性冲突的时候,接口和类型别名的行为也不同接口extends的时候,如果子接口的属性类型和父接口不兼容会直接报错而交叉类型会把冲突的属性交叉成never类型不会直接报错,但是使用的时候,会有问题这也是一个区别。
- 接口的报错信息更友好:一般来说,接口的类型错误提示更友好更清晰而复杂的类型别名(特别是深层嵌套的条件类型映射类型)错误提示可能会比较难读这也是一个实际使用中的区别。
什么时候用接口什么时候用类型别名?
根据上面的区别一般的最佳实践是:
- 定义对象形状特别是可能需要扩展或合并的用接口:如果你定义的是一个对象的形状,而且可能需要被扩展或声明合并(比如公共API的类型第三方库的类型定义)优先用接口,因为接口支持声明合并extends语法也更直观。
- 定义非对象类型或需要组合复杂类型的用类型别名:如果你需要定义基本类型别名联合类型交叉类型元组等非对象类型,或者需要用条件类型映射类型等高级类型操作来组合复杂类型用类型别名,因为接口做不到这些。
- 团队项目中保持一致:在一个团队项目中最好保持一致的风格约定好什么时候用接口什么时候用类型别名避免混用导致代码风格不统一。
其实在大部分简单场景下接口和类型别名的区别不大用哪个都可以不用太纠结,但是理解它们的区别能让你在需要的时候,做出正确的选择也能避免一些坑。
六、泛型的底层原理
泛型(Generics)是TypeScript中一个非常重要也很强大的特性它让我们能写出可复用的类型安全的代码很多朋友刚学泛型的时候,觉得它很抽象很难理解今天从原理层面来剖析一下泛型的底层机制。
什么是泛型?
简单说泛型就是类型的参数化让我们能把类型作为参数传给函数类接口等这样同一个函数类接口就能支持多种类型,同时保持类型安全而不用写多份代码也不用用any放弃类型检查。
举个最经典的例子identity函数:
// 不用泛型用any失去类型安全
function identity(arg: any): any {
return arg;
}
let result = identity("hello");
// result的类型是any没有类型提示也不会检查错误
// 用泛型保持类型安全
function identity<T>(arg: T): T {
return arg;
}
let result2 = identity("hello");
// result2的类型是string有类型提示类型安全可以看到用泛型后identity函数能接受任意类型的参数,同时返回值的类型和参数类型一致保持了类型安全这就是泛型的价值它让代码既通用又类型安全。
泛型的底层原理:类型参数的实例化和擦除
泛型的底层原理其实可以从两个方面来理解编译期的类型参数实例化和,运行时的类型擦除。
- 编译期:类型参数的实例化:在编译阶段TypeScript编译器会把泛型的类型参数看作一个占位符当你使用泛型函数或类的时候,编译器会根据你传的参数类型推断出类型参数的具体类型,然后用这个具体类型替换掉占位符进行类型检查这个过程叫类型参数的实例化(Instantiation)。
比如上面的identity函数当你调用identity("hello")的时候,编译器会根据参数"hello"的类型string推断出类型参数T是string然后把函数签名里的T都替换成string得到(arg: string) => string然后用这个具体的类型来检查调用是否正确返回值类型是否匹配等等。
这个过程有点像C++的模板实例化,但是TypeScript的泛型实例化只发生在编译阶段做类型检查不会像C++那样为每个类型生成一份单独的代码这就涉及到第二点类型擦除。
- 运行时:类型擦除:前面讲类型擦除的时候,提到了泛型的类型参数在编译后会被完全擦除不会保留到运行时,也就是说,不管你传什么类型参数给泛型函数编译后生成的JavaScript代码都是一样的,只有一份函数不会为每个类型生成单独的函数这和C++的模板不同C++模板会为每个类型生成一份代码而TypeScript泛型编译后,只有一份代码类型参数都被擦除了。
还是用identity函数的例子:
// TypeScript
function identity<T>(arg: T): T {
return arg;
}
let a = identity("hello"); // T是string
let b = identity(123); // T是number
// 编译后的JavaScript
function identity(arg) {
return arg;
}
var a = identity("hello");
var b = identity(123);可以看到编译后,只有一份identity函数类型参数T完全消失了不管调用的时候,传什么类型都用这同一份函数这就是泛型的类型擦除它保证了运行时的性能不会,因为泛型而产生额外的代码和开销,但是也意味着你不能在运行时获取泛型参数的具体类型前面已经讲过了。
泛型约束:限制类型参数的范围
泛型,虽然灵活,但是有时候我们需要对类型参数做一些限制,比如要求类型参数必须有某个属性或方法这时候就需要用泛型约束(Generic Constraint)用extends关键字来指定类型参数必须满足的条件。
举个例子:
interface Lengthwise {
length: number;
}
function logLength<T extends Lengthwise>(arg: T): T {
console.log(arg.length); // 没问题,因为T一定有length属性
return arg;
}
logLength("hello"); // 没问题string有length属性
logLength([1, 2, 3]); // 没问题数组有length属性
logLength(123); // 错误number没有length属性在这个例子里T extends Lengthwise就是泛型约束它要求类型参数T必须满足Lengthwise接口的结构也就是必须有一个number类型的length属性这样在函数内部就能安全地访问arg.length了,因为编译器知道T一定有这个属性,如果传了没有length属性的类型(比如number)编译器就会报错。
泛型约束的底层原理其实就是在类型参数实例化的时候,检查传入的类型是否满足约束的结构(用结构化类型系统来检查)如果满足就允许不满足就报错,同时在泛型内部访问属性的时候,编译器知道类型参数至少有约束里定义的属性,所以允许访问不会报错。
条件类型和映射类型:泛型的高级应用
TypeScript的泛型还支持一些高级特性,比如条件类型(Conditional Types)和映射类型(Mapped Types)这些是TypeScript类型系统中非常强大的特性能让我们写出非常灵活和复杂的类型操作这里简单介绍一下它们的原理。
- 条件类型:条件类型允许我们根据类型关系来选择不同的类型语法类似三元表达式
T extends U ? X : Y意思是,如果T是U的子类型(结构兼容)就取X类型,否则取Y类型。
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<number>; // false条件类型的底层原理是在编译阶段编译器会判断类型参数T是否满足约束U(用结构化类型系统判断兼容性)然后根据结果选择对应的类型这个过程是,在编译阶段完成的运行时没有任何痕迹条件类型让类型系统具备了一定的"编程"能力能根据条件动态地生成类型非常强大。
- 映射类型:映射类型允许我们基于已有的类型生成新的类型通过遍历已有类型的属性来创建新类型常见的PartialReadonlyRecordPick等工具类型都是用映射类型实现的。
// Partial的实现把所有属性变成可选
type MyPartial<T> = {
[P in keyof T]?: T[P];
};
interface User {
name: string;
age: number;
}
type PartialUser = MyPartial<User>;
// 等价于 { name?: string; age?: number; }映射类型的底层原理是在编译阶段编译器会遍历类型T的所有属性键(keyof T)然后对每个键应用转换规则生成新的属性和类型最终组成一个新的对象类型这个过程也是,在编译阶段完成的运行时没有痕迹映射类型让我们能方便地对已有类型做各种转换生成新类型大大提升了类型的复用性和表达力。
条件类型和映射类型结合起来能实现非常强大的类型操作是TypeScript类型系统的高级部分也是很多工具类型和,类型体操的基础理解了它们的原理就能更好地使用和编写复杂的类型。
七、写在最后
以上就是我对TypeScript入门原理的一些剖析包括TypeScript编译器的工作原理(扫描语法分析绑定类型检查代码生成)类型擦除机制结构化类型系统接口和类型别名的区别以及,泛型的底层原理希望能帮助大家更深入地理解TypeScript的底层机制而不只是停留在会用基本类型注解的层面。
TypeScript是一个设计得非常精巧和优秀的语言它在不改变JavaScript运行时的前提下给JavaScript加了一层强大的编译期类型系统既保持了和,JavaScript生态的完全兼容又提供了类型安全和更好的开发体验它的类型擦除设计结构化类型系统泛型等机制都非常有智慧值得我们深入学习和理解。
当然这篇文章只是入门级别的原理剖析TypeScript的类型系统非常复杂和强大,还有很多高级特性和原理没有涉及,比如类型收窄类型推断的详细规则装饰器的原理命名空间模块解析等等以后有机会再继续深入分享希望这篇文章能给大家打下一个基础帮助大家更好地学习和使用TypeScript。
如果你还没有用过TypeScript强烈建议你尝试一下特别是在中大型项目中TypeScript能大大提升代码的可维护性和可靠性减少很多低级bug虽然有一定的学习成本,但是收益非常大值得投入时间学习而理解了它的底层原理能让你用得更得心应手更能发挥它的威力。
最后用一句话结束这篇文章:"TypeScript不只是加了类型的JavaScript它是一种更安全更可维护的编程方式理解它的底层原理能让你真正驾驭它写出高质量的代码。"
愿大家都能学好TypeScript用好TypeScript写出更安全更可靠的代码。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录