TypeScript 是一种由微软开发的自由和开源的编程语言。它是 JavaScript 的一个超集,而且本质上向js基础上添加了可选的静态类型和基于类的面向对象编程。
TypeScript 提供最新的和不断发展的 JavaScript 特性,包括那些来自 2015 年的 ECMAScript 和未来的提案中的特性,比如异步功能和 Decorators,以帮助建立健壮的组件。下图显示了 TypeScript 与 js、ES5、ES2015 和 ES2016 之间的关系:
时间回到2004年,距离HTML上一次版本(4.01)更新已有四年之久。就在这一年,几大知名浏览器厂商(Apple、Mozilla、Opera和Google)集结在了一起,其初衷是想要发展下一代HTML技术,从而使浏览器拥有更优的用户体验。与此同时,新一轮的浏览器大战也悄然拉开了序幕。想要拥有更好的用户体验,那么提供完善的功能与出色的性能这两点缺一不可。浏览器厂商们纷纷开始支持HTML 5中定义的新特性,并且在JavaScript引擎优化方面展开了一场“军备竞赛”。从那之后,JavaScript程序的运行速度有了数十倍的提升,这为使用JavaScript语言开发大型应用程序提供了强有力的支撑。如今,JavaScript不仅能够用在网页端程序的开发,还被用在了服务器端应用的开发上。但有一个不争的事实—JavaScript语言不是为编写大型应用程序而设计的。例如,JavaScript语言在相当长的时间里都缺少对模块的支持。此外,在编写JavaScript代码的过程中也缺少开发者工具的支持。因此,编写并维护大型JavaScript程序是困难的。
微软公司有一部分产品是使用JavaScript语言进行开发和维护的,例如必应地图和Office 365应用等,因此微软也面临同样的问题。在微软技术院士Steve Lucco先生的带领下,微软公司组建了一个数十人的团队开始着手设计和实现一种JavaScript开发工具,用以解决产品开发和维护中遇到的问题。随后,另一位重要成员也加入了这个团队,他就是C#和Turbo Pascal编程语言之父、微软技术院士Anders Hejlsberg先生。该团队决定推出一款新的编程语言来解决JavaScript程序开发与维护过程中所面临的难题。凭借微软公司在编程语言设计与开发方面的丰富经验,在历经了约两年的开发后,这款编程语言终于揭开了它神秘的面纱……
2012年10月1日,微软公司对外发布了这款编程语言的第一个公开预览版v0.8。
2014年4月2日,TypeScript 1.0版本发布;
2016年9月22日,TypeScript 2.0版本发布;
2018年7月30日,TypeScript 3.0版本发布。
2020年8月,TypeScript 4.0版本发布。
当前最新版本为TypeScript 4.7
TypeScript是一门专为开发大规模JavaScript应用程序而设计的编程语言,是JavaScript的超集,包含了JavaScript现有的全部功能,并且使用了与JavaScript相同的语法和语义。因此,JavaScript程序本身已经是合法的TypeScript程序了。
TypeScript代码不能直接运行,它需要先被编译成JavaScript代码然后才能运行。Type-Script编译器(tsc)将负责把TypeScript代码编译为JavaScript代码。不过现在也有可以运行ts的浏览器和系统
正如TypeScript其名,类型系统是它的核心特性。TypeScript为JavaScript添加了静态类型的支持。我们可以使用类型注解为程序添加静态类型信息。
同时,TypeScript中的静态类型是可选的,它不强制要求为程序中的每一部分都添加类型注解。TypeScript支持类型推断的功能,编译器能够自动推断出大部分表达式的类型信息,开发者只需要在程序中添加少量的类型注解便能拥有完整的类型信息。
TypeScript 是开源的,其源代码可以在 Apache 2 License 下从 CodePlex 获得。这个项目由 Microsoft 维持,但是任何人可以通过经 CodePlex 项目页发送反馈,建议和 bugfixes 而做出贡献
TypeScript语言是跨平台的。TypeScript程序经过编译后可以在任意的浏览器、JavaScript宿主环境和操作系统上运行。
方舟开发框架针对不同目的和技术背景的开发者提供了两种开发范式,分别是基于JS扩展的类Web开发范式(简称“类Web开发范式”)和基于TS扩展的声明式开发范式(简称“声明式开发范式”)
声明式开发范式无需JS Framework进行页面DOM管理,渲染更新链路更为精简,占用内存更少,因此更推荐开发者选用声明式开发范式来搭建应用UI界面。
学习一门编程语言,我觉得比较快捷的方式就是动手练习。理解语法,动手实践,在实践中揣摩。
我们只要为了学习openHarmony,可以直接使用线上的 TypeScript Playground 来学习新的语法或新特性,不需要安装Typescript 编译环境/
TypeScript Playground:https://www.typescriptlang.org/play/
也可以使用Node.js 包来安装
npm install -g typescript
另外 VSCode 也是一个不错的选择,大家可以参考网上资料安装配置一下啊
在Playground输入栏输入代码
const hello = "Hello World"
console.log(hello)
点击运行,可以看到日志信息
此例第1行声明了一个名为hello的常量,它的值为字符串“hello,world”。第3行调用了标准的控制台API来打印hello的值。
在计算机程序中,一个变量使用给定的符号名与内存中的某个存储地址相关联并且可以容纳某个值。变量的值可以在程序的执行过程中改变。当我们操作变量时,实际上操作的是变量对应的存储地址中的数据。因此,在程序中可以使用变量来存储和操作数据。在typescript中变量也可以理解为一系列值及可以对其执行的操作。
▪允许包含字母、数字、下划线和美元符号“$”。
▪允许包含Unicode转义序列,如“\u0069\u{6F}”。
▪仅允许使用字母、Unicode转义序列、下划线和美元符号($)作为第一个字符,不允许使用数字作为第一个字符。
▪标识符区分大小写。
▪不允许使用保留字作为标识符。
有三种声明变量的方式,它们分别使用以下关键字:
▪var 在声明变量时,可以为变量赋予一个初始值。若变量未初始化,则其默认值为undefined
▪let 在声明变量时,可以为变量赋予一个初始值。若变量未初始化,则其默认值为undefined
▪const 用于定义一个常量,在定义时必须设置一个初始值。const声明在初始化之后不允许重新赋值
其中,var声明是在ECMAScript 2015之前就已经支持的变量声明方式,而let和const声明则是在ECMAScript 2015中新引入的变量声明方式。在很多编程语言中都提供了对块级作用域的支持,它能够帮助开发者避免一些错误。使用let和const关键字能够声明具有块级作用域的变量,这弥补了var声明的不足。因此,推荐在程序中使用let和const声明来代替var声明。
let isDone: boolean = false;
// ES5:var isDone = false;
let count: number = 10;
// ES5:var count = 10;
let name: string = "Semliker";
// ES5:var name = 'Semlinker';
使用枚举我们可以定义一些带名字的常量。 使用枚举可以清晰地表达意图或创建一组有区别的用例。 TypeScript 支持数字的和基于字符串的枚举。
数字枚举
enum Direction {
NORTH,
SOUTH,
EAST,
WEST,
}
let dir: Direction = Direction.NORTH;
默认情况下,NORTH 的初始值为 0,其余的成员会从 1 开始自动增长。换句话说,Direction.SOUTH 的值为 1,Direction.EAST 的值为 2,Direction.WEST 的值为 3。上面的枚举示例代码经过编译后会生成以下代码:
"use strict";
var Direction;
(function (Direction) {
Direction[(Direction["NORTH"] = 0)] = "NORTH";
Direction[(Direction["SOUTH"] = 1)] = "SOUTH";
Direction[(Direction["EAST"] = 2)] = "EAST";
Direction[(Direction["WEST"] = 3)] = "WEST";
})(Direction || (Direction = {}));
var dir = Direction.NORTH;
当然我们也可以设置 NORTH 的初始值,比如:
enum Direction {
NORTH = 3,
SOUTH,
EAST,
WEST,
}
字符串枚举
在 TypeScript 2.4 版本,允许我们使用字符串枚举。在一个字符串枚举里,每个成员都必须用字符串字面量,或另外一个字符串枚举成员进行初始化。
enum Direction {
NORTH = "NORTH",
SOUTH = "SOUTH",
EAST = "EAST",
WEST = "WEST",
}
异构枚举
异构枚举的成员值是数字和字符串的混合:
enum Enum {
A,
B,
C = "C",
D = "D",
E = 8,
F,
}
在 TypeScript 中,任何类型都可以被归为 any 类型。这让 any 类型成为了类型系统的顶级类型(也被称作全局超级类型)。
let notSure: any = 666;
notSure = "Semlinker";
notSure = false;
any
类型本质上是类型系统的一个逃逸舱。作为开发者,这给了我们很大的自由:TypeScript 允许我们对 any
类型的值执行任何操作,而无需事先执行任何形式的检查。比如:
let value: any;
value.foo.bar; // OK
value.trim(); // OK
value(); // OK
new value(); // OK
value[0][1]; // OK
在许多场景下,这太宽松了。使用 any
类型,可以很容易地编写类型正确但在运行时有问题的代码。如果我们使用 any
类型,就无法使用 TypeScript 提供的大量的保护机制。为了解决 any
带来的问题,TypeScript 3.0 引入了 unknown
类型。
就像所有类型都可以赋值给 any
,所有类型也都可以赋值给 unknown
。这使得 unknown
成为 TypeScript 类型系统的另一种顶级类型(另一种是 any
)。下面我们来看一下 unknown
类型的使用示例:
let value: unknown;
value = true; // OK
value = 42; // OK
value = "Hello World"; // OK
value = []; // OK
value = {}; // OK
value = Math.random; // OK
value = null; // OK
value = undefined; // OK
value = new TypeError(); // OK
value = Symbol("type"); // OK
对 value
变量的所有赋值都被认为是类型正确的。但是,当我们尝试将类型为 unknown
的值赋值给其他类型的变量时会发生什么?
let value: unknown;
let value1: unknown = value; // OK
let value2: any = value; // OK
let value3: boolean = value; // Error
let value4: number = value; // Error
let value5: string = value; // Error
let value6: object = value; // Error
let value7: any[] = value; // Error
let value8: Function = value; // Error
unknown
类型只能被赋值给 any
类型和 unknown
类型本身。直观地说,这是有道理的:只有能够保存任意类型值的容器才能保存 unknown
类型的值。毕竟我们不知道变量 value
中存储了什么类型的值。
现在让我们看看当我们尝试对类型为 unknown
的值执行操作时会发生什么。以下是我们在之前 any
章节看过的相同操作:
let value: unknown;
value.foo.bar; // Error
value.trim(); // Error
value(); // Error
new value(); // Error
value[0][1]; // Error
将 value
变量类型设置为 unknown
后,这些操作都不再被认为是类型正确的。通过将 any
类型改变为 unknown
类型,我们已将允许所有更改的默认设置,更改为禁止任何更改。
某种程度上来说,void 类型像是与 any 类型相反,它表示没有任何类型。当一个函数没有返回值时,你通常会见到其返回值类型是 void:
// 声明函数返回值为void
function warnUser(): void {
console.log("This is my warning message");
}
以上代码编译生成的 ES5 代码如下:
"use strict";
function warnUser() {
console.log("This is my warning message");
}
需要注意的是,声明一个 void 类型的变量没有什么作用,因为它的值只能为 undefined
或 null
:
let unusable: void = undefined;
TypeScript 里,undefined
和 null
两者有各自的类型分别为 undefined
和 null
。
let u: undefined = undefined;
let n: null = null;
默认情况下 null
和 undefined
是所有类型的子类型。 就是说你可以把 null
和 undefined
赋值给 number
类型的变量。然而,如果你指定了--strictNullChecks
标记,null
和 undefined
只能赋值给 void
和它们各自的类型。
never
类型表示的是那些永不存在的值的类型。 例如,never
类型是那些总是会抛出异常或根本就不会有返回值的函数表达式或箭头函数表达式的返回值类型。
// 返回never的函数必须存在无法达到的终点
function error(message: string): never {
throw new Error(message);
}
function infiniteLoop(): never {
while (true) {}
}
在 TypeScript 中,可以利用 never 类型的特性来实现全面性检查,具体示例如下:
type Foo = string | number;
function controlFlowAnalysisWithNever(foo: Foo) {
if (typeof foo === "string") {
// 这里 foo 被收窄为 string 类型
} else if (typeof foo === "number") {
// 这里 foo 被收窄为 number 类型
} else {
// foo 在这里是 never
const check: never = foo;
}
}
数组对象是使用单独的变量名来存储一系列的值。
数组非常常用。
假如你有一组数据(例如:网站名字),存在单独变量如下所示:
var site1="Google";
var site2="Runoob";
var site3="Taobao";
如果有 10 个、100 个这种方式就变的很不实用,这时我们可以使用数组来解决:
var sites:string[];
sites = ["Google","Runoob","Taobao"]
TypeScript 声明数组的语法格式如下所示:
var array_name[:datatype]; //声明 array_name = [val1,val2,valn..] //初始化
或者直接在声明时初始化:
var array_name[:datatype] = [val1,val2…valn]
如果数组声明时未设置类型,则会被认为是 any 类型,在初始化时根据第一个元素的类型来推断数组的类型。
众所周知,数组一般由同种类型的值组成,但有时我们需要在单个变量中存储不同类型的值,这时候我们就可以使用元组。在 JavaScript 中是没有元组的,元组是 TypeScript 中特有的类型,其工作方式类似于数组。
元组可用于定义具有有限数量的未命名属性的类型。每个属性都有一个关联的类型。使用元组时,必须提供每个属性的值。为了更直观地理解元组的概念,我们来看一个具体的例子:
let tupleType: [string, boolean];
tupleType = ["Semlinker", true];
在上面代码中,我们定义了一个名为 tupleType
的变量,它的类型是一个类型数组 [string, boolean]
,然后我们按照正确的类型依次初始化 tupleType 变量。与数组一样,我们可以通过下标来访问元组中的元素:
console.log(tupleType[0]); // Semlinker
console.log(tupleType[1]); // true
在元组初始化的时候,如果出现类型不匹配的话,比如:
tupleType = [true, "Semlinker"];
此时,TypeScript 编译器会提示以下错误信息:
[0]: Type 'true' is not assignable to type 'string'.
[1]: Type 'string' is not assignable to type 'boolean'.
很明显是因为类型不匹配导致的。在元组初始化的时候,我们还必须提供每个属性的值,不然也会出现错误,比如:
tupleType = ["Semlinker"];
此时,TypeScript 编译器会提示以下错误信息:
Property '1' is missing in type '[string]' but required in type '[string, boolean]'.
Map 对象保存键值对,并且能够记住键的原始插入顺序。
任何值(对象或者原始值) 都可以作为一个键或一个值。
Map 是 ES6 中引入的一种新的数据结构,可以参考 ES6 Map 与 Set。
TypeScript 使用 Map 类型和 new 关键字来创建 Map:
let myMap = new Map();
初始化 Map,可以以数组的格式来传入键值对:
let myMap = new Map([ ["key1", "value1"], ["key2", "value2"] ]);
联合类型通常与 null
或 undefined
一起使用:
const sayHello = (name: string | undefined) => {
/* ... */
};
例如,这里 name
的类型是 string | undefined
意味着可以将 string
或 undefined
的值传递给sayHello
函数。
sayHello("Semlinker");
sayHello(undefined);
通过这个示例,你可以凭直觉知道类型 A 和类型 B 联合后的类型是同时接受 A 和 B 值的类型。
TypeScript 可辨识联合(Discriminated Unions)类型,也称为代数数据类型或标签联合类型。它包含 3 个要点:可辨识、联合类型和类型守卫。
这种类型的本质是结合联合类型和字面量类型的一种类型保护方法。如果一个类型是多个类型的联合类型,且多个类型含有一个公共属性,那么就可以利用这个公共属性,来创建不同的类型保护区块。
可辨识
可辨识要求联合类型中的每个元素都含有一个单例类型属性,比如:
enum CarTransmission {
Automatic = 200,
Manual = 300
}
interface Motorcycle {
vType: "motorcycle"; // discriminant
make: number; // year
}
interface Car {
vType: "car"; // discriminant
transmission: CarTransmission
}
interface Truck {
vType: "truck"; // discriminant
capacity: number; // in tons
}
在上述代码中,我们分别定义了 Motorcycle
、 Car
和 Truck
三个接口,在这些接口中都包含一个 vType
属性,该属性被称为可辨识的属性,而其它的属性只跟特性的接口相关。
联合类型
基于前面定义了三个接口,我们可以创建一个 Vehicle
联合类型:
type Vehicle = Motorcycle | Car | Truck;
现在我们就可以开始使用 Vehicle
联合类型,对于 Vehicle
类型的变量,它可以表示不同类型的车辆。
类型守卫
下面我们来定义一个 evaluatePrice
方法,该方法用于根据车辆的类型、容量和评估因子来计算价格,具体实现如下:
const EVALUATION_FACTOR = Math.PI;
function evaluatePrice(vehicle: Vehicle) {
return vehicle.capacity * EVALUATION_FACTOR;
}
const myTruck: Truck = { vType: "truck", capacity: 9.5 };
evaluatePrice(myTruck);
对于以上代码,TypeScript 编译器将会提示以下错误信息:
Property 'capacity' does not exist on type 'Vehicle'.
Property 'capacity' does not exist on type 'Motorcycle'.
原因是在 Motorcycle 接口中,并不存在 capacity
属性,而对于 Car 接口来说,它也不存在 capacity
属性。那么,现在我们应该如何解决以上问题呢?这时,我们可以使用类型守卫。下面我们来重构一下前面定义的 evaluatePrice
方法,重构后的代码如下:
function evaluatePrice(vehicle: Vehicle) {
switch(vehicle.vType) {
case "car":
return vehicle.transmission * EVALUATION_FACTOR;
case "truck":
return vehicle.capacity * EVALUATION_FACTOR;
case "motorcycle":
return vehicle.make * EVALUATION_FACTOR;
}
}
在以上代码中,我们使用 switch
和 case
运算符来实现类型守卫,从而确保在 evaluatePrice
方法中,我们可以安全地访问 vehicle
对象中的所包含的属性,来正确的计算该车辆类型所对应的价格。
类型别名用来给一个类型起个新名字。
type Message = string | string[];
let greet = (message: Message) => {
// ...
};
TypeScript 交叉类型是将多个类型合并为一个类型。 这让我们可以把现有的多种类型叠加到一起成为一种类型,它包含了所需的所有类型的特性。
interface IPerson {
id: string;
age: number;
}
interface IWorker {
companyId: string;
}
type IStaff = IPerson & IWorker;
const staff: IStaff = {
id: 'E1006',
age: 33,
companyId: 'EFT'
};
console.dir(staff)
在上面示例中,我们首先为 IPerson 和 IWorker 类型定义了不同的成员,然后通过 &
运算符定义了 IStaff 交叉类型,所以该类型同时拥有 IPerson 和 IWorker 这两种类型的成员。
有时候你会遇到这样的情况,你会比 TypeScript 更了解某个值的详细信息。通常这会发生在你清楚地知道一个实体具有比它现有类型更确切的类型。
通过类型断言这种方式可以告诉编译器,“相信我,我知道自己在干什么”。类型断言好比其他语言里的类型转换,但是不进行特殊的数据检查和解构。它没有运行时的影响,只是在编译阶段起作用。
类型断言有两种形式:
let someValue: any = "this is a string";
let strLength: number = (someValue).length;
let someValue: any = "this is a string";
let strLength: number = (someValue as string).length;
A type guard is some expression that performs a runtime check that guarantees the type in some scope. —— TypeScript 官方文档
类型保护是可执行运行时检查的一种表达式,用于确保该类型在一定的范围内。换句话说,类型保护可以保证一个字符串是一个字符串,尽管它的值也可以是一个数值。类型保护与特性检测并不是完全不同,其主要思想是尝试检测属性、方法或原型,以确定如何处理值。目前主要有四种的方式来实现类型保护:
interface Admin {
name: string;
privileges: string[];
}
interface Employee {
name: string;
startDate: Date;
}
type UnknownEmployee = Employee | Admin;
function printEmployeeInformation(emp: UnknownEmployee) {
console.log("Name: " + emp.name);
if ("privileges" in emp) {
console.log("Privileges: " + emp.privileges);
}
if ("startDate" in emp) {
console.log("Start Date: " + emp.startDate);
}
}
function padLeft(value: string, padding: string | number) {
if (typeof padding === "number") {
return Array(padding + 1).join(" ") + value;
}
if (typeof padding === "string") {
return padding + value;
}
throw new Error(`Expected string or number, got '${padding}'.`);
}
typeof
类型保护只支持两种形式:typeof v === "typename"
和 typeof v !== typename
,"typename"
必须是 "number"
, "string"
, "boolean"
或 "symbol"
。 但是 TypeScript 并不会阻止你与其它字符串比较,语言不会把那些表达式识别为类型保护。
interface Padder {
getPaddingString(): string;
}
class SpaceRepeatingPadder implements Padder {
constructor(private numSpaces: number) {}
getPaddingString() {
return Array(this.numSpaces + 1).join(" ");
}
}
class StringPadder implements Padder {
constructor(private value: string) {}
getPaddingString() {
return this.value;
}
}
let padder: Padder = new SpaceRepeatingPadder(6);
if (padder instanceof SpaceRepeatingPadder) {
// padder的类型收窄为 'SpaceRepeatingPadder'
}
function isNumber(x: any): x is number {
return typeof x === "number";
}
function isString(x: any): x is string {
return typeof x === "string";
}
运算符用于执行程序代码运算,会针对一个以上操作数项目来进行运算。
考虑以下计算:
7 + 5 = 12
以上实例中 7、5 和 12 是操作数。
运算符 + 用于加值。
运算符 = 用于赋值。
TypeScript 主要包含以下几种运算:
条件语句用于基于不同的条件来执行不同的动作。
TypeScript 条件语句是通过一条或多条语句的执行结果(True 或 False)来决定执行的代码块。
可以通过下图来简单了解条件语句的执行过程:
通常在写代码时,您总是需要为不同的决定来执行不同的动作。您可以在代码中使用条件语句来完成该任务。
在 TypeScript 中,我们可使用以下条件语句:
有的时候,我们可能需要多次执行同一块代码。一般情况下,语句是按顺序执行的:函数中的第一个语句先执行,接着是第二个语句,依此类推。
编程语言提供了更为复杂执行路径的多种控制结构。
循环语句允许我们多次执行一个语句或语句组,下面是大多数编程语言中循环语句的流程图:
函数是一组一起执行一个任务的语句。
您可以把代码划分到不同的函数中。如何划分代码到不同的函数中是由您来决定的,但在逻辑上,划分通常是根据每个函数执行一个特定的任务来进行的。
函数声明告诉编译器函数的名称、返回类型和参数。函数定义提供了函数的实际主体。
函数就是包裹在花括号中的代码块,前面使用了关键词 function:
语法格式如下所示:
function function_name() { // 执行代码 }
函数只有通过调用才可以执行函数内的代码。
语法格式如下所示:
function_name()
有时,我们会希望函数将执行的结果返回到调用它的地方。
通过使用 return 语句就可以实现。
在使用 return 语句时,函数会停止执行,并返回指定的值。
语法格式如下所示:
function function_name():return_type { // 语句 return value; }
return_type 是返回值的类型。
return 关键词后跟着要返回的结果。
一般情况下,一个函数只有一个 return 语句。
返回值的类型需要与函数定义的返回类型(return_type)一致。
在调用函数时,您可以向其传递值,这些值被称为参数。
这些参数可以在函数中使用。
您可以向函数发送多个参数,每个参数使用逗号 , 分隔:
语法格式如下所示:
function func_name( param1 [:datatype], param2 [:datatype]) { }
param1、param2 为参数名。
datatype 为参数类型。
在 TypeScript 函数里,如果我们定义了参数,则我们必须传入这些参数,除非将这些参数设置为可选,可选参数使用问号标识 ?。
我们也可以设置参数的默认值,这样在调用函数的时候,如果不传入该参数的值,则使用默认参数,语法格式为:
function function_name(param1[:type],param2[:type] = default_value) { }
注意:参数不能同时设置为可选和默认。
// 可选参数
function createUserId(name: string, age?: number, id: number): string {
return name + id;
}
// 默认参数
function createUserId(
name: string = "Semlinker",
age?: number,
id: number
): string {
return name + id;
}
有一种情况,我们不知道要向函数传入多少个参数,这时候我们就可以使用剩余参数来定义。
剩余参数语法允许我们将一个不确定数量的参数作为一个数组传入。
function push(array, ...items) {
items.forEach(function (item) {
array.push(item);
});
}
let a = [];
push(a, 1, 2, 3);
函数重载或方法重载是使用相同名称和不同参数数量或类型创建多个方法的一种能力。要解决前面遇到的问题,方法就是为同一个函数提供多个函数类型定义来进行函数重载,编译器会根据这个列表去处理函数的调用。
function add(a: number, b: number): number;
function add(a: string, b: string): string;
function add(a: string, b: number): string;
function add(a: number, b: string): string;
function add(a: Combinable, b: Combinable) {
if (typeof a === "string" || typeof b === "string") {
return a.toString() + b.toString();
}
return a + b;
}
在以上代码中,我们为 add 函数提供了多个函数类型定义,从而实现函数的重载。之后,可恶的错误消息又消失了,因为这时 result 变量的类型是 string
类型。在 TypeScript 中除了可以重载普通函数之外,我们还可以重载类中的成员方法。
方法重载是指在同一个类中方法同名,参数不同(参数类型不同、参数个数不同或参数个数相同时参数的先后顺序不同),调用时根据实参的形式,选择与它匹配的方法执行操作的一种技术。所以类中成员方法满足重载的条件是:在同一个类中,方法名相同且参数列表不同。下面我们来举一个成员方法重载的例子:
class Calculator {
add(a: number, b: number): number;
add(a: string, b: string): string;
add(a: string, b: number): string;
add(a: number, b: string): string;
add(a: Combinable, b: Combinable) {
if (typeof a === "string" || typeof b === "string") {
return a.toString() + b.toString();
}
return a + b;
}
}
const calculator = new Calculator();
const result = calculator.add("Semlinker", " Kakuqo");
这里需要注意的是,当 TypeScript 编译器处理函数重载时,它会查找重载列表,尝试使用第一个重载定义。 如果匹配的话就使用这个。 因此,在定义重载的时候,一定要把最精确的定义放在最前面。另外在 Calculator 类中,add(a: Combinable, b: Combinable){ }
并不是重载列表的一部分,因此对于 add 成员方法来说,我们只定义了四个重载方法。
对象是包含一组键值对的实例。 值可以是标量、函数、数组、对象等,如下实例:
var object_name = {
key1: "value1", // 标量
key2: "value",
key3: function() {
// 函数
},
key4:["content1", "content2"] //集合
}
在JavaScript中存在这样一种说法,那就是“一切皆为对象”。有这种说法是因为JavaScript中的绝大多数值都可以使用对象来表示。例如,函数、数组和对象字面量等本质上都是对象。对于原始数据类型,如String类型,JavaScript也提供了相应的构造函数来创建能够表示原始值的对象。例如,下例中使用内置的String构造函数创建了一个表示字符串的对象,示例如下:
const hello = new String("hello world")
鸭子类型(英语:duck typing)是动态类型的一种风格,是多态(polymorphism)的一种形式。
在这种风格中,一个对象有效的语义,不是由继承自特定的类或实现特定的接口,而是由"当前方法和属性的集合"决定。
可以这样表述:
"当看到一只鸟走起来像鸭子、游泳起来像鸭子、叫起来也像鸭子,那么这只鸟就可以被称为鸭子。"
在鸭子类型中,关注点在于对象的行为能做什么,而不是关注对象所属的类型。例如,在不使用鸭子类型的语言中,我们可以编写一个函数,它接受一个类型为"鸭子"的对象,并调用它的"走"和"叫"方法。在使用鸭子类型的语言中,这样的一个函数可以接受一个任意类型的对象,并调用它的"走"和"叫"方法。如果这些需要被调用的方法不存在,那么将引发一个运行时错误。任何拥有这样的正确的"走"和"叫"方法的对象都可被函数接受的这种行为引出了以上表述,这种决定类型的方式因此得名。
interface IPoint {
x:number
y:number
}
function addPoints(p1:IPoint,p2:IPoint):IPoint {
var x = p1.x + p2.x
var y = p1.y + p2.y
return {x:x,y:y}
}
// 正确
var newPoint = addPoints({x:3,y:4},{x:5,y:1})
// 错误
var newPoint2 = addPoints({x:1},{x:4,y:3})
在面向对象语言中,接口是一个很重要的概念,它是对行为的抽象,而具体如何行动需要由类去实现。
interface interface_name {
}
TypeScript 中的接口是一个非常灵活的概念,除了可用于对类的一部分行为进行抽象以外,也常用于对「对象的形状(Shape)」进行描述。
对象的形状
interface Person {
name: string;
age: number;
}
let Semlinker: Person = {
name: "Semlinker",
age: 33,
};
interface Person {
readonly name: string;
age?: number;
}
只读属性用于限制只能在对象刚刚创建的时候修改其值。此外 TypeScript 还提供了 ReadonlyArray
类型,它与 Array
相似,只是把所有可变方法去掉了,因此可以确保数组创建后再也不能被修改。
let a: number[] = [1, 2, 3, 4];
let ro: ReadonlyArray = a;
ro[0] = 12; // error!
ro.push(5); // error!
ro.length = 100; // error!
a = ro; // error!
TypeScript 是面向对象的 JavaScript。
类描述了所创建的对象共同的属性和方法。
TypeScript 支持面向对象的所有特性,比如 类、接口等。
TypeScript 类定义方式如下:
class class_name { // 类作用域 }
定义类的关键字为 class,后面紧跟类名,类可以包含以下几个模块(类的数据成员):
字段 − 字段是类里面声明的变量。字段表示对象的有关数据。
构造函数 − 类实例化时调用,可以为类的对象分配内存。
方法 − 方法为对象要执行的操作。
在面向对象语言中,类是一种面向对象计算机编程语言的构造,是创建对象的蓝图,描述了所创建的对象共同的属性和方法。
在 TypeScript 中,我们可以通过 Class
关键字来定义一个类:
class Greeter {
// 静态属性
static cname: string = "Greeter";
// 成员属性
greeting: string;
// 构造函数 - 执行初始化操作
constructor(message: string) {
this.greeting = message;
}
// 静态方法
static getClassName() {
return "Class name is Greeter";
}
// 成员方法
greet() {
return "Hello, " + this.greeting;
}
}
let greeter = new Greeter("world");
那么成员属性与静态属性,成员方法与静态方法有什么区别呢?这里无需过多解释,我们直接看一下以下编译生成的 ES5 代码:
"use strict";
var Greeter = /** @class */ (function () {
// 构造函数 - 执行初始化操作
function Greeter(message) {
this.greeting = message;
}
// 静态方法
Greeter.getClassName = function () {
return "Class name is Greeter";
};
// 成员方法
Greeter.prototype.greet = function () {
return "Hello, " + this.greeting;
};
// 静态属性
Greeter.cname = "Greeter";
return Greeter;
}());
var greeter = new Greeter("world");
在 TypeScript 中,我们可以通过 getter
和 setter
方法来实现数据的封装和有效性校验,防止出现异常数据。
let passcode = "Hello TypeScript";
class Employee {
private _fullName: string;
get fullName(): string {
return this._fullName;
}
set fullName(newName: string) {
if (passcode && passcode == "Hello TypeScript") {
this._fullName = newName;
} else {
console.log("Error: Unauthorized update of employee!");
}
}
}
let employee = new Employee();
employee.fullName = "Semlinker";
if (employee.fullName) {
console.log(employee.fullName);
}
继承 (Inheritance) 是一种联结类与类的层次模型。指的是一个类(称为子类、子接口)继承另外的一个类(称为父类、父接口)的功能,并可以增加它自己的新功能的能力,继承是类与类或者接口与接口之间最常见的关系。
继承是一种 is-a 关系:
在 TypeScript 中,我们可以通过 extends
关键字来实现继承:
class Animal {
name: string;
constructor(theName: string) {
this.name = theName;
}
move(distanceInMeters: number = 0) {
console.log(`${this.name} moved ${distanceInMeters}m.`);
}
}
class Snake extends Animal {
constructor(name: string) {
super(name);
}
move(distanceInMeters = 5) {
console.log("Slithering...");
super.move(distanceInMeters);
}
}
let sam = new Snake("Sammy the Python");
sam.move();
在 TypeScript 3.8 版本就开始支持ECMAScript 私有字段,使用方式如下:
class Person {
#name: string;
constructor(name: string) {
this.#name = name;
}
greet() {
console.log(`Hello, my name is ${this.#name}!`);
}
}
let semlinker = new Person("Semlinker");
semlinker.#name;
// ~~~~~
// Property '#name' is not accessible outside class 'Person'
// because it has a private identifier.
与常规属性(甚至使用 private
修饰符声明的属性)不同,私有字段要牢记以下规则:
私有字段以 #
字符开头,有时我们称之为私有名称;
每个私有字段名称都唯一地限定于其包含的类;
不能在私有字段上使用 TypeScript 可访问性修饰符(如 public 或 private);
私有字段不能在包含的类之外访问,甚至不能被检测到。
软件工程中,我们不仅要创建一致的定义良好的 API,同时也要考虑可重用性。 组件不仅能够支持当前的数据类型,同时也能支持未来的数据类型,这在创建大型系统时为你提供了十分灵活的功能。
在像 C# 和 Java 这样的语言中,可以使用泛型来创建可重用的组件,一个组件可以支持多种类型的数据。 这样用户就可以以自己的数据类型来使用组件。
设计泛型的关键目的是在成员之间提供有意义的约束,这些成员可以是:类的实例成员、类的方法、函数参数和函数返回值。
泛型(Generics)是允许同一个函数接受不同类型参数的一种模板。相比于使用 any 类型,使用泛型来创建可复用的组件要更好,因为泛型会保留参数类型。
interface GenericIdentityFn {
(arg: T): T;
}
class GenericNumber {
zeroValue: T;
add: (x: T, y: T) => T;
}
let myGenericNumber = new GenericNumber();
myGenericNumber.zeroValue = 0;
myGenericNumber.add = function (x, y) {
return x + y;
};
对刚接触 TypeScript 泛型的小伙伴来说,看到 T 和 E,还有 K 和 V 这些泛型变量时,估计会一脸懵逼。其实这些大写字母并没有什么本质的区别,只不过是一个约定好的规范而已。也就是说使用大写字母 A-Z 定义的类型变量都属于泛型,把 T 换成 A,也是一样的。下面我们介绍一下一些常见泛型变量代表的意思:
T(Type):表示一个 TypeScript 类型
K(Key):表示对象中的键类型
V(Value):表示对象中的值类型
E(Element):表示元素类型
为了方便开发者 TypeScript 内置了一些常用的工具类型,比如 Partial、Required、Readonly、Record 和 ReturnType 等。出于篇幅考虑,这里我们只简单介绍 Partial 工具类型。不过在具体介绍之前,我们得先介绍一些相关的基础知识,方便读者自行学习其它的工具类型。
在 TypeScript 中,typeof
操作符可以用来获取一个变量声明或对象的类型。
interface Person {
name: string;
age: number;
}
const sem: Person = { name: 'semlinker', age: 30 };
type Sem= typeof sem; // -> Person
function toArray(x: number): Array {
return [x];
}
type Func = typeof toArray; // -> (x: number) => number[]
keyof
操作符可以用来一个对象中的所有 key 值:
interface Person {
name: string;
age: number;
}
type K1 = keyof Person; // "name" | "age"
type K2 = keyof Person[]; // "length" | "toString" | "pop" | "push" | "concat" | "join"
type K3 = keyof { [x: string]: Person }; // string | number
in
用来遍历枚举类型:
type Keys = "a" | "b" | "c"
type Obj = {
[p in Keys]: any
} // -> { a: any, b: any, c: any }
在条件类型语句中,可以用 infer
声明一个类型变量并且对它进行使用。
type ReturnType = T extends (
...args: any[]
) => infer R ? R : any;
以上代码中 infer R
就是声明一个变量来承载传入函数签名的返回值类型,简单说就是用它取到函数返回值的类型方便之后使用。
有时候我们定义的泛型不想过于灵活或者说想继承某些类等,可以通过 extends 关键字添加泛型约束。
interface ILengthwise {
length: number;
}
function loggingIdentity(arg: T): T {
console.log(arg.length);
return arg;
}
现在这个泛型函数被定义了约束,因此它不再是适用于任意类型:
loggingIdentity(3); // Error, number doesn't have a .length property
这时我们需要传入符合约束类型的值,必须包含必须的属性:
loggingIdentity({length: 10, value: 3});
Partial
的作用就是将某个类型里的属性全部变为可选项 ?
。
定义:
/**
* node_modules/typescript/lib/lib.es5.d.ts
* Make all properties in T optional
*/
type Partial = {
[P in keyof T]?: T[P];
};
在以上代码中,首先通过 keyof T
拿到 T
的所有属性名,然后使用 in
进行遍历,将值赋给 P
,最后通过 T[P]
取得相应的属性值。中间的 ?
号,用于将所有属性变为可选。
示例:
interface Todo {
title: string;
description: string;
}
function updateTodo(todo: Todo, fieldsToUpdate: Partial) {
return { ...todo, ...fieldsToUpdate };
}
const todo1 = {
title: "organize desk",
description: "clear clutter",
};
const todo2 = updateTodo(todo1, {
description: "throw out trash",
});
在上面的 updateTodo
方法中,我们利用 Partial
工具类型,定义 fieldsToUpdate
的类型为 Partial
,即:
{
title?: string | undefined;
description?: string | undefined;
}
它是一个表达式
该表达式被执行后,返回一个函数
函数的入参分别为 target、name 和 descriptor
执行该函数后,可能返回 descriptor 对象,用于配置 target 对象
类装饰器(Class decorators)
属性装饰器(Property decorators)
方法装饰器(Method decorators)
参数装饰器(Parameter decorators)
类装饰器声明:
declare type ClassDecorator = (
target: TFunction
) => TFunction | void;
类装饰器顾名思义,就是用来装饰类的。它接收一个参数:
target: TFunction - 被装饰的类
看完第一眼后,是不是感觉都不好了。没事,我们马上来个例子:
function Greeter(target: Function): void {
target.prototype.greet = function (): void {
console.log("Hello Semlinker!");
};
}
@Greeter
class Greeting {
constructor() {
// 内部实现
}
}
let myGreeting = new Greeting();
myGreeting.greet(); // console output: 'Hello Semlinker!';
上面的例子中,我们定义了 Greeter
类装饰器,同时我们使用了 @Greeter
语法糖,来使用装饰器。
友情提示:读者可以直接复制上面的代码,在 TypeScript Playground 中运行查看结果。
有的读者可能想问,例子中总是输出 Hello Semlinker!
,能自定义输出的问候语么 ?这个问题很好,答案是可以的。
具体实现如下:
function Greeter(greeting: string) {
return function (target: Function) {
target.prototype.greet = function (): void {
console.log(greeting);
};
};
}
@Greeter("Hello TS!")
class Greeting {
constructor() {
// 内部实现
}
}
let myGreeting = new Greeting();
myGreeting.greet(); // console output: 'Hello TS!';
属性装饰器声明:
declare type PropertyDecorator = (target:Object,
propertyKey: string | symbol ) => void;
属性装饰器顾名思义,用来装饰类的属性。它接收两个参数:
target: Object - 被装饰的类
propertyKey: string | symbol - 被装饰类的属性名
趁热打铁,马上来个例子热热身:
function logProperty(target: any, key: string) {
delete target[key];
const backingField = "_" + key;
Object.defineProperty(target, backingField, {
writable: true,
enumerable: true,
configurable: true
});
// property getter
const getter = function (this: any) {
const currVal = this[backingField];
console.log(`Get: ${key} => ${currVal}`);
return currVal;
};
// property setter
const setter = function (this: any, newVal: any) {
console.log(`Set: ${key} => ${newVal}`);
this[backingField] = newVal;
};
// Create new property with getter and setter
Object.defineProperty(target, key, {
get: getter,
set: setter,
enumerable: true,
configurable: true
});
}
class Person {
@logProperty
public name: string;
constructor(name : string) {
this.name = name;
}
}
const p1 = new Person("semlinker");
p1.name = "kakuqo";
以上代码我们定义了一个 logProperty
函数,来跟踪用户对属性的操作,当代码成功运行后,在控制台会输出以下结果:
Set: name => semlinker
Set: name => kakuqo
方法装饰器声明:
declare type MethodDecorator = (target:Object, propertyKey: string | symbol,
descriptor: TypePropertyDescript) => TypedPropertyDescriptor | void;
方法装饰器顾名思义,用来装饰类的方法。它接收三个参数:
target: Object - 被装饰的类
propertyKey: string | symbol - 方法名
descriptor: TypePropertyDescript - 属性描述符
废话不多说,直接上例子:
function LogOutput(tarage: Function, key: string, descriptor: any) {
let originalMethod = descriptor.value;
let newMethod = function(...args: any[]): any {
let result: any = originalMethod.apply(this, args);
if(!this.loggedOutput) {
this.loggedOutput = new Array();
}
this.loggedOutput.push({
method: key,
parameters: args,
output: result,
timestamp: new Date()
});
return result;
};
descriptor.value = newMethod;
}
class Calculator {
@LogOutput
double (num: number): number {
return num * 2;
}
}
let calc = new Calculator();
calc.double(11);
// console ouput: [{method: "double", output: 22, ...}]
console.log(calc.loggedOutput);
下面我们来介绍一下参数装饰器。
参数装饰器声明:
declare type ParameterDecorator = (target: Object, propertyKey: string | symbol,
parameterIndex: number ) => void
参数装饰器顾名思义,是用来装饰函数参数,它接收三个参数:
target: Object - 被装饰的类
propertyKey: string | symbol - 方法名
parameterIndex: number - 方法中参数的索引值
function Log(target: Function, key: string, parameterIndex: number) {
let functionLogged = key || target.prototype.constructor.name;
console.log(`The parameter in position ${parameterIndex} at ${functionLogged} has
been decorated`);
}
class Greeter {
greeting: string;
constructor(@Log phrase: string) {
this.greeting = phrase;
}
}
// console output: The parameter in position 0
// at Greeter has been decorated
JavaScript 有一个 Error
类,用于处理异常。你可以通过 throw
关键字来抛出一个错误。然后通过 try/catch
块来捕获此错误:
try {
throw new Error('Something bad happened');
} catch (e) {
console.log(e);
}
除内置的 Error
类外,还有一些额外的内置错误,它们继承自 Error
类:
当数字类型变量或者参数超出其有效范围时,出现 RangeError
的错误提示:
// 使用过多参数调用 console
console.log.apply(console, new Array(1000000000)); // RangeError: 数组长度无效
当引用无效时,会出现 ReferenceError
的错误提示:
'use strict';
console.log(notValidVar); // ReferenceError: notValidVar 未定义
当解析无效 JavaScript 代码时,会出现 SyntaxError
的错误提示:
1 *** 3 // SyntaxError: 无效的标记 *
变量或者参数不是有效类型时,会出现 TypeError
的错误提示:
'1.2'.toPrecision(1); // TypeError: '1.2'.toPrecision 不是函数。
当传入无效参数至 encodeURI()
和 decodeURI()
时,会出现 URIError
的错误提示:
decodeURI('%'); // URIError: URL 异常
Error
JavaScript 初学者可能有时候仅仅是抛出一个原始字符串:
try {
throw 'Something bad happened';
} catch (e) {
console.log(e);
}
不要这么做,使用 Error
对象的基本好处是,它能自动跟踪堆栈的属性构建以及生成位置。
原始字符串会导致极差的调试体验,并且在分析日志时,将会变得错综复杂。
throw
抛出一个错误传递一个 Error
对象是没问题的,这种方式在 Node.js
回调函数中非常常见,它用第一个参数作为错误对象进行回调处理。
function myFunction (callback: (e: Error)) {
doSomethingAsync(function () {
if (somethingWrong) {
callback(new Error('This is my error'));
} else {
callback();
}
})
}
「Exceptions should be exceptional」是计算机科学中常用用语。这里有一些原因说明在 JavaScript(TypeScript) 中也是如此。
考虑如下代码块:
try {
const foo = runTask1();
const bar = runTask2();
} catch (e) {
console.log('Error:', e);
}
下一个开发者可能并不清楚哪个函数可能会抛出错误。在没有阅读 task1/task2
代码以及他们可能会调用的函数时,对代码 review
的人员可能也不会知道错误会从哪里抛出。
你可以通过为每个可能抛出错误的代码显式捕获,来使其优雅:
try {
const foo = runTask1();
} catch (e) {
console.log('Error:', e);
}
try {
const bar = runTask2();
} catch (e) {
console.log('Error:', e);
}
但是现在,如果你想从第一个任务中传递变量到第二个任务中,代码会变的混乱(注意:foo 变量需要用 let 显式注解它,因为它不能从 runTask1
中返回出来):
let foo: number; // Notice 使用 let 并且显式注明类型注解
try {
foo = runTask1();
} catch (e) {
console.log('Error:', e);
}
try {
const bar = runTask2(foo);
} catch (e) {
console.log('Error:', e);
}
考虑如下函数:
function validate(value: number) {
if (value < 0 || value > 100) {
throw new Error('Invalid value');
}
}
在这种情境下使用 Error
不是一个好的主意。因为没有用来验证函数的类型定义(如:(value: number) => void
),取而代之一个更好的方式是创建一个验证方法:
function validate(
value: number
): {
error?: string;
} {
if (value < 0 || value > 100) {
return { error: 'Invalid value' };
}
}
现在它具有类型定义了。
TIP
除非你想用以非常通用(try/catch)的方式处理错误,否则不要抛出错误。
我们先来看看到底什么是异步。提到异步就不得不提另外一个概念:同步。那什么又叫同步呢。很多初学者在刚接触这个概念时会想当然的认为同步就是同时进行。显然,这样的理解是错误的,咱不能按字面意思去理解它。同步,英文全称叫做Synchronization。它是指同一时间只能做一件事,也就是说一件事情做完了才能做另外一件事。比如咱们去火车站买票,假设窗口只有1个,那么同一时间只能处理1个人的购票业务,其余的需要进行排队。这种one by one的动作就是同步。这种同步的情况其实有很多,任何需要排队的情况都可以理解成同步。那如果在程序中呢,我们都知道代码的执行是一行接着一行的,比如下面这段代码:
let ary = [];
for(let i = 0;i < 100;i++){
ary[i] = i;
}
console.log(ary);
这段代码的执行就是从上往下依次执行,循环没执行完,输出的代码就不会执行,这就是典型的同步。在程序中,绝大多数代码都是同步的。
同步操作的优点在于做任何事情都是依次执行,井然有序,不会存在大家同时抢一个资源的问题。你想想,如果火车站取消排队机制,那么大家势必会争先恐后去抢着买票,造成的结果就是秩序大乱,甚至可能引发一系列安全问题。如果代码不是同步执行的又会发生什么呢?有些代码需要依赖前面代码执行后的结果,但现在大家都是同时执行,那结果就不一定能获取到。而且这些代码可能在对同一数据就进行操作,也会让这个数据的值出现不确定的情况。
当然同步也有它的缺点。由于是依次进行,假如其中某一个步骤花的时间比较长,那么后续动作就会等待它的完成,从而影响效率。
不过,在有些时候我们还是希望能够在效率上有所提升,也就是说可以让很多操作同时进行。这就是另外一个概念:异步。假设火车站有10个人需要买票,现在只有1个窗口提供服务,如果平均每个人耗费5分钟,那么总共需要50分钟才能办完所有人的业务。火车站为了提高效率,加开了9个窗口,现在一共有10个窗口提供服务,那么这10个人就可以同时办理了,总共只需要5分钟,他们所有人的业务都可以办完。这就是异步带来的优势。
22.2、异步的实现
多线程
像刚才例子中开多个窗口的方式称为多线程。线程可以理解成一个应用程序中的执行任务,每个应用程序至少会有一个线程,它被称为主线程。如果你想实现异步处理,就可以通过开启多个线程,这些线程可以同时执行。这是异步实现的一种方式。不过这种方式还是属于阻塞式的。什么叫做阻塞式呢。你想想,开10个窗口可以满足10个人同时买票。但是现在有100个人呢?不可能再开90个窗口吧,所以每个窗口实际上还是需要排队。也就是说虽然我可以通过开启多个线程来同时执行很多任务,但是每个任务中的代码仍然是同步的。当某个任务的代码执行时间过长,也只会影响到当前线程的代码,而其他线程的代码不会受到影响。
单线程非阻塞式
假设现在火车站不想开那么多窗口,还是只有1个窗口提供服务,那如何能够提高购票效率呢?我们可以这样做,把购票的流程分为两步,第一步:预定及付款。第二步:取票。其中,第一步可以让购票者在网上操作。第二步到火车站的窗口取票。这样,最耗时的工作已经提前完成,不需要排队。到火车站时,虽然只有1个窗口,1次也只能接待1个人,但是取票的动作很快,平均每个人耗时不到1分钟,10个人也就不到10分钟就可以处理完成。这样既提高了效率,又少开了窗口。这也是一种异步的实现。我们可以看到,开1个窗口,就相当于只有1个线程。然后把耗时的一些操作分成两部分,先把快速能做完的事情做了,这样保证它不会阻塞其他代码的运行。剩下耗时的部分再单独执行。这就是单线程阻塞式的异步实现机制。
JS中的异步实现
我们知道JS引擎就是以单线程的机制来运行代码。那么在JS代码中想要实现异步就只有采用单线程非阻塞式的方式。比如下面这段代码:
console.log("start");
setTimeout(function(){
console.log("timeout");
},5000);
console.log("end");
这段代码先输出一个字符串”start”,然后用时间延迟函数,等到5000秒钟后输出”timeout”,在代码的最后输出”end”。最后的执行结果是:
start
end
//等待5秒后
timeout
从结果可以看到end的输出并没有等待时间函数执行完,实际上setTimeout就是异步的实现。代码的执行流程是这样的:
首先执行输出字符串”start”,然后开始执行setTimeout函数。由于它是一个异步操作,所以它会被分为两部分来执行,先调用setTimeout方法,然后把要执行的函数放到一个队列中。代码继续往下执行,当把所有的代码都执行完后,放到队列中的函数才会被执行。这样,所有异步执行的函数都不会阻塞其他代码的执行。虽然,这些代码都不是同时执行,但是由于任何代码都不会被阻塞,所以执行效率会很快。
大家认真看这个图片,然后思考一个问题:当setTimeout执行后,什么时候开始计时的呢?由于单线程的原因,不可能在setTimeout后就开始执行,因为一个线程同一时间只能做一件事情。执行后续代码的同时就不可能又去计时。那么只可能是在所有代码执行完后才开始计时,然后5秒后执行队列中的回调函数,是这样吗?我们用一段代码来验证下:
console.log("start");
setTimeout(function(){
console.log("timeout");
},5000);
for(let i = 0;i <= 500000;i++){
console.log("i:",i);
}
console.log("end");
这段代码在之前的基础上加了一个循环,循环次数为50万次,然后每次输出i的值。这段循环是比较耗时的,从实际运行来看,大概需要14秒左右(具体时间可自行测算)。这个时间已经远远大于setTimeout的等待时间。按照之前的说法,应该先把所有同步的代码执行完,然后再执行异步的回调方法,结果应该是:
start
i:1
(...) //一直输出到500000
//耗时14秒左右
end
//等待5秒后
timeout
但实际的运行结果是:
start
i:1
(...) //一直输出到500000
//耗时14秒左右
end
//没有等待
timeout
从结果可以看到setTimeout的计时应该是早就开始了,但是JS是单线程运行,那谁在计时呢?要解释这个问题,大家一定要先搞明白一件事。JS的单线程并不是指整个JS引擎只有1个线程。它是指运行代码只有1个线程,但是它还有其他线程来执行其他任务。比如时间函数的计时、AJAX技术中的和后台交互等操作。所以,实际情况应该是:JS引擎中执行代码的线程开始运行代码,当执行到异步方法时,把异步的回调方法放入到队列中,然后由专门计时的线程开始计时。代码线程继续运行。如果计时的时间已到,那么它会通知代码线程来执行队列中对应的回调函数。当然,前提是代码线程已经把同步代码执行完后。否则需要继续等待,就像这个例子中一样。
最后,大家一定要注意一件事情,由于执行代码只有1个线程,所以在任何同步代码中出现死循环,那么它后续的同步代码以及异步的回调函数都无法执行,比如:
console.log("start");
setTimeout(function(){
console.log("timeout");
},5000);
console.log("end");
for(;;){}
timeout用于也不会输出,因为执行代码的线程已经陷入死循环中。
在调用setTimeout函数时我们传递了一个函数进去,这个函数并没有立即被调用,而是在5秒后被调用。这种函数也被称为回调函数(关于回调函数请参看前面的内容)。由于JS中的函数是一等公民,它和其他数据类型一样,可以作为参数传递也可以作为返回值返回,所以经常能够看到回调函数使用。
在异步实现中,回调函数的使用是不可避免的。之前我不是讲过吗,JS的异步是单线程非阻塞式的。它将一个异步动作分为两步,第一步执行异步方法,然后代码接着往下执行。然后在后面的某个时刻调用第二步的回调函数,完成后续动作。
有的时候,我们希望在异步操作中加入同步的行为。比如,我想打印4句话,但是每句话都在前一句话的基础上延迟2秒输出。代码如下:
setTimeout(function(){
console.log("first");
setTimeout(function(){
console.log("second");
setTimeout(function(){
console.log("third");
setTimeout(function(){
console.log("fourth");
},2000);
},2000);
},2000);
},2000);
这段代码能够实现想要的功能,但是总觉得哪里不对。如果输出的内容越来越多,嵌套的代码也会增多。那无论是编写还是阅读起来都会很恐怖。造成这种情况的罪魁祸首就是回调函数。因为你想在前面的异步操作完成后再进行接下来的动作,那只能在它的回调函数中进行,这样就会越套越多,代码越来越来复杂,俗称“回调地狱”。
为了解决这个问题,在ES6中加入了一个新的对象Promise。Promise提供了一种更合理、更强大的异步解决方案。接下来我们来看看它的用法。
new Promise(function(resolve,reject){
//dosomething
});
首先需要创建一个Promise对象,该对象的构造函数中接收一个回调函数,回调函数中可以接收两个参数,resolve和reject。注意,这个回调函数是在Promise创建后就会调用。它实际上就是异步操作的第一步。那第二步操作再在哪里做呢?Promise把两个步骤分开了,第二步通过Promise对象的then方法实现。
let pm = new Promise(function(resolve,reject){
//dosomething
});
console.log("go on");
pm.then(function(){
console.log("异步完成");
});
不过要注意的是,then方法的回调函数不是说只要then方法一调用它就会调用,而是在Promise的回调函数中通过调用resolve触发的。
let pm = new Promise(function(resolve,reject){
resolve();
});
console.log("go on");
pm.then(function(){
console.log("异步完成");
});
实际上Promise实现异步的原理和之前纯用回调函数的原理是一样的。只是Promise的做法是显示的将两个步骤分开来写。then方法的回调函数同样会先放入队列中,等待所有的同步方法执行完后,同时Promise中的resolve也被调用后,该回调函数才会执行。
如图:
调用resolve时还可以把数据传递给then的回调函数。
let pm = new Promise(function(resolve,reject){
resolve("this is data");
});
console.log("go on");
pm.then(function(data){
console.log("异步完成",data);
});
reject是出现错误时调用的方法。它触发的不是then中的回调函数,而是catch中的回调函数。比如:
let err = false;
let pm = new Promise(function(resolve,reject){
if(!err){
resolve("this is data");
}else{
reject("fail");
}
});
console.log("go on");
pm.then(function(data){
console.log("异步完成",data);
});
pm.catch(function(err){
console.log("出现错误",err);
});
下面,我把刚才时间函数的异步操作用Promise实现一次。当然,其中setTimeout还是需要使用,只是在它外面包裹一个Promise对象。
let pm = new Promise(function(resolve,reject){
setTimeout(function(){
resolve();
},2000);
});
console.log("go on");
pm.then(function(){
console.log("异步完成");
});
效果和之前一样,但是代码复杂了不少,感觉有点多此一举。接下来做做同步效果。
let timeout = function(time){
return new Promise(function(resolve,reject){
setTimeout(function(){
resolve();
},time);
});
}
console.log("go on");
timeout(2000).then(function(){
console.log("first");
return timeout(2000);
}).then(function(){
console.log("second");
return timeout(2000);
}).then(function(){
console.log("third");
return timeout(2000);
}).then(function(){
console.log("fourth");
return timeout(2000);
});
由于需要多次创建Promise对象,所以用了timeout函数将它封装起来,每次调用它都会返回一个新的Promise对象。当then方法调用后,其内部的回调函数默认会将当前的Promise对象返回。当然也可以手动返回一个新的Promise对象。我们这里就手动返回了一个新的计时对象,因为需要重新开始计时。后面继续用then方法来触发异步完成的回调函数。这样就可以做到同步的效果,从而避免了过多的回调嵌套带来的“回调地狱”问题。
实际上Promise的应用还是比较多,比如前面讲到的fetch,它就利用了Promise来实现AJAX的异步操作:
let pm = fetch("/users"); // 获取Promise对象
pm.then((response) => response.text()).then(text => {
test.innerText = text; // 将获取到的文本写入到页面上
})
.catch(error => console.log("出错了"));
注意:response.text()返回的不是文本,而是Promise对象。所以后面又跟了一个then,然后从新的Promise对象中获取文本内容。
Promise作为ES6提供的一种新的异步编程解决方案,但是它也有问题。比如,代码并没有因为新方法的出现而减少,反而变得更加复杂,同时理解难度也加大。因此它并不是异步实现的最终形态,后续我们还会继续介绍其他的异步实现方法。
Promise来实现异步也会存在一些问题,比如代码量增多,不易理解。接下来看看另外一种异步操作的方法—-生成器。这是ES6新增的方法,在讲它之前,咱们还得理解另外一个东西:迭代器。
迭代器是一种接口,也可以说是一种规范。它提供了一种统一的遍历数据的方法。我们都知道数组、集合、对象都有自己的循环遍历方法。比如数组的循环:
let ary = [1,2,3,4,5,6,7,8,9,10];
//for循环
for(let i = 0;i < ary.length;i++){
console.log(ary[i]);
}
//forEach循环
ary.forEach(function(ele){
console.log(ele);
});
//for-in循环
for(let i in ary){
console.log(ary[i]);
}
//for-of循环
for(let ele of ary){
console.log(ele);
}
集合的循环:
let list = new Set([1,2,3,4,5,6,7,8,9,10]);
for(let ele of list){
console.log(ele);
}
对象的循环:
let obj = {
name : 'tom',
age : 25,
gender : '男',
intro : function(){
console.log('my name is '+this.name);
}
}
for(let attr in obj){
console.log(attr);
}
从以上的代码可以看到,数组可以用for、forEach、for-in以及for-of来遍历。集合能用for-of。对象能用for-in。也就是说,以上数据类型的遍历方式都各有不同,那么有没有统一的方式遍历这些数据呢?这就是迭代器存在的意义。它可以提供统一的遍历数据的方式,只要在想要遍历的数据结构中添加一个支持迭代器的属性即可。这个属性写法是这样的:
const obj = {
[Symbol.iterator]:function(){}
}
[Symbol.iterator]属性名是固定的写法,只要拥有了该属性的对象,就能够用迭代器的方式进行遍历。迭代器的遍历方法是首先获得一个迭代器的指针,初始时该指针指向第一条数据之前。接着通过调用next方法,改变指针的指向,让其指向下一条数据。每一次的next都会返回一个对象,该对象有两个属性。其中value代表想要获取的数据,done是个布尔值,false表示当前指针指向的数据有值。true表示遍历已经结束。
let ary = [1,2,3];
let it = ary[Symbol.iterator](); // 获取数组中的迭代器
console.log(it.next()); // { value: 1, done: false }
console.log(it.next()); // { value: 2, done: false }
console.log(it.next()); // { value: 3, done: false }
console.log(it.next()); // { value: undefined, done: true }
数组是支持迭代器遍历的,所以可以直接获取其中的迭代器。集合也是一样。
let list = new Set([1,2,3]);
let it = list.entries(); // 获取set集合中的迭代器
console.log(it.next()); // { value: [ 1, 1 ], done: false }
console.log(it.next()); // { value: [ 2, 2 ], done: false }
console.log(it.next()); // { value: [ 3, 3 ], done: false }
console.log(it.next()); // { value: undefined, done: true }
set集合中每次遍历出来的值是一个数组,里面的第一和第二个元素都是一样的。
由于数组和集合都支持迭代器,所以它们都可以用同一种方式来遍历。es6中提供了一种新的循环方法叫做for-of。它实际上就是使用迭代器来进行遍历,换句话说只有支持了迭代器的数据结构才能使用for-of循环。在JS中,默认支持迭代器的结构有:
这里面并没有包含自定义的对象,所以当我们创建一个自定义对象后,是无法通过for-of来循环遍历它。除非将iterator接口加入到该对象中:
let obj = {
name : 'tom',
age : 25,
gender : '男',
intro : function(){
console.log('my name is '+this.name);
},
[Symbol.iterator]:function(){
let i = 0;
let keys = Object.keys(this); // 获取当前对象的所有属性并形成一个数组
return {
next: function(){
return {
value:keys[i++], // 外部每次执行next都能得到数组中的第i个元素
done:i > keys.length // 如果数组的数据已经遍历完则返回true
}
}
}
}
}
for(let attr of obj){
console.log(attr);
}
通过自定义迭代器就能让自定义对象使用for-of循环。
迭代器的概念及使用方法我们清楚了,接下来就是生成器。
生成器也是ES6新增加的一种特性。它的写法和函数非常相似,只是在声明时多了一个”*”号。
function* say(){}
const say = function*(){}
注意:这个”*”只能写在function关键字的后面。
生成器函数和普通函数并不只是一个“*”号的区别。普通函数在调用后,必然开始执行该函数,直到函数执行完或遇到return为止。中途是不可能暂停的。但是生成器函数则不一样,它可以通过yield关键字将函数的执行挂起,或者理解成暂停。它的外部在通过调用next方法,让函数继续执行,直到遇到下一个yield,或函数执行完毕。
function* say(){
yield "开始";
yield "执行中";
yield "结束";
}
let it = say(); // 调用say方法,得到一个迭代器
console.log(it.next()); // { value: '开始', done: false }
console.log(it.next()); // { value: '执行中', done: false }
console.log(it.next()); // { value: '结束', done: false }
console.log(it.next()); // { value: undefined, done: true }
调用say函数,这句和普通函数的调用没什么区别。但是此时say函数并没有执行,而是返回了一个该生成器的迭代器对象。接下来就和之前一样,执行next方法,say函数执行,当遇到yield时,函数被挂起,并返回一个对象。对象中包含value属性,它的值是yield后面跟着的数据。并且done的值为false。再次执行next,函数又被激活,并继续往下执行,直到遇到下一个yield。当所有的yield都执行完了,再次调用next时得到的value就是undefined,done的值为true。
如果你能理解刚才讲的迭代器,那么此时的生成器也就很好理解了。它的yield,其实就是next方法执行后挂起的地方,并得到你返回的数据。那么这个生成器有什么用呢?它的yield关键字可以将执行的代码挂起,外部通过next方法让它继续运行。这和异步操作的原理非常类似,把一个操作分为两部分,先执行一部分,然后再执行另外一部分。所以生成器可以处理和异步相关的操作。我们知道,异步操作主要是依靠回调函数实现。但是纯回调函数的方式去处理同步效果会带来“回调地域“的问题.Promise可以解决这个问题。但是Promise写起来代码比较复杂,不易理解。而生成器又提供了一种解决方案。看下面这个例子:
function* delay(){
yield new Promise((resolve,reject) => {setTimeout(()=>{resolve()},2000)})
console.log("go on");
}
let it = delay();
it.next().value.then(()=>{
it.next();
});
这个例子实现了等待2秒钟后,打印字符串”go on”。下面我们来分析下这段代码。在delay这个生成器中,yield后面跟了一个Promise对象。这样,当外部调用next时就能得到这个Promise对象。然后调用它的then函数,等待2秒钟后Promise中会调用resolve方法,接着then中的回调函数被调用。也就是说,此时指定的等待时间已到。然后在then的回调函数中继续调用生成器的next方法,那么生成器中的代码就会继续往下执行,最后输出字符串”go on”。
例子中时间函数外面为什么要包裹一个Promise对象呢?这是因为时间函数本身就是一个异步方法,给它包裹一个Promise对象后,外部就可以通过then方法来处理异步操作完成后的动作。这样,在生成器中,就可以像写同步代码一样来实现异步操作。比如,利用fetch来获取远程服务器的数据(为了测试方便,我将用MockJS来拦截请求)。
Mock.mock(/\.json/,{
'stuents|5-10' : [{
'id|+1' : 1,
'name' : '@cname',
'gender' : /[男女]/, //在正则表达式匹配的范围内随机
'age|15-30' : 1, //年龄在15-30之间生成,值1只是用来确定数据类型
'phone' : /1\d{10}/,
'addr' : '@county(true)', //随机生成中国的一个省、市、县数据
'date' : "@date('yyyy-MM-dd')"
}]
});
function* getUsers(){
let data = yield new Promise((resolve,reject) => {
$.ajax({
type:"get",
url:"/users.json",
success:function(data){
resolve(data)
}
});
});
console.log("data",data);
}
let it = getUsers();
it.next().value.then((data) => {
it.next(data);
});
在Promise中调用JQuery的AJAX方法,当数据返回后调用resolve,触发外部then方法的回调函数,将数据返回给外部。外部的then方法接收到data数据后,再次调用next,移动生成器的指针,并将data数据传递给生成器。所以,在生成器中你可以看到,我声明了一个data变量来接收异步操作返回的数据,这里的代码就像同步操作一样,但实际上它是个异步操作。当异步的数据返回后,才会执行后面的打印操作。这里的关键代码就是yield后面一定是一个Promise对象,因为只有这样外部才能调用then方法来等待异步处理的结果,然后再继续做接下来的操作。
之前我们还讲过一个替代AJAX的方法fetch,它本身就是用Promise的方法来实现异步,所以代码写起来会更简单:
function* getUsers(){
let response = yield fetch("/users");
let data = yield response.json();
console.log("data",data);
}
let it = getUsers();
it.next().value.then((response) => {
it.next(response).value.then((data) => {
it.next(data);
});
});
由于mock无法拦截fetch请求,所以我用nodejs+express搭建了一个mock-server服务器。
这里的生成器我用了两次yield,这是因为fetch是一个异步操作,获得了响应信息后再次调用json方法来得到其中返回的JSON数据。这个方法也是个异步操作。
从以上几个例子可以看出,如果单看生成器的代码,异步操作可以完全做的像同步代码一样,比起之前的回调和Promise都要简单许多。但是,生成器的外部还是需要做很多事情,比如需要频繁调用next,如果要做同步效果依然需要嵌套回调函数,代码依然很复杂。市面也有很多的插件可以辅助我们来执行生成器,比如比较常见的co模块。它的使用很简单:
co(getUsers);
引入co模块后,将生成器传入它的方法中,这样它就能自动执行生成器了。关于co模块这里我就不再多讲,有兴趣的话可以参考这篇文章:http://es6.ruanyifeng.com/#docs/generator-async
生成器这种方式需要编写外部的执行器,而执行器的代码写起来一点也不简单。当然也可以使用一些插件,比如co模块来简化执行器的编写。
在ES7中,加入了async函数来处理异步。它实际上只是生成器的一种语法糖而已,简化了外部执行器的代码,同时利用await替代yield,async替代生成器的(*)号。下面还是来看个例子:
async function delay(){
await new Promise((resolve) => {setTimeout(()=>{resolve()},2000)});
console.log("go on);
}
delay();
这个例子我们之前用生成器也写过,其中把生成器的(*)号被换成了async。async关键字必须写在function的前面。如果是箭头函数,则写在参数的前面:
const delay = async () => {}
在函数中,第一句用了await。它替代了之前的yield。后面同样需要跟上一个Promise对象。接下来的打印语句会在上面的异步操作完成后执行。外部调用时就和正常的函数调用一样,但它的实现原理和生成器是类似的。因为有了async关键字,所以它的外部一定会有相应的执行器来执行它,并在异步操作完成后执行回调函数。只不过这一切都被隐藏起来了,由JS引擎帮助我们完成。我们需要做的就是加上关键字,在函数中使用await来执行异步操作。这样,可以大大的简化异步操作。同时,能够像同步方法一样去处理它们。
接下来我们再来看看更细节的一些问题。await后面必须是一个Promise对象,这个很好理解。因为该Promise对象会返回给外部的执行器,并在异步动作完成后执行resolve,这样外部就可以通过回调函数处理它,并将结果传递给生成器。
如图:
那如果await后面跟的不是Promise对象又会发生什么呢?
const delay = async () => {
let data = await "hello";
console.log(data);
}
这样的代码是允许的,不过await会自动将hello字符串包装一个Promise对象。就像这样:
let data = await new Promise((resolve,reject) => resolve("hello"));
创建了Promise对象后,立即执行resolve,并将字符串hello传递给外部的执行器。外部执行器的回调函数再将这个hello传递回来,并赋值给data变量。所以,执行该代码后,马上就会输出字符串hello。虽然代码能够这样写,但是await在这里的意义并不大,所以await还是应该用来处理异步方法,同时该异步方法应该使用Promise对象。
async函数里面除了有await关键字外,感觉和其他函数没什么区别,那它能有返回值吗?答案是肯定的,
const delay = async () => {
await new Promise((resolve) => {setTimeout(()=>{resolve()},2000)});
return "finish";
}
let result = delay();
console.log(result);
在delay函数中先执行等待2秒的异步操作,然后返回字符串finish。外部调用时我用一个变量接收它的返回值。最后输出的结果是:
// 没有任何等待立即输出
Promise { }
// 2秒后程序结束
我们可以看到,没有任何等待立即输出了一个Promise对象。而整个程序是在2秒钟后才结束的。由此看出,获取async函数的返回结果实际上是return出来的一个Promise对象。假如return后面跟着的本来就是一个Promise对象,那么它会直接返回。但如果不是,则会像await一样包裹一个Promise对象返回。所以,想要得到返回的具体内容应该这样:
const delay = async () => {
await new Promise((resolve) => {setTimeout(()=>{resolve()},2000)});
return "finish";
}
let result = delay();
console.log(result);
result.then(function(data){
console.log("data:",data);
});
执行的结果:
// 没有任何等待立即输出
Promise { }
//等待2秒后输出
data: finish
那如果函数没有任何返回值,得到的又是什么呢?我将上面代码中取掉return,再次运行:
// 没有任何等待立即输出
Promise { }
//等待2秒后输出
data: undefined
可以看到,仍然可以得到Promise对象,但由于函数没有返回值,所以就不会有任何数据传递出来,那么打印的结果就是undefined。
async的基本原理我们清楚了,下面我们把之前的AJAX例子用async重写下:
Mock.mock(/\.json/,{
'stuents|5-10' : [{
'id|+1' : 1,
'name' : '@cname',
'gender' : /[男女]/, //在正则表达式匹配的范围内随机
'age|15-30' : 1, //年龄在15-30之间生成,值1只是用来确定数据类型
'phone' : /1\d{10}/,
'addr' : '@county(true)', //随机生成中国的一个省、市、县数据
'date' : "@date('yyyy-MM-dd')"
}]
});
async function getUsers(){
let data = await new Promise((resolve,reject) => {
$.ajax({
type:"get",
url:"/users.json",
success:function(data){
resolve(data)
}
});
});
console.log("data",data);
}
getUsers();
这是用JQuery的AJAX方法实现。
async function getUsers(){
let response = await fetch("/users");
let data = await response.json();
console.log("data",data);
}
getUsers();
这是fetch方法的实现。
从这两个例子可以看出,async和生成器两种方式都很类似,但async可以不借助任何的第三方模块,也更易于理解,async表示该函数要做异步处理。await表示后面的代码是一个异步操作,等待该异步操作完成后再执行后面的动作。如果异步操作有返回的数据,则在左边用一个变量来接收它。
我们知道,await可以让异步操作变为同步的效果。但是,有的时候为了提高效率,我们需要让多个异步操作同时进行怎么办呢?方法就是执行异步方法时不加await,这样它们就可以同时进行,然后在获取结果时用await。比如:
function time(ms){
return new Promise((resolve,reject) => {
setTimeout(()=>{resolve()},ms);
});
}
const delay = async () => {
let t1 = time(2000);
let t2 = time(2000);
await t1;
console.log("t1 finish");
await t2;
console.log("t2 finish");
}
delay();
我先把时间函数的异步操作封装成了函数,并返回Promise对象。在delay函数中调用了两次time方法,但没有用await。也就是说这两个时间函数的执行是“同时”(其实还是有先后顺序)进行的。然后将它们的Promise对象分别用t1和t2表示。先用await t1。表示等待t1的异步处理完成,然后输出t1 finish。接着再用await t2,等待t2的异步处理完成,最后输出t2 finish。由于这两个时间函数是同时执行,而且它们的等待时间也是一样的。所以,当2秒过后,它们都会执行相应的回调函数。运行的结果就是:等待2秒后,先输出t1 finish,紧接着立即输出 t2 finish。
const delay = async () => {
await time(2000);
console.log("t1 finish");
await time(2000);;
console.log("t2 finish");
}
如果是这样写,那么执行的结果会是等待2秒后输出t1 finish。再等待2秒后输出t2 finish。
async确实是一个既好用、又简单的异步处理方法。但是它的问题就是不兼容老的浏览器,只有支持了ES7的浏览器才能使用它。
最后,还需要注意一个问题:await关键字必须写在async定义的函数中。
了不起的 TypeScript 入门教程 (qq.com)
<
TypeScript 教程 | 菜鸟教程 (runoob.com)
一文看懂JS的异步 - 知乎 (zhihu.com)