TypeScript 闪念随手记……
:: 不要试图把一门语言的边边角角都一次性都搞清楚,没啥用,你又不准备成为“参考书”。一则平时使用的部分并没有那么多,二则脱离了实际应用的抽象文字并不容易记忆。
为什么需要 TypeScript
JavaScript 的每个值执行不同的操作时会有不同的行为。这听起来有点抽象,所以让我们举个例子,假设我们有一个名为 message
的变量,试想我们可以做哪些操作:
|
|
第一行代码是获取属性 toLowerCase
,然后调用它。第二行代码则是直接调用 message
。
但其实我们连 message
的值都不知道呢,自然也不知道这段代码的执行结果。每一个操作行为都先取决于我们有什么样的值。
message
是可调用的吗?message
有一个名为toLowerCase
的属性吗?- 如果有,
toLowerCase
是可以被调用的吗? - 如果这些值都可以被调用,它们会返回什么?
当我们写 JavaScript 的时候,这些问题的答案我们需要谨记在心,同时还要期望处理好所有的细节。
让我们假设 message
是这样定义的:
|
|
你完全可以猜到这段代码的结果,如果我们尝试运行 message.toLowerCase()
,我们可以得到这段字符的小写形式。
那第二段代码呢?如果你对 JavaScript 比较熟悉,你肯定知道会报如下错误:
TypeError: message is not a function
如果我们能避免这样的报错就好了。
当我们运行代码的时候,JavaScript 会在运行时先算出值的类型(type),然后再决定干什么。所谓值的类型,也包括了这个值有什么行为和能力。 当然 TypeError
也会暗示性的告诉我们一点,比如在这个例子里,它告诉我们字符串 Hello World
不能作为函数被调用。
对于一些值,比如基本值 string
和 number
,我们可以使用 typeof
运算符确认他们的类型。但是对于其他的比如函数,就没有对应的方法可以确认他们的类型了,举个例子,思考这个函数:
|
|
我们通过阅读代码可以知道,函数只有被传入一个拥有可调用的 flip
属性的对象,才会正常执行。但是 JavaScript 在代码执行时,并不会把这个信息体现出来。在 JavaScript 中,唯一可以知道 fn
在被传入特殊的值时会发生什么,就是调用它,然后看会发生什么。这种行为让你很难在代码运行前就预测代码执行结果,这也意味着当你写代码的时候,你会更难知道你的代码会发生什么。
从这个角度来看,类型就是描述什么样的值可以被传递给 fn
,什么样的值则会导致崩溃。JavaScript 仅仅提供了动态类型(dynamic typing),这需要你先运行代码然后再看会发生什么。
替代方案就是使用静态类型系统(static type system),在代码运行之前就预测需要什么样的代码。
在代码运行之前就找到错误,这就是静态类型检查器比如 TypeScript 做的事情。静态类型系统(Static types systems)描述了值应有的结构和行为。一个像 TypeScript 的类型检查器会利用这个信息,并且在可能会出错的时候告诉我们。
TypeScript 不仅在我们犯错的时候,可以找出错误,还可以防止我们犯错。
类型检查器因为有类型信息,可以检查比如说是否正确获取了一个变量的属性。也正是因为有这个信息,它也可以在你输入的时候,列出你可能想要使用的属性。
这意味着 TypeScript 对你编写代码也很有帮助,核心的类型检查器不仅可以提供错误信息,还可以提供代码补全功能。
TypeScript 的功能很强大,除了在你输入的时候提供补全和错误信息。还可以支持“快速修复”功能,即自动的修复错误,重构成组织清晰的代码。同时也支持导航功能,比如跳转到变量定义的地方,或者找到一个给定的变量所有的引用。
所有这些功能都建立在类型检查器上,并且跨平台支持。
类型注解
记住,我们并不需要总是写类型注解,大部分时候,TypeScript 可以自动推断出类型。这是一个特性,如果类型系统可以正确的推断出类型,最好就不要手动添加类型注解了。
类型注解并不是 JavaScript 的一部分。所以并没有任何浏览器或者运行环境可以直接运行 TypeScript 代码。这就是为什么 TypeScript 需要一个编译器,它需要将 TypeScript 代码转换为 JavaScript 代码,然后你才可以运行它。
谨记:类型注解并不会更改程序运行时的行为。
有哪些类型
类型可以出现在很多地方,不仅仅是在类型注解 (type annotations)中。我们不仅要学习类型本身,也要学习在什么地方使用这些类型产生新的结构。
我们先复习下最基本和常见的类型,这些是构建更复杂类型的基础。
原始类型, JavaScript 有三个非常常用的原始类型 :string
,number
和 boolean
,每一个类型在 TypeScript 中都有对应的类型。它们的名字跟你在 JavaScript 中使用 typeof
操作符得到的结果是一样的。
7 种原始数据类型:number bigint string boolean undefined null symbol
数组类型 - string[]
或 Array<string>
。
any, TypeScript 有一个特殊的类型,any
,当你不希望一个值导致类型检查错误的时候,就可以设置为 any
。
当你使用 const
、var
或 let
声明一个变量时,你可以选择性的添加一个类型注解,显式指定变量的类型。不过大部分时候,这不是必须的,因为 TypeScript 会自动推断类型。
函数类型,是 JavaScript 传递数据的主要方法。TypeScript 允许你指定函数的输入值和输出值的类型(参数类型注解和返回值类型注解)。
对象类型,定义一个对象类型,我们只需要简单的列出它的属性和对应的类型。多个属性之间可以使用 ,
或者 ;
分开属性,最后一个属性的分隔符加不加都行。
除了原始类型,最常见的类型就是对象类型了。
其中, 对象类型可以指定一些甚至所有的属性为可选的,你只需要在属性名后添加一个 ?
。
联合类型,TypeScript 类型系统允许你使用一系列的操作符,基于已经存在的类型构建新的类型。
注意,TypeScript 会要求 你做的事情,必须对每个联合的成员都是有效的。解决方案是用代码收窄联合类型。
后面,我们还会提到[[#字面量类型]] 。
类型别名和接口
我们已经学会在类型注解里直接使用对象类型和联合类型,这很方便,但有的时候,一个类型会被使用多次,此时我们更希望通过一个单独的名字来引用它。
:: 本质上都是为了方便复用。
你可以使用类型别名给任意类型一个名字。如:
|
|
注意,别名是唯一的别名,你不能使用类型别名创建同一个类型的不同版本。
接口声明(interface declaration)是命名对象类型的另一种方式,如下:
|
|
类型别名和接口有什么不同呢?
大部分时候,你可以任意选择使用。两者最关键的差别在于类型别名本身无法添加新的属性,而接口是可以扩展的。
但其实我们也可以通过组合类型别名来实现类似扩展的效果。
类型断言
有的时候,你知道一个值的类型,但 TypeScript 不知道。TypeScript 仅仅允许类型断言转换为一个更加具体或者更不具体的类型。 就像类型注解一样,类型断言也会被编译器移除,并且不会影响任何运行时的行为。
谨记:因为类型断言会在编译的时候被移除,所以运行时并不会有类型断言的检查,即使类型断言是错误的,也不会有异常或者
null
产生。
前面说过,TypeScript 仅仅允许类型断言转换为一个更加具体或者更不具体的类型。有的时候,这条规则会显得非常保守,阻止了你原本有效的类型转换。如果发生了这种事情,你可以使用双重断言,先断言为 any
(或者是 unknown
),然后再断言为期望的类型。
|
|
非空断言操作符 !
, TypeScript 提供了一个特殊的语法,可以在不做任何检查的情况下,从类型中移除 null
和 undefined
,这就是在任意表达式后面写上 !
,这是一个有效的类型断言,表示它的值不可能是 null
或者 undefined
。
|
|
就像其他的类型断言,这也不会更改任何运行时的行为。重要的事情说一遍,只有当你明确的知道这个值不可能是
null
或者undefined
时才使用!
。
字面量类型
除了常见的类型 string
和 number
,我们也可以将类型声明为更具体的数字或者字符串。
字面量类型本身并没有什么太大用,如果结合联合类型,就显得有用多了。举个例子,当函数只能传入一些固定的字符串时:
|
|
当你初始化变量为一个对象的时候,TypeScript 会假设这个对象的属性的值未来会被修改,举个例子,如果你写下这样的代码:
|
|
TypeScript 并不会认为 obj.counter
之前是 0
, 现在被赋值为 1
是一个错误。换句话说,obj.counter
必须是 number
类型,但不要求一定是 0
,因为类型可以决定读写行为。
这也同样应用于字符串:
|
|
在上面这个例子里,req.method
被推断为 string
,而不是 "GET"
,因为在创建 req
和 调用 handleRequest
函数之间,可能还有其他的代码,或许会将 req.method
赋值一个新字符串比如 "Guess"
。所以 TypeScript 就报错了。
:: 这里写的多少是有点绕了,其实,就是
req
的类型没有确定导致的。在req
中,method
属性只会被 TypeScript 推断为string
,因为并没有根据知道它应该用在哪里,自然无从推断。只需要在定义和使用的时候,给它一个明确的类型就可以了。
有两种方式可以解决:
1.添加一个类型断言改变推断结果:
// Change 1:
const req = { url: "https://example.com", method: "GET" as "GET" };
// Change 2
handleRequest(req.url, req.method as "GET");
修改 1 表示“我有意让 req.method
的类型为字面量类型 "GET"
,这会阻止未来可能赋值为 "GUESS"
等字段”。修改 2 表示“我知道 req.method
的值是 "GET"
”.
2.你也可以使用 as const
把整个对象转为一个类型字面量:
const req = { url: "https://example.com", method: "GET" } as const;
handleRequest(req.url, req.method);
as const
效果跟 const
类似,但是对类型系统而言,它可以确保所有的属性都被赋予一个字面量类型,而不是一个更通用的类型比如 string
或者 number
。
类型收窄
在上一个章节,我们已经提到这方面的知识。也不准备多讲,基本上是符合直觉的。
函数
让我们来学习一下如何书写描述函数的类型(types)。
最简单描述一个函数的方式是使用 函数类型表达式(function type expression)。 它的写法有点类似于箭头函数:
|
|
语法 (a: string) => void
表示一个函数有一个名为 a
,类型是字符串的参数,这个函数并没有返回任何值。
如果一个函数参数的类型并没有明确给出,它会被隐式设置为 any
。
当然了,我们也可以使用类型别名(type alias)定义一个函数类型:
|
|
函数类型表达式并不能支持声明属性,如果我们想描述一个带有属性的函数,我们可以在一个对象类型中写一个 调用签名(call signature)。
|
|
注意这个语法跟函数类型表达式稍有不同,在参数列表和返回的类型之间用的是 :
而不是 =>
。
JavaScript 函数也可以使用 new
操作符调用,当被调用的时候,TypeScript 会认为这是一个构造函数(constructors),因为他们会产生一个新对象。你可以写一个 构造签名(Construct Signatures),方法是在调用签名前面加一个 new
关键词:
|
|
一些对象,比如 Date
对象,可以直接调用,也可以使用 new
操作符调用,而你可以将调用签名和构造签名合并在一起:
|
|
:: 实际应用场景中,函数表达式是最常用的,调用签名可能会用,构造签名基本不用卅~
泛型函数,我们经常需要写这种函数,即函数的输出类型依赖函数的输入类型,或者两个输入的类型以某种形式相互关联。
让我们考虑这样一个函数,它返回数组的第一个元素:
|
|
注意此时函数返回值的类型是 any
,如果能返回第一个元素的具体类型就更好了。
在 TypeScript 中,泛型就是被用来描述两个值之间的对应关系。我们需要在函数签名里声明一个类型参数 (type parameter):
|
|
通过给函数添加一个类型参数 Type
,并且在两个地方使用它,我们就在函数的输入(即数组)和函数的输出(即返回值)之间创建了一个关联。现在当我们调用它,一个更具体的类型就会被判断出来。
|
|
尽管写泛型函数很有意思,但也容易翻车。如果你使用了太多的类型参数,或者使用了一些并不需要的约束,都可能会导致不正确的类型推断。如何写好泛型函数是一个值得进一步学习的问题,好在实际应用场景中,并不怎么需要我们书写泛型函数。
可选参数
JavaScript 中的函数经常会被传入非固定数量的参数,我们可以使用 ?
表示这个参数是可选的:
|
|
需要注意的是,尽管这个参数被声明为 number
类型,x
实际上的类型为 number | undefiend
,这是因为在 JavaScript 中未指定的函数参数就会被赋值 undefined
。
回调中的可选参数
在你学习过可选参数和函数类型表达式后,你很容易在包含了回调函数的函数中,犯下面这种错误:
|
|
将 index?
作为一个可选参数,本意是希望下面这些调用是合法的:
|
|
但 TypeScript 并不会这样认为,TypeScript 认为想表达的是回调函数可能只会被传入一个参数!
:: TypeScript:“我不要你以为,按我以为的来!” 😂
换句话说,myForEach
函数也可能是这样的:
|
|
TypeScript 会按照这个意思理解并报错,尽管实际上这个错误并无可能。
那如何修改呢?不设置为可选参数其实就可以:
|
|
在 JavaScript 中,如果你调用一个函数的时候,传入了比需要更多的参数,额外的参数就会被忽略。TypeScript 也是同样的做法。
当你写一个回调函数的类型时,不要写一个可选参数, 除非你真的打算调用函数的时候不传入实参。
剩余参数
不准备多讲~
对象类型
在 JavaScript 中,最基本的将数据成组和分发的方式就是通过对象。在 TypeScript 中,我们通过对象类型(object types)来描述对象。
对象类型可以是匿名的:
|
|
也可以使用接口进行定义:
|
|
或者通过类型别名:
|
|
:: 实际应用场景中,对象类型用的最多,单独拎出来重视一下。
对象类型中的每个属性可以说明它的类型、属性是否可选、属性是否只读等信息。
可选属性, 我们可以在属性名后面加一个 ?
标记表示这个属性是可选的。
只读属性,在 TypeScript 中,属性可以被标记为 readonly
,这不会改变任何运行时的行为,但在类型检查的时候,一个标记为 readonly
的属性是不能被写入的。
属性继承,对接口使用 extends
关键字允许我们有效的从其他声明过的类型中拷贝成员,并且随意添加新成员。
交叉类型,TypeScript 也提供了名为交叉类型(Intersection types)的方法,用于合并已经存在的对象类型。交叉类型的定义需要用到 &
操作符。
:: 一直感觉这里叫 “交叉” 很奇怪,因为明明应该是两个类型组合的,结果,它指的是两个类型组合时,其内部属性相同时,取其类型的交集。如此,便合理了。
泛型对象类型,见泛型。
类和模块
类就不多说了,基本不用系列。模块详见另一篇博文 「 [[模块化编程]] 」。