TypeScript入门完全指南(基础篇)

8,698 阅读18分钟

本文来源于团队内分享。TypeScript的官方文档虽然较为全面,但通读下来却要耗时不少;另外,TypeScript中文资料本身也比较缺乏,本文可作为准备尝试TypeScript的同学入门使用,涵盖了上手TypeScript之前所需要的所有基础知识。

为什么JS需要类型检查

TypeScript的设计目标在这里可以查看到,简单概括为两点:

  1. 为JavaScript提供一个可选择的类型检查系统;
  2. 为JavaScript提供一个包含将来新特性的版本。

TypeScript的核心价值体现在第一点,第二点可以认为是TypeScript的向后兼容性保证,也是TypeScript必须要做到的。

那么为什么JS需要做静态类型检查呢?在几年前这个问题也许还会存在比较大的争议,在前端日趋复杂的今天,经过像Google、Microsoft、FaceBook这样的大公司实践表明,类型检查对于代码可维护性和可读性是有非常大的帮助的,尤其针对于需要长期维护的规模性系统。

TypeScript优势

在我看来,TypeScript能够带来最直观上的好处有三点:

  1. 帮助更好地重构代码;
  2. 类型声明本身是最好查阅的文档。
  3. 编辑器的智能提示更加友好。

一个好的代码习惯是时常对自己写过的代码进行小的重构,让代码往更可维护的方向去发展。然而对于已经上线的业务代码,往往测试覆盖率不会很高,当我们想要重构时,经常会担心自己的改动会产生各种不可预知的bug。哪怕是一个小的重命名,也有可能照顾不到所有的调用处造成问题。

如果是一个TypeScript项目,这种担心就会大大降低,我们可以依赖于TypeScript的静态检查特性帮助找出一个小的改动(如重命名)带来的其他模块的问题,甚至对于模块文件来说,我们可以直接借助编辑器的能力进行“一键重命名”操作。

另外一个问题,如果你接手过一个老项目,肯定会头痛于各种文档的缺失和几乎没有注释的代码,一个好的TypeScript项目,是可以做到代码即文档的,通过声明文件我们可以很好地看出各个字段的含义以及哪些是前端必须字段:

// 砍价用户信息
export interface BargainJoinData {
  curr_price: number; // 当前价
  curr_ts: number; // 当前时间
  init_ts: number; // 创建时间
  is_bottom_price: number; // 砍到底价
}

TypeScript对开发者是友好的

TypeScript在设计之初,就确定了他们的目标并不是要做多么严格完备的类型强校验系统,而是能够更好地兼容JS,更贴合JS开发者的开发习惯。可以说这是MS的商业战略,也是TS能够成功的关键性因素之一。它对JS的兼容性主要表现为以下三个方面:

隐式的类型推断

var foo = 123;
foo = "456"; // Error: cannot assign `string` to `number`

当我们对一个变量或函数等进行赋值时,TypeScript能够自动推断类型赋予变量,TypeScript背后有非常强大的自推断算法帮助识别类型,这个特性无疑可以帮助我们简化一些声明,不必像强类型的语言那样处处是声明,也可以让我们看代码时更加轻松。

结构化的类型

TypeScript旨在让JS开发者更简单地上手,因此将类型设计为“结构化”(Structural)的而非“名义式”(Nominal)的。

什么意思呢?意味着TypeScript的类型并不根据定义的名字绑定,只要是形似的类型,不管名称相不相同,都可以作为兼容类型(这很像所谓的duck typing),也就是说,下面的代码在TypeScript中是完全合法的:

class Foo { method(input: string) { /* ... */ } }
class Bar { method(input: string) { /* ... */ } }
let test: Foo = new Bar(); // no Error!

这样实际上可以做到类型的最大化复用,只要形似,对于开发者也是最好理解的。(当然对于这个示例最好的做法是抽出一个公共的interface)

知名的JS库支持

TypeScript有强大的DefinitelyTyped社区支持,目前类型声明文件基本上已经覆盖了90%以上的常用JS库,在编写代码时我们的提示是非常友好的,也能做到安全的类型检查。(在使用第三方库时,可以现在这个项目中检索一下有没有该库的TS声明,直接引入即可)

回顾两个基础知识

在进入正式的TS类型介绍之前,让我们先回顾一下JS的两个基础:

相等性判断

我们都知道,在JS里,两个等号的判断会进行隐式的类型转换,如:

console.log(5 == "5"); // true 
console.log(0 == ""); // true

在TS中,因为有了类型声明,因此这两个结果在TS的类型系统中恒为false,因此会有报错:

This condition will always return 'false' since the types '5' and '"5"' have no overlap.

所以在代码层面,一方面我们要避免这样两个不同类型的比较,另一方面使用全等来代替两个等号,保证在编译期和运行期具有相同的语义。

对于TypeScript而言,只有nullundefined的隐式转换是合理的:

console.log(undefined == undefined); // true
console.log(null == undefined); // true
console.log(0 == undefined); // false
console.log('' == undefined); // false
console.log(false == undefined); // false

类(Class)

对于ES6的Class,我们本身已经很熟悉了,值得一提的是,目前对于类的静态属性、成员属性等有一个提案——proposal-class-fields已经进入了Stage3,这个提案包含了很多东西,主要是类的静态属性、成员属性、公有属性和私有属性。其中,私有属性的提案在社区内引起了非常大的争议,由于它的丑陋和怪异遭受各路人马的抨击,现TC39委员会已决定重新思考该提案。

现在让我们来看看TypeScript对属性访问控制的情况:

可访问性 public protected private
类本身
子类
类的实例

可以看到,TS中的类成员访问和其他语言非常类似:

class FooBase {
    public x: number;
    private y: number;
    protected z: number;
}

对于类的成员构造函数初始化,TS提供了一个简单的声明方式:

class Foo {
    constructor(public x:number) {
    }
}

这段代码和下面是等同的:

class Foo {
    x: number;
    constructor(x:number) {
        this.x = x;
    }
}

TS类型系统基础

基本性准则

在正式了解TypeScript之前,首先要明确两个基本概念:

  1. TypeScript的类型系统设计是可选的,意味着JavaScript就是TypeScript。
  2. TypeScript的报错并不会阻止JS代码的生成,你可以渐进式地将JS逐步迁移为TS。

基本语法

:<TypeAnnotation>

TypeScript的基本类型语法是在变量之后使用冒号进行类型标识,这种语法也揭示了TypeScript的类型声明实际上是可选的。

原始值类型

var num: number;
var str: string;
var bool: boolean;

TypeScript支持三种原始值类型的声明,分别是numberstringboolean

对于这三种原始值,TS同样支持以它们的字面量为类型:

var num: 123;
var str: '123';
var bool: true;

这类字面量类型配合上联合类型还是十分有用的,我们后面再讲。

数组类型

对于数组的声明也非常简单,只需要加上一个中括号声明类型即可:

var boolArray: boolean[];

以上就简单地定义了一个布尔类型的数组,大多数情况下,我们数组的元素类型是固定的,如果我们数组内存在不同类型的元素怎么办?

如果元素的个数是已知有限的,可以使用TS的元组类型:

var nameNumber: [string, number];

该声明也非常的形象直观,如果元素个数不固定且类型未知,这种情况较为罕见,可直接声明成any类型:

var arr: any[]

接口类型

接口类型是TypeScript中最常见的组合类型,它能够将不同类型的字段组合在一起形成一个新的类型,这对于JS中的对象声明是十分友好的:

interface Name {
    first: string;
    second: string;
}

var personName:Name = {
    first: '张三'
} // Property 'second' is missing in type '{ first: string; }' but required in type 'Name'

上述例子可见,TypeScript对每一个字段都做了检查,若未定义接口声明的字段(非可选),则检查会抛出错误。

内联接口

对于对象来说,我们也可以使用内联接口来快速声明类型:

var personName:{ first: string, second: string } = {
    first: '张三'
} // Property 'second' is missing in type '{ first: string; }' but required in type 'Name'

内联接口可以帮助我们快速声明类型,但建议谨慎使用,对于可复用以及一般性的接口声明建议使用interface声明。

索引类型

对于对象而言,我们可以使用中括号的方式去存取值,对TS而言,同样支持相应的索引类型:

interface Foo {
  [key:string]: number
}

对于索引的key类型,TypeScript只支持numberstring两种类型,且Number是string的一种特殊情况。

对于索引类型,我们在一般化的使用场景上更方便:

interface NestedCSS {
  color?: string;
  nest?: {
    [selector: string]: NestedCSS;
  }
}

const example: NestedCSS = {
  color: 'red',
  nest: {
    '.subclass': {
      color: 'blue'
    }
  }
}

类的接口

对于接口而言,另一个重要作用就是类可以实现接口:

interface Point {
    x: number; y: number;
    z: number; // New member
}

class MyPoint implements Point { // ERROR : missing member `z`
    x: number; y: number;
}

对类而言,实现接口,意味着需要实现接口的所有属性和方法,这和其他语言是类似的。

函数类型

函数是TypeScript中最常见的组成单元:

interface Foo {
    foo: string;
}

// Return type annotated as `: Foo`
function foo(sample: Foo): Foo {
    return sample;
}

对于函数而言,本身有参数类型和返回值类型,都可进行声明。

可选参数

对于参数,我们可以声明可选参数,即在声明之后加一个问号:

function foo(bar: number, bas?: string): void {
    // ..
}

void和never类型

另外,上述例子也表明,当函数没有返回值时,可以用void来表示。

当一个函数永远不会返回时,我们可以声明返回值类型为never

function bar(): never {
    throw new Error('never reach');
}

callable和newable

我们还可以使用接口来定义函数,在这种函数实现接口的情形下,我们称这种定义为callable:

interface Complex {
  (bar?: number, ...others: boolean[]): number;
}

var foo: Complex;

这种定义方式在可复用的函数声明中非常有用。

callable还有一种特殊的情况,该声明中指定了new的方法名,称之为newable

interface CallMeWithNewToGetString {
  new(): string
}

var foo: CallMeWithNewToGetString;

new foo();

这个在构造函数的声明时非常有用。

函数重载

最后,一个函数可以支持多种传参形式,这时候仅仅使用可选参数的约束可能是不够的,如:

unction padding(a: number, b?: number, c?: number, d?: number) {
    if (b === undefined && c === undefined && d === undefined) {
        b = c = d = a;
    }
    else if (c === undefined && d === undefined) {
        c = a;
        d = b;
    }
    return {
        top: a,
        right: b,
        bottom: c,
        left: d
    };
}

这个函数可以支持四个参数、两个参数和一个参数,如果我们粗略的将后三个参数都设置为可选参数,那么当传入三个参数时,TS也会认为它是合法的,此时就失去了类型安全,更好的方式是声明函数重载:

function padding(all: number);
function padding(topAndBottom: number, leftAndRight: number);
function padding(top: number, right: number, bottom: number, left: number);
function padding(a: number, b?: number, c?: number, d?: number) {
   //...
}

函数重载写法也非常简单,就是重复声明不同参数的函数类型,最后一个声明包含了兼容所有重载声明的实现。这样,TS类型系统就能准确的判断出该函数的多态性质了。

使用callable的方式也可以声明重载:

interface Padding {
  (all: number): any
  (topAndBottom: number, leftAndRight: number): any
  (top: number, right: number, bottom: number, left: number): any
}

特殊类型

any

any在TypeScript中是一个比较特殊的类型,声明为any类型的变量就像动态语言一样不受约束,好像关闭了TS的类型检查一般。对于any类型的变量,可以将其赋予任何类型的值:

var power: any;

power = '123';
power = 123;

any对于JS代码的迁移是十分友好的,在已经成型的TypeScript项目中,我们要慎用any类型,当你设置为any时,意味着告诉编辑器不要对它进行任何检查。

null和undefined

nullundefined作为TypeScript的特殊类型,它同样有字面量的含义,之前我们已经了解到。

值得注意的是,nullundefined可以赋值给任意类型的变量:

var num: number;
var str: string;

// 赋值给任意类型的变量都是合法的
num = null;
str = undefined;

void和never

在函数类型中,我们已经介绍了两种类型,专门修饰函数返回值。

readonly

readonly是只读属性的修饰符,当我们的属性是只读时,可以用该修饰符加以约束,在类中,用readonly修饰的属性仅可以在构造函数中初始化:

class Foo {
    readonly bar = 1; // OK
    readonly baz: string;
    constructor() {
        this.baz = "hello"; // OK
    }
}

一个实用场景是在react中,propsstate都是只读的:

interface Props {
    readonly foo: number;
}
interface State {
    readonly bar: number;
}
export class Something extends React.Component<Props,State> {
  someMethod() {
    this.props.foo = 123; // ERROR: (props are immutable)
    this.state.baz = 456; // ERROR: (one should use this.setState)  
  }
}

当然,React本身在类的声明时会对传入的propsstate做一层ReadOnly的包裹,因此无论我们是否在外面显式声明,赋值给propsstate的行为都是会报错的。

注意,readonly听起来和const有点像,需要时刻保持一个概念:

  • readonly是修饰属性的
  • const是声明变量的

泛型

在更加一般化的场景,我们的类型可能并不固定已知,它和any有点像,只不过我们希望在any的基础上能够有更近一步的约束,比如:

function reverse<T>(items: T[]): T[] {
    var toreturn = [];
    for (let i = items.length - 1; i >= 0; i--) {
        toreturn.push(items[i]);
    }
    return toreturn;
}

reverse函数是一个很好的示例,对于一个通用的函数reverse来说,数组元素的类型是未知的,可以是任意类型,但reverse函数的返回值也是个数组,它和传入的数组类型是相同的,对于这个约束,我们可以使用泛型,其语法是尖括号,内置泛型变量,多个泛型变量用逗号隔开,泛型变量名称没有限制,一般而言我们以大写字母开头,多个泛型变量使用其语义命名,加上T为前缀。

在调用时,可以显示的指定泛型类型:

var reversed = reverse<number>([1, 2, 3]);

也可以利用TypeScript的类型推断,进行隐式调用:

var reversed = reverse([1, 2, 3]);

由于我们的参数类型是T[],而传入的数组类型是一个number[],此时T的类型被TypeScript自动推断为number

对于泛型而言,我们同样可以作用于接口和类:

interface Array<T> {
 reverse(): T[];
 // ...
}

联合类型

在JS中,一个变量的类型可能拥有多个,比如:

function formatCommandline(command: string[]|string) {
    var line = '';
    if (typeof command === 'string') {
        line = command.trim();
    } else {
        line = command.join(' ').trim();
    }
}

此时我们可以使用一个|分割符来分割多种类型,对于这种复合类型,我们称之为联合类型

交叉类型

如果说联合类型的语义等同于或者,那么交叉类型的语义等同于集合中的并集,下面的extend函数是最好的说明:

function extend<T, U>(first: T, second: U): T & U {
    let result = <T & U> {};
    for (let id in first) {
        result[id] = first[id];
    }
    for (let id in second) {
        if (!result.hasOwnProperty(id)) {
            result[id] = second[id];
        }
    }
    return result;
}

该函数最终以T&U作为返回值值,该类型既包含了T的字段,也包含了U的字段,可以看做是两个类型的并集

类型别名

TypeScript为类型的复用提供了更便捷的方式——类型别名。当你想复用类型时,可能在该场景下要为已经声明的类型换一个名字,此时可以使用type关键字来进行类型别名的定义:

interface state {
  a: 1
}

export type userState = state;

我们同样可以使用type来声明一个类型:

type Text = string | { text: string };
type Coordinates = [number, number];
type Callback = (data: string) => void;

对于type和interface的取舍:

  • 如果要用交叉类型或联合类型,使用type。
  • 如果要用extend或implement,使用interface。
  • 其余情况可看个人喜好,个人建议type更多应当用于需要起别名时,其他情况尽量使用interface。

枚举类型

对于组织一系列相关值的集合,最好的方式应当是枚举,比如一系列状态集合,一系列归类集合等等。

在TypeScript中,枚举的方式非常简单:

enum Color {
    Red,
    Green,
    Blue
}
var col = Color.Red;

默认的枚举值是从0开始,如上述代码,Red=0Green=1依次类推。

当然我们还可以指定初始值:

enum Color {
    Red = 3,
    Green,
    Blue
}

此时Red=3, Green=4依次类推。

大家知道在JavaScript中是不存在枚举类型的,那么TypeScript的枚举最终转换为JavaScript是什么样呢?

var Color;
(function (Color) {
    Color[Color["Red"] = 0] = "Red";
    Color[Color["Green"] = 1] = "Green";
    Color[Color["Blue"] = 2] = "Blue";
})(Color || (Color = {}));

从编译后的代码可以看到,转换为一个key-value的对象后,我们的访问也非常方便:

var red = Color.Red; // 0
var redKey = Color[0]; // 'Red'
var redKey = Color[Color.Red]; // 'Red'

既可以通过key来访问到值,也可以通过值来访问到key。

Flag标识位

对于枚举,有一种很实用的设计模式是使用位运算来标识(Flag)状态:

enum EnvFlags {
  None = 0,
  QQ = 1 << 0,
  Weixin = 1 << 1
}

function initShare(flags: EnvFlags) {
  if (flags & EnvFlags.QQ) {
    initQQShare();
  }
  if (flags & EnvFlags.Weixin) {
    initWeixinShare();
  }
}

在我们使用标识位时,可以遵循以下规则:

  • 使用 |= 增加标志位
  • 使用 &=~清除标志位
  • 使用 | 联合标识位

如:

var flag = EnvFlags.None;
flag |= EnvFlags.QQ;    // 加入QQ标识位
Flag &= ~EnvFlags.QQ;   // 清除QQ标识位
Flag |=  EnvFlags.QQ | EnvFlags.Weixin; // 加入QQ和微信标识位

常量枚举

在枚举定义加上const声明,即可定义一个常量枚举:

enum Color {
    Red = 3,
    Green,
    Blue
}

对于常量枚举,TypeScript在编译后不会产生任何运行时代码,因此在一般情况下,应当优先使用常量枚举,减少不必要代码的产生。

字符串枚举

TypeScript还支持非数字类型的枚举——字符串枚举

export enum EvidenceTypeEnum {
  UNKNOWN = '',
  PASSPORT_VISA = 'passport_visa',
  PASSPORT = 'passport',
  SIGHTED_STUDENT_CARD = 'sighted_tertiary_edu_id',
  SIGHTED_KEYPASS_CARD = 'sighted_keypass_card',
  SIGHTED_PROOF_OF_AGE_CARD = 'sighted_proof_of_age_card',
}

这类枚举和我们之前使用JavaScript定义常量集合的方式很像,好处在于调试或日志输出时,字符串比数字要包含更多的语义。

命名空间

在没有模块化的时代,我们为了防止全局的命名冲突,经常会以命名空间的形式组织代码:

(function(something) {

    something.foo = 123;

})(something || (something = {}))

TypeScript内置了namespace变量帮助定义命名空间:

namespace Utility {
    export function log(msg) {
        console.log(msg);
    }
    export function error(msg) {
        console.error(msg);
    }
}

对于我们自己的工程项目而言,一般建议使用ES6模块的方式去组织代码,而命名空间的模式可适用于对一些全局库的声明,如jQuery:

namespace $ {
  export function ajax(//...) {}
}

当然,命名空间还可以便捷地帮助我们声明静态方法,如和enum的结合使用:

enum Weekday {
    Monday,
    Tuesday,
    Wednesday,
    Thursday,
    Friday,
    Saturday,
    Sunday
}
namespace Weekday {
    export function isBusinessDay(day: Weekday) {
        switch (day) {
            case Weekday.Saturday:
            case Weekday.Sunday:
                return false;
            default:
                return true;
        }
    }
}

const mon = Weekday.Monday;
const sun = Weekday.Sunday;
console.log(Weekday.isBusinessDay(mon)); // true
console.log(Weekday.isBusinessDay(sun)); // false

关于命名规范

变量名、函数和文件名

  • 推荐使用驼峰命名。
// Bad
var FooVar;
function BarFunc() { }

// Good
var fooVar;
function barFunc() { }

类、命名空间

  • 推荐使用帕斯卡命名。
  • 成员变量和方法推荐使用驼峰命名。
// Bad
class foo { }

// Good
class Foo { }

// Bad
class Foo {
    Bar: number;
    Baz() { }
}

// Good
class Foo {
    bar: number;
    baz() { }
}

Interface、type

  • 推荐使用帕斯卡命名。
  • 成员字段推荐使用驼峰命名。
// Bad
interface foo { }

// Good
interface Foo { }

// Bad
interface Foo {
    Bar: number;
}

// Good
interface Foo {
    bar: number;
}

关于模块规范

export default的争论

关于是否应该使用export default这里有详尽的讨论,在AirBnb规范中也有prefer-default-export这条规则,但我认为在TypeScript中应当尽量不使用export default

关于链接中提到的重命名问题, 甚至自动import,其实export default也是可以做到的,借助编辑器和TypeScript的静态能力。所以这一点还不是关键因素。

不过使用一般化的export更让我们容易获得智能提示:

import /* here */ from 'something';

在这种情况下,一般编辑器是不会给出智能提示的。 而这种:

import { /* here */ } from 'something';

我们可以通过智能提示做到快速引入。

除了这一点外,还有以下几点好处:

  • 对CommonJS是友好的,如果使用export default,在commonJS下需要这样引入:
const {default} = require('module/foo');

多了个default无疑感觉非常奇怪。

  • 对动态import是友好的,如果使用export default,还需要显示的通过default字段来访问:
const HighChart = await import('https://code.highcharts.com/js/es-modules/masters/highcharts.src.js');
Highcharts.default.chart('container', { ... }); // 注意 `.default`
  • 对于re-exporting是友好的,如果使用export default,那么进行re-export会比较麻烦:
import Foo from "./foo"; export { Foo }

相比之下,如果没有export default,我们可以直接使用:

export * from "./foo"

实践中的一些坑

实践篇即将到来,敬请期待~