3.3 TypeScript:支持类型的 JavaScript
TypeScript 是 JavaScript 的超级,在 JavaScript 已有功能上扩展了类型系统,可以说 TypeScript 是支持静态类型的 JavaScript。
TypeScript 由微软开发,于 2012 年发布了第一个公开版本,此后逐渐成为了风靡前端的热门技术。到目前为止,TypeScript 已经广泛应用于现代前端应用,尤其是开源项目,几乎都在用 TypeScript 开发或重写。
TypeScript 可以在浏览器和 Node.js 环境中运行,可以编译成纯 JavaScript,也可以与现代编译工具结合使用。因此 TypeScript 不光有类型系统,还有强大的扩展能力。
3.3.1 应该使用 TypeScript 吗?
多数人在了解 TypeScript 之后,都会产生一个疑问:我应该使用 TypeScript 吗?
在回答这个问题之前,我们先了解一下,TypeScript 带来了什么。
静态类型
静态类型是 TypeScript 最关键最核心的功能,先看一个普通 JavaScript 变量的例子:
var name = "奥特家族";
name = false;
上面代码中,变量 name 是一个字符串,将其修改为布尔值 false,这在 JavaScript 中是被允许的。
现在将上述代码片段改造为 TypeScript,结果如下:
var name: string = "奥特家族";
name = false; // 错误:不能将类型“boolean”分配给类型“string”
因为变量 name 指定了类型为 string,也就限定了这个变量的值只能是 string 类型。如果将变量修改为非 string 类型的值,TypeScript 会在代码执行前抛出异常,这就是静态类型检测。
静态类型检测大大避免了在开发中因为逻辑不严谨导致的错误,保证了前端应用的健壮性。
快捷提示
在使用一些第三方包的时候,常常能看到快捷提示。比如在对象(软件包的实例)后输入一个 .
符号,编辑器会会自动将对象下的属性和方法全部列出来。如图 3-1 所示:
从图中可以看出,快捷提示不仅列出了所有方法名,还能看到方法的具体用法和作用是什么。这些都是由 TypeScript 提供的,相当于一个内置的精简版文档。
快捷提示极大的提高了前端开发效率,省去了查阅文档的麻烦。在 TypeScript 的支持下,不用担心属性或方法名书写错误,也不用担心参数传递错误,快捷提示会及时发现错误并提示如何修复,这样开发者在编码过程中会非常“省心”,这也是 TypeScript 最主要的魅力之一。
当然,TypeScript 的快捷提示是否友好,是否全面,在于 TypeScript 类型文件是如何编写的。如果类型文件编写的糟糕,大量使用 any,那么快捷提示的优势也发挥不出来。
3.3.2 常用类型全览
TypeScript 提供了丰富的类型以应对不同的场景。首先是最常用的 8 个类型:
string
:字符串number
:数值boolean
:布尔null
:nullundefined
:undefinedsymbol
:Symboltype[]
:数组object
:对象
这 8 个类型分别对应 JavaScript 不同的数据类型,其中 string、number、boolean 是最基本也是使用最多的类型。下面分别介绍这些类型。
基本类型
为变量指定类型非常简单,只需在变量名后添加 :类型
就可以,代码如下:
var name: string = "孙悟空";
var age: number = 100;
var isgod: boolean = true;
null 和 undefined 在 JavaScript 中是两个基本值,在 TypeScript 中它们又分别是两个类型。因此,null 和 undefined 即是类型也是值,它们的类型就是值本身,如下:
var age: null = null;
var name: undefined = undefined;
var isgod: boolean = null; // 错误:不能将类型“null”分配给类型“boolean”
在 JavaScript 开发中,通常会将一个对象的初始值设置为 null,声明变量未赋值时默认是 undefined。因此这两个值一般都可以作为任意数据类型的初始值使用。
但在 TypeScript 中对类型有严格的限制,默认情况下 null 和 undefined 只能赋值给它们自身。但是也可以通过配置将任意类型的变量赋值为 null 或 undefined。
找到 TypeScript 的配置文件 tsconfig.json(下文会详细介绍),在 compilerOptions 选项下添加 "strictNullChecks": false
,表示不对 null 和 undefined 严格限制,此时 null 和 undefined 就变成了任意类型的子类型。以下代码 TypeScript 不会再报错:
var name: number = undefined;
var json: object = null;
ES6 新增了一个基本类型 Symbol,TypeScript 也有一个 symbol 类型与之对应,如下:
var smb: symbol = new Symbol("标志");
引用类型
在 JavaScript 数据类型中,除基本类型之外其余都是引用类型。引用类型使用最多的是数组、对象、函数。
数组是某一个或多个类型的集合,它有两种定义方式,代码如下:
let num1: number[] = [1, 2, 3];
let num2: Array<number> = [4, 5, 6];
这两种定义数组类型的格式,开发中更推荐使用第一种,也就是 type[]
这种格式。上面代码中定义了一个由 number 组成的数组,因此数组类型是 number[]
。如果数组项全部都是字符串,数组类型就变成了 string[]
。
数组的元素可以是任意类型,也可以同时包含多种类型。
假设数组的元素包含字符串和数字,那么数组类型可写为 number | string[]
(这种写法叫联合类型,后面会讲到)。如果数组元素类型不确定,那么可以直接写为 any[]
。不过非必要最好不要使用 any,会丢失一部分类型提示。
let arr1: number | string[] = [1, 2, "3", "4"];
let arr2: any[] = [1, "2", true, null];
JavaScript 中对象的概念非常广泛,一般情况下,引用类型的数据
都可称之为对象。这些广义上的对象类型统一用 object
表示,如下:
var date: object = new Date();
var fun: object = () => {};
var arr: object = ["object"];
类型 object 虽然表示对象,却不能区分对象(如区分函数和数组)。它的作用仅仅是区分基本类型和引用类型。设置为 object 类型的变量,不可以赋值为基本类型,如下:
var num: object = 2; // 错误:不能将类型“number”分配给类型“object”
var str: object = "hello world"; // // 错误:不能将类型“string”分配给类型“object”
函数类型
函数是一个比较灵活的类型,因为它有参数和返回值,因此函数的类型是由参数类型
和返回值类型
组成的。先看一个基本的无参数无返回值的函数:
// 普通函数
function fun1(): void {
console.log("这是一个函数");
}
// 箭头函数
var fun2 = (): void => {
console.log("这是一个函数");
};
关键字 void
也是一个类型,表示函数无返回值。如果函数返回字符串,那么就用 string 替代 void,其他类型同理。函数的参数类型直接在参数后指定(参数:类型),和变量的指定方式一样。再看一个有参数且有返回值的函数:
var fun = (name: string): string => {
return "姓名:" + name;
};
上面代码中的函数有一个参数 name,类型为字符串,且函数执行的返回值为字符串。通过为函数添加一些“类型的限定”,我们知道了这个函数应该如何使用。
函数的参数可能是动态的。比如某个参数是必传,某个参数非必传,这样的话需要标记参数是否必传。函数参数默认是必传的,设置为非必传的方法是在参数名后加一个 ?
号,如下:
var fun2 = (name: string, tag?: string): string => {
return tag || "" + name;
};
fun2("你好");
fun2("你好", "中国");
代码中参数 tag 就是非必传参数(可选参数),可选参数要做判空处理。可选参数还要放在所有必传参数之后,因为 JavaScript 的函数参数是按照顺序定位的,如果可选参数在前面就必须传一个占位符,这在 TypeScript 中是不被允许的。
上面的方式是为已有函数指定函数类型,那么我们能不能在函数声明之前定义一个完整的函数类型
?当然是可以的,下面例子为一个变量指定函数类型:
// 声明变量
var fun: (name: string, tag?: string) => string;
// 赋值函数
fun = (arg1: string, arg2?: string) => arg1 + arg2 || "";
代码中 (name: string, tag?: string) => string
就是一个完整的函数类型,这个类型与 string、number 并没有本质的区别。当为某个变量指定该函数类型后,这个变量就只能赋值为符合该类型的函数了。
因为函数类型根据参数和返回值的不同而变化,所以也可以将常用的函数类型设置为一个自定义类型。自定义类型(也叫类型别名)用 type
关键字声明,一些复杂类型非常适合用自定义类型替代,使用时可以减少代码量,例子如下:
// 自定义类型
type myFunType = (name: string, tag?: string) => string;
// 绑定类型
var fun: myFunType = (name, tag) => {
return name + tag;
};
联合类型
前面介绍的所有类型都是单独类型,实际上某些值并不只会有一种类型,可能是多种类型的组合,比如既可以是 string 又可以是 number,这个时候就要用到联合类型。
联合类型用 |
符号将多个类型连接起来,使用非常简单,如下:
var val1: string | number = "";
var val2: object | null = null;
联合类型表示某个值可能有多种类型,当使用联合类型时,TypeScript 只会将多个类型的共有属性看作是值的属性,如果不是共有属性则 TypeScript 会提示错误(方法与属性同理)。如下:
var val3: string | number = "hello";
console.log(val3.toString());
console.log(val3.length); // error:类型“number”上不存在属性“length”
代码中的值是字符串,但是类型是字符串与数值的联合类型。toString() 方法是字符串与数值共有的方法,因此不会报错;但是 length 属性只有字符串有,数值没有,因此 TypeScript 类型验证不通过。
然而变量值本来就是字符串,length 属性一定是存在的。此时我们就要告诉 TypeScript,变量 val3 只能是 string 类型,不会是 number 类型。这种为变量“强制指定某一个类型”的方式叫做类型断言
。
类型断言通过 as
关键字来实现,将上面代码中报错的地方添加类型断言,改造如下:
var val3: string | number = 'hello'
let length = (val3 as string).length
console.log(length) // 5
添加类型断言后,变量 val3 的类型被当作 string,代码正常运行。不过要注意,只有在非常确定数据类型的情况下才使用类型断言,否则还是交给 TypeScript 来判断。
3.3.3 接口与泛型
引用数据类型用 object 表示,它是数组、对象、函数等所有非基本数据类型的统称。JavaScript 中狭义的对象是指 JSON 对象,JSON 对象的类型可以用 object 来表示。
然而在实际情况中,我们需要通过类型知道 JSON 对象有哪些属性,属性值的类型是什么,这些能力 object 类型并不能提供,我们还需要其他的类型。
接口
TypeScript 提供了一个叫 “接口” 的类型,用关键字 interface
表示,专门用来设置 JSON 对象的类型。通过 interface 可以定义对象的属性名,属性是否可选,属性值的类型等。如下:
interface studentType {
id: number;
name: string;
desc?: string;
}
var student: studentType = {
id: 1,
name: "小帅",
desc: "三好学生",
};
代码中的接口类型 studentType 包含了三个属性:id 和 name 是必须的,值类型分别是 number 和 string;desc 是可选的,值类型是 string。为变量 student 赋值时必须遵照 studentType 类型的规定,不规范的一律报错,代码如下:
var student: studentType = {
name: "小帅",
}; // error:缺少属性 "id"
var student: studentType = {
id: 1,
name: "小帅",
age: 18,
}; // error:“age”不在类型“studentType”中
interface 的好处在于,当我们使用一个对象的时候,可以很清楚的知道对象下有哪些属性。还记得前面我们介绍过的编辑器快捷提示吗?为一个变量指定 interface 类型,当使用这个变量时,编辑器就会自动提示,非常的方便。
JSON 数据有可能是多层嵌套
,因此 interface 也支持多层嵌套以满足丰富的数据格式。例子如下:
interface baseType = {
value: number,
label: string
}
interface listType = {
tag: string,
list: baseType[]
}
var citys: listType = {
tag: '高校',
list: [
{
value: 1,
label: '清华大学'
},
{
value: 2,
label: '北京大学'
},
]
}
interface 接口类型是复杂应用中最重要的类型,它几乎可以包含 TypeScript 中的所有类型。因此是在一些公共函数、公共组件中,合理的编写 interface 不仅会让应用的健壮性更高,也会使快捷提示更加友好。
泛型
前面我们介绍了函数的类型如何定义。但是在一些特殊的场景中,函数的类型可能并不是某一个确定的类型。比如下面这个函数:
const repeat = (value) => {
return value + value;
};
repeat(1); // 2
repeat("1"); // 11
这个函数只接收一个参数,且参数类型只能是 string 或者 number。如果是 string 则将参数字符相连,如果是 number 则将参数相加。
现在为这个函数添加类型。因为函数的参数是 string 或 number,函数返回也是 string 或 number,因此我们可能会想到用联合类型来定义,代码如下:
const repeat = (value: string | number): string | number => {
return value + value;
};
这段代码看似没有问题,但联合类型却与实际情况不符。我们期望的结果是:函数参数为 string 则返回 string,函数参数为 number 则返回 number。而代码中的定义的类型,允许参数为 string 时返 number,显然函数的返回类型不正确。
那怎么办呢?这里最适合的方案就是 TypeScript 中另一个特殊且强大的类型 —— 泛型
。
什么是泛型呢?从字面理解,泛型就是一种宽泛的类型。这么理解也不无道理,因为泛型并不是一个具体的类型,可以把它当作是一个“类型变量”
,一个占位符号。在声明泛型时它只是一个符号,表示可能的任何类型,在使用时才会指定具体的类型。
提示:TypeScript 中有一个 any 类型也表示任意类型,但它和泛型的区别很大。any 可以直接屏蔽 TypeScript 的类型验证,而泛型是一个“类型变量”,有严格的类型验证。
泛型一般用字母 T 来表示,还是上面的代码,我们用泛型替代具体的类型。
const repeat = <T>(value: T): T => {
return value + value;
};
在函数中使用泛型,需要先在小括号前用 <T>
声明一个泛型,然后才能在函数中将泛型当作普通类型来使用。此时泛型 T 的实际类型是未知的,只有在函数调用时才会指定。如下:
repeat<number>(1);
repeat<string>("1");
现在想必你已经明白了。函数调用时将 T 设置为 number 类型,那么函数中所有 T 的部分都会替换为 number,这样就实现了我们最开始的需求:函数参数为 string 则返回 string,函数参数为 number 则返回 number。
上面代码中,repeat 函数使用泛型并没有语法问题,但是函数体内会报错。原因是泛型 T 可能是任意类型(比如函数),不可以用 “+” 号相连。
但我们希望这个泛型 T 并不代表任意类型,只包含 string 和 number 就够了。此时我们就要用到“泛型约束”
,将泛型可能的类型做一个约束,代码修改如下:
const repeat = <T extends string | number>(value: T): T => {
return value + value;
};
泛型约束很简单,通过 extends
继承某几个类型,就可以将泛型可能的类型限制在这个范围内。
当然泛型并不只有一个,可以指定多个泛型实现更灵活的需求。如下:
const getArray = <T, U>(val1: T, val2: U): (T | U)[] => {
return [val1, val2];
};
代码中定义了 T 和 U 两个泛型,相当于两个类型变量,代码逻辑的扩展性更高了。
3.3.4 装饰器的妙用
装饰器是一种高阶语法,它可以装饰某个数据对象。但装饰器目前只是 ECMAScript 的提案,尚没有标准化,并且在 TypeScript 中也是一个实验性功能,需要在配置中开启才能使用。
开启方法:在配置文件 tsconfig.json 中的 compilerOptions 对象下,添加属性"experimentalDecorators": true
即可。
装饰器通过 @函数名
定义,这里的函数叫做“装饰函数”。装饰函数有一个 target 参数表示被装饰的对象,可以在函数内决定如何操作该对象。
提示:装饰器不能装饰函数,因为函数存在变量提升。大多数场景下装饰器被用于装饰类
我们看一个基本的装饰器代码:
// 装饰函数
const addTag = (target) => {
console.log(target.name);
};
@addTag
class Test {}
@addTag
就是一个装饰器。将它写在一个类的前面,代码运行后就会打印出类的名称 “Test”,这就是装饰器的基本作用。
装饰器根据被装饰目标的不同,可以分为两类,分别是:
类装饰器
类成员装饰器
顾名思义,第一类装饰器用来装饰类,第二类装饰器用来装饰类的成员(类的属性、方法、方法的参数)。我们先看第一类。
类装饰器
用装饰器装饰类,要把装饰器放在紧挨着类的上方,装饰器声明后装饰函数就会被调用。如下:
const getName = (target) => {
target.prototype.nick = "雪球";
};
@getName
class Cat {
name = "黑仔";
}
var cat = new Cat();
console.log(cat.name); // 黑仔
console.log(cat.nick); // 雪球
上面代码执行会在控制台打印“雪球”两个字。说明通过装饰器,我们在类的原型对象上创建了新的属性。当然了除了修改类本身,还可以基于目标对象做任何操作。
提示:装饰器装饰类时,装饰函数的 target 参数表示类本身,通过 target.prototype 可以访问到类的原型对象。
装饰器的本质就是以被装饰者为参数,调用一个装饰函数。那么装饰函数可以传参吗?答案是可以的,但并不是直接传参,因为装饰函数有自己的参数(target 或其他属性参数),不可以把自定义参数直接传给装饰函数。但我们可以基于闭包,用一个工厂函数来实现。
工厂函数是指在函数内部返回另一个函数,我们用工厂函数接受参数,返回一个装饰函数,如下:
// 工厂函数
const setName = (name: string) => {
return target => {
target.prototype.name = name
}
}
@setName('工厂函数')
这样我们用工厂函数接收参数,不影响装饰函数的参数规则,实现了装饰器传参。
成员装饰器
成员装饰器,包括对类的属性、方法,方法的参数等代码块内的元素进行装饰。
先看类的属性如何装饰:
const setProp = (target, prop) => {
console.log(target, prop);
// 打印结果:{constructor: ƒ} 'name'
};
class Info {
@setProp
name: string;
}
上面的装饰函数有两个参数:
target
:装饰对象,此处是类的原型对象prop
:类的属性名
属性装饰器只能用来判断属性名,不能修改属性,但是可以修改类原型上的属性。
类的方法装饰器与属性装饰器功能一致,不过装饰函数多了一个参数(第三个参数),表示属性描述符(属性描述符在 ES6 部分介绍过)。下面代码是使用方法装饰器的例子:
const setFun = (target, prop, descriptor) => {
// descriptor 是属性描述符
descriptor.writable = false;
descriptor.value = () => {
console.log("齐天大圣");
};
};
class Info {
@setFun
getName() {
console.log("孙悟空");
}
}
var info = new Info();
info.getName(); // 齐天大圣
上述代码中,类方法 getName 打印字符 “孙悟空”,但因为在方法装饰器中修改了属性描述符的 value 属性,也就是修改了方法本身,因此执行 getName() 方法实际打印结果是 “齐天大圣”。
注意:在类中装饰不同的类成员时,装饰函数的 target 参数含义不同。装饰静态成员时,target 是类本身;装饰实例成员时,target 是类的原型对象。
最后看一下参数装饰器。参数装饰器也有三个参数,前两个参数与方法装饰器一致,第三个参数表示参数的索引位置。代码如下:
const required = (target, prop, index) => {
console.log(target, prop, index);
};
class City {
getAdress = (city: string, @required area: string) => {
console.log(city + area);
};
}
上面代码运行后,required 函数被触发。它接受了三个参数,分别是类的原型对象、被装饰的方法名、被装饰参数的位置,因此 required 函数体内的打印结果如下:
target:{constructor: ƒ}
prop:'getAdress'
index:1
当然了,装饰器可以同时指定多个。当为一个类同时指定多个装饰器时,装饰器按照 “从上到下” 的顺序依次执行。
3.3.5 吃透 tsconfig.json
前面我们提到了配置文件 tsconfig.json,TypeScript 的所有规则都在这个配置文件中定义。
在这之前首先要知道一些原理。TypeScript 与其他高级语法一样,浏览器并不认识,需要通过编译器将 TypeScrip 转换成 JavaScript 代码。TypeScript 的编译器就是 tsc
命令。
先来看一下,tsconfig.json 文件里的几个主要配置项:
{
"compileOnSave": true,
"include": [],
"exclude": [],
"compilerOptions": {}
}
前三个配置项都是 tsc 编译器的选项,其表示的含义如下:
compileOnSave
:是否在文件保存时自动编译include
:指定哪些目录/文件会被编译exclude
:指定哪些目录/文件不会被编译
这三个选项确定了 tsc 编译器需要编译哪些文件,最后一个选项 compilerOptions
则表示详细的编译规则,这个选项才是重中之重。它包含的属性如下:
target
:编译后的 ES 版本,可选值有 ES3(默认值)、ES5、ES6、ESNEXT 等。module
:编译后的模块标准,可选值有 commonjs 和 es6。baseUrl
:重要,模块的基本路径。paths
:重要,设置基于 baseUrl 的模块路径。allowJs
:是否允许编译 JS 文件,默认 false。checkJs
:是否检查和报告 JS 文件中的错误,默认 false。sourceMap
:是否生成 .map 文件。strictNullChecks
:是否严格检查 null 和 undefined。
这里面比较重要且常会被修改的是 baseUrl 和 paths 这两个属性。比如在 webpack 中配置了一个路径别名 @/
,但这个别名 TypeScript 并不认识,所以需要在 paths 属性中配置这个路径。
配置 paths 属性必须配置 baseUrl,因为 paths 配置的路径是基于 baseUrl 的,代码如下:
{
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
这个配置告诉 TypeScript,@/*
这个路径实际指向的地址是 ./src/*
,这样 TypeScript 就不会报错了。
更多关于 compilerOptions 的配置请查看这里: https://www.tslang.cn/docs/handbook/compiler-options.html
本章小结
本章我们从面向未来的 JavaScript 角度,挑选核心内容讲解了 ES6,Node.js 和 TypeScript 这三个至关重要的新时代 JavaScript 高级技能。这三部分是 JavaScript 的进阶,在现代 JavaScript 开发中已经成为了不可获取的基础技能。
掌握好本章的内容,你已经成为了基础扎实,面向新时代的 JavaScript 开发者。下一章我们正进入框架学习,一步步解开框架的神秘面纱。