前言
从弱类型到强类型,开发更加严谨,是前端发展的必然趋势。TypeScript 作为 JS 的超集,自然也推动着前端开发人员去学习 TS,特别是 Vue 3 出现之后且大部分第三方库都有 TS 编译文件之后。TS 的掌握就渐渐变成一项基础技能。
开始 TypeScript
学习准备
开始 TypeScript(以下简称 TS)正式学习之前,推荐做好以下准备:
- Node 版本 > 8.0
- IDE(推荐 VS Code,TS 是微软推出的,VS Code 也是微软推出,且轻量。对 TS 代码更友好)
初识 TypeScript
打开 TypeScript 官网可以看到官方对 TS 的定义是这样的
- JavaScript and More
- A Result You Can TrustGradual Adoption
这三个点就很好地诠释了 TypeScript 的特性。在此之前,先来简单体验下 TypeScript 给我们的编程带来的改变。
这是一个 .js 文件代码:
- let a = 123a = '123'
这是 .ts 文件代码:
- let b = 123b = '123'
当我们在 TS 文件中试图重新给 b 赋值的时候,发生了错误,鼠标移动到标红处,系统提示:
- Type ·"123"' is not assignable to type 'number'
原因是什么呢?
答案很简单,在 TS 中所有变量都是静态类型,let b = 123 其实就是 'let b:number = 123'。b 只能是 number 类型的值,不能赋值给其他类型。
TypeScript 的优势
- TS 静态类型,可以让我们在开发过程中发现问题
- 更友好的编辑器自动提示
- 代码语义清晰易懂,协作更方便
配上代码来好好感受下这三个优势带给我们的编程体验有多直观,建议边在编辑器上敲代码。
先上最熟悉的 JS:
- function add(data) {
- return data.x + data.y
- }add() //当直接这样写,在运行的时候才会有错误告知
- add({x:2,y:3})
再上一段 TS 代码(如果对语法有疑问可以先不纠结,后续会有讲解,此处可以先带着疑问)
- interface Point { x: number, y: number }
- function tsAdd(data: Point): number {
- return data.x + data.y
- }tsAdd() //直接这样写,编辑器有错误提示
- tsAdd({ x: 1,y: 123})
当我们在 TS 中调用 data 变量中的属性的时候,编辑器会有想 x、y 属性提示,并且我们直接看函数外部,不用深入,就能知道 data 的属性值。这就是 TS 带给我们相比于 JS 的便捷和高效。
TypeScript 环境搭建
搭建 TypeScript 环境,可以直接在终端执行命令:
- npm install -g typescript
然后我们就可以直接 cd 到 ts 文件夹下,在终端运行:
- tsc demo.ts
tsc 简而言之就是 typescript complaire,对 demo.ts 进行编译,然后我们就可以看到该目录下多了一个同名的 JS 文件,可以直接用 Node 进行编译。
到这里我们就可以运行 TS 文件了,但是这只是一个文件,而且还要先手动编译成 TS 在手动运行 Node,有没有一步到位的命令呢?当然有,终端安装 ts-node:
- npm install -g ts-node
这样我们可以直接运行:
- ts-node demo.ts
来运行 TS 文件,如果要初始化 ts 文件夹,进行 TS 相关配置,可以运行:
- tsc --init
关于相关配置,这里我们先简单提下,后面将会分析常用配置,可以先自行打开 tsconfig.json 文件,简单看下其中的配置,然后带着疑问继续往下看。
再理解下 TypeScript 中的 Type
正式介绍 TS 的语法之前,还需要再把开篇提到的静态类型再来说清楚一些。
- const a: number = 123
之前说过,代码的意思是 a 是一个 number 类型的常量,且类型不能被改变。这里我要说的深层意思是,a 具有 number 的属性和方法,当我们在编辑器调用 a 的属性和方法的时候,编辑器会给我们 number 的属性和方法供我们选择。
TS 不仅允许我们给变量定义基础类型,还可以定义自定义类型:
- interface Point {
- x: number
- y: number
- }
- const a: Point = {
- x: 2,
- y: 3
- }
把 a 定义为 Point 类型,a 就拥有了 Point 的属性和方法。而我们把 a 定义为 Point 类型之后,a 必须 Point 上 的 x 和 y 属性。这样我们就把 Type 理解的差不多了。
TypeScript 的类型分类
类比于 JavaScript 的类型,TypeScript 也分为基础类型和引用类型。
原始类型
原始类型分为 boolean、number、string、void、undefined、null、symbol、bigint、any、never
JS 中也有的这里就不多解释,主要说下之前没有见过的几种类型,但是需要注意一下的是我们在声明 TS 变量类型的时候都是小写,不能写成大写,大写是表示的构造函数。
void 表示没用任何类型,通常我们会将其赋值给一个没有返回值的函数:
- function voidDemo(): void {
- console.log('hello world')
- }
bigint 可以用来操作和存储大整数,即使这数已经超出了 JavaScript 构造函数 Number 能够表示的安全整数范围,实际场景中使用较少,有兴趣的同学可以自行研究下。
any 指的是任意类型,在实际开发中应该尽量不要将对象定义为 any 类型:
- let a: any = 4
- a = '4'
never 表示永不存在的值的类型,最常见的就是函数中不会执行到底的情况:
- function error(message: string): never {
- throw new Error(message)
- console.log('永不执行')
- }function errorEmitter(): never {
- while(true){}
- }
引用类型
对象类型:赋值时,内必须有定义的对象属性和方法
- const person: {
- name: string
- age: number
- } = {
- name: 'aaa'
- age: 18
- }
数组类型:数组中每一项都是定义的类型。
- const numbers: number[] = [1, 2, 3]
类类型:可以先不关注写法,后面还会详细讲解。
- class Peron {}
- const person: Person = new Person()
类型的介绍差不多就这么些知识点,先在脑海里有个印象,不懂的地方可以继续带着疑问往下看。
TypeScript 类型注解和推断
之前已经讲过 TypeScript 的类型和它的类型种类,这一小节还是想继续把有关类型的知识讲全,那么就是类型注解和类型推断。
类型注解
- let a: number
- a = 123
上面代码中这种写法就是类型注解,通过显式声明,来告诉 TS 变量的类型:
- let b = 123
这里我们并没有显式声明 b 的类型,但是我们在编辑器中把光标放在 b 上,编辑器会告诉我们它的类型。这就是类型推断。
简单的情况,TS 是可以自动分析出类型,但是复杂的情况,TS 无法分析变量类型,我们就需要使用类型注释。
- // 场景一
- function add(first,second) {
- return first + second
- }const sum = add(1,2)
- // 场景二function add2(first: nnumber,second: number) {
- return first + second
- }const sum2 = add2(1,2)
在场景一中,形参 first、second 的类型 TS 推断为 any,且函数的返回值也是推断为 any,因为这种情况下,TS 无法判断类型,传参的时候可能传 number 或者 string 等。
场景二中,即使我们没有定义 sum2 的类型,TS 一样可以推断出 number,这是因为 sum2 是由 first second 求和的结果,所以它一定是 number。
不管是类型推断还是类型注解,我们的目的都是希望变量的类型是固定的,这样不会把 typescript 变成 anyscript。
补充:函数结构中的类型注解。
- // 情况一
- function add({ first }: {first: number }): number {
- return first
- }
- // 情况二
- function add2({first, second}: {first: number, second: number}): number {
- return first + second
- }
- const sum2 = add({ first: 1, second: 2})
- const sum2 = add2({ first: 1, second: 2})
TypeScript 进阶
配置文件
之前我们提到过,当我们要运行 TS 文件时,执行命令 tsc 文件名 .ts 就可以编译 TS 文件生成一个同名 JS 文件,这个过程是怎么来的呢,或者如果我们想修改生成的文件名和文件目录该怎么办呢?
相信你已经心里有答案了,没错,和 webpack 打包或者 babel 编译一样,TS 也有一个编译配置文件 tsconfig.json。当我们执行ts --init,文件目录下就多了一个 TS 配置文件,TS 编译成 js,就是由 tsconfig 中配置而来。
为了验证下 tsconfig 文件确实会对 TS 文件编译做配置,修改里面的:
- "removeComments": true //移除文件中的注释
然后新建一个 demo.ts 文件:
- // 这是一个注释
- const a: number = 123
执行 tsc demo.ts,打开 demo.js 文件,发现注释并没有被移除,这是怎么回事,配置文件不生效?
真相是这样的,当我们直接执行文件的时候,并不会使用 tsconfig 中的配置,只有我们直接执行 tsc,就会使用 tsconfig 中的配置,直接运行 tsc,你就发现了,amazing!
当运行 tsc 命令的时候,直接会先去找到 tsconfig 配置文件,如果没有做其他改动,会默认编译根目录下的 TS 文件。
如果想编译指定文件,则可以在 compilerOptions 配置项同级增加:
- "include": ["./demo.ts"]或者"files": ["./demo.ts"]
如果想要不包含某个文件,则可以同上增加:
- "exclude": ["./demo.ts"]
有关于这一块的更多配置,可以参考 tsconfig 配置文档。
下面再来关注下 compilerOptions 中的属性,由这个英文名就知道,这其实就是指的编译配置的意思。
- "compilerOptions": {
- "increments": true // 增量编译,只编译新增加的内容
- "target": "es5", // 指定 ECMAScript 目标版本: 'ES5'
- "module": "commonjs", // 指定使用模块: 'commonjs', 'amd', 'system', 'umd' or 'es2015'
- "moduleResolution": "node", // 选择模块解析策略
- "experimentalDecorators": true, // 启用实验性的ES装饰器
- "allowSyntheticDefaultImports": true, // 允许从没有设置默认导出的模块中默认导入。
- "sourceMap": true, // 把 ts 文件编译成 js 文件的时候,同时生成对应的 map 文件
- "strict": true, // 启用所有严格类型检查选项
- "noImplicitAny": true, // 在表达式和声明上有隐含的 any类型时报错
- "alwaysStrict": true, // 以严格模式检查模块,并在每个文件里加入 'use strict'
- "declaration": true, // 生成相应的.d.ts文件
- "removeComments": true, // 删除编译后的所有的注释
- "noImplicitReturns": true, // 不是函数的所有返回路径都有返回值时报错
- "importHelpers": true, // 从 tslib 导入辅助工具函数
- "lib": ["es6", "dom"], // 指定要包含在编译中的库文件
- "typeRoots": ["node_modules/@types"],
- "outDir": "./dist", // 生成文件目录
- "rootDir": "./src" // 入口文件
- },
接口(interface)
接口是用来自定义类型或者为我们的第三方 JS 库做翻译的一种方式。之前的代码中已经使用到了接口,其实就是用来描述类型的。每个人都有姓名和年龄,那我们就会这样去约束 person。
- interface Person {
- name: string
- age: number
- }let person: Person
当我们进行这样的类型约束的时候,person 这个对象在初始化的时候就必须要有 name 和 age,初始化有两种方式,再来看下其中的不同支出:
- // 承接上面的代码
- // 第一种初始化方式
- person = {
- name: 'aaa',
- age: 18
- }
- // 第二种初始化方式
- let p = {
- name: 'aaa',
- age: 18,
- sex: 'male'
- }
- person = p
第一种方式和第二种方式相比,p 对象中多了一个 sex 属性,然后赋值给了 person,编辑器没有提示错误,但是如果在第一种方式中添加一个 sex 属性则会报错,这是为什么呢?
这是因为,当我们直接赋值(也就是通过第一种方式)的时候,TS 会进行强类型检查,因此必须和接口定义的类型一致才行。
注意我们上面提到一致,一致的意思是,属性名和属性值类型一致,且属性个数不多不少。而当使用第二种方式进行赋值的时候,则会进行弱检查。属性个数一致会较弱,表现在,当属性多了一个的时候,不会有语法错误。
此时我们会产生一个疑问,如果我们想让第一种方式也能做到和第二种方式一样,或者说,每个人年龄和姓名是必须的,但是所在城市 city 是选填的,那该如何呢?我们可以用可选属性描述。
- interface Person {
- name: string
- age: number
- city?: string
- }
如果这样的话,我们在调用 p 属性的时候就可以看到 city 属性可能是 string,也可能是 undefined:
不仅如此,我们还希望,age 属性是不可修改的,readonly 属性自然就派上用场了,当你试图修改定义了 readonly 属性的时候,那么编辑器就会发出警告:
- interface Person {
- name: string
- readonly age: number
- city?: string
- }let person: Person = {
- name: 'aaa',
- age: 18
- }// person.age = 18
当然这还没结束,如果有一天,还想再扩展一个接口,是公司职员的接口,但是职员接口类肯定有 Person 类的所有信息,再扩展一个 id,又该如何呢?这时候继承(extends)就上场了。
- interface Employee extends Person {
- id: number
- }
接口还可以用来约束类,让定义的类必须有某种属性或者方法,这时候关键字就不是 extends,而是 implements。
- interface User {
- name: string getName(): string}class Student implements User {
- name = 'aaa'
- getName() { return this.name
- }}
interface VS type
interface 和 type 作用看起来似乎是差不多的,都是用来定义类型,接下让我们看下它的相同点与不同点。
相同点:
1. 都可以描述对象或函数
- interface Person {
- name: string
- age: number}type Person1 = { //type 定义类型有等号
- name: string
- age: number}interface getResult { (value: string): void
- }type getResult1 = (value: string): void
2. 都可以实现继承
- // interface 继承 interface
- interface People extends Person { sex: string
- }// interface 继承 typeinterface People extends Person1 { sex: string
- }// type 继承 type
- type People1 = Person1 & { sex: string
- }// type 继承 interface
- type People1 = Person & { sex: string
- }
不同点:
1. type 可以声明基本类型、联合类型,interface 不行
- // 基本类型
- type Person = string
- // 联合类型type User = Person | number
2. interface 可以类型合并
- interface People {
- name: string
- age: number
- }interface People {
- sex: string
- }//People 最终类型为
- {
- name: string
- age: number
- sex: string
- }
3. interface 可以定义可选和只读属性(之前讲过,这里不再赘述)
接口的基础知识差不多就介绍完了,当然接口在实际开发场景中应用会更复杂,如果你还有很多疑惑,接着往下看,下面的讲解将会解答你的疑惑。
联合类型和类型保护
和其他分享资料不同,我希望每一个知识点都能先让你先有所疑惑,启发你的思考,然后我再慢慢解决你的疑惑,这样我相信你会记忆更加深刻,否则可能将成效见微。
闲话少叙,直接上一段代码:
- interface Bird {
- fly: boolean
- sing: () => {}
- }interface Dog {
- fly: boolean
- bark: () => {}
- }function trainAnimal(animal: Bird | Dog) {
- // animal.sing()
- }
上面代码中我定义了两个类型,一个 Bird 类型,一个是 Dog 类型。函数 trainAnimal 的形参接收一个 animal 的参数,这个参数可能是 Bird 类型,也可能是 Dog 类型,这就是联合类型。当在函数中调用的时候,编辑器给的提示只有 fly:
这还真有点东西,但是仔细想想,就觉得只有 fly 没毛病。因为联合类型的 animal 无法确定具体是哪个类型,因此只能提示共有的属性。而独有方法经过联合类型阻隔之后是无法进行语法提示。如果我们强行调用某个类型独有的方法,可以看到编辑器会有错误提示。
如果确实需要使用独有方法,该当如何?
这就需要类型保护了,确实,如果联合类型只能调用共有方法,似乎看起来也用处不是很大,好在有类型保护。类型保护也有好多种,我们分别来介绍下。
1. 类型断言
- function trainAnimal(animal: Bird | Dog) {
- if (animal.fly) {
- (animal as Bird).sing()
- } else {
- (animal as Dog).bark()
- }}
上面代码中通过一个 as 关键字实现了类型断言。因为按照逻辑,我们知道,如果有 fly 方法,那么 animal 一定是 Bird 类型,但是编辑器不知道,所以通过 as 告诉编辑器此时 animal 就是 Bird 类型,Dog 类型的确定也是同理。
2. 通过 in 来类型断言,TS 语法检查就能确定参数类型
- function trainAnimalSecond(anmal: Bird | Dog ) {
- if ('sing' in animal) {
- animal.sing()
- }}
3. 通过 typeof 来做类型保护
- function add(first: string | number, second: string | number) {
- if (typeof first === 'string' || typeof second === 'string') {
- return `first:${first}second:${second}`
- } return first + second
- }
上面代码中如何没有 if 里面的逻辑,直接进行判断,编辑器则会给错,因为如果是数字和字符串相加,则可能存在错误,因此通过 typeof 来确定,当 first 和 second 都是数字的时候,进行相加。
4. 通过 instanceof 来类型保护
- class NumberObj {
- count: number}function addSecond(first: object | NumberObj, second: object | NumberObj) {
- if (first instanceof NumberObj && second instanceof NumberObj) {
- return first.count + second.count
- }}
在 TS 中,类不仅可以用来实例化对象,也可以用来定义变量类型,当一个对象被一个类定义以后,表明这个对象的值就是这个类的实例,关于类这一块的写法有疑问,可以查阅下 ES7 相关内容,这里不做过多讲解。
从代码中我们可以看出,通过 instanceof 来确定具有联合类型的形参是否是类的类型,当然这里如果要用 instanceof 来判断,我们的自定义类型定义只能用 class。如果是 interface 定义的类型,使用 instanceof 则会报错。
枚举类型
枚举这个概念,我们在 JS 中就已经接触的比较多了,关于概念也不就不做过多的讲解,直接上一段代码。
- const Status = {
- OFFLINE: 0,
- ONLINE: 1,
- DELETED: 2
- }function getStatus(status) {
- if (status == Status.OFFLINE) {
- return 'offline'
- } else if (status == Status.ONLINE) {
- return 'online'
- } else if (status == Status.DELETED) {
- return 'deleted'
- } return error
- }
这是我们在 JS 中比较常见的写法,TS 中也有枚举类型,而且比 JS 的更好用。
- enum Status {
- OFFLINE, ONLINE, DELETED}// 方式一
- const status = Status.OFFLINE // 0
- // 方式二
- const status = Status[0] // OFFLINE
通过上面的代码可以看出,TS 的枚举类型默认会有赋值,而且写法也很简单。再看方式一和方式二对枚举类型的使用,我们可以看出,TS 枚举类型还支持正反调用。
刚才说到枚举类型默认有值,如果我想改默认值又该如何呢?请看下面的代码:
- enum Status {
- OFFLINE = 3,
- ONLINE, DELETED}const status = Status.OFFLINE // 3
- const status = Status.ONLINE // 4
- enum Status1 { OFFLINE = 6,
- ONLINE = 10,
- DELETED}const status = Status.OFFLINE // 6
- const status = Status.ONLINE // 10
- const status = Status.DELETED // 11
由上可以看出,TS 枚举类型支持自定义值,且后面的枚举属性没有赋值的话,会在原来的基础上递增。
上面我们说到 enum 支持双向使用,为什么它如此之秀,怎么灵活呢,我们看下枚举类型编译成 JS 后的代码:
- var Status;
- (function (Status) {
- Status[Status["OFFLINE"] = 6] = "OFFLINE";
- Status[Status["ONLINE"] = 10] = "ONLINE";
- Status[Status["DELETED"] = 12] = "DELETED";
- })(Status || (Status = {}))
函数泛型
泛型在 TS 的开发中使用非常广泛,因此这一节,同样会由浅入深,先看代码:
- function result(first: string | number, second: string | number) {
- return `${first} + ${second}`
- }join('1', 1)
- join(1,'1')
这是我们之前讲过的联合类型,两个参数既可以是数字也可以字符串。
但是现在我有个需求是这样的,如果 first 是字符串,则 second 只能是字符串,同理 first 是数字,则 second。如果不知道泛型,我们只能在函数内部去进行逻辑约定,但是泛型一出手,问题就迎刃而解。
- function result<T>(first: T,second: T) {
- return `${first} + ${second}`
- }join<number>(1,1)
- join<string>('1','1')
通过在函数中定义一个泛型 T(名字可以自定义,一般用 T),这样的话,我们就可以约束 first,second 类型一致,当我们试图调用的时候实参类型不一致的时候,那么编辑器就会报错。
- function map<T>(params: T[]) {
- return params
- }map([1])
这种形式也是可以的,虽然调用的时候没有显示定义 T,但是 TS 可以推断出 T 的类型。T[] 是数组一种定义类型的方式,表明数组每个值的类型。
注意:Array 这种形式在 3.4 之后,会有警告。统一使用方括号形式。
这是单一泛型,但实际场景中往往是多个泛型:
- function result<T, U>(first: T,second: U) {
- return `${first} + ${second}`
- }join<number,string>(1,'1')
- join(1, '1') //这种形式也可
泛型如此之好用,肯定不可能只在函数中使用,因此接下来再来说下类中使用泛型:
- class DataManager {
- constructor(private data: string[] | number[]) {}
- getItem(index: number): string | number {
- return this.data[index]
- }}const data = new DataManager([1])
- data.getItem(0)
DataManager 类中构造函数通过联合类型来定义 data 的类型,这在复杂的业务场景中显然是不可取的,因为如果我们也不确定类型,在传参之前,那么只能写更多的类型或者定义成 any 类型,这就显得很不灵活,这时候我们想到了泛型,是否可以应用到类中呢?
答案是肯定的。
- class DataManager<T> {
- constructor(private data: <T>) {}
- getItem(index: number): <T> { return this.data[index]
- }}const data = new DataManager([1])
- // const data = new DataManager<number>([1]) //直观的写法,和上面等价
- data.getItem(0)
看起来好像已经很灵活了,但是还有一个问题,没有规矩不成方圆,函数编写者允许调用者具有传参灵活度,但是需要符合函数内部的一些逻辑,也就是说之前函数 return this.data[index],但是现在函数逻辑里面,返回的是 this.data[index].name,也就是函数调用者可以传 T 类型进来,但是每一项必须要有 name 属性,这又该当如何?
那么我们可以再定义一个接口,让 T 继承接口,这样既能保持灵活度,又能符合函数逻辑。
- interface Item {
- name: string
- }class DataManager<T extends Item> {
- constructor(private data: T[]) {}
- getItem(index: number): number {
- return this.data[index].name
- }}const data = new DataManager([
- name: 'dell'
- ])
讲到这里,泛型差不多结束了,但是还有一个疑问,上面number | string 这种联合类型想用泛型来约束,该怎么写呢,也就是 T 只能是 string 或者 number。
- class DataManager<T extends number | string> {
- constructor(private data: T[]) {}
- getItem(index: number): T {
- return this.data[index]
- }}
命名空间
讲到这里,我们之前已经新建了很多的 demo 文件,不知道你有没有发现这样一个奇怪的现象。
demo.ts
- let a = 123// dosomething
demo1.ts
- let a = '123'
当我们在 demo1.ts 文件中再去定义 a 这个变量的时候,a 会标红,告诉我们 a 已经被声明了 number 类型,这是为什么呢?
我们明明在 demo1.ts 文件中没有定义过 a,再仔细看下提示,它告诉我们已经在 demo.ts 中定义过了。对 JS 很熟练的伙伴一定知道了,应该是模块化的问题。
没错,TS 跟 JS 一样,一个文件中不带有顶级的 import 或者 export 声明,它的内容是全局可见的,换句话说,如果我们文件中带有 import 或者 export,则是一个模块化。
- export const let a = '123'
这样就没有问题了,我们再看下下面这段代码:
- class A {
- // do something
- }
- class B {
- // do something
- }
- class C {
- // do something
- }
- class D {
- constructor() {
- new A()
- new B()
- new C()
- }
- }
代码中,我定义了四个类,上面提到,如果我把 D 这个类通过 export 导出,这样其他文件中就可以继续使用 A 或者其他几个类名了,但是我现在有个需求是这样的,我不想把 A、B、C 三个类暴露出去,而且在外面能不能通过想通过对象的方式去调用 D 这个类。namespace 登场,看下代码:
- namespace Total{
- class A {
- // do something
- }
- class B {
- // do something
- }
- class C {
- // do something
- }
- export class D {
- constructor() {
- new A()
- new B()
- new C()
- }
- }
- }
- Total.D
这样写就可以了,通过 namespace 就只能调用到 D。如果还想调用其他类,只需要在前面去 export 这个类就好了。
namespace 在实际开发中,我们一般用在写一些 .d.ts 文件。也就是 JS 解释文件。
命名空间本质上是一个对象,它的作用就是将一系列相关的全局变量变成一个对象的属性,再看下上面的代码编译成 JS 是怎么样的。
- var Total;
- (function (Total) {
- var A = /** @class */ (function () {
- function A() {
- } return A;
- }()); var B = /** @class */ (function () {
- function B() {
- } return B;
- }()); var C = /** @class */ (function () {
- function C() {
- } return C;
- }()); var D = /** @class */ (function () {
- function D() {
- new A();
- new B();
- new C();
- } return D;
- }()); Total.D = D;})(Total || (Total = {}));Total.D;
从上面可以看出,通过一个立即执行函数并且传了一个变量进去,然后把导出的方法挂载在变量上,这样就可以在外面通过对象属性的方式调用类。
最后再补充下 declare,它的作用是,为第三方 JS 库编写声明文件,这样才可以获得对应的代码补全和接口提示:
- //常用的声明类型
- declare function 声明全局方法
- declare class 声明全局类
- declare enum 声明全局枚举类型
- declare global 扩展全局变量
- declare module 扩展模块
也可以使用 declare 做模块补充。下面摘自官方的一个示例:
- // observable.ts
- export class Observable<T> { // ... implementation left as an exercise for the reader ...
- }// map.tsimport { Observable } from "./observable";
- declare module "./observable" {
- interface Observable<T> { map<U>(f: (x: T) => U): Observable<U>; }}Observable.prototype.map = function (f) {
- // ... another exercise for the reader
- }// consumer.tsimport { Observable } from "./observable";
- import "./map";
- let o: Observable<number>;
- o.map(x => x.toFixed());
代码的意思在 map.js 中定制一个文件,补充你想要的类型 map 方法并实现函数挂载在 Observable 原型上,然后在 consumer.ts 就可以使用 Observable 类型里面的 map。
TypeScript 高级语法
类的装饰器
装饰器我们在 JS 就已经接触比较久了,并且在我的另一篇 Chat《写给前端同学容易理解并掌握的设计模式》中也详细讲解了装饰器模式,对设计模式感兴趣的同学,欢迎订阅。装饰器本质上就是一个函数。@description 这种语法其实就是一个语法糖。TS 和 JS 装饰器使用大同小异,先看一个简单的例子:
- function Decorator(constructor: any) {
- console.log('decorator')
- }@Decorator
- class Demo{}
- const text = new Test()
当我们觉得完美的时候,编辑器给了我们一个标红:
其实装饰器是一个实验性质的语法,所以不能直接使用,需要打开实验支持,修改 tsconfig 的以下两个选项:
- "experimentalDecorators": true,
- "emitDecoratorMetadata": true,
修改完配置之后,就发现终端正确输出了。
但是这里我还要再抛出一个问题,装饰器的运行时机是什么时候呢,是在类实例化的时候吗?
其实装饰器在类创建的时候就已经运行装饰器了,可以自行注释掉实例化语句,再运行,看控制台是否有 log。
类的装饰器修饰函数接受的参数是类的构造函数,我们可以改一下 Decorator 来验证一下:
- function Decorator(constructor: any) {
- constructor.prototype.getResult = () => {
- console.log('constructor')
- }}@Decorator
- class Demo{}
- const text = new Test()
- text.getResult()
控制台正确打印出 constructor 就可以证明接收的参数确实是类的构造函数。上面的代码中我们只在类中使用了一个装饰器,但其实可以给一个类使用多个装饰器,写法如下:
- @Decorator
- @Decorator1
- class Demo{}
多个装饰器执行顺序为先下后上。
上面的装饰器写法,我们把整个函数都给了类做装饰,但是实际情况是,我函数有一些逻辑,是不给类装饰使用的,那么我们写成一个工厂模式去给类装饰:
- function Decorator() {
- // do something
- return function (constructor: any) {
- console.log('descorator')
- }}@Decorator()class Test()
通过这样,我们可以传一些参数进去,然后函数内部去控制装饰器的装饰。
不知道你有没有发现,我们在验证装饰器参数的时候,当我们通过类的实例去调用我们挂载在装饰器原型的方法的时候,虽然没有报错,但是编辑器没有给我们提示,这是很不符合我们预期的。上面那种装饰器写法很简单,但很直观。
但在 TS 中我们往往是像下面这种方式使用的,而且也能解决上面提到的那个问题:
- function Decorator() {
- return function <T extends new (...args: any[]) => any>(constructor: T) {
- return class extends constructor{
- name = 'bbb'
- getName } }} const Test = Decorator()( class {
- name: string
- constructor(name: string) {
- console.log(this.name,'1')
- this.name = name
- console.log(this.name,'2')
- }})const test = new Test('aaa')
- console.log(test.getName())
我们把之前的代码大变样,看起来似乎高大上了许多,但是理解起来也挺有难度的。别急,让我来一一进行解释。
- <T extends new (...args: any[]) => any>
这个是一个泛型,T 继承了一个构造函数也可以说是继承了一个类,构造函数参数是一个展开运算符,表示接收多个参数。
这样泛型 T 就可以用来定义 constructor。而 Decorator 函数,跟上面一样,我们写成函数柯里化形式,并且把类作为参数传递进去,摒弃了之前的语法糖,这样我们在调用装饰在类上的方法的时候编辑器就能给我们提示。
方法装饰器
上一节,分享完了类的装饰器,大家肯定对装饰器意犹未尽,这一小节,再分享下给类的方法装饰,先上个代码,来看下:
- function getNameDecorator(
- target: any,
- key: string,
- descriptor: PropertyDescriptor
- ) {
- console.log(target);
- } class Test {
- name: string
- constructor(name: string) {
- this.name = name
- } @getNameDecorator
- getName() { return this.name
- } }const test - new Test('aaa')
- console.log(test.getName())
这就实现了给类的方法进行装饰,当我们给类的普通方法进行装饰的时候,装饰器函数中接收的参数 target 对应的是类的 prototype,key 是装饰的普通方法的名字。
注意,我上面说的是普通方法。和类的装饰器一样,方法装饰器的执行时机同样是当方法被定义的时候。
刚才我已经强调了普通方法,接下来我就要说静态方法了。
- class Test {
- name: string
- constructor(name: string) {
- this.name = name
- } @getNameDecorator
- static getName() {
- return this.name
- }}
静态方法的装饰器函数中,第一个参数 target 对应的是类的构造函数。
类的方法装饰器函数中,我们还有一个参数没有讲,那就是 descriptor。
不知道你有没有发现,这个函数接收三个参数,而且第三个参数还是 descriptor,有点像 Object.defineProperty 这个 API,当我们在函数中调用 descriptor 的时候,编辑器会给我们提示。
这几个属性和 Object.defineProperty 中的 descriptor 可设置属性一样,没错,功能也是一样的.比如,我们不想在外部,getName 方法被重写,那么我们可以这样:
- function getNameDecorator(
- target: any,
- key: string,
- descriptor: PropertyDescriptor
- ) {
- console.log(target);
- descriptor.writable = false
- }
当你试图这样去修改它的时候,运行编译后文件将会报错:
- const test = new Test('aaa')
- console.log(test.getName())
- test.getName = () => {
- return 'aaa'
- }
这是运行结果:
访问器装饰器
在 ES6 的 class 中新增访问器,通过 get 和 set 方法访问属性,如果上面的知识点你都消化了,那么访问器装饰器的用法也是如出一辙。
- function visitDecorator(
- target: any,
- key: string,
- descriptor: PropertyDescriptor
- ){}
- class Test {
- provate _name: string
- constructor(name: string) {
- this._name = name
- } get name() {
- return this._name
- } @visitDecorator
- set name() {
- this._name = name
- }}
访问器装饰器的用法跟类的普通方法装饰器用法差不多,这里就不展开来讲了。同样地,在类中,我们也可以给属性添加装饰器,参数添加装饰器。
装饰器业务场景使用
之前我们花了比较长的篇幅来介绍装饰器,这一小节,将跟大家分享下实际业务场景中,装饰器的使用。首先来看这样一段代码:
- const uerInfo: any = undefined
- class Test { getName() { return userInfo.name
- } getAge() { return userInfo.name
- }}const test = new Test()
- test.getName()
这段代码不用运行,我们都能知道,会报错,因为 userInfo 没有 name 属性。因此如果我们想要不报错,就会写成这样:
- class Test {
- getName() { try {
- return userInfo.name
- } catch (e) {
- console.log('userInfo.name 不存在')
- } } getAge() { try {
- return userInfo.age
- } catch (e) {
- console.log('userInfo.age 不存在')
- } }}
把类改成这样,似乎就没有问题了,为什么说似乎呢?
那是因为运行虽然没有问题,但是如果我们还有很多类似于这样的方法,我们是否要重复处理错误呢?能否用到之前讲的装饰器来处理错误:
- const userInfo: any = undefined
- function catchError( target: any,
- key: string,
- descriptor: PropertyDescriptor){ const fn = descriptor.value
- descriptor.value = function() {
- try {
- fn() } catch (e) {
- console.log('userinfo 出问题啦')
- } }}class Test {
- @catchError
- getName() { return userInfo.name
- } @catchError
- getAge() { return userInfo.age
- }}
这样我们就把捕获异常的逻辑提取出来了,通过装饰器来复用。
但是和我们之前写的还有点差异,就是报错信息都一样,我们不知道具体是哪个函数报的错,也就是说,我们希望装饰器函数可以接收一个参数,来完善报错信息,这样的话,我们就可以用到讲过的,把装饰器包装成一个工厂函数,代码如下:
- function catchError(msg: string) {
- return function (
- target: any,
- key: string,
- descriptor: PropertyDescriptor
- ){
- const fn = descriptor.value
- descriptor.value = function() {
- try {
- fn() } catch (e) {
- console.log(`userinfo.${msg} 出问题啦`)
- } } }}class Test {
- @catchError('name')
- getName() { return userInfo.name
- } @catchError('age)'
- getAge() { return userInfo.age
- }}
这样我们的代码就能满足我们的需求了,后面我们再添加其他函数函数,也可以用装饰器对其进行装饰。
项目中应用 TypeScript
脚手架搭建一个 TypeScript
现在的开发越来越专业,一般我们初始化一个项目,如果不用脚手架进行开发的话,需要自己去配置一大堆东西,比如 package.json、.gitignore,还有一些构建工具,像 webpack 等以及他们的配置。
而当我们去使用 TypeScript 编写一个项目的时候,还需要配置 TypeScript 的编译配置文件 tsconfig 以及 tslint.json 文件。
如果我们只是想做一个小项目或者只想学习这块的开发,那前期的磨刀准备工作将让很多人望而却步,一头雾水。因此,一个脚手架工具就可以帮我们把刀磨好,而且磨的铮鲜亮丽的,这个工具就是 TypeScript Library Starter。让我们一起来了解下。
查看它的官网,我们知道这是一个以 TypeScript 为基础的开源脚手架工具,帮助我们快速开始一个 TypeScript 项目,使用方法如下:
- git clone https://github.com/alexjoverm/typescript-library-starter.git ts-project
- cd ts-projectnpm install
这几行命令的意思是,把代码拉下来然后给项目重命名。进入到项目,通过 npm install 去给项目安装依赖,然后我们来看下我们的文件目录:
├── package.json // 项目配置文件
- ├── rollup.config.ts // rollup 配置文件
- ├── src // 源码目录
- ├── test // 测试目录
- ├── tools // 发布到 GitHup pages 以及 发布到 npm 的一些配置脚本工具
- ├── tsconfig.json // TypeScript 编译配置文件
- └── tslint.json // TypeScript lint 文件
TypeScript library starter 创建的项目确实集成了很多优秀的开源工具,包括打包、单元测试、格式化代码等,有兴趣的同学可以自行深入研究下。
还有需要介绍的是,TypeScript library starter 在 package.json 中帮我们配置了一套完整的前端工作流:
- npm run lint:使用 TSLint 工具检查 src 和 test 目录下 TypeScript 代码的可读性、可维护性和功能性错误。
- npm start:观察者模式运行 rollup 工具打包代码。
- npm test:运行 Jest 工具跑单元测试。
- npm run commit:运行 commitizen 工具提交格式化的 git commit 注释。
- npm run build:运行 rollup 编译打包 TypeScript 代码,并运行 typedoc 工具生成文档。
其他一些命令在我们日常开发中使用不是非常多,有需要的同学可以再自行去了解。
TypeScript 实战
现在我们的前端项目基本都是使用框架进行开发,今天我就介绍如何使用 React + TypeScript 进行 React 项目开发。当然这里我们还是会使用 React 提供的脚手架迅速搭建项目框架,为了避免你本地之前的脚手架版本影响 TypeScript 的开发,建议先执行:
- npm uninstall create-react-app
然后执行官方提供的 React TypeScript 生成命令:
- npx create-react-app react-project --template typescript --use-npm
这个命令的意思是下载最新脚手架(如果当前环境没有这个脚手架的话),然后通过 create-react-app 脚手架去生成以 typescript 为开发模板的项目,项目名字叫 react-project,并通过 npm 去安装依赖,如果没有 --use-npm 则会默认是使用 Yarn。
项目搭建完成之后,我们把文件整理下,删除一些我们不用的文件,同时把相关引用也删除,最终文件目录如下:
当我们使用 TS 去写 React 的时候, jsx 就变成了 tsx。在 APP.tsx 文件中:
- const App: React.FC = () => {
- return <div className="App"></div>
- }
通过 React.FC 给函数定义了一个 React.FC 的函数类型,这是 React 中定义的函数类型。
前端 UI 开发,现在市面上也有很多封装好的框架,让我们可以快速搭建一个页面,这里我们选用 ant-design,这个框架也是使用 TypeScript 进行开发的,所以我们使用它进行开发的时候,会有很多类型可以供我们使用,因此使用它去巩固我们刚学习的 TypeScript 知识点会有更多的好处。
首先让我们来安装下这个组件库:
- npm install antd --save
安装好之后,再 index.tsx 中引入 CSS 样式:
- import 'antd/dist/antd.css'
接下来我们去写个登录页面,首页新建一个 login.css:
- .login-page {
- width: 300px;
- padding: 20px;
- margin: 100px auto;
- border: 1px solid #ccc;
- }
然后我们去 antd-design 官网,把登录组件代码复制到我们的 App.ts 中:
- import React from "react";
- // import ReactDOM from 'react-dom'
- import "./login.css";
- // function App() {
- // return <div className="login-page">Hello world</div>;
- // }
- // export default App;
- import { Form, Input, Button, Checkbox } from "antd";
- // import { Store } from "antd/lib/form/interface";
- import { ValidateErrorEntity, Store } from "rc-field-form/lib/interface";
- const layout = { labelCol: { span: 8,
- }, wrapperCol: { span: 16,
- },};const tailLayout = { wrapperCol: { offset: 8,
- span: 16,
- },};const App = () => {
- const onFinish = (values: Store) => {
- console.log("Success:", values);
- }; // const onFinishFailed = (errorInfo: Store) => {
- const onFinishFailed = (errorInfo: ValidateErrorEntity) => {
- console.log("Failed:", errorInfo);
- }; return (
- <div className="login-page">
- <Form {...layout} name="basic"
- initialValues={{ remember: true,
- }} onFinish={onFinish} onFinishFailed={onFinishFailed} > <Form.Item label="Username"
- name="username"
- rules={[ { required: true,
- message: "Please input your username!",
- }, ]} > <Input />
- </Form.Item>
- <Form.Item
- label="Password"
- name="password"
- rules={[
- {
- required: true,
- message: "Please input your password!",
- },
- ]}
- >
- <Input.Password />
- </Form.Item>
- <Form.Item {...tailLayout} name="remember" valuePropName="checked">
- <Checkbox>Remember me</Checkbox>
- </Form.Item>
- <Form.Item {...tailLayout}>
- <Button type="primary" htmlType="submit">
- Submit
- </Button>
- </Form.Item>
- </Form>
- </div>
- );
- };
- // ReactDOM.render(<Demo />, mountNode);
- export default App;
其中,onFinish 函数的 values 编辑器给我们报隐患提示,我们也无法确定 value 的类型,但是又不能填写 any。因此,我们可以去找下 Form 中定义的类型。mac 用户把鼠标放在 import 中的 From 标签上( windows 用户按住 cmd),进入到源代码中去,然后一直去查找我们的方法的定义,首先我们进入到了:
然后 InternalForm 继承了 InternalForm,我们再继续去寻找,最后找到了源头:
同理我们也可以找到 onFinishFailed:
最后在文件中引入这两个类型即可。
经过上面的测试之后,我们的项目基本上就算已经搭建好了,接下来就可以继续充实相关的页面了。
这里再把文件整理下,把不需要的删除,src 目录下新建一个 pages 的目录,然后我们的页面组件都放在这里,把 login 的代码也在这个文件夹下新建一个文件存放,然后我们再修改下 App.ts:
- import { Route, HashRouter, Switch } from "react-router-dom";
- import React from "react";
- import LoginPage from "./pages/login";
- import Home from "./pages/home";
- function App() {
- return (
- <div>
- <HashRouter>
- <Switch>
- <Route path="/" exact component={Home}></Route>
- <Route path="/login" exact component={LoginPage}></Route>
- </Switch>
- </HashRouter>
- </div>
- );}export default App;
由于 react-router-dom 是 JS 编写的文件,因此需要再安装一个类型定义文件:
- npm install @types/react-router-dom -D
因为本篇篇幅的原因,项目后面的深入就不继续了,大家在闲暇之余可以继续享受用 TS 编写代码带来的快感。