学习Typescript

最近用TypeScript写了一个游戏,顺道把博客的后端项目也改成了ts版本,下面整理在学习TypeScript时遇见的的问题。

<!--more-->

参考文档

1. 开发环境

1.1. 安装

首先全局安装tsc

npm install -g typescript

然后就可以编写typescript文件并将其编译成javascript文件并执行了

# 创建test.ts文件并写入内容
touch test.ts && echo 'let a:number = 100; console.log(a);' > test.ts
# 编译ts文件
tsc test.ts
# 在当前目录下生成test.js
node test.js

vscode对typescript的支持十分友好(毕竟vscode就是ts写的),因此建议使用vscode或webStorm作为ts开发工具。

如果每次修改ts文件都需要编译一次再运行,则显得比较繁琐,可以使用ts-node这个工具直接运行ts代码,这在编写demo代码时非常有效

npm install -g ts-node
# 直接执行ts文件
ts-node test.ts

1.2. tsconfig

除了上面指定tsc filename.ts的基础调用方式之外,还可以配置额外的命令行参数,如输入文件、输出目录等

tsc index.ts --相关参数

将编译配置参数都通过命令行的形式传入是一件比较麻烦的事,因此ts提供了一个叫tsconfig.json的配置文件,用来指定ts编译的一些参数信息,包括用来编译这个项目的根文件和编译选项。

如果一个目录下包含tsconfig.json文件,那么该目录将会作为这个ts项目的根目录

具体配置参数可以参考

tsconfig并不是项目必须的,初学时可以直接跳过。了解了开发环境的搭建周后,接下来学习TypeScript的基础语法。

2. 变量类型

TypeScript里的类型注解是一种轻量级的为函数或变量添加约束的方式。其格式为

variableName: variableType

首先需要明确的是,ts中存在两种声明空间:类型声明空间与变量声明空间

  • 类型声明空间包含用来当做类型注解的内容,如 class XXXinterface XXXtype XXX
  • 变量声明空间包含可用作变量的内容,如let iconst j

下面整理了ts中的变量类型,包括基础类型和可自定义类型的一些写法,建议直接阅读官方文档

2.1. 基础类型

原始类型

TS在类型声明空间中,内置了一些基础的数据类型

  • boolean,布尔值

  • number,ts所有数字都是浮点数

  • string,与js相同,支持模板字符串

  • 数组:

    • 基础类型[],例如number[]
    • 数组泛型,例如Array<number>
  • 元组[string, number],需要保证对应位置的元素类型保持一致

  • enum

  •   enum Color {Red = 1, Green = 2, Blue = 4}
      let c: Color = Color.Green;
  • any,主要用于在编程阶段还不清楚类型的变量指定一个类型,使用any可以直接让这些变量通过编译阶段的检查

  • void,与any相反,表示没有任何类型,通常声明函数没有任何返回值

  • null和undefined,在--strictNullChecks模式下,nullundefined只能赋值给void和它们各自

  • never,表示的是那些永不存在的值的类型

非原始类型

除了上面的基本类型之外的所有类型,都被称为object,表示非原始类型。

在某些时候,我们需要主动确定某个变量的类型,此时可以通过类型断言告诉ts编译器

let someValue: any = "this is a string";
let strLength: number = (<string>someValue).length;

let strLength2: number = (someValue as string).length;

类型断言可以理解为强制类型转换。

2.2. 接口

TypeScript的核心原则之一是对值所具有的结构进行类型检查,接口的作用就是为这些类型命名和为你的代码或第三方代码定义契约。

声明变量的类型

传入的对象参数实际上会包含很多属性,但是编译器只会检查那些必需的属性是否存在,并且其类型是否匹配,可以使用interface接口来自定义变量的类型

// interface定义接口
interface Person {
    name: string; //定义属性名name,对应数据类型为string,不包含该属性会报错
    age?: number; // 可选属性,如果不传也不会报错
    readonly from: string; // 只读属性
    [prop: string]: any; // 允许包含其他数据类型为any属性,去掉则无法传递avatar参数
}
// 指定了greet函数的参数类型为Person
function greet(p: Person):void {
    console.log(p.name);
    // p.from = '123'; 无法修改只读属性
      console.log(p.xxx); // 因为声明了[prop: string]: any,导致此处不会报错

    if (p.age) {
        console.log(p.age);
    }
}
// 此处就会对参数类型进行检测
greet({ name: "shymean", from:"chengdu", age: 18, avatar: "http://xxx/xx.jpg" });

类型检测限制了变量的类型,而可选属性的好处有

  • 可以对可能存在的属性进行预定义,放宽了对于变量的属性检测限制
  • 相比较使用[prop: string]: any,可以捕获引用了不存在的属性时的错误

声明函数的签名

函数的签名包括了参数和返回值类型,由于JavaScript中函数可以通过函数表达式进行声明,因此在ts中,接口除了描述自定义变量的类型,也可以用来描述函数的类型

interface greetFunc {
  (person: Person): void;
}
let greet: greetFunc = function greet(p: Person) {};
// 调用方式同上
greet({ name: "shymean", from:"chengdu", age: 18, avatar: "http://xxx/xx.jpg" });

类的接口

接口也可以用来限定某个类的实现

interface ClockInterface {
    currentTime: Date;
      showTime(time: Date):void
}
// 类需要实现接口的属性和方法
class Clock implements ClockInterface {
    currentTime: Date; // 如果不声明ClockInterface接口上的属性,则会提示错误
    constructor(h: number, m: number) { }
    showTime(){}
}

需要注意的是:当一个类实现了一个接口时,只对其实例部分进行类型检查,类的静态部分不再检查范围内。

其他

接口也可以互相继承,组合成新的接口,这样可以在多个接口间复用公共的属性

2.3. 类

class是一种比较常见的自定义类型,注意class Person除了在类型声明空间提供了一个Person的类型,也在变量声明空间提供了一个可以使用的变量Person用于实例化对象

下面是一个简单的例子


class Person {
    // 增加访问修饰符
    private age: number; // 只能在当前类中访问
    protected name: string; // 只能在当前类及其子类访问
    gender: number; // 默认public,可在实例上访问

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

    // 方法也可以使用访问修饰符
    public show() {
        console.log(`${this.name}:${this.age} ${this.gender}`);
    }
}

let p: Person = new Person("shymean", 10, 1);
console.log(p.gender);
// console.log(p.name); // 访问protected属性会报错
// console.log(p.age); // 访问private属性会报错
p.show();

// 继承
class Student extends Person {
    static count: number = 0;
    readonly grade: string = undefined; // 只读属性
    constructor(name: string, age: number, gender: number, grade: string) {
        // 构造函数里访问 this的属性之前,一定先要调用 super()
        super(name, age, gender);
        this.grade = grade // 只读属性只能在声明时或者构造函数中初始化

        // 静态属性
        Student.count++;
    }
      // 重写父类的show方法
    show(){
        // 可以访问父类的public和protected属性
        console.log(`${this.name} ${this.gender}`);
        // 无法访问父类的私有属性
        // console.log(this.age);

        // 调用父类方法
        super.show(); 
    }
    // 静态方法
    static getCount() {
        // this.show() // 静态方法类无法调用实例方法
        console.log(Student.count)
    }
}

let s = new Student("shymean", 10, 1, "freshman");
s.show()

let s2 = new Student("shymean2", 10, 1, "freshman");

Student.getCount() // 返回 2

function test(p:Person){
    p.show()
}

test(p);
test(s); // 子类也可以通过类型检测

2.4. 类型别名

类型别名会给一个类型起个新名字。 类型别名有时和接口很像,但是可以作用于原始值,联合类型,元组以及其它任何你需要手写的类型

type User = {
    name: string,
    age: number
}
type Name = string;

类型别名与接口的区别在于

  • 接口创建了一个新的名字,可以在其它任何地方使用。 类型别名并不创建新名字—比如,错误信息就不会使用别名
  • 类型别名不能像接口一样被继承和实现

3. 代码复用

3.1. 泛型

为了扩展函数的可复用性,接口不仅要能够支持当前的数据类型,也需要能够支持未来的数据类型,这就是泛型的概念。也就是说,我们可以在编写代码时(如调用方法、实例化对象)指定数据类型。

泛型变量

一个典型的例子是:函数需要返回与参数类型相同的值

// T可以看做是正则表达式里面的捕获,获取了参数的类型后,就可以用来声明返回值的类型了
function identity<T>(arg: T): T {
    return arg;
}
let a: number = identity<number>(2)
let b: string = identity<number>(2) // 报错:无法将number类型赋值给string类型
let c: string = identity<string>("hello"); // 传入不同的T类型

泛型接口

在上面的例子中,可以通过泛型接口来指定泛型类型

interface identityFunc {
    <T>(arg: T): T
}

let identity: identityFunc = function<T>(arg: T): T {
    return arg;
};

泛型类

泛型类看上去与泛型接口差不多。 泛型类使用( <>)括起泛型类型,跟在类名后面。

class GenericNumber<T> {
    zeroValue: T;
    add: (x: T, y: T) => T;
}

// 实现一个数字的add方法
let myGenericNumber = new GenericNumber<number>();
myGenericNumber.zeroValue = 0;
myGenericNumber.add = function(x, y) {
    return x + y;
};
let a: number = myGenericNumber.add(10, 20);

// 实现一个字符串的add方法
let myGenericString = new GenericNumber<string>();
myGenericString.zeroValue = '';
myGenericString.add = function(x, y) {
    return x + y;
};
let d: string = myGenericString.add("hello", "world");

泛型约束

在某些时候需要指定实现某些特定的数据类型

interface Lengthwise {
    length: number;
}
function loggingIdentity<T extends Lengthwise>(arg: T): T {
    console.log(arg.length);  
    return arg;
}
loggingIdentity(1) // T需要实现 Lengthwise,即包含length属性

3.2. 模块

模块是自声明的;两个模块之间的关系是通过在文件级别上使用imports和exports建立的。

当目录下包含了tsconfig.json文件后,该项目就成了一个ts项目

任何包含顶级import或者export的文件都被当成一个模块,模块在其自身的作用域里执行,而不是在全局作用域里。相反地,如果一个文件不带有顶级的import或者export声明,那么它的内容被视为当前ts项目中全局可见的(因此对模块也是可见的)。

通过export关键字来导出变量,函数,类,类型别名或接口

// mod1.ts
export interface Person {
      name: string
}
export const numberRegexp = /^[0-9]+$/;

let count = 100;
export { count as defaultCount }; // 导出重命名

// 默认导出
export default {
    test():void{
        console.log('mod1 test')
    }
}

通过import关键字来导入模块的部分或全部内容

import {Person} from './mod1'
import { numberRegexp as re } from "./mod1";
import * as mod1 from "./mod1";
import mod1Util from "./mod1"; // 引入默认导出的内容,不需要与模块中的声明同名

// 使用mod1模块中的内容
let p: Person = {name:'shyean'}
console.log(re.test('123'));
console.log(mod1.defaultCount);
mod1Util.test();

为了兼容CommonJS和AMD的环境里的exports变量,ts还支持export =方式

// mod3.ts
export = {
    test():void{
        console.log('mod3 test')
    }
}

上面这种形式导出的模块,需要通过import = require()引入

import mod2 = require('./mod3')
mod2.test()

js支持多种模块语法,如CommonJS、AMD、CMD、ES6模块等,在将ts编译为js时,可以通过指定--module moduleName的形式将代码编译为指定模块形式的代码

tsc --module commonjs Test.ts

编译完成后,每个模块文件都会生成一个单独的.js文件

4. 从JavaScript项目迁移

4.1. *.d.ts声明文件

由于ts增加了类型声明,使用变量前需要先进行声明,这导致在调用很多原生接口(浏览器、Node.js)或者第三方模块的时候,因为变量未声明而导致编译器的类型检查失败。

由于目前主流的库都是通过JavaScript编写的,且用 ts 写的模块在发布的时候仍然是用 js 发布,因此手动修改原生接口或者第三方模块源码肯定是不现实的,如何对原有的JS库也提供类型推断呢?

typescript先后提出了 tsd(已废弃)、typings(已废弃)等功能,最后提出了 DefinitelyTyped,该规范需要用户编写.d.ts类型定义文件,用来给编译器以及IDE识别是否符合API定义类型。

下面是一个描述d.ts文件作用的例子。假设有一个之前编写的js工具库util.js

// util.js
module.exports = {
    log(msg) {
        console.log("util.log: ", msg);
    }
};

在新的ts项目中,我们需要使用这个工具库

// a.ts
import util = require('./util.js') // 由于util模块使用commonjs规范,使用import = require语法导入模块

// 此处编辑器不会帮我们做任何提示
util.log("msg")
util.log() // 不知道参数的个数、类型等信息

由于util是js文件,我们无法使用ts的类型推断功能,也不知道util.log方法的参数类型和返回值类型。接下来让我们编写util.d.ts来帮助编辑器和ts编译器

// util.d.ts
declare var util: {
    test(msg: string): void;
};

export = util;

此时查看a.ts中的代码(可能需要重启下vscode),就可以看见下面的错误提示

这样,我们就完成了在ts项目中为js库增加类型推断的功能。

上面的例子展示了在现有ts项目中引入js模块,并增加类型检测的方法。如果想要了解更多关于d.ts的内容,可以参考

另外上面这个例子也从侧面展示了将现有js项目迁移到ts的方式。一般来说,将现有JavaScript项目迁移到TypeScript项目是十分简单的,由于任何JS文件都是有效的TS文件,因此最简单的迁移流程应该是

  • 添加一个 tsconfig.json 文件,方便配置项目和编译相关信息
  • 把文件扩展名从 .js 改成 .ts,开始使用 any 来减少错误;
  • 开始在 TypeScript 中写代码,尽可能的减少 any 的使用;
  • 回到旧代码,开始添加类型注解,并修复已识别的错误;
  • 手动编写d.ts文件,为第三方 JavaScript 代码定义环境声明。

4.2. 开发node服务

有了ts-node,搭建node服务开发环境就变得比较简单了,结合nodemon,还可以实现文件热更新等功能。

export NODE_ENV=development && nodemon --watch 'server/**/*' -e ts,tsx --exec ts-node ./server/index.ts

参考

4.3. 开发前端应用

vue

vue源码中使用flow作为类型检测机制,在正在开发的vue3版本中,计划改用typescript,因此在学习typescript并在vue开发中ts就变得理所应当,参考TypeScript 支持-Vue文档

react

根据描述,typescript支持内嵌、类型检查以及将JSX直接编译为js文件,因此在react中使用ts是十分方便的。参考TypeScript 中文手册-React

5. 小结

Typescript有下面几个优点

  • 在开发阶段,如果参数类型不正确,或者调用了不存在的方法,就会在编译阶段抛出错误,减少潜在的bug
  • 强类型的变量和参数,允许IDE提供代码智能提示,也方便代码阅读

本文主要整理了在学习Typescript过程中的一些笔记

  • 介绍了ts开发环境的安装,使用ts-node快速运行ts代码
  • 整理了ts中的基本类型(number、boolean、enum等)和自定义类型(接口、类、类型别名)相关语法
  • 整理了ts中泛型和模块的相关概念
  • .d.ts出发,了解了从JavaScript项目迁移到TypeScript的大致流程

当然还遗漏了很多细节语法等问题,需要在项目使用中进一步学习。最后放上一个问题:弱类型、强类型、动态类型、静态类型语言的区别是什么?