TypeScript语言


TypeScript语言

PART 1

1、前言

TypeScript是一门基于Javascript之上的编程语言,它重点解决了JavaScript语言自有的类型系统的不足,而通过使用TypeScript语言就可以大大提高代码的可靠程度。虽然说本文的标题只有TypeScript语言,但其实这里要介绍的内容远不止这些,因为这里我们要重点探讨的是JavaScript自有类型系统的问题以及如何去借助一些优秀的技术方案去解决这些问题,而TypeScript只是在这个过程当中我们涉及到的语言。不过,TypeScript这门语言目前可以说是此类问题的最终极解决方案,所以说我们也会着重去学习他。除此之外,我们也会去介绍一些其他的技术方案。

内容概要:

  • 强类型与弱类型
  • 静态类型与动态类型
  • JavaScript自由类型系统的问题
  • Flow静态类型检查方案
  • TypeScript语言规范与基本应用

首先我们会去了解一下什么是强类型,什么是弱类型,什么是静态类型,什么是动态类型,他们之间到底有什么不一样以及为什么JavaScript是弱类型的,为什么是动态类型的。然后我们再去一起了解一下JavaScript自有类型系统存在的问题以及这些问题给我们的开发工作都照成了哪些影响。再往后,我们会去了解一下Flow和TypeScript这两个最主流的JavaScript类型系统方案,其中Flow只是一个小工具,他弥补了JavaScript类型系统的不足。而TypeScript则是基于JavaScript基础之上的编程语言,所以说相对来讲需要了解的内容会更多一些。不过也不需要担心,TypeScript也是属于渐进式的,即便我们现在什么特性都不知道,也可以立马按照JavaScript的语法去使用他。

2、强类型与弱类型

在具体介绍JavaScript类型系统的问题之前,先来解释两组区分不同编程语言时,经常提及的名词:强类型与弱类型,静态类型与动态类型。他们分别是从类型安全,类型检查这两个维度区分了不同的编程语言。先来看类型安全的维度,从类型安全的角度来说,编程语言分为强类型和弱类型,这种强弱类型的概念最早是1974年时美国的两个计算机专家提出的,当时对强类型的一个定义就是在语言层面限制函数的实参类型必须与形参类型相同。而弱类型则完全相反,他在语言层面不会限制实参的类型。由于这种强弱类型的分类根本不是某一个权威机构的定义,而且当时这两位计算机专家也没有给出具体的规则,所以就导致了后人对这种界定方式的细节出现了一些不一样的理解,但整体上大家的界定方式都是在描述强类型有更强的类型约束,而弱类型中几乎没有什么约束。而笔者比较同意的说法是强类型语言当中不允许任意的隐式类型转换,但在弱类型语言中则允许任意的数据隐式类型转换

我们以JavaScript为例,在JavaScript当中,他就允许任意的隐式类型转换:

// 这里的'300'会被隐式转换为300然后进行运算
console.log('300' - 50);  //250
// 再如Math.floor方法,按理来说这个方法应该接收一个数字,但实际上我们可以传入任意类型,都不会报错
console.log(Math.floor('foo'));  //NaN
console.log(Math.floor(true));  //1

注意我们这里提到的强类型是从语言的语法层面就限制了不允许传入不同类型的值,如果我们传入的是不同类型的值,我们在编译阶段就会报错,而不是在运行阶段通过逻辑判断去限制。而在JavaScript中所有报出的类型错误,都是在代码层面运行时通过逻辑判断手动抛出的

这里,我们再来看看强类型的例子,python语言:

>>> '100' - 50
# Traceback (most recent call last):
#   File "<stdin>", line 1, in <module>
# TypeError: unsupported operand type(s) for -: 'str' and 'int'

>>> abs('100')
# Traceback (most recent call last):
#   File "<stdin>", line 1, in <module>
# TypeError: bad operand type for abs(): 'str'

需要注意的是,这里在语言层面就报了对应的错误。

另外,在代码中变量类型允许随时改变的特点,并不是强弱类型的差异。就如Python,他是一门强类型的语言,但他的变量仍然可以随时改变类型,而这点在很多资料中可能都表示得有些不太妥当。

3、静态类型与动态类型

除了类型安全的角度有强类型语言和弱类型语言之分,在类型检查的角度还可以将编程语言分为静态类型语言和动态类型语言。关于静态类型语言和动态类型语言之间并没有什么争议,大家都很统一。对于静态类型语言,最主要的表现就是,一个变量声明时它的类型就是明确的,而且在变量声明过后,他的类型就不允许再修改。相反,动态类型语言的特点就是在运行阶段才能够明确一个变量的类型,而且变量的类型也可以随时发生变化。例如在js中通过var去声明一个变量:

var foo = 100;  //程序运行到此时明确foo是一个Number
foo = 'bar';  //然后将他的值修改为一个字符串,这种用法也是被允许的
console.log(foo);  // bar

我们也可以说,在动态类型语言中,他的变量是没有类型的,而变量中存放的值是有类型的,JavaScript就是一门标准的动态类型语言。总之,静态类型和动态类型的区别就是是否允许随时修改变量的类型。

下图就描述了一些常见的编程语言在这两个角度下的表现:

静态、动态类型与强、弱类型编程语言

需要注意的是,这里我们不要混淆了类型安全和类型检查这两个区分维度。更不要认为弱类型就是动态类型,强类型就是静态类型。

4、JavaScript类型系统特征

由于JavaScript是一门弱类型、动态类型语言,语言本身的系统是非常薄弱的,甚至可以说JavaScript根本没有一个类型系统,这种语言的特征用一个比较流行的词来说就是「任性」。因为JavaScript这门语言几乎没有任何的类型限制,所以他也是极其灵活多变的。但在灵活多变的表象之后,丢掉的就是类型系统的可靠性,我们在代码中每遇到一个变量都担心他是否是我们想要的类型,整体的感受用另外一个流行的词来形容就是「不靠谱」。

那可能有人会问,为什么JavaScript不能设计成一门强类型/静态类型这种更靠谱的语言呢?这自然和JavaScript的背景有关,首先,早前的JavaScript应用简单,其次,JavaScript是一门脚本语言,不需要编译就直接在运行环境中运行,即他没有编译环节,所以即便把他设计成静态语言也没有什么意义,因为静态类型语言需要在编译阶段做类型检查,而JavaScript根本就没有这个环节。

根据以上的这些原因,JavaScript就选择成为了一门更灵活更多变的若类型/动态类型语言。这放在当时的环境当中并没有什么问题,甚至也可以说是优势,而现如今前端应用的规模已经完全不同了,遍地都是一些大规模的应用,那我们JavaScript的代码也就会变得越来越复杂,开发周期也会变得越来越长。在这种情况下,之前JavaScript这种弱类型/动态类型的优势就变成了短板。就像以前杀鸡时用小刀很顺手,但现在要杀牛了,这种杀鸡小刀子就有些不够看了。

5、弱类型的问题

JavaScript作为一种弱类型的语言,在应对大规模应用开发时,可能会出现一些常见的问题。

const obj = {}
obj.foo();  // Uncaught TypeError: obj.foo is not a function

很明显,obj对象中并不存在foo方法,但在语言的语法层面,这样写是可行的。只不过我们一旦把代码放到环境中运行一遍,就会报错。也就是说像JavaScript这样弱类型的语言当中,我们就必须要等到运行阶段才能够发现代码中的类型异常,而且若这里并不是立即去执行这个方法而是在某个特定的时间才去执行,例如在setTimeout的回调中,程序在刚刚启动运行时还没办法发现这个异常,一直等到这行代码执行了,才有可能去抛出这样一个异常。如果我们在测试时没有测试到这行代码,那么这一个隐患就会被留到代码当中。而如果是强类型的语言的话,我们直接调用在对象中不存在的成员,则语法中就会报错,根本不用等到运行这行代码。

还有一类因类型不确定而造成的典型问题:

function sum(a, b){
    return a + b;
}
console.log(sum(100, 100));  //200
console.log(sum(100, '100'))  //100100

可能还有人说,我们可以用约定去规避这样的问题。但必须注意约定是没有任何保障的,特别是在多人协同开发的情况下,我们根本没办法保证每个人都能遵循所有的约定,而如果我们使用的是一门强类型的语言的话,这种情况就会被彻底避免。在强类型语言中,如果我们要求传入的是数字,若传入的是其他类型的值,则在语法上就会不通过。

我们在ES新特性中讲过,对象的属性名只能够是字符串或ES6所推出的Symbol,但由于JavaScript是弱类型的,所以我们可以在索引器中使用任意类型的值作为属性,而在他的内部会自动转为字符串:

const obj = {}
onj[true] = 100;
console.log(obj['true']);  //100

如果之前我们不知道对象属性名会自动转换为字符串的特点,这里就会感觉很奇怪,而这奇怪的根源就是因为我们用的是比较「任性」的弱类型语言。如果是强类型语言的话,这种问题就可以彻底避免,因为在强类型的情况下,这里的索引器会有明确的类型要求,我们不满足类型要求的成员在语法上就行不通。

总结一下,第一点我们提到在弱类型中程序异常要等到运行时才能发现。第二点则是因为弱类型的类型不明确,从而会导致函数功能可能会发生意料之外的改变。第三点中因为弱类型的关系就出现了我们对对象索引器的一种错误的用法。

总之,弱类型这种语言的弊端是十分明显的,只是在代码量小的情况下这些问题都可以通过约定的方式去规避。而对于一些开发周期特别长的大规模项目,这就显得有些相形见绌了。「君子约定有隐患,强制要求有保障」。只有在语法层面的强制要求,才能够提供更可靠的保障。所以说强类型语言的代码在代码可靠程度上是有明显优势的,而使用强类型语言则可以提前消灭一大把可能出现的异常,而不必等到运行过程中去慢慢debug。

6、强类型的优势

经过对JavaScript这种弱类型语言的分析,强类型语言的优势也逐渐体现出来了。当然,对于强类型语言的优势当然远不止这些,我们可以先总结出四大点。

  • 错误更早暴露,我们可以在编码阶段就提前消灭一大部分可能存在的类型异常。

  • 强类型的代码会更加智能,我们的编码也可以更加准确一点,例如更准确的代码智能提示 。

  • 重构更牢靠,例如删除对象中的某个成员,或修改一个已经存在的成员名称。对于弱类型语言来讲,修改某一个成员名称后,在很多地方用到的名称还是以前的名称,即便有错误也没办法立刻表现出来。但对于强类型语言,一旦对象的属性名发生变化,在重新编译时就会立即报出错误,我们就可以轻松定位所有使用到这个成员的地方,甚至还有些工具可以自动把所有引用到这个对象中的成员的地方自动修正过来。

  • 强类型语言可以减少不必要的类型判断:

function sum(a, b){
    if(typeof a !== 'number' || typeof b !== 'number'){
        throw new TypeError('arguments must be a number');
    }
    return a + b;
}
console.log(sum('a','b'));  //Uncaught TypeError: arguments must be a number

在上面sum函数中的if判断,实际的目的就是保证对应的数据类型是我们需要的Number,而对于强类型语言来讲,这段判断是根本没有任何意义的。

PART 2

7、Flow概述

Flow是2014年由FaceBook推出的一款工具,是JavaScript的静态类型检查器,使用他的话就可以弥补JavaScript弱类型所带来的一些弊端。目前在很多React和Vue.js的项目中都可以看到Flow的使用。他的工作原理就是让我们在代码当中通过添加一些类型注解的方式来标记代码当中每个变量或参数应是什么类型的。然后Flow就可以通过这些类型注解检查代码当中是否存在类型异常从而实现在开发阶段对类型的检查,这也避免了我们在运行阶段再去发现这种类型上的错误。

例如常见的sum函数:

function sum(a: number, b: number){
    return a + b;
}

上面sum参数中“: number”的用法叫做“类型注解”,表示前面的成员必须接受一个number类型的值。

而在代码中这些额外的类型注解,我们可以在运行之前通过babel或Flow官方提供的模块自动去去除。所以说在生产环境中这些类型注解不会有任何的影响。而且Flow还有一个特点就是他不要求我们给每个变量都添加注解:

function sum(a: number, b){
    // a: number
    // b: any
    return a + b;
}

所以我们可以根据自己的需要添加。另外,相比于我们之后要提及的TypeScript,Flow只是一个小工具,学习起来也特别简单。

8、Flow快速上手

因为Flow是以npm模块的形式去工作的,所以我们需要先初始化一个package.json文件用来管理项目的依赖:

yarn init --yes

然后我们就可以使用yarn来安装Flow。Flow类型检查工具的模块叫做flow-bin:

yarn add flow-bin --dev

我们可以选择把Flow安装到全局,但在此我们选择把他作为项目依赖来安装,因为他是这个项目当中才会用到的工具。作为项目的开发依赖去安装的话可以和项目一起去管理,这样的话可以更合理一些,而且别人拿到项目后可以直接去安装我们项目的依赖。

之后我们可以开始使用flow了:

// @flow
function sum(a: number, b: number){
    return a + b;
}
sum(100, 100);
sum('100', '100');

注意在代码开始的位置需要添加// @flow 这样的注释标记,这样的话flow在执行检查时才会检查这个文件,之后就可以在代码中为我们代码中的成员添加类型注释,如上面的“: number”。注意,“: number”这种语法在vscode等IDE中可能会因语法校验而报错,在此我们可以在VSCode的Settings里搜索“JavaScript Validate”来关闭他。之后我们就可以先执行:

yarn flow init

来初始化flow配置文件”.flowconfig”,再通过执行:

yarn flow

来启动flow,检测代码中的类型异常,注意第一次执行flow会比较慢,因为第一次执行flow会启动一个后台服务去接受我们的文件,后续再去执行就会快很多。而在完成编码之后,我们可以使用:

yarn flow stop

来结束之前的后台服务。

9、Flow编译移除注解

我们上文中提到用类型注解的形式来使用flow,但类型注解这种形式并不是JavaScript的标准语法,添加了这种注解后我们的代码是没办法运行的。不过我们可以使用工具在我们完成编码之后自动移除添加的类型注解。移除类型注解目前有两种比较主流的方案,一种是用官方提供的flow-remove-types模块,这也是最快速,最简单的方案。

我们可以在项目中输入:

yarn add flow-remove-types --dev

来安装flow-remove-types模块。在安装完成后我们就可以使用这个模块提供的命令行工具去自动移除类型注解:

yarn flow-remove-types src -d dist

其中,flow-remove-types后的第一个参数是有类型注解的js文件所在目录,而-d后的参数则是去掉类型注解后的相关js文件需要保存的目录。实际上,flow-remove-types这种方案就是把我们编写的代码和实际生产环境运行的代码分开,中间加入编译的环节,这样的话我们就可以在开发阶段使用一些扩展语法,使类型检测变得可能。

提及编译,最常见的JavaScript编译工具就是babel,第二种方法就是用babel去配合插件实现自动移除代码中的类型注解。

首先,我们要先去安装一下babel及其相关插件:

yarn add @babel/core @babel/cli @babel/preset-flow --dev

其中@babel/core是babel的核心模块。@babel/cli是babel的cli工具,他可以让我们在命令行中直接使用babel命令来完成编译。而@babel/preset-flow则包含了我们转换类型注解的一个插件。

之后我们还需要在项目根目录下新建一个.babelrc的配置文件,然后打开该文件添加配置(若已有该文件则直接打开该文件添加配置):

{
    "presets": ["@babel/preset-flow"],
}

之后和第一种方法类似,在命令行中输入:

yarn babel src -d dist

把src文件夹下的所有js文件都编译转换到dist文件夹下。然后在dist文件夹下就可以看到所有移除类型注解后的js文件了。

10、Flow开发工具插件

了解了之前我们提到的类型注解,检测类型以及如何移除注解的环节后,我们就可以在项目中直接使用Flow了。但在目前的这种方式下,Flow所检测到的问题都是输出到控制台当中的,我们在开发过程中每次都需要打开命令行终端运行命令才能看到对应的类型问题,这种体验并不是很直观。不过我们可以选择安装一个开发工具插件来让我们的开发工具更直观地体现当前代码中的类型问题。

在VSCode中,可以安装“Flow Language Support”工具来支持这种功能,需要注意的是,我们每次要保存代码后才会有相关类型错误的提示。如果当前你用的是其他开发工具,可以使用类似的插件,详见Flow官方网站:传送门

11、Flow类型推断(Type Inference)

在Flow当中,除了使用类型注解的方式去标记代码中每个成员的类型,Flow还可以很聪明地推断我们代码中每个成员的类型:

// @flow
function square(n){
    return n * n;  //报错
}
square('100');

此处我们没有使用类型注解,但Flow仍然可以帮我们发现类型错误。他会根据我们在调用时传入square函数中的是字符串推断这里的n参数是字符串类型,而字符串类型是不能够进行乘法运算的,所以在这里就会报错。这种根据代码中变量的使用情况推断出代码类型的特征,就称为“类型推断”。不过在大多数情况下,还是建议大家添加类型注解,因为这可以让我们的代码有更好的可读性。

12、Flow类型注解

类型注解不仅仅可以用在函数的参数上,还可以用来标记变量的类型以及函数返回值的类型:

// @flow

// 标记变量类型
let num: number = 100;
num = 'string';  //报错

//标记函数返回值类型
function foo(): number {
    return 'string';  //报错
}
// 需要注意的是,如果函数没有返回值的话,可以标记为void(注意在JS中没有返回值默认返回undefined)
function foo(): void {
    console.log('hello');
}

13、Flow原始类型

以下罗列Flow中使用标记的各种数据类型:

// @flow

const a: string = 'foo';  //string标记只能放字符串值
const b: number = 100;  //number标记可以放数字
const c: number = NaN;  //number标记也可放NaN(表示一个非数字,但在JS中也为Number类型)
const d: number = Infinity;  //number标记还可放Infinity(表示无穷大,也是JS中Number的特殊值)
const e: boolean = true;
const f: boolean = false;  //boolean标记只能放true/false
const g: null = null;  //null标记只能放null
const h: void = undefined;  //注意Flow中undefined是用void标记的(与上文中函数无返回值时相同)
const i: symbol = Symbol;  //symbol标记只能放Symbol类型 

14、Flow数组类型

Flow除了可以对普通的数值做类型限制,还支持对有结构的数据(对象、数组)做类型限制。

我们先看数组类型,Flow支持两种数组类型的表示方法:

// @flow

// 在Array后用泛型参数来表示数组中每个元素的类型,用<...>指定。关于泛型的概念会在后文TypeScript中详细介绍。
const arr1: Arrar<number> = [1, 2, 3];

// 另一种表示方法
const arr2: number[] = [1, 2, 3];

除此之外,如果我们需要表示固定长度的数组,可以使用类似数组字面量的形式表示:

// @flow
const foo: [string, number] = ['foo', 100];

对于这种固定长度的数组,有一种更专业的名称:元组。元组一般在函数中返回多个返回值时使用。

15、Flow对象类型

对象是JavaScript中最常见的数据结构,在Flow中描述对象的方式和对象字面量语法非常相似:

// @flow
// 明确对象中的成员及其数据类型
const obj1: { foo: string, bar: number } = { foo: 'string', bar: 100 };
// 若对象中有可选属性,可用:?表示
const obj2: { foo?: string, bar: number } = { bar: 100 };
// 将对象作为键值对动态添加属性时可限制键值的数据类型
obj3: { [string]: string } = {};  //用类似索引器的形式约束键和值都是string类型
obj3.key1 = 'string1';
obj3.key2 = 'string2';

16、Flow函数类型

之前我们已经介绍过对函数的参数使用类型注解以及对函数的返回值使用类型注解来约束对应的数据类型。除此之外,我们知道函数在JavaScript中也是一个特殊的数据类型,有时候我们也会把函数放在变量当中,例如我们在传递回调函数参数的时候,就会把函数放在回调参数的变量中,而在这种情况下,我们又应该如何限制存放函数的这种变量类型?

// @flow
// 我们可以用类似箭头函数的语法来约束参数函数的对应参数类型及返回值类型。
// 下面表示该参数函数有一个string和number类型的参数,无返回或返回一个undefined。
function foo(callback: (string, number) => void){
    callback('string', 100);
}
foo(function(str, n){
    //传入的str是string类型,且n是number类型,无返回或返回一个undefined。
    console.log(str, n);  //string 100
})

17、Flow特殊类型

除了常规的几种原始数据类型以外,在flow中还支持几种特殊的类型,或者说是几种特殊的情况:

// @flow
// 字面量类型,限制变量必须是某一个值
const a: 'foo' = 'foo';  //此时a中只能放'foo'了
// 字面量类型常常配合联合类型(或类型)的用法,去组合几个特定的值
const type: 'success' | 'warning' | 'success' = 'success';  //此时type就只能放三种值其中之一
// 而联合类型(或类型)还可以用在普通的类型上
const b: string | number = 'string';  // 还可以是number类型
// 还可以使用type关键词去声明一个类型(别名),可用来表示多个类型联合后的结果
type StringOrNumber = string | number;  //之后就可用StringOrNumber去进行类型注解了
const c:StringOrNumber = 'string';  // StringOrNumber的效果和string | number相同
// 除此之外,在Flow中还支持一种maybe类型,在普通类型标注前加?来表示,使变量可额外为null或undefined
const gender: ?number = undefined;
// 实际上,maybe类型就是普通类型联合null和undefined后的结果
const gender: number | null | void = undefined;  //这行代码的效果和上面的代码效果一致

18、Flow Mixed与Any

除此之外,还有两个特殊的类型:Mixed和Any。

// @flow
// Mixed类型可以接收任意类型的值,可以理解为:string|number|boolean|...
function passMixed(value: mixed){
    console.log(value);
}
passMixed('string');  //string
passMixed(100);  //100

// Any类型也有和Mixed类型类似的效果
function passAny(value: any){
    console.log(value);
}
passAny('string');  //string
passAny(100);  //100

此时,大家应该就会不理解:既然都是接收任意类型的数据,他们两者间的差异到底在什么地方?实际上,Any是弱类型,而Mixed则是强类型的:

// @flow
function passAny(value: any){
    console.log(value.substr(1));  //11
    console.log(value * value);  //12321
}
passAny('111');

function passMixed(value: mixed){
    console.log(value.substr(1));  //报出语法错误
    console.log(value * value);  //报出语法错误
}
passMixed('111');

因为Mixed类型是一个具体的类型,所以如果我们没有明确他内部是一个字符串的话,就不能把他当字符串使用,对于数字同理。而若我们想要明确Mixed类型的value到底是不是字符串则可以用typeof这种方式去明确,也即以前传统的类型判断的方式:

// @flow
function passMixed(value: mixed){
    if(typeof value === 'string'){
        console.log(value.substr(1));  //11
    }
    if(typeof value === 'number'){
        console.log(value * value);  //12321
    }
}
passMixed('111');
passMixed(111);

可以看出,我们使用Mixed类型还是类型安全的,相比较而言,Any则是不安全的。所以说,我们在实际使用的过程中,尽量不要去使用any类型。而any类型的存在意义则主要是为了兼容以前的老代码,因为在很多的陈旧代码中我们可能会借助与JavaScript的弱类型或动态类型做一些特殊的情况。

19、Flow类型小结

关于Flow的类型,我们在此只是了解了一部分常见的。因为对于Flow的学习,我们最主要的目的就是为了处理以后理解一些vue或react的第三方项目的源码时可能会遇到用Flow的情况,我们必须要能够看懂。不过在这些项目中可能会存在一些我们没有了解过的类型,这时我们可以再查阅一些相关的文档。详见:

Flow官方对所有类型的描述文档:传送门

另外推荐一个第三方的类型手册:传送门,这个类型手册整理得更直观些。

20、Flow运行环境API

最后,我们再来了解一些Flow对一些运行环境提供的一些API的支持。我们知道JavaScript不是独立工作的,他必须要运行在某一个特定的运行环境中,例如浏览器环境或Node环境。而这个运行环境一定会提供一些API给我们使用,例如在浏览器中有DOM和BOM,在Node中有各种各样的模块。所以这也就是说我们的代码必然会使用到这些环境中所提供的API或对象,对于这些API和对象也同样有一定的类型限制。

// @flow
// 例如对于浏览器DOM的getElementById,他要求我们传入一个字符串,并返回一个HTMLElement或null类型的返回值。
const element: HTMLElement | null = document.getElementById('app');

像上面所示,这就属于运行环境内置的类型限制。在这里,我们还可以在VSCode中对HTMLElement右键选择“Go to Definition”来找到这个类型的声明文件,这个声明文件的源代码也可以在Flow的官方仓库中找到。详见官方仓库中:

JS自身标准库中的类成员相关,例如Object,Array,Math,JSON等:传送门

DOM相关:传送门

BOM相关:传送门

CSSOM相关:传送门

node相关:传送门

PART 3

21、TypeScript概述

TypeScript是一门基于JavaScript基础之上的编程语言,很多时候我们会认为他是JavaScript的超集(superset)或扩展集。即TypeScript在JavaScript上加了很多扩展特性,多了一套更加强大的类型系统以及对ESMAScript新特性的支持。

在TypeScript中,类型系统与之前的Flow类似,而对于新特性的支持方面,TypeScript还可转换新特性到旧版本中从而保持良好的兼容性。由于TypeScript最终还是会被编译为JavaScript,所以任何一种JavaScript运行环境都支持TypeScript。

相较于之前介绍过的Flow,TypeScript作为一门完整的编程语言,他的功能更为强大,生态也更健全、更完善,特别对于开发工具而言,微软自家的开发工具对TypeScript的支持特别友好。慢慢地可以发现,TypeScript俨然成为了前端领域中的第二语言。如果是小项目,自然可以选择JavaScript,而对于长周期开发的大型项目,则建议使用TypeScript。

当然,再美好的东西都会有缺点,而TypeScript最大的缺点就是这个语言本身多了很多概念,例如接口,泛型,枚举等,这些概念就会提高我们的学习成本。不过好在TypeScript属于「渐进式」的,即哪怕我们什么特性都不知道,也可以立马按照JavaScript标准语法编写JS代码,然后在学习过程中了解了一个特性就可以使用这个特性。

再者,对于周期比较短的项目,TypeScript有可能会增加一些开发成本,因为在项目初期可能要编写一些类型声明,例如对象,函数等的类型声明需要我们单独去编写。而如果是一个长期维护的大型项目的话,这些成本根本不算什么,并且很多时候都是一劳永逸的。所以整体而言,TypeScript是前端行业往后发展必要的一门语言。

22、TypeScript快速上手

首先,我们要先去初始化一个项目的package文件,用来管理项目的依赖项:

yarn init --yes

接着我们把TypeScript作为开发依赖来安装:

yarn add typescript --dev

之后在node_modules文件夹下的.bin文件夹中会多出一个tsc命令,用于编译TypeScript代码。

然后我们就可以新建一个hello.ts文件来编写TypeScript代码了:

// 因为TypeScript是基于JavaScript的,所以在这里可以完全按照JavaScript标准语法来编写代码。
// 又因TypeScript支持最新的ECMAScript标准,所以我们可以用最新的标准编码
const hello = name =>{
    console.log(`hello, ${name}`);
}
hello('TypeScript');

之后我们可以用tsc命令编译该文件(hello.ts):

yarn tsc hello.ts

完成之后,在项目根目录下就会多出一个同名文件,打开该文件后:

// 因为TypeScript是基于JavaScript的,所以在这里可以完全按照JavaScript标准语法来编写代码。
// 又因TypeScript支持最新的ECMAScript标准,所以我们可以用最新的标准编码
var hello = function (name) {
    console.log("hello, ".concat(name));
};
hello('TypeScript');

我们就可以发现这里所有使用ES6的部分,都会被转化为ES3标准的代码。所以即便我们不使用TypeScript的类型系统也可以使用TypeScript去直接使用最新的ECMAScript的标准。当然TypeScript最重要的还是为我们提供了一套更强大的类型系统:

const hello = (name: string) =>{
    console.log(`hello, ${name}`);
}
hello('TypeScript');
hello(100);  //报错

可以看出,这里我们使用类型系统的方式和Flow中的方式基本上差不多,而且VSCode默认就支持对TypeScript的语法做对应的类型检查,不需要和Flow一样需要等到编译而在编辑器中可以直接看到所有的错误提示。之后再对该文件进行编译:

var hello = function (name) {
    console.log("hello, ".concat(name));
};
hello('TypeScript');

可以看出类型注解也被自动移除了。

23、TypeScript配置文件

实际上,tsc命令不仅仅可以编译指定的某个文件,还可以用来编译整个项目/工程。不过一般在编译整个项目之前,会先给项目创建一个TypeScript的配置文件,这里可以使用TypeScript的命令自动生成这个文件:

yarn tsc --init

之后在项目的根目录下就会生成一个tsconfig.json文件,里面默认只有一个compilerOptions属性,这个属性就是TypeScript编译器所对应的配置选项。其中绝大多数的选项都被注释掉了,且在每个选项中都配有简要的说明。

这里介绍几个常用到的选项:

  • target:用来设置编译后的JavaScript所采用的ECMAScript标准,我这里设置为“es2015”
  • module:输出的代码采用什么方式进行模块化,我这里设置为“commonjs”
  • outDir:用来设置编译结果输出的文件夹,我这里设置为“dist”
  • rootDir:配置源代码(TypeScript代码)所在位置,一般为src
  • sourceMap:是否开启源代码映射,设置为true后在调试时就可以使用sourceMap文件调试TypeScript源代码了
  • strict:是否开启严格模式,开启后对类型检查会非常严格,例如必须要为每个成员指定类型,默认为true

之后我们可以直接使用tsc命令来编译整个项目(注意要把源文件放在src文件夹下):

yarn tsc

之后tsc就会按我们的配置将src下所有的TypeScript文件都编译到dist目录中了。并且如果我们开启了sourceMap,dist文件夹中还会有相应的.map文件。

24、TypeScript原始类型

接下来我们先来了解一下目前在JavaScript中,6种原始数据类型在TypeScript中的基本应用,而绝大多数的情况与在Flow中了解的都是类似的:

const a: string = 'foo';  //string类型只能放字符串值
const b: number = 100;  //number类型只能放数字
const c: number = NaN;  //number类型也可放NaN(表示一个非数字,但在JS中也为Number类型)
const d: number = Infinity;  //number类型还可放Infinity(表示无穷大,也是JS中Number的特殊值)
const e: boolean = true;
const f: boolean = false;  //boolean类型只能放true/false
const g: null = null;  //null类型只能放null
const h: void = undefined;  //与Flow中一样,undefined是用void类型表示的(与上文中函数无返回值时相同)
const i: symbol = Symbol();  //symbol类型只能放Symbol类型的值

需要注意的是,string、number、boolean三种原始值类型相比较Flow中有所不同,他们在TypeScript中是允许为空的,即在这里可以给他们赋值为null或undefined(在关闭严格模式后),若想关闭严格模式但需要判断是否为空,可以只开启配置文件中的“strictNullChecks”。

其中还需要注意,若在TypeScript的配置文件中设置了target为“es2015”以下的版本,则使用Symbol()就会报错,具体原因会在下一节中讲到。

25、TypeScript标准库声明

在上节中,我们尝试用全局的Symbol函数去创建一个symbol值的时候,假如target是es2015以下的话就会报错,这是为什么呢。其实原因也很简单,我们知道Symbol实际上是JavaScript中内置的标准对象,与我们之前使用的Object,Array的性质是相同的。只不过Symbol是es6新增的,而对于这种内置的对象,其实他自身也是有类的,而且这些内置对象的类都在TypeScript中已经帮我们定义好了。在VSCode中我们可以通过移动鼠标到在这些标准对象上,右键,点击“Go to Definition”到相关标准库文件中找到相关类的声明。而Symbol是在es2015中定义的,在之前版本中的标准库自然就没有相关的定义。

其实不仅是Symbol,任何在es6中新增的对象若我们直接去使用都会遇到这样的问题,例如Promise。解决的办法有两种,一种就是直接改TypeScript配置文件中的target选项从而修改默认标准库。但如果我们必须要编译到es6之前的话,还可以用TypeScript配置文件中的lib选项指定标准库。需要注意的是,设置了lib后会覆盖默认的标准库,所以我们在加入了es2015标准库后,还需要把缺少的标准库添加进来。另外需要注意,在TypeScript中把dom和bom都归为一个标准库文件dom了。

总之,标准库就是内置对象所对应的声明文件,我们要在代码中使用内置对象就必须要引用对应的标准库,否则TypeScript就找不到所对应的类型从而报错。

26、TypeScript中文错误消息

TypeScript本身是支持多语言话错误消息的,默认会根据操作系统和开发工具语言的设置选择错误的语言。如果我们使用的是英文版的VSCdode但想显示中文消息的话,可在使用tsc命令时带上“–locale zh-CN”参数:

yarn tsc --locale zh-CN

而对于VSCode中的错误消息,可以在settings配置项中搜索”TypeScript local“选项,把他设置为zh-CN即可。但对于开发而言,笔者还是更推荐使用英文。

27、TypeScript作用域问题

在学习TypeScript的过程中,需要涉及到在不同的文件中尝试TypeScript的不同特性,这种情况下我们就可能会遇到不同文件中会有相同变量名称的情况。在不同文件中,若存在多个相同名称的变量都定义在全局作用域下,TypeScript在编译整个项目时,就会报重复变量的错误。可以把他们装到不同作用域中来解决,例如可以用一个立即执行函数去创建一个单独作用域,把变量都放在函数的内部就不会有错误了。

// 其他文件中也有全局下的a变量时,直接在全局作用域下定义变量a就会报错。
// 可以用IIFE(立即执行函数)解决。
(function(){
    const a = 123;
})()

或者还可以在文件中使用export导出,也即使用es module,这样的话,文件就会作为模块,而模块会有单独的模块作用域:

// 其他文件中也有全局下的a变量时,直接在全局作用域下定义变量a就会报错。
// 还可以用还可以用es module解决。
cosnt a = 123
export {}

需要注意,这里的“{}”只是export的语法,并不是导出一个空对象,而之后我们在这个文件中的所有成员就变成了模块作用域中的局部成员,也就不会出现冲突的问题了。这样的问题在实际开发时一般不会用到,因为在绝大多数情况下我们每个文件都会以模块的形式去工作。但本文中的这些例子难免可能会用到一些重复的变量,所以可以在每个例子中添加一个export {}。

28、TypeScript Object类型

TypeScript中的Object并不单只普通的对象类型,而是泛指所有的非原始类型,也即对象,数组,函数:

expert{}  //确保和其他的例子中没有成员冲突(之后的例子中省略)
const foo: object = {}
const bar: object = []
const far: object = function(){}
const czs: object = 123;  //报错,object不能接收原始值

// 对象的类型限制还可以使用类似对象字面量语法的形式
const obj: { foo: number, bar: string } = { foo: 123, bar: 'string' }

但对于对象的类型限制更专业的方式是使用“接口”,这个我们会在之后讲到。

29、TypeScript数组类型

TypeScript中定义数组的方式和Flow当中几乎完全一致:

// 第一种就是使用Array泛型
const arr1: Array<number> = [1, 2, 3]

// 而第二种类型可能更为常见些,即:类型[]
const arr2: number[] = [1, 2, 3]

// 在TypeScript中使用强类型的优势举例
function sum(...args: number[]){
    // 只需要添加类型注解后就可自动判断每个成员是否数字
    return args.reduce((prev, current) => prev + current, 0)
}
sum(1, 2, 3, 'foo')  //报错

30、TypeScript元组类型

元组类型是一种特殊的数据结构,实际上元组就是明确元素数量,以及每个元素类型的数组,各个元素类型不必要完全相同,在TypeScript中我们可以使用类似数组字面量的语法定义元组类型:

const tuple: [number, string] = [25, 'czs']
// 如果想要访问元组中的某元素,仍然可以使用数字下标的方式去访问
const age = tuple[0]
const name = tuple[1]
// 或者使用数组解构的方式去提取每一个元素
const [age1, name1] = tuple

元组一般可以用来在一个函数中返回多个返回值,而现在这种类型已经越来越常见了。如在react中最新添加的useHooks函数中返回的就是元组类型,在比如使用ES2017中提供的object.entries方法获取对象中的所有键值数组,这里所得到的每个键值就是一个元组,因为他是固定长度的:

Object.entries({
    foo: 123,
    bar: 345,
})

31、TypeScript枚举类型

在开发过程中,经常涉及到要用某几个数值去代表某几种状态,这时就常常要用到枚举类型(Enum)。

// 注意在JS中没有枚举类型,所以需要用对象来模拟枚举类型
const PostStatus = {
    Draft: 0,
    Unpublished: 1,
    Published: 2,
}

// 而在TS中有专门的枚举类型
enum PostStatus {
    Draft = 0,
    Unpublished = 1,
    Published = 2,
}

const post = {
    title: 'Hello TypeScript',
    content: 'TypeScript is a typed superset of JavaScript',
    status: PostStatus.Draft,  // 0
}

如果在枚举中不指定相关的值,则会自动从0开始累加:

enum PostStatus {
    Draft,
    Unpublished,
    Published,
}

const post = {
    title: 'Hello TypeScript',
    content: 'TypeScript is a typed superset of JavaScript',
    status: PostStatus.Draft,  // 0
}

而如果给枚举中的第一个成员指定了具体的值,则后面值都会在这个基础上进行累加:

enum PostStatus {
    Draft = 6,
    Unpublished,
    Published,
}

const post = {
    title: 'Hello TypeScript',
    content: 'TypeScript is a typed superset of JavaScript',
    status: PostStatus.Unpublished,  // 7
}

枚举的值除了可以是数字外,还可以是字符串(字符串枚举):

// 因为字符串无法像数字一样自增加,所以需要手动指定一个值
enum PostStatus {
    Draft = 'aaa',
    Unpublished = 'bbb',
    Published = 'ccc',
}

const post = {
    title: 'Hello TypeScript',
    content: 'TypeScript is a typed superset of JavaScript',
    status: PostStatus.Unpublished,  // bbb
}

枚举类型还会“入侵”我们运行时的代码,即他会影响我们编译后的结果。我们在TypeScript中使用的大多数类型经过编译转换后最终都会被移除掉,因为大多数类型都只是为了在编译中可以做类型检查。但枚举不会,他最终会变成一个双向的键值对对象,即可以通过键去获取值,也可以通过值去获取键。

// ts代码
enum PostStatus {
    Draft,
    Unpublished,
    Published,
}
const post = {
    title: 'Hello TypeScript',
    content: 'TypeScript is a typed superset of JavaScript',
    status: PostStatus.Unpublished,  // 1
}

// 编译后产生的js代码
var PostStatus;
(function (PostStatus) {
    // 双向键值对其实就是把枚举的名称作为对象的键存储枚举的值,再用枚举的值做键存储枚举中的键
    PostStatus[PostStatus["Draft"] = 0] = "Draft";
    PostStatus[PostStatus["Unpublished"] = 1] = "Unpublished";
    PostStatus[PostStatus["Published"] = 2] = "Published";
})(PostStatus || (PostStatus = {}));
const post = {
    title: 'Hello TypeScript',
    content: 'TypeScript is a typed superset of JavaScript',
    status: PostStatus.Unpublished, // 1
};

这样做的目的是为了让我们可以动态根据枚举值去获取枚举的名称,即我们可以通过索引器的方式访问对应的枚举名称:

var PostStatus = {}
console.log(PostStatus['Draft'] = 0)  //0
console.log(PostStatus[PostStatus['Draft'] = 0] = 'Draft')  //Draft
console.log(PostStatus)  //{0: 'Draft', Draft: 0}
console.log(PostStatus.Draft)  //0
console.log(PostStatus[0])  //Draft

如果我们确定我们的代码中不会使用索引器的方式去访问枚举,建议使用使用常量枚举:

// ts代码
const enum PostStatus {
    Draft,
    Unpublished,
    Published,
}
const post = {
    title: 'Hello TypeScript',
    content: 'TypeScript is a typed superset of JavaScript',
    status: PostStatus.Unpublished,  // 1
}

// 编译后产生的js代码
const post = {
    title: 'Hello TypeScript',
    content: 'TypeScript is a typed superset of JavaScript',
    status: 1 /* Unpublished */, // 1      //枚举名称会在之后以注释的方式显示
};

32、TypeScript函数类型

对函数的约束本质上就是对函数的输入输出进行类型限制,输入指的是参数,输出指的是返回值。不过在JavaScript中有两种函数定义的方式,分别是函数声明与函数表达式,所以我们在这里还需要分别了解这两种方式下函数如何进行类型约束:

// 先来看如何在使用函数声明的方式时进行类型约束
function func1(a: number, b: number): string {
    return 'func1'
}
func1(100, 200)  //只能传入两个数字

// 若存在可选的参数,可使用"?:"来指定
function func2(a: number, b?: number): string {
    return 'func2'
}
func2(100)  //可以只传一个数字

// 还可以使用ES2015中新增的参数默认值来实现可选参数
function func3(a: number, b: number = 200): string {
    return 'func3'
}
func3(100)  //可以只传一个数字
// 需要注意的是,无论是可选参数还是默认参数,都必须要出现在参数列表的最后,因为参数都是按照位置传递的

// 如果需要接收任意参数,可以用ES2015的rest操作符“...”
function func4(a: number, b: number = 200, ...rest:number[]): string {
    return 'func4'
}
func4(100, 200, 300, 400)  //可以传任意个数字

再来看使用函数表达式声明时如何进行类型限制:

const func = function(a: number,b: number): string {
    return 'func'
}

注意函数表达式最终是放到变量中的,而接收函数的变量也应该是有类型的,一般TypeScript也都可以根据函数表达式推断出变量的类型。但如果我们把一个函数作为参数,也即回调函数的方式,在这种情况下我们就必须约束这个回调函数的参数,也即形参的类型,这时就可以用类似箭头函数的方式去表示这个参数可以接收什么样的函数:

// func为有两个数字型参数并返回字符串类型的函数表达式(可作为对应的函数参数传递)
const func: (a: number, b: number) => string = function(a: number, b: number): string {
    return 'func'
}

function foo(bar: (a: number, b: number) => string) {
    console.log(bar(100, 200))
}

foo(func)  //func

这种方式在之后定义接口时经常用到,具体请看下面相关内容。

33、TypeScript任意类型

因为JavaScript是弱类型的,很多内置的API本身就支持接收任意类型的参数,而TypeScript是基于JavaScript之上的,所以我们难免会在代码中接收任意类型的数据:

function stringify (value: any) {
    // 因为JSON.stringify可以接收任意类型参数,所以用any去表示接收任意类型的参数
    return JSON.stringify(value)
}
console.log(stringify ('string'))  //"string"
console.log(stringify (100))  //100
console.log(stringify (true))  //true

需要注意的是any类型仍是动态类型,特点与普通的JavaScript变量是一致的,可以接收任意类型的值,且在运行中还可以接收其他类型值。因为any可以存放任意类型值,所以TypeScript不会对any这种类型做类型检查,这也表明我们仍然可以像之前在JavaScript中一样在他之上调用任意成员,语法上都不会报错。也正是因为any类型不会有任何类型检查,所以说他仍然存在类型安全的问题,所以我们不要轻易使用any。但若有时需要兼容一些老的代码,则可能难免会用到。

34、TypeScript隐式类型推断

在TypeScript中,如果我们没有明确通过类型注解去标记一个变量的类型,那TypeScript会根据变量的使用情况去推断该变量的类型,这样一种特性叫做隐式类型推断:

let age = 25  // age被推断为number
age = 'string'  // 再赋值为string时会报错

如果TypeScript无法推断一个变量的具体类型,就会将这个变量标记为any:

let foo  // 声明时并没有去赋值,无法判断该变量具体类型,标记为any
foo = 100
foo = 'string'

虽然隐式类型推断可以帮我们简化一部分代码,但仍然建议大家给每一个变量去添加明确的类型,这样的话更便于我们后期更直观理解代码。

35、TypeScript类型断言

在有些特殊的情况下,TypeScript无法推断出一个变量的具体类型,而我们作为开发者可以根据代码的使用情况可以明确知道变量是什么类型的。

// 假设这个nums来自一个明确的接口
const nums = [110, 120, 119, 112]
// 注意TypeScript会推断res可能是number或undefined,因为find可能会找不到
const res = nums.find(i => i > 0)
// 当TypeScript认为res还可能为undefined时就不能把res当number使用
const square = res * res  // 报错
// 这时我们就可以用断言明确告诉TypeScript相应的类型
const num1 = res as number
// 还可以在变量前使用尖括号"<类型>"去断言
const num2 = <number>res  // 注意在JSX中尖括号方式会和相关标签产生冲突,在JSX下不能使用尖括号去断言

类型断言在很多情况都可以辅助TypeScript去更加明确我们代码中每个成员的类型,需要注意的是,类型断言并不是类型转换,用类型断言并不是把一个类型转换为另一个类型。因为类型转换是代码在运行时的概念,而类型断言只是在编译中的一个概念,在代码编译后断言也不会存在了,所以断言和类型转换是有本质差异的。

PART 4

36、TypeScript接口

接口(Interface)可以把他理解为一种规范或者说是契约,他是一种抽象的概念,可以约定对象的结构,使用一个接口就必须要去遵循该接口的全部约定。在TypeScript中,接口最直观的体现就是可以约定一个对象中具体应该有哪些成员,而且这些成员的类型都是什么样的。

// 在函数中打印了post对象的title和content,所以要求post对象要有title和content属性,但没有明确表达出来(隐性的)
function printPost(post) {
    console.log(post.title)
    console.log(post.content)
}
// 可以用接口去表现出来这种约束
interface Post {
    title: string;  //注意,分号可以省略
    content: string;
}
// 定义接口后可以给对应的类型设置post接口,显式表示该对象要有title与content属性,且都是string类型
function printPostInterface(post: Post) {
    console.log(post.title)
    console.log(post.content)
}
printPostInterface({
    title: 'Hello TypeScript',
    content: "A javascript superset"
})

总的来说,接口的功能就是用来约束对象的结构,一个对象实现一个接口就必须要拥有这个接口中约束的所有成员。

另外,TypeScript中的接口只是为了给有结构的数据做类型约束的,在实际运行阶段,这种接口并没有意义。

// 编译前(TS)
interface Post {
    title: string;
    content: string;
}
function printPostInterface(post: Post) {
    console.log(post.title)
    console.log(post.content)
}
printPostInterface({
    title: 'Hello TypeScript',
    content: "A javascript superset"
})

// 编译后(JS),没有interface的痕迹
function printPostInterface(post) {
    console.log(post.title);
    console.log(post.content);
}
printPostInterface({
    title: 'Hello TypeScript',
    content: "A javascript superset"
});

37、TypeScript接口拼接

对于接口中约定的成员,还有一些特殊的用法:

interface Post {
    title: string;  //注意,分号可以省略
    content: string;
    subtitle?: string;  // 可选成员,即表示该成员可为指定类型或undefined
    readonly summary: string;  // 只读成员,初始化后就不能修改
}
const article: Post = {
    title: 'Hello TypeScript',
    content: 'A javascript superset',
    summary: 'TypeSctipt',
}
article.summary = 'other';  // 修改只读成员会报错

// 动态成员,一般用于有动态成员的对象,例如程序中的缓存对象
interface CacheObj {
    // 注意“key”可以是任意名称,只是代表了属性名称,是一种格式。
    // 第一个string表示成员名的类型,即键的类型为string,而后面的string则设置动态属性的值为string类型。
    [key: string]: string;
}
const cache: CacheObj = {}
// 可以在cache对象上添加任意成员,但必须是string类型的键值
cache.foo = 'George';
cache.bar = 'Sam';

38、TypeScript类的基本使用

类可以说是面向对象编程的一个最重要的概念,简单来讲,类的作用就是就是描述一类具体事物的抽象特征。例如把手机当做一个类,他可以打电话发短信,这个类下还有细分的子类,例如智能手机,除了满足父类的所有特征,还多出一些额外的特征。但我们不能直接使用类,而是去使用属于此类的具体事物,例如一加10,小米11,苹果12等。类比到程序的角度,类也是一样的,他可以用来描述一类具体对象的抽象成员。在ES6以前,JavaScript都是用函数配合原型去模拟实现类,但从ES6开始JavaScript中有了专门的class,而在TypeScript中除了可以使用ECMAScript标准中所有类的功能,还添加了一些额外的功能与用法,比如对类成员有特殊的修饰符,还有一些抽象类的概念。

class Person {
    // 注意在TypeScript中,我们需要明确在类中声明他所拥有的属性,而不能在构造函数中动态添加
    // 这个语法是ES2016(ES7)中定义的
    name: string = 'init value'  //可以为属性添加初始值,但一般情况下还是会在构造函数中动态赋值
    age: number  //注意在TypeScript中,类的属性必须要有一个初始值,可以在定义后赋值或在构造函数中赋值
    // 在构造函数中同样可以使用类型注解的方式去标注每个参数的类型
    constructor(name: string, age: number){
        this.name = name
        this.age = age
    }
    // 可以按照ES6标准中的语法为类声明一个方法,在方法中也可用注解的方式限制参数与返回值类型
    sayHi(msg: string): void {
        // 在方法的内部,同样可以使用this去访问当前实例对象
        console.log(`I am ${this.name}, ${msg}`)
    }
}

const person = new Person('George',25)
person.sayHi('hello')  // I am George, hello

39、TypeScript类的访问修饰符

访问修饰符是TypeScript中类的特殊用法:

class Person {
    // 在TypeScript中,类的访问修饰符默认就是public,可加可不加,但建议还是手动加上,增加代码可读性
    public name: string = 'init value';
    // 在age属性前加private表示age属性是私有属性,只能在当前类内部访问
    private age: number;
    // protected访问修饰符表示允许在当前类内部及其子类中访问对应成员,在外部不能访问
    protected gender: boolean;
    constructor(name: string, age: number){
        this.name = name;
        this.age = age;
        this.gender = true;
    }
    sayHi(msg: string): void {
        console.log(`I am ${this.name}, ${this.age} years old.`);
    }
}

class Studnet extends Person{
    constructor(name: string, age: number){
        super(name, age);  //通过super来调用父类的构造函数
        console.log(this.gender);  //类的protected属性能在类的子类内访问
    }
}

const person = new Person('George',25);
console.log(person.age);  //报错,类的私有属性只能在类内部访问
console.log(person.gender);  //报错,类的protected属性只能在类及其子类内访问

总之,我们可以通过添加类的访问修饰符来设置成员的访问级别。还有需要注意的一点就是对于构造函数的访问修饰符,构造函数的访问修饰符默认也是public,如果被设置为private或protected就不能在外部实例化,也不能在外部被继承。在这种情况下,我们就只能在类的内部添加一个静态方法,然后在静态方法中创建这个类的实例

class Person {
    public name: string = 'init value';
    private age: number;
    protected gender: boolean;
    constructor(name: string, age: number){
        this.name = name;
        this.age = age;
        this.gender = true;
    }
    sayHi(msg: string): void {
        console.log(`I am ${this.name}, ${this.age} years old.`);
    }
}

class Student extends Person{
    // 构造函数的访问修饰符设置为private或protected后就不能在外部被实例化或继承了
    private constructor(name: string, age: number){
        super(name, age);
        console.log(this.gender);
    }
    // static关键词也是在ES6标准中定义的,在类内通过静态方法创建构造函数私有的类
    static create(name: string, age: number){
        // 在create静态方法中用new创建这个实例。
        // 因为在create方法中还属于类内,所以是可以调用到构造函数的
        return new Student(name, age);
    }
}
// 可以在类外部通过静态函数来创建该类的对象
const czs = Student.create('czs', 25);

40、TypeScript类的只读属性

对属性成员除了可以使用private或Protected去控制访问级别,还可以用一个叫做readonly的关键词去把成员设置为只读:

class Person {
    public name: string = 'init value';
    private age: number;
    // 若属性已有访问修饰符,readonly应该跟在后面,
    // readonly修饰的属性只能选择定义时初始化(且不能在构造函数中修改)或在构造函数中初始化
    protected readonly gender: boolean;
    constructor(name: string, age: number){
        this.name = name;
        this.age = age;
        this.gender = true;
    }
    sayHi(msg: string): void {
        console.log(`I am ${this.name}, ${this.age} years old.`);
    }
}

class Student extends Person{
    private constructor(name: string, age: number){
        super(name, age);
        this.gender = false;  // 报错,gender标记为只读属性,不能修改
        console.log(this.gender);
    }
    static create(name: string, age: number){
        return new Student(name, age);
    }
}
const czs = Student.create('czs', 25);

41、TypeScript类与接口

与类相比,接口的概念则更为抽象。例如之前提过的手机,他是一个类,能用来打电话,发短信。但能打电话的不仅仅只有手机,座机也可以打电话,但他不能发短信,也就不属于手机类。这样的话,不同的类与类之间就会有不同的特征,而对于这些公共的特征我们则可用接口去抽象。可以理解为手机能打电话,因为手机实现了能打电话的协议,座机能打电话,因为他也实现了能打电话的协议。这里的协议,在程序中则称之为接口。

// Person与Animal都有eat和run能力,但能力的实现是不同的。
// 这种情况下就可以使用接口约束这两个类的公共能力
interface EatAndRun {
    // 使用函数签名的方式去约束方法的类型,而不做具体实现
    eat(food: string): void;
    run(distance: number): void;
}
// 用implements去实现接口,注意对象中必须要有对应的成员
class Person implements EatAndRun {
    eat(food: string): void{
        console.log(`优雅地进餐:${food}`);
    }
    run(distance: number): void{
        console.log(`直立跑:${distance}`);
    }
}

class Animal implements EatAndRun {
    eat(food: string): void{
        console.log(`呼噜呼噜地吃:${food}`);
    }
    run(distance: number): void{
        console.log(`爬行:${distance}`);
    }
}

注意,在C#和java这些语言中建议我们尽量让每一个接口的定义更为简单,更加细化

// 可以让一个接口约束一个能力,而让一个类同时去实现多个接口来让接口的定义更加简单、细化
interface Eat{
    eat(food: string): void;
}
interface Run{
    run(distance: number): void;
}
// 然后在类的后面同时使用两个接口
class Person implements Eat, Run {
    eat(food: string): void{
        console.log(`优雅地进餐:${food}`);
    }
    run(distance: number): void{
        console.log(`直立跑:${distance}`);
    }
}

class Animal implements Eat, Run {
    eat(food: string): void{
        console.log(`呼噜呼噜地吃:${food}`);
    }
    run(distance: number): void{
        console.log(`爬行:${distance}`);
    }
}

一个建议:大家千万不要把自己框在某个语言或技术上,可以多接触,多学习一些周边的语言或技术,来补充我们的知识体系。例如最近比较主流的前端框架,大都是采用MVVM的思想,而这样的思想,其实最早出现在微软的WPF这样的技术中的。如果我们有更宽的技术面的话,则可以把多家的思想融汇在一起,创造出不一样的产品,所以说要让我们的视野放得更宽一些。

42、TypeScript抽象类

抽象类在某种程度上和接口有点类似,也可以约束子类中必须要有某一个成员,但不同于接口的是,抽象类可以包含一些具体的实现,而接口只能是成员的一个抽象描述。一般比较大型的类,建议大家使用抽象类,例如上文中说到的动物类,其实就应该是抽象的,因为我们所说的动物都只是泛指并不具体,他的下面一定会有更细化的分类。

// 通过在class前加abstract来定义一个抽象类。
// 类被定义成抽象类后,就只能被继承,不能创建实例对象了
abstract class Animal {
    eat(food: string): void {
        console.log(`呼噜呼噜的吃:${food}`);
    }
    // 在抽象类中还可以定义一些抽象方法,通过abstract来修饰
    // 注意抽象方法也不需要方法体,当父类中有抽象方法时,则子类必须要去实现这个方法
    abstract run(distance: number): void;
}
// 继承抽象类
class Dog extends Animal {
    // 实现父类方法
    run(distance: number): void{
        console.log('四脚爬行:', distance);
    }
}
// 当我们用子类创建对象时,就可以同时拥有父类以及自身所拥有的方法。
const dog = new Dog();
dog.eat('大西瓜');  // 呼噜呼噜的吃:大西瓜
dog.run(100);  // 四脚爬行: 100

43、TypeScript泛型

泛型,就是指在定义函数,接口或类的时候,没有指定具体的类型,等到使用时再去指定具体类型的特征。以函数为例,泛型就是在声明这个函数时不去指定具体的类型,等到调用的时候再去传递一个具体的类型。使用泛型可以极大程度上复用我们的代码。

// 定义一个创建指定长度数组的方法
function createNumberArray(length: number, value: number): number[]{
    // 因为Array默认创建的是any类型的数组,所以还需要通过泛型参数去指定其里面元素的类型。
    // 其实这里的Array就是一个泛型类,可以在调用时传递一个泛型参数去指定具体类型
    // 可使用ES2015中数组对象新增的fill方法去填充数组里的元素值
    const arr = Array<number>(length).fill(value)
    return arr
}
const res = createNumberArray(3, 100);
console.log(res);  //[ 100, 100, 100 ]

// 但上面的函数有一个问题,他只能创建number类型的数组,若要创建string类型的话则还需要创建新函数。
// 这时就可以使用泛型,让具体的类型变成参数,调用时再传递进来。
// 注意:定义函数时一般泛型参数都会以大写的T作为名称
function createArray<T>(length: number, value: T): T[]{
    const arr = Array<T>(length).fill(value)
    return arr
}
// 用<类型>的方式传递泛型参数
const resT = createArray<string>(3, 'foo');
console.log(resT);   //[ 'foo', 'foo', 'foo' ]

44、TypeScript类型声明

在项目的实际开发中,我们难免会用到第三方的npm模块,而这些npm模块并不一定都是用TypeScript编写的,所以提供的成员也不会有强类型的体验,这时我们就可以使用类型声明为其指定一个具体的类型。

// 从lodash模块中导入camelCase函数,该函数可以把字符串转成驼峰格式,传入及返回都是string
import { camelCase } from 'lodash'  // 若没有安装lodash可用yarn或npm来进行安装
// 使用declare语句声明函数类型
declare function camelCase(input: string): string
const res = camelCase('hello typed');  // 声明函数类型后就会有相关类型提示了

由于TypeScript的社区非常强大,目前绝大多数比较常用的模块都提供了对应的声明,我们只需要安装对应的类型声明模块即可:

# 注意,类型声明模块应该是一个开发依赖,因为他不会提供任何具体的代码,只是做一些类型声明
# 大多数声明模块都是"@type/模块名"形式
yarn add @type/lodash --dev

若在相关文件夹里找到相关的模块,会发现在模块文件夹中大多数都是“.d.ts”后缀的文件,这种类型的文件就是TypeScript中专门用来做类型声明的文件。目前越来越多的模块已经在内部集成了这种类型的声明文件,很多时候我们甚至都不需要去安装对应的声明模块了,例如“query-string”模块,安装后我们就可以直接在内部找到对应的类型声明文件。


文章作者: 智升
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 智升 !
评论
  目录