TypeScript 是 JavaScript 的超集,JavaScript 能够做的事情,它都可以做且还增加了很多功能,例如静态类型、增强的面向对象编程能力等。
本文是笔者日常学习、使用 TypeScript 过程中自己记录的一些知识点,现在总结分享给大家。包含了做为初学者在学习 TypeScript 时应关注的核心知识,在掌握了这些知识点后,就是在项目中的灵活应用。
下图为本文概览:
社区发展现状调研
近些年 TypeScript 在前端圈备受推崇,正在被越来越多的前端开发者所接受,包括在 Node.js 后端开发中同样如此。
下面从几份开发者报告调查数据看 TypeScript 的发展现状。
2022 年前端开发者现状报告
本报告来源于 https://tsh.io/state-of-frontend/#report 调查面比较广泛。来自 125 个国家的 3703+ 前端开发者及 19 位前端专家参与了该次调查。
关于 TypeScript ,过去一年里有将近 84.1% 的参与者表示使用过。
对于 TypeScript 前景描述,近 43% 开发者表示 “TypeScript 将超越 JavaScript 成为新的前端标准” 还是比较看好的。
2021 年 Node.js 年度报告
下面来看一份来国内的 Node.js 2021 年度开发者调查报告 https://nodersurvey.github.io/reporters。
从 “代码转译” 角度调研,有 47.5% 的开发者使用了 TypeScript。在 Node.js 后端框架中 Nest.js 最近几年也是一个热门选择,该框架一个特点是完全基于 TypeScript,受到了更多开发者的青睐。
一些其它的调查者报告数据
还有一些其它的调查报告数据,例如 2021 年 Stack Overflow 调查者报告、Google 搜索量趋势,NPM 下载量趋势 从上面可以看出 TypeScript 近几年的发展趋势还是很快的。
现在的你可能还在考虑要不要学习 TypeScript,但在将来也许会是每个前端开发者、Node.js 开发者必备的技能。
TS 困扰与收益
初学者的 TS 困扰
做为一个初学者刚开始使用 TypeScript 时,你无法在使用一些 JavaScript 的思维来编码,下面的一部分观点也许是每一个 TypeScript 初学者都会遇到的疑问。
- 认为给每一个变量、函数设置类型会拖累代码编写速度。
- 一些难以理解的编译错误,会让你不知所措,为了能使得项目运行起来,又不得不试图找出问题所在。
- 难以理解的范型概念,特别是对于只有 JavaScript 经验的开发人员。
- 从未参与过企业级应用程序开发,不知道如何更好的开始。
TS 的收益
- 类型安全:类比 Java 这些强类型语言,通过类型检查也可及早发现问题。
- 增强的面向对象能力:支持面向对象的封装、继承、多态三大特性
- 类似 babel:ES6 ES7 新语法都可以写,最终 TS 会进行编译。
- 生产力工具的提升:VS Code + TS 使 IDE 更容易理解你的代码。
- 静态代码分析能力:解决原先在运行时(runtime) JavaScript 无法发现的错误。
- 易于项目后期维护、重构:当版本迭代时,比如当我们为一个函数新增加一个参数属性或为一个类型增加状态,如果忘记更新引用的地方,编译器就会给予我们警告或错误提示。
开发环境搭建
TypeScript 不能被浏览器或 Node.js 和 Deno 这些运行时所理解,最终要编译为 JavaScript 执行,我们需要一个 compiler 做编译。另外你可能会想到在 Deno 中不是可以直接写 TypeScript 吗,本质上 Deno 也不是直接运行的 TypeScript,同样需要先编译为 JavaScript 来运行。
在线编译
想尝试一下而不想本地安装的,可以看看以下这两个在线工具:
- www.typescriptlang.org/play:这个是 TypeScript 官网提供的在线编译运行,可将 TS 代码编译为 JS 代码执行。
- codesandbox.io:这个工具支持的框架很多,包括前端的 React、服务端的 Nest.js 等,在这里练手 TypeScript 也是可以的。
tsc VS ts-node
tsc 是将 TypeScript 代码编译为 JavaScript 代码,全局安装 npm install -g typescript 即可得到一个 tsc 命令,之后通过 tsc hello.ts 编译 typescript 文件。
// 编译前 hello.ts
const message: string = 'Hello Nodejs';
// 编译后 hello.js
var message = 'Hello Nodejs';
与 tsc 不同的是 ts-node 是编译 + 执行。可以在开发时使用 ts-node,生产环境使用 tsc 编译。
# 安装全局依赖
$ npm install -g typescript
$ npm install -g ts-node
# 运行
$ ts-node hello.ts
框架/库支持
使用 CRA、Vite 这种库创建出来的前端 TS 项目、及后端 Nest.js 这样的框架,默认都是支持 TypeScript 的,一些基础的 tsconfig.json 配置文件,也都帮你配置好了。
初始化配置文件
TypeScript 编译时会使用 tsconfig.json 文件做为配置文件,该配置所在的项目会被认为是根目录。
使用 npx tsc --init 命令快速创建一个 tsconfig.json 配置文件,具体的配置可参考 文档。
数据类型核心概念
TypeScript 除了包含 JavaScript 已有的 string、number、boolean、symbol、bigint、undefined、null、array、object 数据类型之外,还包括 tuple、enum、any、unknown、never、void、范型概念及类型声明符号 ineterface、type。
基础数据类型
在参数名称后面使用冒号指定参数类型,同时也可在类型后面赋默认值,const 声明的变量必须要赋予默认值否则 IDE 编译器会提示错误,let 则不是必须的。
对于一些基础的数据类型,如果后面有值,TS 可以自动进行类型推断,不需要显示声明,例如 const nickname: string = '五月君' 等价于 const nickname = '五月君'。
const nickname: string = '五月君'; // 字符串
const age: number = 20; // Number 类型
const man: boolean = true; // 布尔型
let hobby: string; // 字符串仅声明一个变量
let a: undefined = undefined; // undefined 类型
let b: null = null; // null 类型,TS 区分了 undefined、null
let list: any[] = [1, true, "free"]; // 不需要类型检查器检测直接通过编译阶段检测的可以使用 any,但是这样和直接使用 JavaScript 没什么区别了
let c: any;
c = 1;
c = '1';
数组 VS 元组
数组(Array)通常用来表示所有元素类型相同的集合,也可以使用数组泛型:Array<element type> 允许这个集合中存在多种类型。
const list1: number[] = [1, 2, 3];
const list2: Array<number|string> = [1, '2', 3];
元组(Tuple)允许一个已知元素数量的数组中各元素的类型可以是不同的,通常是事先定义好的不能被修改,元组的定义和赋值必须要一一对应。
const list1: [number, string, boolean] = [1, '2', true]; // 正确
const list2: [number, string, boolean] = [1, 2, true]; // 元素 2 会报错,不能将类型 "number" 分配给类型 "string"
元组更严格一些,不可出现越界操作,例如 list1 只有三个元素,下标从 0 ~ 2,如果执行 list1[3] 就会报错,如下所示,这在数组操作中是不会出现报错的。
list1[3]
Tuple type '[number, string, boolean]' of length '3' has no element at index '3'.
函数类型声明
可选参数使用 “?” 符号声明,可选参数、函数参数的默认值需要声明在必选参数之后。函数也可以声明返回值类型,在某些情况下不需要显示声明,能够自动推断出返回值类型。
当一个函数没有返回值时用 void 表示,在 JavaScript 中一个函数没有返回值,它的结果也等同于 undefined。
// 定义函数返回值为空
// 给传入的参数定义类型
// 给传入的参数赋予默认值
const fn = function(content: string='Hello', nickname?: string): void {
console.log(content, nickname);
}
fn(); // Hello undefined
fn('Hello', '五月君'); // Hello 五月君
// 指定函数的返回值为 string
function fn(): string {
return 'str';
}
// 根据传入的参数可以自动推断类型
const add = (a: number, b: number) => {
return a + b;
}
任何值 - any? 还是 unknown?
俗话说:“一入 any 深似海,从此类型是路人”,any 不会做任何类型检查,下面这段代码运行之后肯定会报 TypeError: value.toLocaleLowerCase is not a function 错误,并且这种错误只能在运行时才会发现,应尽可能的避免使用 any,否则就失去了使用 TypeScript 的意义。
const fn = (value: any) => {
value.toLocaleLowerCase();
}
fn(1);
unknown 也表示任何值,相比于 any 它更加严谨,在使用上会有很多限制。
下面代码在编译时 fn1 函数会报错 TSError: ⨯ Unable to compile TypeScript: Object is of type 'unknown'。
const fn1 = (value: unknown) => {
value.toLocaleLowerCase(); // 编译失败
}
const fn2 = (value?: unknown) => {
return typeof value === 'string'; // 编译通过
}
fn1(1); fn2(1);
不对 unknown 声明的类型做任何取值操作,是没问题的,正如上例的 fn2 函数。这个时候就有疑问了,既然什么都不能操作,有什么应用场景呢?
unknown 与类型守卫
unknown 的意义在于我们可以结合 “类型守卫” 在声明的函数或其它块级作用域内获取精确的参数类型。这样在运行时也能避免出现类型错误这种常见问题。
例如,想对一个数组执行 length 操作时,首先通过 Array.isArray 进一步精确参数的类型之后,在做一些操作。
const fn = (value?: unknown) => {
if (Array.isArray(value)) {
return value.length; // 编译通过
}
}
使用 is 关键词自定义类型守卫 {参数名称} is {参数类型},这个意思是告诉 TypeScript 函数 isString() 返回的是一个 string 类型。
function isString(str: unknown): str is string {
return typeof str === 'string';
}
const fn = (value?: unknown) => {
if (isString(value)) {
return value.toLocaleLowerCase(); // 编译通过
}
}
例如,react-query 这个库返回的 error 默认为 unknown 类型,如果在 render 时直接这样写 <p>${error.message}<p>是不行的,一个解决方案是拿到错误后用类型守卫处理,如下所示:
const isError = (error: unknown): error is Error => {
return error instanceof Error;
};
<p>Error: {isError(error) && error.message}</p>
另外一种方案是在调用它的 Api 时直接传入一个 Error 对象:useMutation<TResponse, Error, string>()
枚举
枚举定义了一组相关值的集合,通过描述性的常量声明使代码更具可读性。默认情况下枚举将字符串的值存储为从 0 开始的数字,也可以显示声明为字符串。
enum OrderStatus {
CREATED, // 0
CANCELLED, // 1
COMPLETED, // 2
}
enum OrderStatus {
CREATED = 'created',
CANCELLED = 'cancelled',
COMPLETED = 'completed',
}
交叉、联合类型、类型别名
交叉类型:是将多个类型合并为一个类型,使用符号 & 表示。例如,将 TPerson & TWorker 合并为一个新的类型 User。
interface TPerson {
name: string,
age: number,
}
interface TWorker {
jobTitle: string,
}
type User = TPerson & TWorker;
const user: User = {
name: 'Tom',
age: 18,
jobTitle: 'Developer'
}
注意,声明类型时不要与系统的关键词冲突,例如上例中的 Worker 尽管使用 interface 声明时没有提示报错,但使用时会有提示,因此才改为 TWorker。
联合类型:表示一个变量通常由多个类型组成,这之间是一种或的关系,使用 | 符号声明。意思是 id 即可以是 string 也可以是 number 类型。
let id: string | number;
类型别名:给一个类型起一个新名字。如果另外一个字段和 id 一样也是由相同的多个类型组成,就要用到类型别名了,使用 type 关键字将多个基本类型声明为一个自定义的类型,这种是 interface 替代不了的。
type StringOrNumber = string | number;
let id: StringOrNumber;
let no: StringOrNumber;
class、interface、type 类型声明
TypeScript 中使用 class、interface、type 关键词均可声明类型。class 声明的是一个类对象,这个好理解,容易迷惑的地方在于 interface 和 type 两者分别该用于何处。
// class 声明类型
class Person {
nickname: string;
age: number;
}
// interface 声明类型
interface Person {
nickname: string;
age: number;
}
// type 声明类型
type Person = {
nickname: string;
age: number;
}
interface 和 type 非常相似,大多数情况下 interface 的特性都可以使用 type 实现。但两者还是有些区别:
- interface:用于声明对象的行为,描述对象的属性、方法,可以被继承(extends)、实现(implements)不能用于定义基本类型,如果你想声明一个接口,用 interface 就好了。
- type:可以用于声明基本类型,尽管 type 也可以联合多个类型,但 type 不是真正的 extends,而是使用一些操作符实现的类型合并。
从概念上每个人都有不同的理解,对于团队来说无论使用哪一个,都应该保持好统一的规范。在 TypeScript 官网文档中也提到了 如果不清楚该使用哪一个,请使用 interface 直到您需要 type。
鸭子类型
鸭子类型在程序设计中是动态类型的一种风格,它的关注点在于对象的行为能做什么,而不是关注对象所属的类型。这个概念的名字源自一个 “鸭子测试”,可以解释为 :
“当看到一只鸟走起来像鸭子、游泳起来像鸭子、叫起来也像鸭子,那么这只鸟就可以被称为鸭子。”
下例,使用 interface 定义了两个类型 Duck、Bird,它们都有共同的特征 “走路”,于是对两个类型分别定义了 walk() 方法。test() 方法中期望的 value 类型为 Duck,但实际调用时传的 value 是 Bird,在 TypeScript 中是可以编译通过的。
interface Duck {
walk: () => void;
}
interface Bird {
walk: () => void;
}
const test = (value: Duck) => {
value.walk(); // 输出 “bird” 测试通过
}
const value: Bird = {
walk: () => {
console.log('bird');
}
}
test(value);
TypeScript 的鸭子类型是面向接口编程,虽然我们的例子 Duck、Bird 类型不一样,但是都有共同的接口,是可以编译通过的,对于面向对象编程的语言,例如 Java 就不行了。
范型
在参数和返回类型未知的情况下,可以使用范型来传递类型,保证组件内的类型安全。
一个有用的场景是用范型定义接口返回对象。例如,接口返回值 Result 对象,每一个接口返回的 data 是不一样的,这时 Result 对象的 data 属性就适合用范型作为类型进行传递。
interface Result<T> = {
code: string;
message: string;
data: T;
}
interface User = {
userId: string;
}
const users: Result<User[]> = { // 获取用户列表接口返回值
code: 'SUCCESS',
message: 'Request success',
data: [
{
userId: '123',
}
]
}
const userInfo: Result<User> = { // 获取用户详情接口返回值
code: 'SUCCESS',
message: 'Request success',
data: {
userId: '123',
}
}
在函数内部使用范型变量时,存在类型约束,不能随意操作它的属性。例如下例,需要事先定义范型 T 拥有 length 属性。
type TLenght = {
length: number;
}
function fn<T extends TLenght>(val: T) {
if (val.length) {
// do something
}
}
fn('hello') // 5
fn([1, 2]) // 2
fn({ length: 3 }) // 3
范型定义也可以有多个。例如,swap 函数交换两个变量。
实用类型工具
Utility type(实用类型) 不是一种新的类型,基于类型别名实现的一个工具集的自定义类型。包括:Parameters<T>、Omit<T,K>。
Parameters
Parameters<T>:接收一个范型 T,这个 T 是一个 Function,将会提取这个函数的返回值为 tuple。
例如,两个函数 fn2 的参数同 fn1 相等,这时使用 Parameters 就很合适。
const fn1 = (name: string, age: number) => {}
const fn2 = (...[name, age]: Parameters<typeof fn1>) => {
console.log(name, age);
}
fn2('五月君', 18);
Pritial
Pritial<T>:将范型 T 的所有属性变为可选。例如,user1 我必须写 name、age,而 user2 就不用了,现在都变为选填了,只填写需要的数据。
interface Person {
name: string,
age: number,
}
const user1: Person = { name: '五月君', age: 18 };
const user2: Partial<Person> = {};
实现原理:实用 keyof 关键词取出 T 的所有的属性,遍历过程为每个属性加了一个 ? 符号,也就表示可选的。
type Partial<T> = {
[P in keyof T]?: T[P];
};
Pick
Pick<T,K>:选取范型 T 中指定的部分属性,如果需要选出多个属性,用联合类型指定。
const user: Pick<Person, 'name'> = { name: '五月君' };
实现原理:首先校验第二个类型 K 的属性必须在第一个类型 T 里面,之后遍历传入的联合类型 K,形成一个新的类型。
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};
Omit
Omit<T,K>:与 Pick 相反,将第一个范型 T 中的部分属性删除,如果要删除多个属性,用联合类型指定需要删除的属性。
const user: Omit<Person, 'age'> = { name: '五月君'}
// const user: Omit<Person, 'name' | 'age'> = {}
实现原理:Omit 使用了 Pick 和 Exclude 组合来实现的,这个地方有点绕:
- Exclude 的第一个类型 T 是一个联合类型,T extends U ? never : T 这块的判断是,如果类型匹配,返回 never 就什么也没有,不匹配则返回。我们的例子中,age 类型匹配没有返回,返回的是未匹配的 name。
- 再通过 Pick 取出 Exclude 的结果,这样也就相当于达到了过滤的效果。
/**
* Exclude from T those types that are assignable to U
*/
type Exclude<T, U> = T extends U ? never : T;
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
// 下面两个是等价的
const user: Omit<Person, 'name' | 'age'> = {}
// 等价于
const user: Pick<Person, Exclude<'name' | 'age', 'age'>> = { name: '五月君' }
增强的面向对象能力
ES6 时代为 JavaScript 增加了 class “语法糖”,可以方便的去定义一个类并通过 extends 关键词实现继承,尽管 ES6 中的 class 本质上仍是基于原型链实现的,但代码编写方式看起来简洁多了(以 class 关键词进行的面向对象编程)。
和其它的面向对象编程语言相比较,会发现 JavaScript 中的 class 少了好多功能。一个常见需求是不能私有化类成员,为了达到这个目的,通常有几种做法:在属性或方法前加上 _ 表示私有化,这属于命名规则约束、使用 symbol 的唯一性实现私有化。
TypeScript 中增强了面向对象的编程能力,具备类的访问权限控制、接口、模块、类型注解等功能。
类成员访问权限控制
对象的成员属性或方法如果没有被封装,实例化后在外部就可通过引用获取,对于用户 phone 这种数据,是不能随意被别人获取的。
封装性做为面向对象编程重要特性之一,它是把类内部成员属性、成员方法统一保护起来,只保留有限的接口与外部进行联系,尽可能屏蔽对象的内部细节,防止外部随意修改内部数据,保证数据的安全性。
同传统的面向对象编程语言类似,TypeScript 提供了 3 个关键词 public、private、protected 控制类成员的访问权限。
class Person {
public name: string; // 属性 “name” 可以被外部调用
protected email: string; // 属性“email”受保护,只能在类“Person”及其子类中访问
private phone: string; // 属性“phone”为私有属性,只能在类“Person”中访问。
constructor(name: string, email: string, phone: string) {
this.name = name;
this.email = email;
this.phone = phone;
}
public info() {
console.log(`我是 ${this.name} 手机号 ${this.formatPhone()}`)
}
private formatPhone() { // 方法 “formatPhone” 为私有属性,只能在类“Person”中访问。
return this.phone.replace(/(\d{3})\d{4}(\d{3})/, '$1****$2');
}
}
接口
接口是一种特殊的抽象类,与抽象类不同的是,接口没有具体的实现,只有定义,通过 interface 关键词声明。
TypeScript 对接口的定义是这样的:
TypeScript 的核心原则之一是对值所具有的结构进行类型检查。它有时被称做 “鸭式辨型法” 或 “结构性子类型化”。在 TypeScript 里,接口的作用就是为这些类型命名和为你的代码或第三方代码定义契约。
TypeScript 中只能单继承,但可以实现多个接口。
interface Person {
name: string;
phone?: string;
}
interface Student {
diploma(): void;
}
class HighSchool implements Student, Person {
name: string;
diploma(): void {
console.log('高中');
}
}
class University implements Student, Person {
name: string;
diploma(): void {
console.log('大学本科')
}
}
面向对象程序设计概念不止这些,参见这篇文章 https://github.com/qufei1993/blog/issues/41。
总结
用还是不用?
最终要不要用 TypeScript,还要结合项目规模、维护周期、团队成员多方面看,以下为个人的一些理解:
- 如果是一些产品的核心项目,维护周期长、参与人员多,TypeScript 是可以尝试的,可能前期会感觉定义类型约束很多,对于后期的维护、重构是有一定帮助的。
- 对于一些项目周期不长或是一些小项目,不想被 TypeScript 的类型约束所束缚,可以选择 JavaScript。
- 不能规避的一个现实问题是 TypeScript 是有一些上手成本的,看团队成员的情况,公司内的产品项目不是一个人单打独斗,开发是一个阶段,后期还需要大家一起维护,可以看大家的意愿,对 TypeScript 了解程度,是否愿意学习尝试。
该怎么学习?
文章开头我们看了一些 TypeScript 社区发展现状调研报告,从目前使用情况、发展趋势看,已然成为前端开发者的必备技能之一。如果你还在犹豫要不要学习 TypeScript,那我建议你在时间允许的情况,开始做一些尝试吧。
下面从个人角度,总结一些建议:
关注点一开始不要太放在工具上,选择一个稳定的编译工具,例如 tsc、ts-node,这些就够了。之后可以尝试一些性能更好的编译工具。
先了解一些 TypeScript 增强的基础类型和类型约束,例如 枚举、any VS unkonwn、数组 VS 元组及类型声明符 type、interface 等,先入门在进阶。不建议刚上来就搞一些很高级的操作,例如 “类型体操”。
多看官方的文档 https://www.typescriptlang.org/ 即使英语不好,也可以尝试着阅读下,这是一手的学习资料,实在有困难的可以去看中文文档,遇到问题多 Google。
学会总结分享,这是学习所有知识通用的方法。在写过一个项目后,多多少少都会遇到一些问题,日常还是还要善于总结,这就是一种知识的沉淀和自我积累。目前你看到本文,并不是笔者一口气写完的,中间的一部分也是日常学习、使用 TypeScript 过程中自己记录的一些知识点,现在总结分享给大家,自己也会加深印象。