前端开发整理(二): TypeScript语言
TypeScript简介和需求原因
TypeScript 简称 TS ,是在 JavaScript 的基础上增加了一套类型系统,它支持所有的 JS 语句,为工程化开发而生,最终在编译的时候去掉类型和特有的语法,生成 JS 代码。
对于下述JavaScript代码:
1
2
3
4
5
6
7
function getFirstWord(msg) {
console.log(msg.split(' ')[0])
}
getFirstWord('Hello World') // 输出 Hello
getFirstWord(123) // TypeError: msg.split is not a function
这里定义了一个用空格切割字符串的方法,并打印出第一个单词;但第二次执行时,由于数值不存在 split 方法,所以传入 123
引起了程序崩溃。因此对于传统的JavaScript,需要在split
前执行一层判断或转换,工作量增加;因此使用TypeScript,在编译的时候就可以执行检查来避免掉这些问题,而且配合 VSCode 等编辑器的智能提示,可以很方便的知道每个变量对应的类型。
Hello TypeScript
运用TypeScript语言的准备
文件目录保持类似,在src文件夹下创建ts文件夹归类本次测试文件,然后创建index.ts
文件在ts
文件夹下:
1
2
3
4
5
6
7
8
9
10
11
hello-node
│ # 源码文件夹
├─src
│ │ # 业务文件夹
│ └─cjs
│ │ # 入口文件
│ ├─index.cjs
│ │ # 模块文件
│ └─module.cjs
│ # 项目清单
└─package.json
然后在命令行通过 cd
命令进入项目所在的目录路径,安装 TypeScript 开发的两个主要依赖包:npm install -D typescript ts-node
(这次添加 -D
参数,因为 TypeScript 和 TS-Node 是开发过程中使用的依赖,所以将其添加到 package.json 的 devDependencies
字段里)
typescript
这个包是用 TypeScript 编程的语言依赖包ts-node
是让 Node 可以运行 TypeScript 的执行环境
然后修改 scripts 字段,增加一个 dev:ts
的 script :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"name": "hello-node",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"...": "...",
"dev:ts": "ts-node src/ts/index.ts",
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"md5": "^2.3.0"
},
"devDependencies": {
"ts-node": "^10.7.0",
"typescript": "^4.6.3"
}
}
TypeScript测试
下述三段代码分别产生三个结果:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// src/ts/index.ts
// 第一段代码
function getFirstWord(msg) {
console.log(msg.split(' ')[0])
}
getFirstWord('Hello World')
getFirstWord(123)
// 第二段代码
function getFirstWord(msg: string) {
console.log(msg.split(' ')[0])
}
getFirstWord('Hello World')
getFirstWord(123)
// 第三段代码
// src/ts/index.ts
function getFirstWord(msg: string) {
console.log(msg.split(' ')[0])
}
getFirstWord('Hello World')
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 第一个结果
TSError: ⨯ Unable to compile TypeScript:
src/ts/index.ts:1:23 - error TS7006: Parameter 'msg' implicitly has an 'any' type.
1 function getFirstWord(msg) {
# 第二个结果
TSError: ⨯ Unable to compile TypeScript:
src/ts/index.ts:7:14 - error TS2345:
Argument of type 'number' is not assignable to parameter of type 'string'.
7 getFirstWord(123)
~~~
# 第三个结果
npm run dev:ts
> demo@1.0.0 dev:ts
> ts-node src/ts/index.ts
Hello
常用的TypeScript类型定义
原始数据类型
原始数据类型是一种既非对象也无方法的数据,常见的JS和TS类型对比如下:
原始数据类型 | JavaScript | TypeScript |
---|---|---|
字符串 | String | string |
数值 | Number | number |
布尔值 | Boolean | boolean |
大整数 | BigInt | bigint |
符号 | Symbol | symbol |
不存在 | Null | null |
未定义 | Undefined | undefined |
语法解析:
1
2
3
4
5
6
7
8
const <变量名> <数据类型> = <具体内容>
// 字符串
const str: string = 'Hello World'
// 数值
const num: number = 1
// 布尔值
const bool: boolean = true
数组类型
除了原始数据类型之外, JavaScript 还有引用类型,数组 Array 就是其中的一种;其在 TS 类型定义的写法中,是非常接近原始数据的一个类型。其写法如下;也可以省略类型,因为TS同样会推导其类型:
数组里的数据 | 类型写法 1 | 类型写法 2 |
---|---|---|
字符串 | string[] | Array<string> |
数值 | number[] | Array<number> |
布尔值 | boolean[] | Array<boolean> |
大整数 | bigint[] | Array<bigint> |
符号 | symbol[] | Array<symbol> |
不存在 | null[] | Array<null> |
未定义 | undefined[] | Array<undefined> |
如下述语法:
1
2
3
4
5
6
7
8
// 字符串数组
const strs: string[] = ['Hello World', 'Hi World']
// 数值数组
const nums: number[] = [1, 2, 3]
// 布尔值数组
const bools: boolean[] = [true, true, false]
注意:如果开始数组为空,则无法自动识别为数组类型而产生报错风险,即使在后续进行入或出的操作都是错误的。
对于复杂的数组,比如数组里面的 item 都是对象,其实格式也是一样,只不过把原始数据类型换成对象的类型即可,例如 UserItem[]
表示这是一个关于用户的数组列表。
对象(接口)
对象也是引用类型,在 TypeScript ,类型定义需要根据值的类型来确定它的类型。定义对象可以通过type
或interface
进行,先进行interface
的学习,其写法与 Object 更为接近并被用得更多。
注意点
对象的类型定义通常采用 Upper Camel Case 大驼峰命名法,也就是每个单词的首字母大写,例如 UserItem 、 GameDetail ,这是为了跟普通变量进行区分(变量通常使用 Lower Camel Case 小驼峰写法,也就是第一个单词的首字母小写,其他首字母大写,例如 userItem )。
以用户信息例,比如要描述 Petter 这个用户,他的最基础信息就是姓名和年龄,那么定义为接口就是这么写:
1
2
3
4
5
6
7
8
9
10
11
// 定义用户对象的类型
interface UserItem {
name: string
age: number
}
// 在声明变量的时候将其关联到类型上
const petter: UserItem = {
name: 'Petter',
age: 20,
}
可选接口属性
上面这样定义的接口类型,表示 name
和 age
都是必选的属性,不可以缺少,一旦缺少,代码运行起来就会报错。在实际的业务中,有可能会出现一些属性并不是必须的,就像这个年龄,可以将其设置为可选属性,通过添加 ?
来定义,格式为?!
。
1
2
3
4
5
6
7
8
9
interface UserItem {
name: string
// 这个属性变成了可选
age?: number
}
const petter: UserItem = {
name: 'Petter',
}
调用自身接口的属性
如果一些属性的结构跟本身一致,也可以直接引用,案例如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
interface UserItem {
name: string
age: number
enjoyFoods: string[]
// 这个属性引用了本身的类型
friendList: UserItem[]
}
const petter: UserItem = {
name: 'Petter',
age: 18,
enjoyFoods: ['rice', 'noodle', 'pizza'],
friendList: [
{
name: 'Marry',
age: 16,
enjoyFoods: ['pizza', 'ice cream'],
friendList: [],
},
{
name: 'Tom',
age: 20,
enjoyFoods: ['chicken', 'cake'],
friendList: [],
}
],
}
接口的继承
接口还可以继承,比如要对用户设置管理员,管理员信息也是一个对象,但要比普通用户多一个权限级别的属性,那么就可以使用继承,它通过 extends
来实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
interface UserItem {
name: string
age: number
enjoyFoods: string[]
friendList: UserItem[]
}
// 这里继承了 UserItem 的所有属性类型,并追加了一个权限等级属性
interface Admin extends UserItem {
permissionLevel: number
}
const admin: Admin = {
name: 'Petter',
age: 18,
enjoyFoods: ['rice', 'noodle', 'pizza'],
friendList: [
{
name: 'Marry',
age: 16,
enjoyFoods: ['pizza', 'ice cream'],
friendList: [],
},
{
name: 'Tom',
age: 20,
enjoyFoods: ['chicken', 'cake'],
friendList: [],
}
],
permissionLevel: 1,
}
如果觉得继承的类型不需要记录这么多属性,也可以在继承的过程中舍弃某些属性,通过 Omit
帮助类型来实现,Omit
的类型如下;其中 T
代表已有的一个对象类型, K
代表要删除的属性名,如果只有一个属性就直接是一个字符串,如果有多个属性,用 |
来分隔开,下面的例子就是删除了两个不需要的属性:
1
type Omit<T, K extends string | number | symbol>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
interface UserItem {
name: string
age: number
enjoyFoods: string[]
friendList?: UserItem[]
}
// 这里在继承 UserItem 类型的时候,删除了两个多余的属性
interface Admin extends Omit<UserItem, 'enjoyFoods' | 'friendList'> {
permissionLevel: number
}
// 现在的 admin 就非常精简了
const admin: Admin = {
name: 'Petter',
age: 18,
permissionLevel: 1,
}
类
类是 JavaScript ES6 推出的一个概念,通过 class 关键字,可以定义一个对象的模板;在 TypeScript ,通过类得到的变量,它的类型就是这个类:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 定义一个类
class User {
// constructor 上的数据需要先这样定好类型
name: string // 成员属性,构造函数前先声明类成员的类型,否则报错
// 入参也要定义类型
// 构造函数,接收参数 userName,类型为 string,参数赋给当前对象 name 属性
constructor(userName: string) {
this.name = userName
}
getName() {
console.log(this.name)
}
}
// 通过 new 这个类得到的变量,它的类型就是这个类
const petter: User = new User('Petter')
petter.getName() // Petter
类与类之间可以继承:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 这是一个基础类
class UserBase {
name: string
constructor(userName: string) {
this.name = userName
}
}
// 这是另外一个类,继承自基础类
class User extends UserBase {
getName() {
console.log(this.name)
}
}
// 这个变量拥有上面两个类的所有属性和方法
const petter: User = new User('Petter')
petter.getName()
类也可以提供给接口去继承:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 这是一个类
class UserBase {
name: string
constructor(userName: string) {
this.name = userName
}
}
// 这是一个接口,可以继承自类
interface User extends UserBase {
age: number
}
// 这样这个变量就必须同时存在两个属性
const petter: User = {
name: 'Petter',
age: 18,
}
如果类上面本身有方法存在,接口在继承的时候也要相应的实现,当然也可以借助在对象(接口)提到的 Omit
帮助类型来去掉这些方法。
联合类型
当一个变量可能出现多种类型的值的时候,可以使用联合类型来定义它,类型之间用 | 符号分隔。例如: |
1
2
3
4
5
6
7
8
// 可以在 demo 里运行这段代码
function counter(count: number | string) {
console.log(`The current count is: ${count}.`)
}
// 不论传数值还是字符串,都可以达到的目的
counter(1) // The current count is: 1.
counter('2') // The current count is: 2.
函数
函数的基本的写法
在 JavaScript ,函数有很多种写法;但其实离不开两个最核心的操作——输入与输出,也就是对应函数的 “入参” 和 “返回值” 。在 TypeScript ,函数本身和 TS 类型有关系的也是在这两个地方。JS和TS的函数写法分别如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// JavaScript 代码
// 写法一:函数声明
function sum1(x, y) {
return x + y
}
// 写法二:函数表达式
const sum2 = function (x, y) {
return x + y
}
// 写法三:箭头函数
const sum3 = (x, y) => x + y
// 写法四:对象上的方法
const obj = {
sum4(x, y) {
return x + y
},
}
// 还有很多……
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// TypeScript 代码
// 写法一:函数声明
function sum1(x: number, y: number): number {
return x + y
}
// 写法二:函数表达式
const sum2 = function(x: number, y: number): number {
return x + y
}
// 写法三:箭头函数
const sum3 = (x: number, y: number): number => x + y
// 写法四:对象上的方法
const obj = {
sum4(x: number, y: number): number {
return x + y
}
}
// 还有很多……
函数的可选参数
实际业务中会遇到有一些函数入参是可选,可以用和对象(接口)一样,用 ?
来定义:
1
2
3
4
5
6
7
8
// 注意 isDouble 这个入参后面有个 ? 号,表示可选
function sum(x: number, y: number, isDouble?: boolean): number {
return isDouble ? (x + y) * 2 : x + y
}
// 这样传参都不会报错,因为第三个参数是可选的
sum(1, 2) // 3
sum(1, 2, true) // 6
无返回值的函数
除了有返回值的函数,更多时候是不带返回值的,这种函数用 void
来定义它的返回,也就是空:
1
2
3
4
5
6
// 注意这里的返回值类型
function sayHi(name: string): void {
console.log(`Hi, ${name}!`)
}
sayHi('Petter') // Hi, Petter!
需要注意的是, void
和 null
、 undefined
不可以混用,如果的函数返回值类型是 null
,那么是真的需要 return
一个 null
值:
1
2
3
4
5
// 只有返回 null 值才能定义返回类型为 null
function sayHi(name: string): null {
console.log(`Hi, ${name}!`)
return null
}
有时候要判断参数是否合法,不符合要求时需要提前终止执行(比如在做一些表单校验的时候),这种情况下也可以用 void
:
1
2
3
4
5
6
7
function sayHi(name: string): void {
// 这里判断参数不符合要求则提前终止运行,但它没有返回值
if (!name) return
// 否则正常运行
console.log(`Hi, ${name}!`)
}
异步函数的返回值
对于异步函数,需要用 Promise<T>
类型来定义它的返回值,这里的 T
是泛型,取决于该函数最终返回一个什么样的值( async / await
也适用这个类型)。
例如这个例子,这是一个异步函数,会 resolve
一个字符串,所以它的返回类型是 Promise<string>
(假如没有 resolve
数据,那么就是 Promise<void>
):
1
2
3
4
5
6
7
8
9
10
// 注意这里的返回值类型
function queryData(): Promise<string> {
return new Promise((resolve) => {
setTimeout(() => {
resolve('Hello World')
}, 3000)
})
}
queryData().then((data) => console.log(data))
函数本身的类型
在 JS/TS 中,函数不仅是映射,而且是一种对象类型;函数可以被赋值、传递、作为参数、返回值,这就要求它有清晰的类型。例如:
1
2
3
4
// 隐式表明函数类型,因为TS会自动推导
const sum = (x: number, y: number): number => x + y
// 显式表明函数类型,明确这个变量是个“可调用对象”,接受两个number参数,返回一个number
const sum: (x: number, y: number) => number = (x: number, y: number): number => x + y
在某些情况下,显式写出函数类型是非常重要的:
-
函数作为参数传递时
1 2 3
function handle(fn: (msg: string) => void) { fn('hello') }
-
函数作为返回值时
1 2 3
function createHandler(): () => void { return () => console.log('hello') }
-
函数作为对象方法时
1 2 3 4
interface User { name: string; greet: (msg: string) => void; }
-
回调函数、事件处理、泛型高阶函数:越复杂的函数逻辑,越依赖明确的函数签名,否则类型系统就“无能为力”了。
函数的重载
利用 TypeScript 的函数重载非常有用,会使得代码书写更清晰、也减少了使用TS类型断言的负担:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 这一次用了函数重载
function greet(name: string): string // TS 类型
function greet(name: string[]): string[] // TS 类型
function greet(name: string | string[]) {
if (Array.isArray(name)) {
return name.map((n) => `Welcome, ${n}!`)
}
return `Welcome, ${name}!`
}
// 单个问候语,此时只有一个类型 string
const greeting = greet('Petter')
console.log(greeting) // Welcome, Petter!
// 多个问候语,此时只有一个类型 string[]
const greetings = greet(['Petter', 'Tom', 'Jimmy'])
console.log(greetings)
// [ 'Welcome, Petter!', 'Welcome, Tom!', 'Welcome, Jimmy!' ]
任意值
如果实在不知道应该如何定义一个变量的类型, TypeScript 也允许使用任意值,通过“显式” 方式指定。为了程序能够正常运行,还要提高一下函数体内的代码健壮性。例如:
1
2
3
4
5
6
7
8
9
// 这里的入参显式指定了 any
function getFirstWord(msg: any) {
// 这里使用了 String 来避免程序报错
console.log(String(msg).split(' ')[0])
}
getFirstWord('Hello World')
getFirstWord(123)
npm包
存在一些包没有默认支持 TypeScript,此时只需要在原有的包名前面拼接@types
,然后将其安装到 package.json 的 devDependencies
里即可解决该问题。
类型断言
当一个变量应用了联合类型时,在某些时候如果不显式的指明其中的一种类型,可能会导致后续的代码运行报错。这个时候就可以通过类型断言强制指定其中一种类型,以便程序顺利运行下去——即默认变量属于该种类型,而不再检查其合理性和是否完整。这样做的经典正例和反例如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 正例
// 对单人或者多人打招呼
function greet(name: string | string[]): string | string[] {
if (Array.isArray(name)) {
return name.map((n) => `Welcome, ${n}!`)
}
return `Welcome, ${name}!`
}
// 已知此时应该是 string[] ,所以用类型断言将其指定为 string[]
const greetings = greet(['Petter', 'Tom', 'Jimmy']) as string[]
// 现在可以正常使用 join 方法
const greetingSentence = greetings.join(' ')
console.log(greetingSentence)
// 反例
// 原本要求 age 也是必须的属性之一
interface User {
name: string
age: number
}
// 但是类型断言过程中,遗漏了
const petter = {} as User
petter.name = 'Petter'
// TypeScript 依然可以运行下去,但实际上的数据是不完整的
console.log(petter) // { name: 'Petter' }
类型推论
类型推论的前提是变量在声明时有明确的值,如果一开始没有赋值,那么会被默认为 any 类型。
编译TypeScript为JavaScript
编译单个文件
先在 package.json 里增加一个 build script :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{
"name": "hello-node",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev:cjs": "node src/cjs/index.cjs",
"dev:esm": "node src/esm/index.mjs",
"dev:ts": "ts-node src/ts/index.ts",
"build": "tsc src/ts/index.ts --skipLibCheck --outDir dist",
"compile": "babel src/babel --out-dir compiled",
"serve": "node server/index.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"md5": "^2.3.0"
},
"devDependencies": {
"@types/md5": "^2.3.2",
"ts-node": "^10.7.0",
"typescript": "^4.6.3"
}
}
这样在命令行运行 npm run build
的时候,就会把 src/ts/index.ts
这个 TS 文件编译,并输出到项目下与 src 文件夹同级的 dist 目录下。
其中 tsc
是 TypeScript 用来编译文件的命令, --outDir
是它的一个选项,用来指定输出目录,如果不指定,则默认生成到源文件所在的目录下面。
例如对于src/ts/index.ts
,可以先执行 npm run dev:ts
测试它的可运行性;然后编译它,在命令行输入 npm run build
并回车执行;可以看到多了一个 dist 文件夹,里面多了一个 index.js
文件;然后在命令行执行 node dist/index.js
,像之前测试 JS 文件一样使用 node
命令,运行 dist 目录下的 index.js
文件,它可以正确运行:
1
2
3
4
5
6
7
8
9
10
11
12
13
hello-node
│ # 构建产物
├─dist
│ │ # 编译后的 JS 文件
│ └─index.js
│ # 依赖文件夹
├─node_modules
│ # 源码文件夹
├─src
│ # 锁定安装依赖的版本号
├─package-lock.json
│ # 项目清单
└─package.json
编译多个模块
刚才只是编译一个 index.ts
文件,如果 index.ts
里引入了其他模块,此时 index.ts
是作为入口文件,入口文件 import
进来使用的模块也会被 TypeScript 一并编译。
package.json 里的 build script
无需修改,依然只编译 index.ts
,但因为导入了 greet.ts
,所以 TypeScript 也会一并编译,来试一下运行 npm run build
, 现在 dist
目录下有两个文件了:
1
2
3
4
5
6
7
8
hello-node
│ # 构建产物
├─dist
│ ├─greet.js # 多了这个文件
│ └─index.js
│
│ # 其他文件这里省略...
└─package.json
在命令行执行 node dist/index.js
,虽然也是运行 dist 目录下的 index.js
文件,但这次它的作用是充当一个入口文件,引用到的 greet.js
模块文件也会被调用。这次一样可以得到正确的结果:
1
2
3
node dist/index.js
Welcome, Petter!
[ 'Welcome, Petter!', 'Welcome, Tom!', 'Welcome, Jimmy!' ]
修改编译后的 JS 版本
可以修改编译配置,让 TypeScript 编译成不同的 JavaScript 版本。修改 package.json 里的 build script ,在原有的命令后面增加一个 --target
选项,--target
选项的作用是控制编译后的 JavaScript 版本。通常还需要配置一个 --module
选项,用于决定编译后是 CJS 规范还是 ESM 规范,但如果缺省,会根据 --target
来决定:
1
2
3
4
5
{
"scripts": {
"build": "tsc src/ts/index.ts --skipLibCheck --outDir dist --target es6"
}
}
其它注意点
来到 修改编译后的JS版本,事情就开始变得复杂,编译的选项和测试成本都增加了很多。因此在实际的项目开发中,需要借助构建工具来处理很多编译过程中的兼容性问题,降低开发成本。
而刚才用到的诸如 --target
这样的选项,可以用一个更简单的方式来管理,类似于 package.json
项目清单, TypeScript 也有一份适用于项目的配置清单,请看 了解 tsconfig.json
部分。
了解 tsconfig.json
TypeScript 项目一般都会有一个 tsconfig.json
文件,放置于项目的根目录下,这个文件的作用是用来管理 TypeScript 在编译过程中的一些选项配置。在开始之前,需要全局安装一下 TypeScript :npm install -g typescript
这样可以使用 TypeScript 提供的全局功能,直接在命令行里使用 tsc 命令(之前本地安装的时候,需要封装成 package.json
的 script 才能调用它)。
在命令行输入 tsc --init
,这是 TypeScript 提供的初始化功能,会生成一个默认的 tsconfig.json
文件。现在的目录结构会多一个 tsconfig.json
文件。
每一个 tsc 的命令行的选项,都可以作为这个 JSON 的一个字段来管理,例如刚才的 --outDir
和 --target
选项,可以直接在生成的 tsconfig.json
上面修改;在这个 JSON 文件里对应的就是:
1
2
3
4
5
6
7
{
"compilerOptions": {
"target": "es6",
"module": "es6",
"outDir": "./dist"
}
}
这一次不需要用到 package.json 里的 build script
了,直接在命令行运行 tsc
,它现在会根据配置的 tsconfig.json
文件,按照的要求来编译。可以看到它依然按照要求在 dist 目录下生成编译后的 JS 文件,而且这一次的编译结果,和在 build script
里使用的 tsc src/ts/index.ts --outDir dist --target es6
这一长串命令是一样的。
所以正常工作中,都是使用 tsconfig.json
来管理 TypeScript 的配置的。
不过实际工作中的项目都是通过一些脚手架创建的,例如 Vue CLI ,或者现在的 Create Vue 或者 Create Preset ,都会在创建项目模板的时候,提前配置好通用的选项,只需要在不满足条件的情况下去调整。