TypeScript
TypeScript 是一门基于 JavaScript 之上的编程语言,它解决了 JavaScript 自有的类型系统的不足,通过使用 TypeScript 这门语言可以大大提高代码的可靠程度。
下面重点介绍 JavaScript 自有类型系统的问题以及如何借助一些优秀的技术方案去解决这些问题。TypeScript 这门语言可以说着此类问题的最终极解决方案,所以下面会重点介绍,除此之外也会介绍一些其他的技术方案。
大致按照以下来介绍:
- 强类型与弱类型(维度:类型安全)
- 静态类型与动态类型(维度:类型检查)
- JavaScript 自由类型系统的问题
- Flow 静态类型检查方案
- TypeScript 语言规范与基本应用
强类型与弱类型
强类型与弱类型是从类型安全的维度来区分编程语言。
强类型:语言层面限制函数的实参类型必须与形参类型相同。 不允许有任意的隐式类型转换。
弱类型:语言层面不会限制实参的类型。允许有任意的隐式类型转换。
静态类型与动态类型
静态类型与动态类型是从类型检查的维度定义的。
静态类型:一个变量声明时它的类型就是明确的,且声明过后,它的类型就不允许再修改。
动态类型:运行阶段才能明确变量类型,且变量的类型随时可以改变。
JavaScript类型系统特征
JavaScript 是弱类型,动态类型。
JavaScript 的类型的灵活多变,丢失掉了类型系统的可靠性。那么 JavaScript 为什么不设计成 强类型/静态类型 的语言呢?这和 JavaScript 的背景有关:
早前的 JavaScript 应用简单,就没有想到会发展到今天的规模,很多时候几百行代码甚至几十行代码就搞定了,这种一眼就能够看到头的情况下,类型系统就会显得很多余。其次,JavaScript 是一门脚本语言,脚本语言是没有编译环节的,直接在运行环境中运行,设计成静态语言也没有意义,因为静态语言需要在编译阶段做类型检查,而 JavaScript 没有这样一个环节。所以 JavaScript 选择成为了这样一门 弱类型 / 动态类型的语言。
放在当时的环境中,这并没有什么问题,并且是 JavaScript 的一大优势,而现如今前端的规模已经完全不同,遍地都是大规模的应用,JavaScript 的代码也变得越来越复杂,开发周期也越来越长,在这种情况下,JavaScript 的这些优势也就变成了短板。
看看这门弱类型语言的问题:
- 如果定义一个obj对象
const obj = {}
,紧接着调用obj.foo()
,obj中并不存在foo方法,但是在语言层面这样写是可行的,只是一旦运行就会报错。也就是说,只有运行阶段才能发现代码中的类型异常。而且如果不是立即执行foo方法,而是在超时调用中使用,那要等到计时结束,才能发现这个错误,如果测试的时候没有测试到,就会把隐患留到代码中。而如果是强类型,这段代码在语法层面就会报出错误,不用等到运行的时候才能发现。 - 假如有一个函数
function sum(a, b) { return a + b }
,我们希望它来计算两个数字相加的结果,如果sum(100,'100')
,就会计算成字符串拼接后的结果,和预期不符,而在强类型中,如果要求传入数字,传入其他类型语法上就行不通。
那我们再看一下强类型的优势:
- 错误更早暴露
- 代码更智能,编码更准确。例如给一个函数传参,想要使用参数的方法,因为编辑器无法判断参数是什么类型,无法给出提示,但是强类型知道参数的类型,可以给出相应的提示。
- 重构更牢靠。例如想修改一个对象中的属性名,但是不知道哪里用到这个属性,不能贸然修改,但是强类型的修改后,引用它的地方都会报错,有的还可以直接定位到位置,就可以有效的重构。
- 减少不必要的类型判断
Flow
Flow 是 JavaScript 的静态类型检查器。2014年由Facebook提出来的工具,可以弥补Javascript弱类型的弊端。通过类型注解的方式给参数设定类型,检测异常.
快速上手
- 安装依赖:
yarn add flow-bin --dev
- 初始化flow配置文件
yarn flow init
- 文件上方加入
// @flow
- 在需要设置类型的参数后加上类型,例如
:number
- 运行
yarn flow
注:因为js语法并没有
:number
这样的语法,所以vscode文件会有错误,例如标记下划线,可以通过设置 - 搜索 JavaScript validate - enable 取消勾选。
使用 flow 注解的文件不是 js 的标准语法,所以导致加了注解代码是无法正常运行的。解决这样的问题可以使用工具在我们完成编码过后自动移除注解。现在有两种解决方案:
- flow-remove-types:官方提供的方案,最简单最快速。
- 安装依赖:
yarn add flow-remove-types --dev
- 执行
yarn flow-remove-types src -d dist
, src 为要移除注解的目录,dist 为生成文件所放的目录,在dist 中就可以看到移除注解后的文件了。
- 使用babel
- 安装依赖
yarn add @babel/core @babel/cli @babel/preset-flow --dev
- 创建文件 .babelrc
{
"presets": [ "@babel/preset-flow" ]
}
- 运行
yarn babel src -d dist
如果项目中已经使用了babel,建议使用babel的插件来移除注解。
Flow 开发工具插件
flow 需要执行命令才能看到类型错误,但是我们想在开发时写了这个错误就直接看到,可以利用插件。
在vscode 中下周插件 flow language support。 这样代码中使用类型异常直接会有红色下波浪提示。
Flow 类型推断
flow 可以根据我们函数中使用参数情况来推断参数类型,例如上图flow根据 a * a 能够推断出 a 应该为Number 类型,所以当传入的不是 Number 类型时,会给出下波浪的提示。不过我们还是建议加上类型注解,可以让代码有更好的可读性。
Flow 类型注解
1. flow 除了可以给函数的参数标记注解,还可以给函数的返回值标记注解,如果没有返回值或者返回值类型不正确,都会提示,对于没有返回值的函数,应该将返回值的类型标记为void
2. flow 也可以给变量标记注解,所有的原始数据类型都可以标记。
需要注意的是,存放 undefined 需要使用 void let e: void = undefined
3. 数组类型
- 第一种方法是使用 Array 类型,这种类型需要一个泛型参数,用来表示每一个元素的类型,下面这种就表示 全部由数字组成的数组
const arr1: Array<number> = [1, 2, 3]
- 元素类型后边跟一个数组的方括号,这种类型可以表示 全部由字符串组成的数组
const arr2: number[] = [1, '', null]
- 表示一个固定长度的数组,可以用类似数组字面量的方式表示
const arr3: [string, number] = ['', 100]
4. 对象类型
限制变量为对象类型,在类型注解里写 {}, 在{}里边添加具体的成员名称和对应的类型限制,如果需要某个成员是可选的,可以在这个成员后加一个 ?
const obj1: { foo?: string, bar: number } = { foo: 'string', bar: 123 }
很多时候会把对象当作键值对集合去使用,这种时候可以使用任意类型的键和任意类型的值。如果需要限制键和值的类型,可以使用类似索引器的语法来设置。
eg:以下这种表示这个对象可以添加任意多个属性,但是每个属性的键和值都必须为字符串
const obj3 : { [string]: string } = {}
obj3.key1 = 'value1'
obj3.key2 = 'value2'
5. 函数类型
前边讲了函数的参数的类型限制和函数的返回值的类型限制,现在补充一点,函数作为参数时,如何进行类型限制。
如上图,函数作为参数时,可以用箭头函数的方式,给回调函数设置参数和返回值的类型。
6.特殊类型
flow还支持几种特殊类型。
- 字面量类型。
声明一个变量,它的类型用一个字符串 'foo' 来表示,那这个变量的值只能是 'foo'。
const a: 'foo' = 'foo'
但是这种字面量类型的一般不会这么用,而是配合一个叫联合类型的用法,去组合几个特定的值。
const type: 'success' | 'warning' | 'danger' = 'success'
例如这个例子,type 只能是 success,warning,danger 中的一个。
- 联合类型不仅可以用在字面量上,也可以用在普通类型上。
const b: string | number = 'string' // 100
可以利用 type 关键词做一个单独的声明,声明一个类型用来表示多个类型联合过后的结果。
type StringOrNumber = string | number
const b: StringOrNumber = 'string' // 100
- maybe 类型
如果一个变量为number类型,那它不能为空的,但是如果想要这个number可以为空,可以给类型前加一个 ?,表示这个变量除了可以接收number,还可以接收 null,undefined。
const num: ?number = null
- Mixed Any
Mixed 和 Any 类型都是接收所有类型。
区别:Mixed是强类型,需要通过类型判断typeof 来操作数据。Any是弱类型,直接操作就可以。
TypeScript
TypeScript 是一门基于 JavaScript 之上的编程语言,是 JavaScript 的超集。其实就是在 JavaScript 基础上加了一些扩展特性,多出来的就是一套强大的类型系统以及对ES6+的支持。最终会被编译为原始的 JavaScript。
TypeScript 相比 flow 功能更加强大,生态也更加健全,更加完善。
缺点:
- 语言本身多了很多概念
- 项目初期,TypeScript会增加一些成本
快速上手
安装依赖:
yarn add typescript --dev
创建文件 a.ts,后缀名为 .ts,ts文件可以完全按照 JavaScript 标准语法编写代码,因为 TypeScript 支持ES6+,所以也可以直接在里边编写新特性的代码。
在文件中写入以下代码
const hello = (name) => {
console.log(name)
}
hello('TypeScript')
然后执行yarn tsc .\a.ts
,会编译出js代码。
给name参数添加一个类型注解
在这里,设定参数 name 为 string 类型,传入参数是number,vscode默认可以对TypeScript进行提示,所以会有波浪线来提示。
我们也可以运行yarn tsc .\a.ts
来编译,会发现编译给出错误提示。
配置文件
使用Typescript对整个项目进行编译,需要一个配置文件,可以通过命令行添加:
yarn tsc --init
会生成一个叫 tsconfig.json 的文件
可以看到文件中有一些默认配置,我们可以来修改默认配置,target 配置的是代码编译后编译成哪个版本,这里是es5,我们也可以改为es2015,module 表示输出的代码是用什么样的方式进行模块化,sourceMap设为true代表生成一个 map 文件,开启源代码映射,outDir表示生成的文件放在什么目录下,rootDir 表示要编译的是什么目录下的文件,strict设为true表示严格模式。
需要注意的是,如果使用 yarn tsc ./a.js 编译一个文件是不会按照配置文件来编译的,要直接执行 yarn tsc
原始类型
用法和flow是差不多的,但是需要注意的一点是,在 TypeScript中,非严格模式下,任何类型的值都可以设为 null 和 undefined 的。或者可以使用另一个专门来控制是否可以为 null 和 undefined 的属性strictNullChecks。
标准库
如果配置文件中target设为 es5,那类型设置为 symbol 类型会有错误提示,因为es5还没有 symbol 类型,而target 这里设置的每一个版本在 typescript 都有一个对应的 标准库。标准库就是内置对象所对应的声明。
但是如果我们就是想最后编译成es5,我们可以使用lib选项指定引用的标准库,这样symbol就不会报错了
但是这样console.log又会有错误提示,这是因为我们设置lib将lib的默认值给覆盖了,只需要再把bom和dom引用回来就好,在TypeScript中DOM和BOM都是用的DOM
作用域问题
使用Typescript在两个文件中声明名字一样的变量时,会提示错误,这是因为他们编译后都是全局变量,这个时候只要把文件作用域改变就好,变成模块作用域,在文件最后使用export {}
Object类型
TypeScript 中的Object并不特指对象类型,而是指所有的非原始类型。
如果要对象类型的结构,可以使用对象字面量的形式:
对象字面量的形式要求属性和类型中的成员一一对应,不能多不能少,否则就会有错误提示。
相比与对象字面量的形式,更好的方式是使用接口,这个后边再详细介绍。
数组类型
数组类型和flow一样可以使用 Array 范式,也可以使用 类型[]
元组类型
元组类型就是明确元素数量以及元素类型的数组。
枚举类型
我们在开发过程中经常遇到需要用某几个值代表某几个状态,eg:
=》
在TypeScript中有一个枚举类型,使用 enum 关键词声明一个枚举,如下图,这里使用的是 等号 而不是 冒号。使用这个枚举和使用对象是一样的。
这里的枚举值可以不指定,默认就是从0开始累加,如果设置了固定值,比如success设置了6,那就从6开始累加。
枚举的值除了可以是数字,还可以是字符串,字符串是无法自增长的,需要手动给每个枚举设置一个值。
还有一点需要注意,枚举类型会入侵到运行时的代码,通俗讲就是影响我们编译后的结果。TypeScript中的类型检查在编译后都会被移除掉,但是enum类型不会,它会被编译为一个双向的键值对对象。我们可以打开终端运行 yarn tsc,打开编译后的文件,可以看到这样一个双向的键值对对象。
所谓双向就是可以通过键去获取值,也可以通过值去获取键。这样做的目的是可以让我们可以动态的根据枚举值获取枚举的名称:PostStatus[0] //success,如果我们确认我们代码中不会使用枚举值获取枚举名称,那我们可以常量枚举,常量枚举的用法就是在 enum 前面加一个const:
再次进行编译,可以看到结果,键值对对象会被去掉,而且使用枚举的地方会被替换为枚举值,枚举名称会以注释的方式放在后面进行标注。
函数类型
- 函数声明的方式
在参数后加类型,在括号后加类型就是给返回值设置类型。这样设置的函数调用时,必须按照参数的类型和个数来调用。
如果某个参数是可选的,就在参数名称后加 ?,那这个参数就可传可不传
或者通过设置默认值的方式,不传就会取默认值
如果需要接收任意个数的参数,可以使用es6的 rest
- 函数表达式的方式
函数表达式的方式也可以给参数和返回值设置类型,但是接受这个函数的变量也应该有类型,一般TypeScript都能根据函数表达式推断出这个变量的类型,不过如果我们是把函数作为参数传递进去,也就是回调函数的方式,那回调函数就必须约束类型,就可以使用箭头函数的方式约束这个函数应该使用什么样的类型 func2: (a: number, b: number) => string
任意类型
由于JavaScript是弱类型语言,本身就支持接收任意类型的参数,而TypeScript是基于JavaScript基础之上的,所以难免在代码中需要接收一个任意类型的数据。那我们可以给它设置为 any 类型,any 类型不会为参数进行类型检查。
隐式类型推断
如果我们没有通过一个注解来标记一个变量的类型,TypeScript 会根据这个变量的使用情况去推断这个变量的类型,这种特性叫隐式类型推断。
这里我们给 age 设置为18,typescript就会推断age为number类型,如果我们在设置age为一个字符串,typescript就会给出错误提示。这个时候就相当于给了age一个类型注解。
如果它无法判断一个变量的类型,那它就会标记为any。如下图声明一个变量foo,但是没有给它赋值,这时候typescript给它类型 any,foo在赋值的时候就可以赋任意类型的值。
尽管typescript可以隐式推断类型,但还是建议我们为每个变量添加明确的类型,有利于我们后期更直观的理解我们的代码。
类型断言
在有些特殊情况下,typescript无法推断出我们变量的类型,而我们开发者可以代码的使用情况是可以明确知道变量是什么类型的。
假如我们有一个数组const nums = [100, 210, 254, 1552]
,这个数组我们是从一个接口得到的明确的结果,我们需要使用find方法找出数组中第一个大于0的数字,const res = nums.find(i => i > 0)
,很明显,它的返回值一定是一个数字,但是typescript并不知道,它推断出我们的返回值是一个number 或 undefined,它认为我们有可能找不到。
这时候我们就不能把返回值当作数字来使用。这个时候我们就可以断言这个res是number类型的,断言的意思就是明确告诉typescript,你相信我,这个地方一定是number类型的。
类型断言的方式有两种:
一种是使用 as 关键词:
这个时候编辑器就能知道num1是一个数字了。
另一种方式是使用 <> 的方式进行断言:
但是 <> 的方式在 JSX 的语法下会产生冲突,就不能使用这种方式了。所以推荐使用 as 关键词的方式。
接口 Interfaces
Interfaces(接口)可以理解为一种规范,契约。它是一种抽象的概念,可以约定对象的结构,使用一个接口,就必须遵循这个接口全部的约定,最直观的就是可以约定一个对象中可以有什么成员,这些成员又是什么类型。
可选成员:如果一个对象中的某个成员可有可无,可以用可选成员这个特性。可选成员只需要给成员后加一个 ? 就可以了
只读成员:在成员名前面加个readonly就是只读成员,只读成员在给属性名赋值后就不可以修改了
动态成员:比如缓存对象,需要动态键值。因为定义的时候无法知道会有哪些具体的成员,所以不能指定具体成员名称,而是使用 [key: string],这里的key不是固定的,可以是任意名称,只是代表了属性名称。
下图的用法就规定了Cache类型的对象必须键和值都是字符串。
类的用法
基本使用
Typescript增强了 class 的相关语法。
constructor 构造函数中使用 this为当前属性赋值会报错,但是直接使用this访问当前类的属性会报错,这是因为typescript中需要明确在类型当中去声明它所拥有的属性,而不是通过在构造函数中动态通过this添加。
在类型中声明的方式就是直接在类中定义 name:string,在这里也可以为name添加默认值,但是一般不这么用,都是在构造函数中为name添加值。
在类中声明的变量必须有默认值,要么声明的时候直接给默认值,要么构造函数中赋值,两者选其一,否则会报错。
访问修饰符
private修饰符:在age属性前加 private ,age就变成了私有属性,只能在内部访问。当在外部访问的时候就会报错。
public修饰符:在name前加 public ,name就是公有成员,不过不加 public 也默认是公有成员,所以加和不加是一样的,但是建议加上 public 修饰符,代码会更容易理解
protected修饰符是受保护的,同样无法在外部访问到。它和private修饰符的区别是,protected 是只允许在子类访问的成员。
constructor 默认也是public类型的,但是如果手动给它加上private,它就变成了私有类型,无法被外部访问,也就无法用 new 方法来实例化对象了。这个时候可以创建一个静态方法 static create来返回一个实例化的对象,外部就可以通过Student.create()创建实例了:
只读属性
可以给属性设置只读属性 readonly,属性就不可以被修改了,不管是在内部还是外部。
需要注意的是只读属性如果和修饰符一起用,要放在修饰符的后面。
类与接口
像这样的两个类都实现了同样的方法,可以使用接口抽离出来,利用interface定义一个接口 EatAndRun,在类名后使用 implements EatAndRun,这样两个类就必须拥有eat和run方法,不然就会报错。
但是更多时候接口的两个方法不会同时存在,所以可以一个接口只约束一个能力,让一个类型同时实现多个接口。我们可以将EatAndRun 拆成 Eat 和 Run 两个接口,然后在类型的后边使用 ‘,’的方式同时使用两个接口。
抽象类
抽象类也是用来约束子类当中必须要有某一个成员。但是抽象类可以包括一些具体的实现,而接口只能抽象一个接口,不包含一个具体的实现。一般比较大的类目都推荐使用抽象类。
使用抽象类的方式就是在类的前面加一个 abstract,加上以后这个类就不能创建实例了,只能够继承。
在抽象类中还可以定义抽象方法,使用 abstract 修饰一下,需要注意的是抽象方法不需要方法体,当父类有抽象方法时,子类就要实现这样一个方法。
泛型
泛型指我们在定义函数,接口,或类的时候没有指定具体的类型,在使用的时候才指定具体类型的特征。以函数为例,就是指在声明的时候没有指定类型,在调用的时候才指定类型。这样做的目的就是为了极大程度的复用我们的代码。
我们来创建一个函数 createNumberArray,这个函数时用来创建一个指定长度的数组,并且元素值也都是number。
function createNumberArray(length: number, value: number): number[] {
// 由于 Array 对象创建的是any类型的数组,所以我们可以通过泛型参数的方式给数组指定类型
return Array<number>(length).fill(value)
}
const res = createNumberArray(3, 10) // [10,10,10]
但是这个函数只能创建数字类型的数组,如果想要创建string类型的数组,这个函数就做不到了。最笨的办法就是再创建一个生成string类型数组的方法,但是这样代码就会有冗余。我们可以使用泛型,把类型变成一个参数,在调用的时候再传递这个类型。我们定义一个 createArray 的函数,在函数名后面使用<>, 在<>中使用泛型参数,一般使用 T,把函数中不明确的类型都用 T 去代表,在调用的时候就可以使用 createArray<string>(3,'f')
这样的方式生成任意类型的数组了。
function createArray<T>(length: number, value: T): T[] {
return Array<T>(length).fill(value)
}
const res = createArray<string>(3, 'f') // ['f','f','f']
类型声明
实际项目开发中难免用到一些第三方模块,而这些npm模块并不一定都是通过typescript编写的,所以它提供的成员就不会有强类型的体验。
比如lodash模块在导入的时候就报错提示找不到类型声明的文件。
我们先不管它,提取一下模块的 camelCase 函数,这个函数的作用就是把字符串转换为驼峰格式,所以它的参数应该是一个字符串,但是当我们在调用它的时候并没有看到它的类型提示
这种情况下就需要单独的类型声明。使用declare 声明:
import { camelCase } from 'lodash'
declare function camelCase(input: string): string
const r = camelCase('hello world')
有了这个声明再使用这个函数就会有对应的类型限制了。
由于typescript的社区特别强大,绝大多数常用的npm模块都提供了对应的声明,我们只需要安装它所对应的声明模块就可以了。模块提示就给出了对应的依赖,所以我们安装 @types/lodash . 安装过后这个模块就会有对应的类型提示了。
目前越来越多的模块已经在内部集成了自己的声明文件,不需要安装单独的声明模块了。例如安装模块 query-string.这个模块用来解析url中的query-string字符串。这个模块内部有自己的生命模块,所以可以直接使用类型提示。