Typescript基础

Typescript是什么?

TypeScript is a typed superset of JavaScript that compiles to plain JavaScript.

上面的话有两个重点:typed supersetcompile。也就是说,ts是一门具有类型系统的语言,并且是通过编译成Javascript来工作的。另外,作为js的超集,ts有一些还未真正写进js标准中的特性和语法,也对于平时写代码有一定的帮助。

完全版的教程

Online Playground

为什么选择Typescript?

对于我来说,(19年)年初的时候写一个小网站时真正感受到了js的弱类型带来的种种糟糕体验,所以从那时起我就对弱类型语言有了心理阴影。嗯,这时候我看到了ts,于是就迅速喜欢上了它。简单来说,Typescript有以下几个优势:

  • 类型系统及其衍生特性。Type就是Typescript的可以说是最重要的部分了,强类型语言对于项目的开发、管理都比弱类型语言方便多了。尽管JS也有类型检查的扩展(没错就是flow,vue2.x使用了它),但还是强类型语言更为出色。
  • M$出品。M$这样的大公司出品,更新速度还是挺快的,质量啥的也有保证。同时,因为VS Code也是Typescript写的,所以VS Code原生支持Typescript的代码提示等特性。
  • 生态发展速度还是比较快的。Angular早就已经钦点了TS,不支持JS了;React也对TS有着非常好的支持了;Vue慢点,但3.0也是为了支持TS重写了大量模块,连Function-based API都搬出来了。

安装

只推荐使用npm安装

> npm install -g typescript

构建代码并编译

我们先来构建一个简单的Hello World:

// HelloWorld.ts

let hw: string = "hello world";

alert(hw);

然后用tsc命令来编译代码:

> tsc HelloWorld.ts

接着我们就可以得到一个HelloWorld.js文件了,按JS文件使用即可

另外更多的tsc编译选项参考这里

基础类型

boolean

最基本的数据类型,值只能为true或者false

number

同Javascript一样,Typescript中所有的数字都是浮点数

除了支持十进制和十六进制外,Typescript还支持二进制和八进制(ES6新特性)

string

Typescript中的字符串可以用单引号'或者双引号"表示

同时,还支持模板字符串,其用反引号表示,并且用 ${ exp }可以嵌入表达式

实例如下:

let str1: string = 'hello world';
let str2: string = "hello world";

let count: number = 0;
let str3: string = `Jump ${ count } times.`

数组

Typescript中有两种方法可以定义数组:

// 元素类型后加[]
let arr: number[] = [1,2,3];

// 泛型
let arr2: Array<number> = [1,2,3];

元组

在Typescript中,元组是表示一个已知类型和数量的数组,但是各元素的类型不必相同。在声明的时候也必须要声明数组中可以包含的类型,例子如下:

let arr: [string, number] = ["hello", 1];  // 初始化也要按照顺序

// 访问越界元素
x[3] = 'world'; // OK, 字符串可以赋值给(string | number)类型

console.log(x[5].toString()); // OK, 'string' 和 'number' 都有 toString

x[6] = true; // Error, 布尔不是(string | number)类型

联合类型是高级特性,我们后面再讨论

any

有时候我们不能确定某些变量的类型(例如在接收到的API数据中),这时候我们便不希望编译器在编译的时候检查这些数据的类型,这时候我们可以用any类型来标识这些变量:

let notSure: any = 2333;

let list: any[] = [1, "hello", false];	// 也可使用any数组

这些变量都是可以随意更改为任何类型的,在某些时候还是比较方便的。

WARNING

虽然any看起来很爽,但是不要忘了我们为什么选择Typescript

所以我觉得,不到万不得已的时候不要使用any

void

也叫空值。实际上与C++等的void含义相同,表示没有任何类型,例如:

function warn(): void {
    console.log("warning message");
}

Null 和 Undefined

这俩哥们是在JS里面就挺让人头疼的东西了。Typescript中,nullundefined被设定为所有类型的子类型,也就是说,你可以把nullundefined赋给任何类型。

当然,如果你使用了--strictNullChecks选项,那么他们就是单独的类型了,无法赋值给其他类型,需要用联合类型来代替原有的功能,例如string | null | undefined

Never

never类型是指那些永远不存在值的类型,一般用于错误处理。例如,never类型是那些总是会抛出异常或根本就不会有返回值的函数表达式或箭头函数表达式的返回值类型; 变量也可能是never类型,当它们被永不为真的类型保护所约束时。

never类型是任何类型的子类型,也可以赋值给任何类型;然而,没有类型是never的子类型或可以赋值给never类型(除了never本身之外)。 即使any也不可以赋值给never

官方文档的一些例子:

// 返回never的函数必须存在无法达到的终点
function error(message: string): never {
    throw new Error(message);
}

// 推断的返回值类型为never
function fail() {
    return error("Something failed");
}

// 返回never的函数必须存在无法达到的终点
function infiniteLoop(): never {
    while (true) {
    }
}

Object

object表示非原始类型,也就是除numberstringbooleansymbolnullundefined之外的类型

类型断言

有时候我们会遇到一个问题:你很清楚这个变量必然是某个类型,但是代码提示、编译器反应不过来,这时候我们就需要类型断言了。类型断言有两种语法:

// 尖括号语法
let someValue: any = "this is a string";
let strLength: number = (<string>someValue).length;

// as语法
let strLength_2: number = (someValue as string).length;

变量声明

var声明

WARNING

在Typescript中我们并不建议在任何地方使用var,此节终结

let及其作用域

letvar的语法是一致的,所以我们来讲讲let所带来的规范可控的作用域

实际上,let的作用域的概念与C++、JAVA这些语言相似,都是块级作用域,比如下面的例子:

function f(input: boolean) {
    let a = 100;

    if (input) {
        // Still okay to reference 'a'
        let b = a + 1;
        return b;
    }

    // Error: 'b' doesn't exist here
    return b;
}

WARNING

let不允许在同一层作用域里重定义变量

另外,对代码规范而言,尽量避免在父子作用域里定义同名变量

暂时性死区

Typescript中允许一个拥有块作用域变量被声明前获取它,只是我们不能在变量声明前去调用那个函数。

例子:

function get(){
    // 可以获取a
    return a;
}

// 不能在a被声明前调用get()
get();

let a;

这种在获取a和声明a之间的区间,我们称为暂时性死区

const声明

与C++的const关键字一样,Typescript中的const声明的是被赋值后不可改变的变量

但是需要注意的是,这里的不可改变,实际上是指针的不可改变,也就是它们指向的对象不能改变。理解了这一点之后,在很多地方我们都可以用const来提高代码的安全性,比如:

const numLivesForCat = 9;
const kitty = {
    name: "Aurora",
    numLives: numLivesForCat,
}

// all "okay"
kitty.name = "Rory";
kitty.name = "Kitty";
kitty.name = "Cat";
kitty.numLives--;

接口

TypeScript的核心原则之一是对值所具有的结构进行类型检查。 它有时被称做“鸭式辨型法”或“结构性子类型化”。 在TypeScript里,接口的作用就是为这些类型命名和为你的代码或第三方代码定义契约。

显然,Typescript中的接口和JAVA中的接口并不相同,Typescript中的接口是为了类型约束而用的。

声明以及使用

interface dict {
    key: string;
    value: number;
}

function readKey(d: dict) {
    console.log(d.key);
}

可选属性

有时候,接口里面的属性并不全都是必须的,或者在某些条件下不存在,Typescript也提供了可选属性的特性,用法如下:

interface Optional {
    name?: string;
    val?: number;
}

可选属性可出现可不出现,这样就方便我们预定义一些可能会出现的属性了。当然,不被定义过的属性是不能出现的,否则编译器会报错。

只读属性

const一样,readonly标签可以定义只读的属性,而且也是指针的不可改变。两者最主要的区别就是分别用在变量属性上。

interface Point {
    readonly x: number;
    readonly y: number;
}

let p1: Point = { x: 10, y: 20 };
p1.x = 5; // error!

函数类型

同样,我们的接口也可以指定函数的类型,用法如下:

interface SearchFunc {
  (source: string, subString: string): boolean;
}

let mySearch: SearchFunc;
mySearch = function(src, sub) {
    let result = src.search(sub);
    return result > -1;
}

函数类型并不要求参数的名字相同,只需要参数的个数、类型和顺序相同即可。

从ES6开始,JS中正式引进了class关键字。Typescript中自然也提供了类的特性,并且语法同C#、JAVA基本一致,所以这一节我们尽量简单地讲

定义、继承、权限

我们直接用例子来直观讲解:

// 定义
class Person {

    // property
    protected name: string;
    protected age: number;

    // constructor
    constructor(name: string, age: number) {
        this.name = name;
        this.age = age;
    }

    // methods
    public SayHello(): string {
        return "Hello\n";
    }
}

// 继承
class Student extends Person {

    private grades: number;

    constructor(name: string, age: number, grades: number) {
        // 构造函数中访问this前必须先调用super
        super(name, age);
        this.grades = grades;
    }

    // 重写父类方法
    public SayHello(): string {
        return "Hello too\n";
    }
}

实际上,Typescript中的类语法与Java基本是一样的。

关于publicprotectedprivate的更详细信息,我想C++肯定已经教过了,忘了的话可以参考这里

WARNING

在编码中给所有属性和方法都加上权限修饰,并遵循最小特权原则

readonly修饰符

同接口的readonly一样,类的readonly修饰符也表示该属性只读。只读属性必须在声明或构造函数中初始化

静态属性

同Java一样,Typescript提供了static关键字来声明静态成员。

静态成员,指的是这些属性存在于类本身上面而不是类的实例上,我们直接使用官方例子来讲解:

class Grid {
    
    static origin = {x: 0, y: 0};
    
    calculateDistanceFromOrigin(point: {x: number; y: number;}) {
        let xDist = (point.x - Grid.origin.x);
        let yDist = (point.y - Grid.origin.y);
        return Math.sqrt(xDist * xDist + yDist * yDist) / this.scale;
    }
    
    constructor (public scale: number) { }
}

在这里,Grid.origin是每个对象都需要用到的属性,所以我们把它定义为静态属性,访问静态属性的方法就是类名.属性

抽象方法、抽象类

同样,Typescript提供了abstract关键字来让我们定义抽象方法和抽象类

抽象方法:只有方法签名而没有方法体的方法,派生类中必须实现父类的抽象方法

抽象类没有实例的类,一般作为基类使用

需要注意的是,抽象方法只能包含在抽象类中

官方例子:

abstract class Department {

    constructor(public name: string) {
    }

    printName(): void {
        console.log('Department name: ' + this.name);
    }

    abstract printMeeting(): void; // 必须在派生类中实现
}

class AccountingDepartment extends Department {

    constructor() {
        super('Accounting and Auditing'); // 在派生类的构造函数中必须调用 super()
    }

    printMeeting(): void {
        console.log('The Accounting Department meets each Monday at 10am.');
    }

    generateReports(): void {
        console.log('Generating accounting reports...');
    }
}

let department: Department; // 允许创建一个对抽象类型的引用
department = new Department(); // 错误: 不能创建一个抽象类的实例
department = new AccountingDepartment(); // 允许对一个抽象子类进行实例化和赋值
department.printName();
department.printMeeting();
department.generateReports(); // 错误: 方法在声明的抽象类中不存在

函数

函数是Javascript的“一等公民”,Typescript给函数添加了一些额外功能,更便于使用。

定义与函数的类型声明

function add(x: number, y: number): number {
    return x + y;
}

// 匿名函数
let myAdd = function(x: number, y: number): number { return x + y; };

// 完整的函数的类型声明
let myAdd: (x:number, y:number) => number =
    function(x: number, y: number): number { return x + y; };

可选参数和默认参数

// 定义可选参数
function add(incre: number, base?: number): number {
    if(base){
        return base + incre;
    }
    return incre;
}

// 定义参数默认值
function add(incre: number, base = 0): number {
    return base + incre;
}

值得注意的是,定义参数默认值的时候,该参数的类型会被编译器自动推导出来,因此,上面两个函数的函数类型是一致的

另外,可选参数必须放在正常的参数后面

剩余参数

与ES6中的rest类似,Typescript提供了剩余参数的功能来在函数中传递任意数量的参数

function buildName(firstName: string, ...restOfName: string[]) {
  return firstName + " " + restOfName.join(" ");
}

let employeeName = buildName("Joseph", "Samuel", "Lucas", "MacKinzie");

// 函数类型
let buildNameFun: (fname: string, ...rest: string[]) => string = buildName;

当然,Javascript中的arguments在Typescript中依然可以使用

this

更具体的概念可以参照Understanding JavaScript Function Invocation and "this",这里我们参照官方文档简单讲讲

Javascript中,this的值在函数被调用的时候才会指定,因此我们使用this的时候,一定要弄清楚函数的上下文是什么

来自官方文档的例子:

let deck = {
    suits: ["hearts", "spades", "clubs", "diamonds"],
    cards: Array(52),
    createCardPicker: function() {
        return function() {
            let pickedCard = Math.floor(Math.random() * 52);
            let pickedSuit = Math.floor(pickedCard / 13);

            return {suit: this.suits[pickedSuit], card: pickedCard % 13};
        }
    }
}

let cardPicker = deck.createCardPicker();
let pickedCard = cardPicker();

alert("card: " + pickedCard.card + " of " + pickedCard.suit);

按照第二段的说法,我们可以简单分析:

  • 首先,cardPicker被赋值为返回的函数,此时该函数并没有被调用,所以this的值还没有被指定
  • 接着,我们调用了cardPicker,但是从cardPicker的上下文来看,它并不在deck之中,而是在与它同在最顶层的作用域中,所以此时它的this就会被指定为Window对象

箭头函数

那么,我们如何解决这个问题呢?思路就是在createCardPicker中就绑定好this

这里我们介绍一下箭头函数。与ES6中的箭头函数一样,它能保存函数创建时的this值,而不是调用时的值:

let deck = {
    suits: ["hearts", "spades", "clubs", "diamonds"],
    cards: Array(52),
    createCardPicker: function() {
        // NOTE: the line below is now an arrow function, allowing us to capture 'this' right here
        return () => {
            let pickedCard = Math.floor(Math.random() * 52);
            let pickedSuit = Math.floor(pickedCard / 13);

            return {suit: this.suits[pickedSuit], card: pickedCard % 13};
        }
    }
}

let cardPicker = deck.createCardPicker();
let pickedCard = cardPicker();

alert("card: " + pickedCard.card + " of " + pickedCard.suit);

实际上,到这里我们的代码已经可以正确编译并运行了,但是这里还存在一个小小的问题——如果你把这段代码复制到VS Code上的话,会看到this的类型是any(这也是拥有类型系统后出现的新问题)

this参数

造成这个小问题的原因在官方文档中被解释为:

这是因为this来自对象字面量里的函数表达式

那么我们来解决这个小问题,方法是函数中添加this参数

function f(this: void) {
    // make sure `this` is unusable in this standalone function
}

this参数是个假参数,它必须出现在参数列表的最前面

我们通过添加接口,来优化上面的代码:

interface Card {
    suit: string;
    card: number;
}
interface Deck {
    suits: string[];
    cards: number[];
    createCardPicker(this: Deck): () => Card;
}
let deck: Deck = {
    suits: ["hearts", "spades", "clubs", "diamonds"],
    cards: Array(52),
    // NOTE: The function now explicitly specifies that its callee must be of type Deck
    createCardPicker: function(this: Deck) {
        return () => {
            let pickedCard = Math.floor(Math.random() * 52);
            let pickedSuit = Math.floor(pickedCard / 13);

            return {suit: this.suits[pickedSuit], card: pickedCard % 13};
        }
    }
}

let cardPicker = deck.createCardPicker();
let pickedCard = cardPicker();

alert("card: " + pickedCard.card + " of " + pickedCard.suit);

现在,TypeScript就知道createCardPicker期望在某个Deck对象上调用。 也就是说thisDeck类型的,而非any

重载

因为Javascript的原因,Typescript中的函数重载与C++、Java等不太相同,需要借助typeof。具体语法直接看例子:

function pickCard(x: {suit: string; card: number; }[]): number;
function pickCard(x: number): {suit: string; card: number; };
function pickCard(x): any {
    
    // Check to see if we're working with an object/array
    // if so, they gave us the deck and we'll pick the card
    
    if (typeof x == "object") {
        let pickedCard = Math.floor(Math.random() * x.length);
        return pickedCard;
    }
    
    // Otherwise just let them pick the card
    else if (typeof x == "number") {
        let pickedSuit = Math.floor(x / 13);
        return { suit: suits[pickedSuit], card: x % 13 };
    }
    
}

总结来说,就是写多个函数原型,但是只在一个语法固定的函数体中用typeof来分别返回

另外,函数原型的参数个数不能不同,所以总结来说这个重载并不是那么好用......