「TypeScript」 开发手册梳理(上)

740 阅读31分钟

基于之前【TypeScript历险记】文章,结合 TypeScript 官网开发手册指南,重新简要概括在学习过程中的重点难点

致力于以下几点:

  1. 一文扫清 TypeScript 官网开发手册指南。
  2. 内容简洁、重点。
  3. 排版清晰、一目了然。
  4. 重构语句、语句使之顺畅。

因字数限制,分为上下两篇。

(结尾附跳转)

介绍

typeScript 是 javaScript 的超集:TypeScript = JavaScript + type + (some other stuff) 其他特性

typescript中文文档

特性

  • 面向对象(prototype、Function、Object)
    • Class interface
  • 类型检查(静态类型、强类型)
    • typeScript 编译成 javaScript时便检查(提前避免bug)
  • 参数自带文档内容
  • IDE或者是编译工具的良好支持(自动完成提示)

环境

  • 创建typeScript-demo文件 -> index.ts文件

  • 全局安装typeScript

npm install typescript -g

  • 编译typeScript代码文件

tsc index.ts -> 产生编译后的index.js文件

  • tsc -w index.ts -> 会自动编译代码

基础类型

  • boolean:true/false
let isDone: boolean = false;
  • number:TS 里所有数字都是浮点数,支持二进制、八进制、十进制、十六进制字面量
let decLiteral: number = 6; 
let hexLiteral: number = 0xf00d; 
let binaryLiteral: number = 0b1010; 
let octalLiteral: number = 0o744;
  • string:文本数据类型,包括模版字符串格式${}
let name: string = "bob";
  • array:有两种方式定义数组元素。
// 第一种:直接在元素类型后面接上`[]`。
let list: number[] = [1, 2, 3];

// 第二种:数组泛型
let list: Array<number> = [1, 2, 3];
let list: number[] = [1, 2, 3];
  • tuple:元组类型允许表示一个已知元素数量和类型的数组,各元素的类型不必相同。
let x: [string, number];

// 有严格顺序要求
x = ['hello', 10]; // OK
x = [10, 'hello']; // Error

当访问一个越界的元素时,会使用联合类型替代,联合类型是高级主题,会在以后的章节里进行学习。

// 该例子中联合类型为 (string | number) 类型

x[3] = 'world'; // 正确
console.log(x[5].toString());	// 正确
x[6] = true; // 错误,联合类型无 boolean
  • enum:枚举类型对 JavaScript 标准数据类型的一个补充,可以为一组数值赋予友好的名字。

利用友好有且意义可读性的字符串更方便操作处理数据。

enum Color {Red, Green, Blue}
let c: Color = Color.Green;

可对枚举进行元素编号,默认从 0 开始,也可以手动指定成员编号。

enum Color {Red = 1, Green, Blue}
let colorName: string = Color[2];	// 显示'Green',因为给 Red 手动编号为 1.
  • any:任意类型,比如来自用户输入或第三方代码库的动态数据内容,只知道部分数据的类型,或对数据类型并未知的情况,而不希望类型检查器对这些值进行检查而是直接让它们通过编译阶段的检查。
let notSure: any = 4;
notSure = "maybe a string instead";
notSure = false; // 三种基础类型都通过

// 数组内元素均为any
let list: any[] = [1, true, "free"];;

可以利用 typeof 来判断数据类型来处理操作

  • void:表示没有任何类型。 通过用于一个函数没有返回值的情况。
function warnUser(): void {
    console.log("This is my warning message");
}

如果直接声明一个void类型的变量没有什么用,只能赋予它nullundefined

  • null 和 undefined:两者各自有自己的类型分别叫做undefinednull
let u: undefined = undefined;
let n: null = null;

当指定了--strictNullChecks标记(默认关闭),nullundefined只能赋值给void和它们各自,这能避免很多常见的问题。

  • never:表示永不存在的值的类型。例如抛出异常情况、根本不会有返回值的函数、执行无终点或变量被永不为真的类型保护所约束时
// 返回never的函数必须存在无法达到的终点
function error(message: string): never {
    throw new Error(message);
}

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

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

never类型是任何类型的子类型,可以赋值给任何类型;

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

  • object:表示非原始类型,也就是基础类型之外的复杂类型。
// 声明函数类型为对象和null,无返回值。
declare function create(o: object | null): void;

create({ prop: 0 }); // OK
create(null); // OK

create(42); // Error
create("string"); // Error
create(false); // Error
create(undefined); // Error
  • 类型断言:清楚地知道一个实体具有比它现有类型更确切的类型,不进行特殊的数据检查和解构。 它没有运行时的影响,只是在编译阶段起作用。类型断言有两种形式:
// “尖括号”语法
let someValue: any = "this is a string";
let strLength: number = (<string>someValue).length;	// 确定 someValue 一定为 string

// as 语法
let someValue: any = "this is a string";
let strLength: number = (someValue as string).length;

两种形式是等价的。 但在TypeScript里使用JSX时,只能用 as语法断言。

  • let:TypeScript 实现 javaScript 该新特性,因为很多历史原因等,尽可能地使用 let

变量声明

简单 let 和 var,let 和 const 的特性与区别,不是重点。

解构

数组

  • 数组变量基础解构
let input = [1, 2];
let [first, second] = input;
console.log(first); // outputs 1
console.log(second); // outputs 2
  • 函数参数
function f([first, second]: [number, number]) {
    console.log(first);
    console.log(second);
}
f(input);

对象

  • 对象变量基础解构
let o = {
    a: "foo",
    b: 12,
    c: "bar"
};
let { a, b } = o;
  • 属性重命名
let { a: newName1, b: newName2 } = o;
// 等同如下
let newName1 = o.a;
let newName2 = o.b;

这里的冒号不是指示类型的。 如果你想指定它的类型, 仍然需要在其后写上完整的模式。

let {a, b}: {a: string, b: number} = o;

函数

  • 函数声明
// 声明类型 C
type C = { a: string, b?: number }
function f({ a, b }: C): void {
    // ...
}

通常使用指定默认值,同样也需要做声明类型

// 表示 a,b 可选,且有默认值
function f({ a="", b=0 } = {}): void {
    // ...
}
f();

// 表示 a,b 可选,且都有默认值,但不可传入无 a 的对象。
function f1({ a, b = 0 } = { a: "" }): void {
    // ...
}
f1({ a: "yes" }); // ok, default b = 0
f1(); // ok, default to {a: ""}, which then defaults b = 0
f1({}); // error, 'a' is required if you supply an argument

展开

数组

  • 数组变量基础展开
let first = [1, 2];
let second = [3, 4];
let bothPlus = [0, ...first, ...second, 5];

对象

  • 对象变量基础展开
let defaults = { food: "spicy", price: "$$", ambiance: "noisy" };
let search = { ...defaults, food: "rich" };

注意对象展开回属性覆盖,search 覆盖 defaults。

  • 仅可展开对象自身的可枚举属性
class C {
  p = 12;
  m() {
  }
}
let c = new C();
let clone = { ...c };
clone.p; // 12
clone.m(); // error!

函数

TypeScript 编译器不允许展开泛型函数上的类型参数,这个特性会在 TypeScript 的未来版本中考虑实现。

接口

在TypeScript里,接口的作用是为了“鸭子类型”、“结构性子类型化”、你的代码或第三方代码定义契约。

基础接口

interface LabelledValue {
  label: string;
}

function printLabel(labelledObj: LabelledValue) {
  console.log(labelledObj.label);
}

let myObj = {size: 10, label: "Size 10 Object"};
printLabel(myObj);

类型检查器不会去检查属性的顺序,即便传入参数与接口声明顺序不一致也无关紧要。

可选属性

  • 可选属性的接口在定义后加一个 ? 符号即可。
interface SquareConfig {
  color?: string;
  width?: number;
}

function createSquare(config: SquareConfig): {color: string; area: number} {
  let newSquare = {color: "white", area: 100};
  if (config.color) {
    newSquare.color = config.color;
  }
  if (config.width) {
    newSquare.area = config.width * config.width;
  }
  return newSquare;
}

let mySquare = createSquare({color: "black"});

只读属性

属性只能可读而不允许修改

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

readonlyconst, 做为变量使用的话用 const,若做为属性则使用readonly

额外的属性检查

  • 但传入参数并未在接口声明中出现,则会额外检查出错误。
interface SquareConfig {
    color?: string;
    width?: number;
}

function createSquare(config: SquareConfig): { color: string; area: number } {
}

// SquareConfig 接口并未声明 colour 属性,报错 error: 'colour' not expected in type 'SquareConfig'
let mySquare = createSquare({ colour: "red", width: 100 });
  • 若你能够确定这个对象可能具有某些做为特殊用途使用的额外属性。 我们可以添加一个字符串索引签名:
interface SquareConfig {
    color?: string;
    width?: number;
    [propName: string]: any;
}
  • 可以使用类型断言,绕开检查。
let mySquare = createSquare({ width: 100, opacity: 0.5 } as SquareConfig);	// 自定义代码类型,无需检查。
  • 将这个对象赋值给一个另一个变量,而不是传入。因为接口只定义了 createSquare 函数, squareOptions不会经过额外属性检查,所以编译器不会报错。
let squareOptions = { colour: "red", width: 100 };
let mySquare = createSquare(squareOptions);

函数类型

  • 除了描述带有属性的普通对象外,接口也可以描述函数类型。
// 为了使用接口表示函数类型,我们需要给接口定义一个调用签名。
interface SearchFunc {
  (source: string, subString: string): boolean;
}
  • 对于函数类型的类型检查来说,函数的参数名不需要与接口里定义的名字相匹配。但函数的参数会逐个进行检查,要求对应位置上的参数类型是兼容的。
let mySearch: SearchFunc;
mySearch = function(src: string, sub: string): boolean {
  let result = src.search(sub);
  return result > -1;
}

可索引类型

  • 可索引类型具有一个索引签名,它描述了对象索引的类型,还有相应的索引返回值类型。
interface StringArray {
  [index: number]: string;
}

let myArray: StringArray;
myArray = ["Bob", "Fred"];

let myStr: string = myArray[0];

这个索引签名表示了当用 number去索引StringArray时会得到string类型的返回值。

  • 字符串索引签名能够很好的描述dictionary模式,确保所有属性与其返回值类型相匹配。
interface NumberDictionary {
  [index: string]: number;
  length: number;    // 可以,length是number类型
  name: string       // 错误,属性类型必须与索引类型返回值匹配
}

因为字符串索引声明了 obj.propertyobj["property"]两种形式都可以。

  • 将索引签名设置为只读,防止了给索引赋值。
interface ReadonlyStringArray {
    readonly [index: number]: string;
}
let myArray: ReadonlyStringArray = ["Alice", "Bob"];
myArray[2] = "Mallory"; // error!

类类型

  • 明确的强制一个类去符合某种契约。
interface ClockInterface {
    currentTime: Date;
    setTime(d: Date);
}

class Clock implements ClockInterface {
    currentTime: Date;
    setTime(d: Date) {
        this.currentTime = d;
    }
    constructor(h: number, m: number) { }
}

接口不会帮你检查类是否具有某些私有成员,因此只描述了类的公共部分,而不是公共和私有两部分。

  • 类的静态部分与实例部分的区别在于,一个类实现了一个接口时,只对其实例部分进行类型检查,类静态部分不在检查的范围内,如 constructor
interface ClockConstructor {
    new (hour: number, minute: number);
}

class Clock implements ClockConstructor {
    currentTime: Date;
    constructor(h: number, m: number) { }
}

我们应该直接操作类的静态部分。

// 构造函数接口,需要 hour 与 minute。
interface ClockConstructor {
    new (hour: number, minute: number): ClockInterface;
}
// 实例方法接口
interface ClockInterface {
    tick();
}

// 构造函数,用传入类创建实例
function createClock(ctor: ClockConstructor, hour: number, minute: number): ClockInterface {
    return new ctor(hour, minute);
}

// 只对实例部分进行类型检查
class DigitalClock implements ClockInterface {
    constructor(h: number, m: number) { }
    tick() {
        console.log("beep beep");
    }
}
class AnalogClock implements ClockInterface {
    constructor(h: number, m: number) { }
    tick() {
        console.log("tick tock");
    }
}

// 这样就会检查传入对象是否符合类的构造函数签名
let digital = createClock(DigitalClock, 12, 17);
let analog = createClock(AnalogClock, 7, 32);

继承接口

  • 和类一样,接口也可以相互继承,可以更灵活地将接口分割到可重用的模块里。也可以一个接口可以继承多个接口,创建出多个接口的合成接口。
interface Shape {
    color: string;
}
interface PenStroke {
    penWidth: number;
}
interface Square extends Shape, PenStroke {
    sideLength: number;
}

let square = <Square>{};
square.color = "blue";
square.sideLength = 10;
square.penWidth = 5.0;

混合类型

  • 接口之所以能够描述 JavaScript 里丰富的类型,是因为 JavaScript 其动态灵活的特点。而有时你会希望一个对象可以同时具有上面提到的多种类型。
// 一个对象可同时作为函数和对象使用,并待用额外属性(start)。
interface Counter {
    (start: number): string;
    interval: number;
    reset(): void;
}

function getCounter(): Counter {
    let counter = <Counter>function (start: number) { };
    counter.interval = 123;
    counter.reset = function () { };
    return counter;
}

let c = getCounter();
c(10);
c.reset();
c.interval = 5.0;

接口继承类

  • 当接口继承了一个类类型时,它会继承类的成员但不包括其实现。
  • 接口同样会继承到类的private和protected成员。

这意味着当你创建了一个接口继承了一个拥有私有或受保护的成员的类时,这个接口类型只能被这个类或其子类所实现(implement)。

class Control {
    private state: any;
}
// 接口继承类,包含 Control 所有成员。
interface SelectableControl extends Control {
    select(): void;
}

// 因为 state 私有属性,只有 Control 的子类才能声明 state。因此只有 Control 的子类才能示爱谢娜 SelectableControl 接口。
class Button extends Control implements SelectableControl {
    select() { }
}

class TextBox extends Control {
    select() { }
}

// 非 Control 子类,缺少定义 state 属性,因此不能实现接口。
class Image implements SelectableControl {
    select() { }
}

实际上, SelectableControl接口和拥有select方法的Control类是一样的。

函数

函数是 JavaScript 应用程序的基础,用于实现抽象层,模拟类,信息隐藏和模块。

在 TypeScript 里,虽然已经支持类,命名空间和模块,但函数仍然是主要的定义行为的地方,因此 TypeScript 为 JavaScript 函数添加了额外的功能,让我们可以更容易地使用。

函数类型

  • 函数声明一般有两种方式
// 命名函数
function add(x, y) {
    return x + y;
}

// 匿名函数
let myAdd = function(x, y) { return x + y; };
  • 函数定义类型:参数类型和返回值类型。
function add(x: number, y: number): number {
    return x + y;
}

let myAdd = function(x: number, y: number): number { return x + y; };

书写完整函数类型

let myAdd: (baseValue: number, increment: number) => number =
    function(x: number, y: number): number { return x + y; };

函数的返回值类型使用( =>)符号分割,如果函数没有返回任何值,你也必须指定返回值类型为 void而不能留空。

函数的类型只是由参数类型和返回值组成的, 函数中使用的捕获变量不会体现在类型里。 这些变量实际上是函数的隐藏状态并不是组成API的一部分。

  • 推断类型:在赋值语句的一边指定了类型但是另一边没有类型的话,TypeScript 编译器会自动识别出类型
let myAdd = function(x: number, y: number): number { return x + y; };

let myAdd: (baseValue: number, increment: number) => number =
    function(x, y) { return x + y; };

// 通过推断类型进行完整函数书写
let myAdd: (baseValue: number, increment: number) => number =
    function(x: number, y: number): number { return x + y; };

可选参数和默认参数

  • TypeScript 中传递给一个函数的参数个数必须与函数期望的参数个数一致,而JavaScript里,每个参数都是可选的。因此 TypeScript 可在参数名使用 ?实现可选参数的功能。
function buildName(firstName: string, lastName?: string) {
    if (lastName)
        return firstName + " " + lastName;
    else
        return firstName;
}

let result1 = buildName("Bob");  // 正确
let result2 = buildName("Bob", "Adams", "Sr.");  // 报错,仅接收两个参数
let result3 = buildName("Bob", "Adams");  // 正确

请注意:可选参数必须跟在必须参数后面。

  • 当用户没有传递这个参数或传递的值是undefined时,TypeScript 也可以为参数提供一个默认值。
// 给 lastName 提供一个默认值为 Smith
function buildName(firstName: string, lastName = "Smith") {
    return firstName + " " + lastName;
}

在所有必须参数后面的带默认初始化的参数都是可选的,并且可选参数与末尾的默认参数共享参数类型,默认参数也无需跟在必须参数后面

剩余参数

  • 在函数中,想同时操作多个参数,或并不知道会有多少参数传递进来。在 TS 中,你可以把所有参数收集到一个变量里。

在JavaScript里,你可以使用 arguments来访问所有传入的参数。

// 剩余参数会被当做个数不限的可选参数。 
function buildName(firstName: string, ...restOfName: string[]) {
  return firstName + " " + restOfName.join(" ");
}

let employeeName = buildName("Joseph", "Samuel", "Lucas", "MacKinzie");
  • 略号也会在带有剩余参数的函数类型定义上使用到
function buildName(firstName: string, ...restOfName: string[]) {
  return firstName + " " + restOfName.join(" ");
}

let buildNameFun: (fname: string, ...rest: string[]) => string = buildName;

this

  • this的值在函数被调用的时候才会指定函数上下文中的内容。
  • 箭头函数可保存函数创建时的 this 值,而不是调用时的值:
let deck = {
    suits: ["hearts", "spades", "clubs", "diamonds"],
    cards: Array(52),
    createCardPicker: function() {
        // 箭头函数保留 this 值,可找到 suits 变量
        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);
  • 当设置了--noImplicitThis标记。 它会指出 this.suits[pickedSuit]里的this的类型为any 的错误。因为 this来自对象字面量里的函数表达式,默认为 any 修改的方法是,提供一个显式的 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),
    // 显示 this 参数让 TypeScript 识别该函数的 this 是 Deck 类型,而非 any,这样就不会报错。
    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);
  • this 参数在回调函数中,将被当成普通函数调用为 undefined,我们可以通过 this 参数来避免错误。
// 首先指定该回调 this 类型
interface UIElement {
    addClickListener(onclick: (this: void, e: Event) => void): void;
}

class Handler {
    info: string;
  	// addClickListener 要求回调函数带有 this: void
    onClickGood(this: void, e: Event) {
        console.log('clicked!');
    }
}
let h = new Handler();
uiElement.addClickListener(h.onClickGood);

// 如果希望在回调函数中使用 this,使用箭头函数。
class Handler {
    info: string;
    onClickGood = (e: Event) => { this.info = e.message }
}

箭头函数不会捕获 this,但缺点是每个 Handler 对象都会创建一个箭头函数,不能像方法添加到 Handler 的原型链上进行共享。

重载

  • JavaScript 是个动态语言,函数会根据传入不同的参数而返回不同类型的数据。
let suits = ["hearts", "spades", "clubs", "diamonds"];

// 接收对象参数
function pickCard(x: {suit: string; card: number; }[]): number;
// 重载进行接收数字参数
function pickCard(x: number): {suit: string; card: number; };
// 不是重载列表的一部分!!是具体实现
function pickCard(x): any {
    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 };
    }
}

let myDeck = [{ suit: "diamonds", card: 2 }, { suit: "spades", card: 10 }, { suit: "hearts", card: 4 }];
let pickedCard1 = myDeck[pickCard(myDeck)];
alert("card: " + pickedCard1.card + " of " + pickedCard1.suit);

let pickedCard2 = pickCard(15);
alert("card: " + pickedCard2.card + " of " + pickedCard2.suit);

为同一个函数提供多个函数类型定义来进行函数重载。 编译器会根据这个列表去处理函数的调用。

而为了让编译器能够选择正确的检查类型,它与 JavaScript 里的处理流程相似。 查找重载列表时,会尝试使用第一个顺序重载定义 因此,在定义重载的时候,一定要把最精确的定义放在最前面。

泛型

当考虑 API 良好的可重用性时,可以使用泛型来创建可重用的组件,一个组件可以支持多种类型的数据。 躺用户可以以自己的数据类型来使用组件。

基础

  • 定义一个函数,会返回任何传入它的值。

添加了类型变量 T。 T 帮助我们捕获用户传入的类型,之后我们就可以使用这个类型。 如使用了 T 当做返回值类型。现在我们可以知道参数类型与返回值类型是相同的了,这允许我们跟踪函数里使用的类型的信息。

// 使用了类型变量,它是一种特殊的变量,只用于表示类型而不是值,可以适用于多个类型。
function identity<T>(arg: T): T {
    return arg;
}

使用any类型会导致函数可以接收任何类型参数,这样便无法约束函数的输入与输出。如我们传入一个数字,我们只知道任何类型的值都有可能被返回。

  • 定义泛型后有两种使用方法
// 传入类型参数
let output = identity<string>("myString");

// 类型推论:编译器会根据传入的参数自动地帮助我们确定 T 的类型,保持代码精简和高可读性。
let output = identity("myString");

使用泛型变量

  • 使用泛型时,编译器要求你在函数体必须正确的使用这个通用的类型。
function identity<T>(arg: T): T {
    return arg;
}

// 编译器识别不到有地方指明 `arg` 具有 `length` 属性。
function loggingIdentity<T>(arg: T): T {
    console.log(arg.length);  // Error: T doesn't have .length
    return arg;
}

// 指明我们操作 T 类型的数组
function loggingIdentity<T>(arg: Array<T>): Array<T> {
    console.log(arg.length);  // Array has a .length, so no more error
    return arg;
}

泛型类型

  • 泛型函数的类型与非泛型函数的类型没什么不同,也可以使用不同的泛型参数名,只要能一一对应上就可以。
function identity<T>(arg: T): T {
    return arg;
}

let myIdentity: <U>(arg: U) => U = identity;
  • 使用带有调用签名的对象字面量来定义泛型函数
function identity<T>(arg: T): T {
    return arg;
}

let myIdentity: {<T>(arg: T): T} = identity;
  • 泛型接口,如上边对象字面量为例。
// 可以把泛型参数当作整个接口的一个参数,就能清楚的知道是哪个泛型类型
interface GenericIdentityFn<T> {
    (arg: T): T;
}

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

let myIdentity: GenericIdentityFn<number> = identity;

泛型类

  • 泛型类看上去与泛型接口差不多,使用( <>)括起泛型类型,跟在类名后面。
class GenericNumber<T> {
    zeroValue: T;
    add: (x: T, y: T) => T;
}

// 使用 number
let myGenericNumber = new GenericNumber<number>();
myGenericNumber.zeroValue = 0;
myGenericNumber.add = function(x, y) { return x + y; };

// 使用 string
let stringNumeric = new GenericNumber<string>();
stringNumeric.zeroValue = "";
stringNumeric.add = function(x, y) { return x + y; };
console.log(stringNumeric.add(stringNumeric.zeroValue, "test"));

类有两部分:静态部分和实例部分。 泛型类指的是实例部分的类型,所以类的静态属性不能使用这个泛型类型。

泛型约束

  • 因为使用泛型时,编译器需要你在函数体中必须正确的使用这个通用的类型。相比于操作 any 所有类型,我们想要限制函数去处理带有某个属性的的所有类型,也就是对泛型的约束要求。
interface Lengthwise {
    length: number;
}

function loggingIdentity<T extends Lengthwise>(arg: T): T {
    console.log(arg.length);
    return arg;
}
loggingIdentity(3);  // 错误,要有 length 属性
loggingIdentity({length: 10, value: 3});
  • 当我们在泛型约束中使用类型参数时。

比如,现在我们想要用属性名从对象里获取这个属性。 并且我们想要确保这个属性存在于对象 obj上。

function getProperty(obj: T, key: K) {
    return obj[key];
}

let x = { a: 1, b: 2, c: 3, d: 4 };

getProperty(x, "a"); // okay
getProperty(x, "m"); // error: Argument of type 'm' isn't assignable to 'a' | 'b' | 'c' | 'd'.
  • 在TypeScript使用泛型创建工厂函数时,需要引用构造函数的类类型。
function create<T>(c: {new(): T; }): T {
    return new c();
}

使用原型属性推断并约束构造函数与类实例的关系。

class ZooKeeper {
    nametag: string;
}

class Animal {
    numLegs: number;
}

class Lion extends Animal {
    keeper: ZooKeeper;
}

// 泛型 A 为继承 Animal 的类类型,并传入一个构造函数(该构造函数返回 泛型A),createInstance 返回泛型 A。
function createInstance<A extends Animal>(c: new () => A): A {
    return new c();
}

createInstance(Lion).keeper.nametag;  // typechecks!
  • 泛型无法创建泛型枚举和泛型命名空间。

类型推论

基础

  • TypeScript 推断发生在初始化变量和成员,设置默认参数值和决定函数返回值时。
let x1 = 3;
let x2 = '3';
let x3 = true;
let x4 = [];
let x5 = {};
  • 当需要从几个表达式中推断类型时候,会使用这些表达式的类型来推断出一个最合适的通用类型。
// 将会推断 `number` 和 `null` 的联合类型
let x = [0, 1, null];
  • 当候选类型并无我们想定义的类型时,我们可明确指出类型。
// 推断为 Animal[] 类型
let zoo: Animal[] = [new Rhino(), new Elephant(), new Snake()];

若无明确类型,类型推断结果为联合组类型 —— (Rhino | Elephant | Snake)[]

上下文类型

  • 上下文归类会发生在表达式的类型与所处的位置相关时。
window.onmousedown = function(mouseEvent) {
    console.log(mouseEvent.button);  //<- Error
};

右边函数表达式未明确指出 mouseEvent 参数类型,TypeScript 类型检查器根据上下文使用Window.onmousedown函数的类型来进行推断,故导致错误。

可以指明 mouseEvent 拥有该 button 属性。

  • 如果上下文类型表达式包含了明确的类型信息,上下文的类型被忽略。
window.onmousedown = function(mouseEvent: any) {
    console.log(mouseEvent.button);	// 此时 any 不会报错
};
  • 上下文归类会在很多情况下使用到,包含函数的参数,赋值表达式的右边,类型断言,对象成员和数组字面量和返回值语句。

  • 上下文类型也会做为最佳通用类型的候选类型。

function createZoo(): Animal[] {
    return [new Rhino(), new Elephant(), new Snake()];
}

最佳通用类型有4个候选者:AnimalRhinoElephantSnakeAnimal会被做为最佳通用类型。

类型兼容性

TypeScript里的类型兼容性是基于结构子类型的,而结构类型是一种只使用其成员来描述类型的方式。

因为JavaScript里广泛地使用匿名对象,例如函数表达式和对象字面量,所以使用结构类型系统来描述这些类型比使用名义类型系统更好。

基础

  • TypeScript结构化类型系统的基本规则是,如果x要兼容y,那么y至少具有与x相同的属性。
interface Named {
    name: string;
}

let x: Named;
// y 的类型根据推断为 { name: string; location: string; }
let y = { name: 'Alice', location: 'Seattle' };
x = y;

// 函数参数类型兼容
function greet(n: Named) {
    console.log('Hello, ' + n.name);
}
greet(y); // OK

这个比较过程是递归进行的,检查每个成员及子成员。当检查到目标类型的成员时,则类型兼容。

函数

函数类型兼容性比较原始类型和对象类型相对难理解一些。

  • 函数能否类型兼容需要注意两个地方,一个是参数列表,另一个是返回值类型。
// 参数列表中形参名字无所谓,只看类型
let x = (a: number) => 0;
let y = (b: number, s: string) => 0;

// y 兼容 x,x 不兼容 y
y = x; // OK
x = y; // Error

// 返回类型也一样
let x = () => ({name: 'Alice'});
let y = () => ({name: 'Alice', location: 'Seattle'});

x = y; // OK
y = x; // Error, because x() lacks a location property
  • 比较函数兼容性的时候,可选参数与必须参数是可互换的。
function invokeLater(args: any[], callback: (...args: any[]) => void) {
    /* ... Invoke callback with 'args' ... */
}

// 传入必须参数可兼容
invokeLater([1, 2], (x, y) => console.log(x + ', ' + y));

// 传入可选参数也可兼容
invokeLater([1, 2], (x?, y?) => console.log(x + ', ' + y));

当一个函数有剩余参数时,它被当做无限个可选参数。

  • 重载函数,源函数的每个重载都要在目标函数上找到对应的函数签名,进行确保目标函数可以在所有源函数可调用的地方调用。

枚举

  • 枚举类型与数字类型兼容,并且数字类型与枚举类型兼容。
  • 不同枚举类型之间是不兼容的。
enum Status { Ready, Waiting };
enum Color { Red, Blue, Green };

let status = Status.Ready;
status = Color.Green;  // 不兼容

  • 类有静态部分和实例部分的类型,比较两个类类型的对象时,只有实例的成员会被比较,静态成员和构造函数不在比较的范围内。
class Animal {
    feet: number;
    constructor(name: string, numFeet: number) { }
}

class Size {
    feet: number;
    constructor(numFeet: number) { }
}

let a: Animal;
let s: Size;

a = s;  // OK
s = a;  // OK
  • 当检查类实例的兼容时,如果目标类型包含一个私有/保护成员,那么源类型必须包含来自同一个类的这个私有/报错成员。 这允许子类赋值给父类,但是不能赋值给其它有同样类型的类。

泛型

  • TypeScript是结构性的类型系统,类型参数只影响使用其做为类型一部分的结果类型。
interface Empty<T> {
}
let x: Empty<number>;
let y: Empty<string>;

x = y;  // x和y是兼容的,因为它们的结构使用类型参数时并没有什么不同。
  • 函数泛型类型的泛型参数也一样,没指定时当作 any 进行比较
let identity = function<T>(x: T): T {
    // ...
}

let reverse = function<U>(y: U): U {
    // ...
}

identity = reverse;  // OK, because (x: any) => any matches (y: any) => any

其他

  • 在TypeScript里,有两种兼容性:子类型和赋值。 它们的不同点在于,赋值扩展了子类型兼容性,增加了一些规则,允许和any来回赋值,以及enum和对应数字值之间的来回赋值。
  • 语言里的不同地方分别使用了它们之中的机制。 实际上,类型兼容性是由赋值兼容性来控制的,即使在implementsextends语句也不例外。

高级类型

交叉类型

  • 交叉类型是将多个类型合并为一个类型,包含了所需的所有类型的特性。
// 交叉泛型 T 和 U
function extend<T, U>(first: T, second: U): T & U {
    let result = <T & U>{};
    for (let id in first) {
        (<any>result)[id] = (<any>first)[id];
    }
    for (let id in second) {
        if (!result.hasOwnProperty(id)) {
            (<any>result)[id] = (<any>second)[id];
        }
    }
    return result;
}

class Person {
    constructor(public name: string) { }
}
interface Loggable {
    log(): void;
}
class ConsoleLogger implements Loggable {
    log() {
        // ...
    }
}

// 包含两个类型特征
var jim = extend(new Person("Jim"), new ConsoleLogger());
var n = jim.name;
jim.log();

大多是在混入(mixins)或其它不适合典型面向对象模型的地方看到交叉类型的使用。

联合类型

  • 可以使用联合类型来指定多种基础类型。
// padding 为 string 和 number 的联合类型
function padLeft(value: string, padding: string | number) {
    // ...
}

let indentedString = padLeft("Hello world", true); // 报错,因为不在联合类型中

联合类型表示一个值可以是几种类型之一。 用竖线( |)分隔每个类型, number | string | boolean表示一个值可以是 numberstring,或 boolean

  • 如果一个值是联合类型,只能访问此联合类型的所有类型里共有的成员。
interface Bird {
    fly();
    layEggs();
}

interface Fish {
    swim();
    layEggs();
}

function getSmallPet(): Fish | Bird {
    // ...
}

let pet = getSmallPet();
pet.layEggs(); // 正确, 因为 layEggs 是共有成员
pet.swim();    // errors

如果一个值的类型是 A | B,我们能够 确定的是它包含了 A B中共有的成员。

类型保护与区别类型

  • 联合类型适合于那些值可以为不同类型的情况,当我们需要确切了解是否为某个类型时,可以使用类型断言来指定类型。
let pet = getSmallPet();

// 指定 Fish 类型中有 swim
if ((<Fish>pet).swim) {
    (<Fish>pet).swim();
}
else {	// 指定 Bird 类型中有 fly
    (<Bird>pet).fly();
}
  • 为了判断具体类型,需要重复使用类型断言,假若一旦检查过类型,就能在之后的每个分支里指定类型,也就是类型保护,它们会在运行时检查以确保在某个作用域里的类型。
// 类型保护的返回值是一个类型谓词
function isFish(pet: Fish | Bird): pet is Fish {
    return (<Fish>pet).swim !== undefined;
}

// 调用 isFish 时,TypeScript 会将变量缩减为那个具体的类型
if (isFish(pet)) {
    pet.swim();
}
else {	// else 分支中此时一定是 Bird 类型
    pet.fly();
}

pet is Fish就是类型谓词。 谓词为 parameterName is Type这种形式, parameterName必须是来自于当前函数签名里的一个参数名(Fish 或 Bird)。

  • typeof 类型保护,可以直接在代码里检查原始类型,不用一直进行类型谓词。
function isNumber(x: any): x is number {
    return typeof x === "number";
}

function isString(x: any): x is string {
    return typeof x === "string";
}

// typeof 类型保护等同上述类型谓词
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并不会阻止你与其它字符串比较,语言不会把那些表达式识别为类型保护。

  • instanceof 类型保护,通过构造函数来细化类型的一种方式。
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;
    }
}

function getRandomPadder() {
    return Math.random() < 0.5 ?
        new SpaceRepeatingPadder(4) :
        new StringPadder("  ");
}

// 返回类型可能为 SpaceRepeatingPadder | StringPadder
let padder: Padder = getRandomPadder();

if (padder instanceof SpaceRepeatingPadder) {
    padder; // 类型细化为'SpaceRepeatingPadder'
}
if (padder instanceof StringPadder) {
    padder; // 类型细化为'StringPadder'
}

instanceof的右侧要求是一个构造函数,TypeScript将细化为:

  1. 如果它的类型不为 any的话,就为此构造函数的 prototype属性的类型
  2. 构造签名所返回的类型的联合

NULL 类型

  • TypeScript具有两种特殊的类型,nullundefined,它们分别具有值null和undefined,默认情况下,类型检查器认为 nullundefined可以赋值给任何类型。

按照JavaScript的语义,nullundefined是不同的类型。

  • 当使用了--strictNullChecks标记配置可避免上述问题,不会自动地包含 nullundefined,并可以使用联合类型明确的包含它们。
let s = "foo";
s = null; // 错误, 'null'不能赋值给'string'

let sn: string | null = "bar";
sn = null; // 可以
sn = undefined; // error, 'undefined'不能赋值给'string | null'

// 可选参数自动加上 (| undefined)
function f(x: number, y?: number) {
    return x + (y || 0);
}
f(1, undefined);
f(1, null); // error, 'null' is not assignable to 'number | undefined'

// 可选属性自动加上(| undefined)
class C {
    a: number;
    b?: number;
}
let c = new C();
c.a = undefined; // error, 'undefined' is not assignable to 'number'
c.b = undefined; // ok
c.b = null; // error, 'null' is not assignable to 'number | undefined'
  • 由于 null 类型可以通过联合类型实现,可以使用类型保护去除 null。
function f(sn: string | null): string {
    if (sn == null) {	// return 去除
        return "default";
    }
    else {
        return sn;
    }
}

// 使用短路运算符
function f(sn: string | null): string {
    return sn || "default";
}
  • 如果编译器不能够去除 nullundefined,你可以使用类型断言手动去除。 (语法是给变量添加 !后缀)
function broken(name: string | null): string {
  function postfix(epithet: string) {
    return name.charAt(0) + '.  the ' + epithet; // error, name 可能为 null 类型,语法错误。
  }
  name = name || "Bob";
  return postfix("great");
}

function fixed(name: string | null): string {
  function postfix(epithet: string) {
    return name!.charAt(0) + '.  the ' + epithet; // 类型断言,去除 null 和 undefined 值。
  }
  name = name || "Bob";
  return postfix("great");
}

因为编译器无法跟踪所有对嵌套函数的调用,尤其是你将内层函数做为外层函数的返回值时。(除非是立即调用的函数表达式) 因此,无法知道函数在哪里被调用,就无法知道调用时变量的类型。

类型别名

  • 类型别名会给一个类型起个新名字(跟接口有些相似),可以作用于原始值,联合类型,元组以及其它任何你需要手写的类型。
type Name = string;
type NameResolver = () => string;
type NameOrResolver = Name | NameResolver;
function getName(n: NameOrResolver): Name {
    if (typeof n === 'string') {
        return n;
    }
    else {
        return n();
    }
}

起别名不会新建一个类型 - 它创建了一个新名字来引用那个类型。

  • 类型别名可以是泛型,可以添加类型参数并且在别名声明的右侧传入。
type Container<T> = { value: T };

// 可以使用类型别名来在属性里引用自己
type Tree<T> = {
    value: T;
    left: Tree<T>;
    right: Tree<T>;
}

// 交叉类型 + 引用自己
type LinkedList<T> = T & { next: LinkedList<T> };
interface Person {
    name: string;
}
var people: LinkedList<Person>;
var s = people.name;
var s = people.next.name;
var s = people.next.next.name;
var s = people.next.next.next.name;
  • 类型别名不能出现在声明右侧的任何地方。
type Yikes = Array<Yikes>; // error
  • 接口与类型别名

    • 接口创建了一个新的名字,可以在其它任何地方使用。 类型别名并不创建新名字。
    type Alias = { num: number }
    interface Interface {
        num: number;
    }
    declare function aliased(arg: Alias): Alias;	// 对象字面量类型
    declare function interfaced(arg: Interface): Interface;	// Inerface 类型
    

字符串字面量类型

  • 允许你指定字符串必须的固定值,可以与联合类型,类型保护和类型别名很好的配合,实现类似枚举类型的字符串。
type Easing = "ease-in" | "ease-out";
class UIElement {
    animate(dx: number, dy: number, easing: Easing) {
        if (easing === "ease-in") {
            // ...
        }
        else if (easing === "ease-out") {
        }
        else {
            // error! should not pass null or undefined.
        }
    }
}

let button = new UIElement();
button.animate(0, 0, "ease-in");
button.animate(0, 0, "uneasy"); // error: "uneasy" is not allowed here
  • 字符串字面量类型用于区分函数重载。
function createElement(tagName: "img"): HTMLImageElement;
function createElement(tagName: "input"): HTMLInputElement;
// ... more overloads ...
function createElement(tagName: string): Element {
    // ... code goes here ...
}

数字字面量类型

  • TypeScript 还具有数字字面量类型,可以用在缩小范围调试 bug 的时候。
function foo(x: number) {
    if (x !== 1 || x !== 2) {
        //         ~~~~~~~
    }
}

经检查,当 x2进行比较的时候,它的值必须为 1,所以出 bug 了。

枚举成员类型

每个枚举成员都是用字面量初始化的时候枚举成员是具有类型的,当谈及“单例类型”的时候,多数是指枚举成员类型和数字/字符串字面量类型,而大多数用户会互换使用“单例类型”和“字面量类型”。

可辨识联合

  • 可辨识联合是一种高级模式,合并单例类型,联合类型,类型保护和类型别名等,在函数式编程很有用处,自动地为你辨识联合。它具有3个要素:
  1. 具有普通的单例类型属性 —— 可辨识的特征。
  2. 一个类型别名包含了那些类型的联合 —— 联合。
  3. 此属性上的类型保护。

可辨识联合也被称为标签联合代数数据类型

// 每个接口都有不同字符串字面量类型的 kind 属性,其他属性则特定于各个接口。
interface Square {
    kind: "square";
    size: number;
}
interface Rectangle {
    kind: "rectangle";
    width: number;
    height: number;
}
interface Circle {
    kind: "circle";
    radius: number;
}

// 使用可辨识联合
type Shape = Square | Rectangle | Circle;
function area(s: Shape) {
    switch (s.kind) {
        case "square": return s.size * s.size;
        case "rectangle": return s.height * s.width;
        case "circle": return Math.PI * s.radius ** 2;
    }
}

有点像枚举(key)

  • 当没有涵盖所有可辨识联合的变化时,我们想让编译器可以通知我们,可以利用 never 类型。
// 添加了 Triangle 到 Shape 可辨识类型
type Shape = Square | Rectangle | Circle | Triangle;

// 利用 never 类型,为除去多有可能情况下剩下的类型,主动抛出错误。
function assertNever(x: never): never {
    throw new Error("Unexpected object: " + x);
}
function area(s: Shape) {
    switch (s.kind) {
        case "square": return s.size * s.size;
        case "rectangle": return s.height * s.width;
        case "circle": return Math.PI * s.radius ** 2;
        default: return assertNever(s); // error here if there are missing cases
    }
}

多态的 this 类型

  • 多态的this类型表示的是某个包含类或接口的子类型。 这被称做 F-bounded多态性,能很容易地表现连贯接口间的继承。
// 操作后均返回 this 类型
class BasicCalculator {
    public constructor(protected value: number = 0) { }
    public currentValue(): number {
        return this.value;
    }
    public add(operand: number): this {
        this.value += operand;
        return this;
    }
    public multiply(operand: number): this {
        this.value *= operand;
        return this;
    }
    // ... other operations go here ...
}

let v = new BasicCalculator(2)
            .multiply(5)
            .add(1)
            .currentValue();

// 我们可以直接继承该类,并无需重新声明,可直接使用之前的方法。
class ScientificCalculator extends BasicCalculator {
    public constructor(value = 0) {
        super(value);	// 构造里继承
    }
    public sin() {
        this.value = Math.sin(this.value);
        return this;
    }
    // ... other operations go here ...
}

let v = new ScientificCalculator(2)
        .multiply(5)
        .sin()
        .add(1)
        .currentValue();

索引类型

  • 使用索引类型,编译器就能够检查使用动态属性名的代码。如从对象中选取属性的子集。
// 通过索引类型查询和索引访问操作符
function pluck<T, K extends keyof T>(o: T, names: K[]): T[K][] {
  return names.map(n => o[n]);
}

interface Person {
    name: string;
    age: number;
}
let person: Person = {
    name: 'Jarid',
    age: 35
};
// 该用例中,编译器会检查 name 是否真的是 Person 的一个属性。
let strings: string[] = pluck(person, ['name']); // ok, string[]

本例还引入了几个新的类型操作符。

  • keyof T索引类型查询操作符。但我们不清楚可能出现的属性名时,编译器会检查清楚是否传入了正确的属性名

对于任何类型 Tkeyof T的结果为 T上已知的公共属性名的联合(keyof Person = 'name' | 'age')也就是联合类型。

  • T[K]索引访问操作符。你可以在普通的上下文里使用 T[K],只要确保类型变量 K extends keyof T就可以了。

意味着 person['name']具有类型 Person['name'](也就是 string 类型)

let name: string = getProperty(person, 'name');
let age: number = getProperty(person, 'age');
let unknown = getProperty(person, 'unknown'); // error, 'unknown' is not in 'name' | 'age'
  • 索引类型和字符串索引签名,也就是keyofT[K]与字符串索引签名进行交互。
interface Map<T> {
    [key: string]: T;
}
let keys: keyof Map<number>; // string
let value: Map<number>['foo']; // number

如果你有一个带有字符串索引签名的类型(key: string),那么 keyof T会是 string,并且 T[string]为索引签名的类型(返回的是 T,当然是 T)。

映射类型

  • 在映射类型里,新类型以相同的形式去转换旧类型里每个属性,避免了重复的类型赋予。
// 可以令每个属性称为 readonly 类型
type Readonly<T> = {
    readonly [P in keyof T]: T[P];
}
// 可选
type Partial<T> = {
    [P in keyof T]?: T[P];
}

// 将 Person 类型均映射为 readonly 类型和可选。
type PersonPartial = Partial<Person>;
type ReadonlyPerson = Readonly<Person>;
  • 在实践中,会基于一些已存在的类型,且按照一定的方式转换字段。

它的语法与索引签名的语法类型,内部使用了 for .. in。 具有三个部分:

  1. 类型变量 K(P),它会依次绑定到每个属性。
  2. 字符串字面量联合的 Keys(keyof T),它包含了要迭代的属性名的集合。
  3. 属性的结果类型。
type Nullable<T> = { [P in keyof T]: T[P] | null }
type Partial<T> = { [P in keyof T]?: T[P] }
  • 编译器知道在添加任何新属性之前可以拷贝所有存在的属性修饰符,keyof T 结果类型 T[P] 通常是映射类型的的模版,但这类转换时同态的,需要注意同态的区别,映射只作用于 T 的属性而没有其它的。
// 同态
type Pick<T, K extends keyof T> = {
    [P in K]: T[P];
}

// 非同态,并不需要输入类型来拷贝属性
type Record<K extends string, T> = {
    [P in K]: T;
}

// 非同态类型会创建新的属性。
type ThreeStringProps = Record<'prop1' | 'prop2' | 'prop3', string>
  • 由映射类型进行推断拆包。
function unproxify<T>(t: Proxify<T>): T {
    let result = {} as T;	// 断言为该对象属性
    for (const k in t) {
        result[k] = t[k].get();
    }
    return result;
}

let originalProps = unproxify(proxyProps);

这个拆包推断只适用于同态的映射类型,如果映射类型不是同态的,那么需要给拆包函数一个明确的类型参数。

  • TypeScript 2.8在lib.d.ts里增加了一些预定义的有条件类型。
  • Exclude<T, U> -- 从T中剔除可以赋值给U的类型。
  • Extract<T, U> -- 提取T中可以赋值给U的类型。
  • NonNullable<T> -- 从T中剔除nullundefined
  • ReturnType<T> -- 获取函数返回值类型。
  • InstanceType<T> -- 获取构造函数类型的实例类型。

Exclude类型是Diff类型的一种实现,其一为了避免破坏已经定义了Diff的代码,其二这个名字能更好地表达类型的语义。

没有增加Omit<T, K>类型,因为可以很容易的用Pick<T, Exclude<keyof T, K>>来表示。

// 示例使用
type T00 = Exclude<"a" | "b" | "c" | "d", "a" | "c" | "f">;  // "b" | "d"
type T01 = Extract<"a" | "b" | "c" | "d", "a" | "c" | "f">;  // "a" | "c"

type T02 = Exclude<string | number | (() => void), Function>;  // string | number
type T03 = Extract<string | number | (() => void), Function>;  // () => void

type T04 = NonNullable<string | number | undefined>;  // string | number
type T05 = NonNullable<(() => string) | string[] | null | undefined>;  // (() => string) | string[]

function f1(s: string) {
    return { a: 1, b: s };
}

class C {
    x = 0;
    y = 0;
}

type T10 = ReturnType<() => string>;  // string
type T11 = ReturnType<(s: string) => void>;  // void
type T12 = ReturnType<(<T>() => T)>;  // {}
type T13 = ReturnType<(<T extends U, U extends number[]>() => T)>;  // number[]
type T14 = ReturnType<typeof f1>;  // { a: number, b: string }
type T15 = ReturnType<any>;  // any
type T16 = ReturnType<never>;  // any
type T17 = ReturnType<string>;  // Error
type T18 = ReturnType<Function>;  // Error

type T20 = InstanceType<typeof C>;  // C
type T21 = InstanceType<any>;  // any
type T22 = InstanceType<never>;  // any
type T23 = InstanceType<string>;  // Error
type T24 = InstanceType<Function>;  // Error

Symbols

  • 自ECMAScript 2015起,symbol成为了一种新的原生类型,其值是通过Symbol构造函数创建的,具有以下几个特点。
// Symbols是不可改变且唯一的。
let sym2 = Symbol("key");
let sym3 = Symbol("key");
sym2 === sym3; // false, symbols是唯一的

// symbols也可以被用做对象属性的键。
let sym = Symbol();
let obj = {
    [sym]: "value"
};
console.log(obj[sym]); // "value"

// 可以与计算出的属性名声明相结合来声明对象的属性和类成员。
const getClassNameSymbol = Symbol();
class C {
    [getClassNameSymbol](){
       return "C";
    }
}
let c = new C();
let className = c[getClassNameSymbol](); // "C"
  • 一些已经众所周知的内置 symbols,用来表示语言内部的行为。
  • Symbol.hasInstance方法,会被instanceof运算符调用。构造器对象用来识别一个对象是否是其实例。

  • Symbol.isConcatSpreadable布尔值,表示当在一个对象上调用Array.prototype.concat时,这个对象的数组元素是否可展开。

  • Symbol.iterator方法,被for-of语句调用。返回对象的默认迭代器。

  • Symbol.match方法,被String.prototype.match调用。正则表达式用来匹配字符串。

  • Symbol.replace方法,被String.prototype.replace调用。正则表达式用来替换字符串中匹配的子串。

  • Symbol.search方法,被String.prototype.search调用。正则表达式返回被匹配部分在字符串中的索引。

  • Symbol.species函数值,为一个构造函数。用来创建派生对象。

  • Symbol.split方法,被String.prototype.split调用。正则表达式来用分割字符串。

  • Symbol.toPrimitive方法,被ToPrimitive抽象操作调用。把对象转换为相应的原始值。

  • Symbol.toStringTag方法,被内置方法Object.prototype.toString调用。返回创建对象时默认的字符串描述。

  • Symbol.unscopables对象,它自己拥有的属性会被with作用域排除在外。

迭代器和生成器

可迭代性

  • 当一个对象实现了Symbol.iterator属性时,我们认为它是可迭代的,对象上的 Symbol.iterator函数负责返回供迭代的值。

一些内置的类型如 ArrayMapSetStringInt32ArrayUint32Array等都已经实现了各自的Symbol.iterator

for...of vs for..in

  • for..of会遍历可迭代的对象,调用对象上的Symbol.iterator方法。
let someArray = [1, "string", false];

for (let entry of someArray) {
    console.log(entry); // 1, "string", false
}
  • for..offor..in均可迭代一个列表,但用于迭代的值却不同。
    • for..in迭代的是对象的键的列表。
    • for..of则迭代对象的键对应的值。
let list = [4, 5, 6];

for (let i in list) {
    console.log(i); // "0", "1", "2",
}
for (let i of list) {
    console.log(i); // "4", "5", "6"
}
  • for..in可以操作任何对象,提供了查看对象属性的一种方法, for..of关注于迭代对象的值。
let pets = new Set(["Cat", "Dog", "Hamster"]);
pets["species"] = "mammals";

for (let pet in pets) {
    console.log(pet); // "species"
}
for (let pet of pets) {
    console.log(pet); // "Cat", "Dog", "Hamster"
}

内置对象Map(字典)和Set(集合)已经实现了Symbol.iterator方法,让我们可以访问它们保存的值。

for...of 代码生成

  • 生成目标为ES5或ES3,迭代器只允许在Array类型上使用,在非数组值上使用 for..of语句会得到一个错误。

就算这些非数组值已经实现了Symbol.iterator属性。

let numbers = [1, 2, 3];
for (let num of numbers) {
    console.log(num);
}

// 编译器会生成一个简单的 for 循环作为 for...of 循环的代码生成。
var numbers = [1, 2, 3];
for (var _i = 0; _i < numbers.length; _i++) {
    var num = numbers[_i];
    console.log(num);
}
  • 目标为兼容ECMAScipt 2015的引擎时(ES6+),编译器会生成相应引擎的for..of内置迭代器实现方式。

模块

TypeScript 1.5里术语名已经发生了变化。 “内部模块”现在称做“命名空间”,“外部模块”现在则简称为“模块”(也就是说 module X {} 相当于现在推荐的写法 namespace X {})。

  • 模块在其自身的作用域里执行,而不是在全局作用域里。因此,定义在一个模块里的变量,函数,类等等在模块外部是不可见的。

除非明确地使用export形式导出它们。 如果想使用其它模块导出的变量,函数,类,接口等,可以使用 import形式进行导入。

  • 模块是自声明的,两个模块之间的关系是通过在文件级别上使用imports和exports建立的。
  • 模块使用模块加载器去导入其它的模块。

在运行时,模块加载器的作用是在执行此模块代码前,去查找并执行这个模块的所有依赖。 最熟知的 JavaScript 模块加载器是服务于 Node.js 的 CommonJS和服务于 Web 应用的 Require.js

  • TypeScript与ECMAScript 2015一样,任何包含顶级import或者export的文件都被当成一个模块。

如果一个文件不带有顶级的import或者export声明,那么它的内容被视为全局可见的(因此对模块也是可见的)。

导出

  • 任何导出声明(比如变量,函数,类,类型别名或接口)都能够通过添加export关键字来导出。
export interface StringValidator {
    isAcceptable(s: string): boolean;
}
export const numberRegexp = /^[0-9]+$/;
export class ZipCodeValidator implements StringValidator {
    isAcceptable(s: string) {
        return s.length === 5 && numberRegexp.test(s);
    }
}
  • 导出语句只需对导出的部分重命名。
class ZipCodeValidator implements StringValidator {
    isAcceptable(s: string) {
        return s.length === 5 && numberRegexp.test(s);
    }
}
export { ZipCodeValidator };
export { ZipCodeValidator as mainValidator };
  • 重新导出功能并不会在当前模块导入那个模块或定义一个新的局部变量。
  • 一个模块可以包裹多个模块,并把将导出的内容联合在一起通过语法:export * from "module"
export * from "./ZipCodeValidator";  // exports class ZipCodeValidator
  • 默认导出使用 default 关键字标记,并且一个模块只能够有一个默认导出。
// JQuery.d.ts
declare let $: JQuery;
export default $;
// 使用
import $ from "JQuery";
$("button.continue").html( "Next Step..." );

// OneTwoThree.ts 变量默认导出
export default "123";
// 使用
import num from "./OneTwoThree";
console.log(num); // "123"
  • 类和函数声明可以直接被标记为默认导出,其默认导出的类和函数的名字是可以省略的。
// ZipCodeValidator.ts 类默认导出
export default class ZipCodeValidator {
    static numberRegexp = /^[0-9]+$/;
    isAcceptable(s: string) {
        return s.length === 5 && ZipCodeValidator.numberRegexp.test(s);
    }
}
// StaticZipCodeValidator.ts 函数默认导出
const numberRegexp = /^[0-9]+$/;
export default function (s: string) {
    return s.length === 5 && numberRegexp.test(s);
}

// 类使用
import validator from "./ZipCodeValidator";
let myValidator = new validator();

// 函数使用
import validate from "./StaticZipCodeValidator";
let strings = ["Hello", "98052", "101"];
strings.forEach(s => {
  console.log(`"${s}" ${validate(s) ? " matches" : " does not match"}`);
});

导入

  • 导入一个模块中的某个导出内容
import { ZipCodeValidator } from "./ZipCodeValidator";
let myValidator = new ZipCodeValidator();
  • 导入重命名
import { ZipCodeValidator as ZCV } from "./ZipCodeValidator";
let myValidator = new ZCV();
  • 将整个模块导入到一个变量,进行访问全部模块导出部分。
import * as validator from "./ZipCodeValidator";
let myValidator = new validator.ZipCodeValidator();
  • 具有副作用的导入模块,一些模块会设置一些全局状态供其它模块使用,可能没有任何的导出或用户根本就不关注它的导出,因此不推荐这么做。
import "./my-module.js";

export = 和 import = require()

  • 为了支持CommonJS和AMD的exports, TypeScript提供了export =语法,且必须特定语法import module = require("module")来导入此模块。

CommonJS和AMD的环境里都有一个exports变量,这个变量一般被赋值为一个对象,其包含了一个模块的所有导出内容。这种情况下其作用就类似于 es6 语法里的默认导出。虽然作用相似,但 export default 语法并不能兼容CommonJS 和 AMD 的exports语法。

let numberRegexp = /^[0-9]+$/;
class ZipCodeValidator {
    isAcceptable(s: string) {
        return s.length === 5 && numberRegexp.test(s);
    }
}
// export =语法定义一个模块的导出对象(类,接口,命名空间,函数或枚举)。
export = ZipCodeValidator;

//导入
import zip = require("./ZipCodeValidator");
let strings = ["Hello", "98052", "101"];

// Validators to use
let validator = new zip();
strings.forEach(s => {
  console.log(`"${ s }" - ${ validator.isAcceptable(s) ? "matches" : "does not match" }`);
});

生成模块代码

下面的例子说明了导入导出语句里使用的名字是怎么转换为相应的模块加载器代码的。

  • SimpleModule.ts
import m = require("mod");
export let t = m.something + 1;
  • AMD / RequireJS SimpleModule.js
define(["require", "exports", "./mod"], function (require, exports, mod_1) {
    exports.t = mod_1.something + 1;
});
  • CommonJS / Node SimpleModule.js
let mod_1 = require("./mod");
exports.t = mod_1.something + 1;
  • UMD SimpleModule.js
(function (factory) {
    if (typeof module === "object" && typeof module.exports === "object") {
        let v = factory(require, exports); if (v !== undefined) module.exports = v;
    }
    else if (typeof define === "function" && define.amd) {
        define(["require", "exports", "./mod"], factory);
    }
})(function (require, exports) {
    let mod_1 = require("./mod");
    exports.t = mod_1.something + 1;
});
  • System SimpleModule.js
System.register(["./mod"], function(exports_1) {
    let mod_1;
    let t;
    return {
        setters:[
            function (mod_1_1) {
                mod_1 = mod_1_1;
            }],
        execute: function() {
            exports_1("t", t = mod_1.something + 1);
        }
    }
});
  • Native ECMAScript 2015 modules SimpleModule.js
import { something } from "./mod";
export let t = something + 1;

可选的模块加载和其他高级加载场景

  • 在TypeScript里,使用下面的方式来实现它和其它的高级加载场景,我们可以直接调用模块加载器并且可以保证类型完全,达到如只想在某种条件下才加载某个模块的场景。

  • 编译器会检测是否每个模块都会在生成的JavaScript中用到,若无则不会生成 require这个模块的代码。这种模式的核心是import id = require("...")语句,可以让我们访问模块导出的类型。

省略掉没有用到的引用将提升性能,并同时提供了选择性加载模块的能力。

模块加载器会被动态调用(通过 require), 它利用了省略引用的优化,让模块只在被需要时加载。 为了让这个模块工作,一定要注意 import定义的标识符只能在表示类型处使用(不能在会转换成JavaScript的地方)。

// typeof关键字,当在表示类型的地方使用时,会得出一个类型值,这里就表示模块的类型,确保类型安全性。

// Node.js 里的动态模块加载
declare function require(moduleName: string): any;

import { ZipCodeValidator as Zip } from "./ZipCodeValidator";
if (needZipValidation) {
    let ZipCodeValidator: typeof Zip = require("./ZipCodeValidator");
    let validator = new ZipCodeValidator();
    if (validator.isAcceptable("...")) { /* ... */ }
}

// require.js 里的动态模块加载
declare function require(moduleNames: string[], onLoad: (...args: any[]) => void): void;

import * as Zip from "./ZipCodeValidator";
if (needZipValidation) {
    require(["./ZipCodeValidator"], (ZipCodeValidator: typeof Zip) => {
        let validator = new ZipCodeValidator.ZipCodeValidator();
        if (validator.isAcceptable("...")) { /* ... */ }
    });
}

// System.js 里的动态模块加载
declare const System: any;

import { ZipCodeValidator as Zip } from "./ZipCodeValidator";
if (needZipValidation) {   	
  	System.import("./ZipCodeValidator").then((ZipCodeValidator: typeof Zip) => {
        var x = new ZipCodeValidator();
        if (x.isAcceptable("...")) { /* ... */ }
    });
}

使用其他的 JavaScript 库

  • 要想描述非TypeScript编写的类库的类型,我们需要声明类库所暴露出的API。

声明因为它不是“外部程序”的具体实现,通常在 .d.ts文件里定义的。

  • 在 Node.js 里可以使用顶级的 export声明来为每个模块都定义一个.d.ts文件,但最好还是写在一个大的.d.ts文件里。

node.d.ts,对模块进行导出声明

declare module "url" {
    export interface Url {
        protocol?: string;
        hostname?: string;
        pathname?: string;
    }
    export function parse(urlStr: string, parseQueryString?, slashesDenoteHost?): Url;
}

declare module "path" {
    export function normalize(p: string): string;
    export function join(...paths: any[]): string;
    export let sep: string;
}

可以/// <reference> node.d.ts并且使用import url = require("url");import * as URL from "url"加载模块。

/// <reference path="node.d.ts"/>
import url = require("url");
// 或
import * as URL from "url";

let myUrl = URL.parse("http://www.typescriptlang.org");
  • 外部模块简写。为了避免用一个新模块之前花时间去编写声明,可以使用声明的简写形式以便能够快速使用。
// declarations.d.ts
declare module "hot-new-module";

导入模块

// xx.ts
import x, {y} from "hot-new-module";
x(y);

简写模块里所有导出的类型将是any

  • 模块声明通配符。些模块加载器如SystemJSAMD支持导入非JavaScript内容。

通常会使用一个前缀或后缀来表示特殊的加载语法。

declare module "*!text" {
    const content: string;
    export default content;
}
// Some do it the other way around.
declare module "json!*" {
    const value: any;
    export default value;
}

导入匹配"*!text""json!*"的内容

import fileContent from "./xyz.txt!text";
import data from "json!http://example.com/data.json";
console.log(data, fileContent);
  • 有些模块被设计成兼容多个模块加载器,或者不使用模块加载器(采用全局变量)。

UMD模块为代表。 这些库可以通过导入的形式或全局变量的形式访问。

// math-lib.d.ts
export function isPrime(x: number): boolean;
export as namespace mathLib;

导入该库使用模块

import { isPrime } from "math-lib";
isPrime(2);	// 正确
mathLib.isPrime(2); // 错误: 不能在模块内使用全局定义。

通过全局变量的形式使用,但只能在不带有模块导入或导出的脚本文件里。

mathLib.isPrime(2);

创建模块结构

  • 尽可能地在顶层导出,用户更容易地使用你模块导出的内容。
  • 避免嵌套层次过多,变得难以处理。
    • 如模块中导出一个命名空间就是一个增加嵌套。
    • 导出类的静态方法也会增加嵌套(类本身就增加了一层嵌套)除非它能方便表述或便于清晰使用,否则应直接导出一个辅助方法。
  • 如果仅导出单个类或方法,应采用默认导出。来减少用户使用难度,可随意命名导入模块的类型,来找到相关对象。
// 默认导出类
export default class SomeType {
  constructor() { ... }
}
  
// 默认导出方法
export default function getThing() { return 'thing'; }

使用默认导出

import t from "./MyClass";
import f from "./MyFunc";
let x = new t();
console.log(f());
  • 需要导出大量内容时,可使用命名空间导入模式。
export class Dog { ... }
export class Cat { ... }
export class Tree { ... }
export class Flower { ... }

使用命名空间导入模式

import * as myLargeModule from "./MyLargeModule.ts";
let x = new myLargeModule.Dog();
  • 使用重新导出进行扩展一个模块功能。

如 JS 常见扩展原对象模式,因模块不会像全局命名空间对象那样去合并。 推荐的方案是不要去改变原来的对象,而是导出一个新的实体来提供新的功能。

// Calculator.ts
export class Calculator {
  //...
}

export function test(c: Calculator, input: string) {
    //...
}

使用原计算机类功能

import { Calculator, test } from "./Calculator";


let c = new Calculator();
test(c, "1+2*33/11=");

扩展该计算机模块功能

// ProgrammerCalculator.ts
import { Calculator } from "./Calculator";

// 继承扩展
class ProgrammerCalculator extends Calculator {
}

// 重新导出新的实体 ProgrammerCalculator 且重命名
export { ProgrammerCalculator as Calculator };
// 同时导出原先的 test 方法
export { test } from "./Calculator";

使用重新导出扩展功能

import { Calculator, test } from "./ProgrammerCalculator";

let c = new Calculator(2);
test(c, "001+010=");
  • 模块里不要使用命名空间,模块具有其自己的作用域,并且只有导出的声明才会在模块外部可见。因此,命名空间在使用模块时几乎没什么价值。
    • 在组织方面,命名空间对于在全局作用域内对逻辑上相关的对象和类型进行分组是很便利的。然而,模块本身已经存在于文件系统,我们必须通过路径和文件名找到它们,这已经提供了一种逻辑上的组织形式。因此在组织方面,我们可以采用文件系统组织的优势进行分组。
    • 命名空间常用于解决全局作用域里命名冲突,而对于模块中来说,在一个模块里从设计角度出发没有理由发生重名的异常情况。

命名空间和模块

  • 检查以下结构要点,确保没有在模块中使用命令空间。
    • 文件的顶层声明是export namespace Foo { ... } (删除Foo并把所有内容向上层移动一层)
    • 文件只有一个export classexport function (考虑使用export default
    • 多个文件的顶层具有同样的export namespace Foo { (错误,这并不会合并分组。)

命名空间

TypeScript 1.5里术语名已经发生了变化。 “内部模块”现在称做“命名空间”。 “外部模块”现在则简称为“模块”

基础

  • 任何使用 module关键字来声明一个内部模块的地方都应该使用namespace关键字来替换,避免让新的使用者被相似的名称所迷惑。
  • 随着更多验证器的加入,组织代码便于在记录它们类型的同时避免产生命名冲突。 因此把验证器包裹到一个命名空间内,而不是全局命名空间下。
// 与之相关类型放在 Validation 的命名空间中
namespace Validation {
  	// export 确保这些接口和类在命名空间之外也可访问 
    export interface StringValidator {
        isAcceptable(s: string): boolean;
    }
		// 变量内部实现细节无需导出
    const lettersRegexp = /^[A-Za-z]+$/;
    const numberRegexp = /^[0-9]+$/;

    export class LettersOnlyValidator implements StringValidator {
        isAcceptable(s: string) {
            return lettersRegexp.test(s);
        }
    }

    export class ZipCodeValidator implements StringValidator {
        isAcceptable(s: string) {
            return s.length === 5 && numberRegexp.test(s);
        }
    }
}

// 示例
let strings = ["Hello", "98052", "101"];

// 使用命名空间
let validators: { [s: string]: Validation.StringValidator; } = {};
validators["ZIP code"] = new Validation.ZipCodeValidator();
validators["Letters only"] = new Validation.LettersOnlyValidator();

for (let s of strings) {
    for (let name in validators) {
        console.log(`"${ s }" - ${ validators[name].isAcceptable(s) ? "matches" : "does not match" } ${ name }`);
    }
}

分离到多文件

  • 当应用变得越来越大时,需要将代码分离到不同的文件中以便于维护,采用多文件的命名空间。

尽管是不同的文件,它们仍是同一个命名空间,如同它们在一个文件中定义的一样。 因为不同文件之间存在依赖关系,加入了引用标签来告诉编译器文件之间的关联。

Validation.ts

namespace Validation {
    export interface StringValidator {
        isAcceptable(s: string): boolean;
    }
}

LettersOnlyValidator.ts

/// <reference path="Validation.ts" />
namespace Validation {
    const lettersRegexp = /^[A-Za-z]+$/;
    export class LettersOnlyValidator implements StringValidator {
        isAcceptable(s: string) {
            return lettersRegexp.test(s);
        }
    }
}

Test.ts

/// <reference path="Validation.ts" />
/// <reference path="LettersOnlyValidator.ts" />

// Some samples to try
let strings = ["Hello", "98052", "101"];

// Validators to use
let validators: { [s: string]: Validation.StringValidator; } = {};
validators["Letters only"] = new Validation.LettersOnlyValidator();

// Show whether each string passed each validator
for (let s of strings) {
    for (let name in validators) {
        console.log(`"${ s }" - ${ validators[name].isAcceptable(s) ? "matches" : "does not match" } ${ name }`);
    }
}
  • 涉及到多文件时,我们必须确保所有编译后的代码都被加载。

    • 第一种,把所有的输入文件编译为一个输出文件,需要使用--outFile标记
    tsc --outFile sample.js Test.ts
    
    • 第二种,可以编译每一个文件(默认方式),每个源文件都会对应生成一个JavaScript文件。 然后,在页面上通过 <script>标签把所有生成的JavaScript文件按正确的顺序引进来
    // 某个 html 中
    <script src="Validation.js" type="text/javascript" />
    <script src="LettersOnlyValidator.js" type="text/javascript" />
    <script src="ZipCodeValidator.js" type="text/javascript" />
    <script src="Test.js" type="text/javascript" />
    

别名

  • 另一种简化命名空间操作的方法是使用import q = x.y.z给常用的对象起一个短的名字。

避免与用来加载模块的 import x = require('name')语法弄混了,这里的语法是为指定的符号创建一个别名。

可以用这种方法为任意标识符创建别名,也包括导入的模块中的对象。

namespace Shapes {
    export namespace Polygons {
        export class Triangle { }
        export class Square { }
    }
}

import polygons = Shapes.Polygons;
let sq = new polygons.Square(); // 等同于 "new Shapes.Polygons.Square()"

别名语法与变量声明类似,并且 import 会生成与原始符号不同的引用,改变别名值不会影响原始变量的值。

使用其他的 JavaScript 库

  • 为了描述不是用 TypeScript 编写的类库的类型,需要声明类库导出的API。 由于大部分程序库只提供少数的顶级对象,可以使用命名空间来表示它们。
  • 示例程序库 D3 在全局对象 d3 里定义它的功能,其声明文件使用内部模块来定义它的类型。 为了让TypeScript编译器识别它的类型,我们使用外部命名空间声明。
declare namespace D3 {
    export interface Selectors {
        select: {
            (selector: string): Selection;
            (element: EventTarget): Selection;
        };
    }
    export interface Event {
        x: number;
        y: number;
    }
    export interface Base extends Selectors {
        event: Event;
    }
}

declare var d3: D3.Base;

命名空间和模块

概括介绍在 TypeScript 里使用模块与命名空间来组织代码的方法,并谈及命名空间和模块的高级使用场景,和在使用它们的过程中常见的陷阱。

使用命名空间

  • 命名空间是位于全局命名空间下的一个普通的带有名字的JavaScript对象,可以在多文件中同时使用,并通过 --outFile结合在一起。
  • 命名空间是组织Web应用不错的方式,可以把所有依赖都放在HTML页面的 <script>标签里。
  • 在大型的应用中,像其它的全局命名空间污染一样,命名空间很难去识别组件之间的依赖关系。

使用模块

  • 模块像命名空间可以包含代码和声明,不同的是模块可以声明它的依赖。
  • 模块会把依赖添加到模块加载器上(例如CommonJs / Require.js)。 在大型应用的场景上,将会带来长久的模块化和可维护性上的便利。
  • 模块提供了更好的代码重用,更强的封闭性以及更好的使用工具进行优化。
  • 对于Node.js应用来说,模块是默认并推荐的组织代码的方式。
  • 从 ES6 开始,模块成为了语言内置的部分,将会被所有正常的解释引擎所支持。 因此,对于新项目来说推荐使用模块做为组织代码的方式。

命名空间和模块常见陷阱

  • 对模块避免使用 /// <reference>,应该使用 import

编译器根据如下规则来根据 import 路径定位模块的类型信息:

编译器首先尝试去查找相应路径下的.ts.tsx再或者.d.ts。 如果这些文件都找不到,编译器会查找外部模块声明(.d.ts文件)。

myModules.d.ts

// 在 .d.ts 文件或者 .ts 文件中没有模块。
declare module "SomeModule" {
    export function fn(): string;
}

myOtherModule.ts

/// <reference path="myModules.d.ts" />
import * as m from "SomeModule";

import 会去查找 SomeModule.ts/.tsx/.d.ts文件最后查找 .d.ts,而引用标签指定了外来模块的位置。

  • 避免使用不必要的命名空间。

shapes.ts 顶层的模块 Shapes 包裹了 Triangle 和 Square。

export namespace Shapes {
    export class Triangle { /* ... */ }
    export class Square { /* ... */ }
}

shapeConsumer.ts

import * as shapes from "./shapes";
let t = new shapes.Shapes.Triangle(); // shapes.Shapes? 命名空间造成困惑

TypeScript 里模块的一个特点是不同的模块永远也不会在相同的作用域内使用相同的名字。 (使用模块的人会为它们命名,完全没有必要把导出的符号包裹在一个命名空间里。)

因此,不应该对模块使用命名空间,使用命名空间是为了提供逻辑分组和避免命名冲突。而模块文件本身已经是一个逻辑分组,并且它的名字是由导入这个模块的代码指定,所以没有必要为导出的对象增加额外的模块层。

shapes.ts

export class Triangle { /* ... */ }
export class Square { /* ... */ }

shapeConsumer.ts

import * as shapes from "./shapes";
let t = new shapes.Triangle();
  • 模块的取舍就像每个JS文件对应一个模块一样,TypeScript 里模块文件与生成的 JS 文件也是一一对应的。

这会产生一种影响,根据你指定的目标模块系统的不同,你可能无法连接多个模块源文件。 例如当目标模块系统为 commonjsumd时,无法使用outFile选项,但是在 TypeScript 1.8以上的版本能够使用outFile当目标为amdsystem

模块解析

  • 模块解析是指编译器在查找导入模块内容时所遵循的流程。

假设有一个导入语句 import { a } from "moduleA"; 为了去检查任何对 a的使用,编译器需要准确的知道它表示什么,并且需要检查它的定义moduleA

  • 编译器会尝试定位表示导入模块的文件。 遵循以下二种策略之一: Classic Node(后续会详细说明)。 这些策略会告诉编译器到哪里去查找moduleA
  • 上面解析失败并且模块名是非相对的(且是在"moduleA"的情况下),编译器会尝试定位一个外部模块声明
  • 如果编译器还是不能解析这个模块,它会记录一个错误,可能为 error TS2307: Cannot find module 'moduleA'.

相对 vs 非相对模块导入

  • 相对导入是以/./../开头的。
import Entry from "./components/Entry";
import { DefaultHeaders } from "../constants/http";
import "/mod";
  • 其他形式的导入被当作非相对的。
import * as $ from "jQuery";
import { Component } from "@angular/core";
  • 相对导入在解析时是相对于导入它的文件,其不能解析为一个外部模块声明。因此应该为自己写的模块使用相对导入,确保它们在运行时的相对位置。
  • 非相对模块的导入可以相对于baseUrl或通过路径映射(下文)来进行解析。 其还可以被解析成 外部模块声明。 使用非相对路径来导入你的外部依赖。

模块解析策略

共有两种可用的模块解析策略:NodeClassic

可以使用 --moduleResolution标记来指定使用哪种模块解析策略。

若未指定,那么在使用了 --module AMD | System | ES2015时的默认值为Classic,其它情况时则为Node

  • Classic 策略在以前是 TypeScript 默认的解析策略,主要是为了向后兼容。

    • 相对导入的模块会基于导入它的文件进行解析。
      • 示例/root/src/folder/A.ts 文件里导入 import { b } from './moduleB' 如下解析:
    /root/src/folder/moduleB.ts
    /root/src/folder/moduleB.d.ts
    

    寻找 .ts / .d.ts 文件。

    • 非相对模块的导入,编译器则会从包含导入文件的目录开始依次向上级目录遍历,尝试定位匹配的声明文件。
      • 示例 /root/src/folder/A.ts 文件里导入 import { b } from 'moduleB'
    /root/src/folder/moduleB.ts
    /root/src/folder/moduleB.d.ts
    /root/src/moduleB.ts
    /root/src/moduleB.d.ts
    /root/moduleB.ts
    /root/moduleB.d.ts
    /moduleB.ts
    /moduleB.d.ts
    

    基于导入文件位置一层层往上,最后直接找 .ts / .d.ts 文件

  • Node 策略试图在运行时模仿Node.js模块解析机制。在Node.js里导入是通过 require函数调用进行的。并且会根据 require的是相对路径还是非相对路径做出不同的行为。

    • 相对路径基于导入文件进行寻找配置。
      • 示例 /root/src/moduleA.js 文件里导入 var x = require("./moduleB");
    1. /root/src/moduleB.js
    2. 检查/root/src/moduleB目录是否包含一个package.json文件,且 package.json 文件指定了一个"main"模块。
    如果文件 /root/src/moduleB/package.json 包含了{ "main": "lib/mainModule.js" },那么会引用/root/src/moduleB/lib/mainModule.js。
    3. 检查/root/src/moduleB目录是否包含一个index.js文件。 这个文件会被隐式地当作那个文件夹下的"main"模块并导入。
    
    • 非相对路径默认认为该模块为外部导入模块,会在一个特殊的文件夹 node_modules里查找你的模块。

      • 示例 /root/src/moduleA.js 文件里导入 var x = require("moduleB");

      node_modules可能与当前文件在同一级目录下,或者在上层目录里。 Node会向上级目录遍历,查找每个 node_modules直到它找到要加载的模块。

    1. /root/src/node_modules/moduleB.js
    2. /root/src/node_modules/moduleB/package.json (如果指定了"main"属性)
    3. /root/src/node_modules/moduleB/index.js
    
    4. /root/node_modules/moduleB.js
    5. /root/node_modules/moduleB/package.json (如果指定了"main"属性)
    6. /root/node_modules/moduleB/index.js
    
    7. /node_modules/moduleB.js
    8. /node_modules/moduleB/package.json (如果指定了"main"属性)
    9. /node_modules/moduleB/index.js
    

完整的Node.js解析算法——Node.js module documentation

下载外部模块到 node_modules 目录

TypeScript 解析模块

TypeScript 模仿 Node.js 运行时的解析策略来在编译阶段定位模块定义文件。 并且在其解析逻辑基础上增加了 TypeScript 源文件的扩展名( .ts.tsx.d.ts)。

同时,TypeScript在 package.json里使用字段"types"来表示类似"main"的意义 - 编译器会使用它来找到要使用的"main"定义文件。

  • 示例 /root/src/moduleA.ts 里相对导入 import { b } from './moduleB'
1. /root/src/moduleB.ts
2. /root/src/moduleB.tsx
3. /root/src/moduleB.d.ts
4. /root/src/moduleB/package.json (如果指定了"types"属性,则更改路径)
5. /root/src/moduleB/index.ts
6. /root/src/moduleB/index.tsx
7. /root/src/moduleB/index.d.ts
  • 示例 /root/src/moduleA.ts 里非相对导入 import { b } from 'moduleB'
1. /root/src/node_modules/moduleB.ts
2. /root/src/node_modules/moduleB.tsx
3. /root/src/node_modules/moduleB.d.ts
4. /root/src/node_modules/moduleB/package.json (如果指定了"types"属性)
5. /root/src/node_modules/moduleB/index.ts
6. /root/src/node_modules/moduleB/index.tsx
7. /root/src/node_modules/moduleB/index.d.ts

// 向上跳一次目录
8. /root/node_modules/moduleB.ts
9. ~ 13.
14. /root/node_modules/moduleB/index.d.ts

// 向上再跳一次目录
15. /node_modules/moduleB.ts
16. ~ 20.
21. /node_modules/moduleB/index.d.ts

附加的模块解析标记

有时工程源码结构与输出结构不同,通常是要经过一系统的构建步骤最后生成输出。 包括将 .ts编译成.js,将不同位置的依赖拷贝至一个输出位置。

最终结果就是运行时的模块名与包含它们声明的源文件里的模块名不同。 或者最终输出文件里的模块路径与编译时的源文件路径不同了。

  • TypeScript 编译器有一些额外的标记用来通知编译器在源码编译成最终输出的过程中都发生了哪个转换。

是编译器不会进行这些转换操作,只是利用这些信息来指导模块的导入。

  • Base URL,告诉编译器到哪里去查找模块,所有非相对模块导入都会被当做相对于 baseUrl。使用方法有两种
    • 命令行中baseUrl的值(如果给定的路径是相对的,那么将相对于当前路径进行计算)
    • tsconfig.json里的 baseUrl 属性(如果给定的路径是相对的,那么将相对于tsconfig.json路径进行计算)

利用AMD模块加载器的应用里使用baseUrl是常见做法,要求在运行时模块都被放到了一个文件夹里。 这些模块的源码可以在不同的目录下,但是构建脚本会将它们集中到一起。

相对模块的导入不会被设置的baseUrl所影响,因为它们总是相对于导入它们的文件。

  • 有时模块不是直接放在baseUrl下面,而路径映射来将模块名映射到运行时的文件。
// TypeScript编译器通过使用tsconfig.json文件里的"paths"来支持这样的声明映射。

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "jquery": ["node_modules/jquery/dist/jquery"] // 此处映射是相对于"baseUrl"
    }
  }
}
  • 通过"paths"我们还可以指定复杂的映射,包括指定多个回退位置(都将会和 baseUrl 合并)。

假设在一个工程配置里,有一些模块位于一处,而其它的则在另个的位置。 构建过程会将它们集中至一处。 工程结构可能如下:

projectRoot
├── folder1
│   ├── file1.ts (imports 'folder1/file2' and 'folder2/file3')
│   └── file2.ts
├── generated
│   ├── folder1
│   └── folder2
│       └── file3.ts
└── tsconfig.json

tsconfig.json 配置如下:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "*": [
        "*",
        "generated/*"
      ]
    }
  }
}

编译器所有匹配"*"(所有的值)模式的模块导入会在以下两个位置查找:

  1. "*": 表示名字不发生改变,所以映射为<moduleName> => <baseUrl>/<moduleName>
  2. "generated/*"表示模块名添加了“generated”前缀,所以映射为<moduleName> => <baseUrl>/generated/<moduleName>
  • 利用rootDirs,可以告诉编译器生成这个虚拟目录的roots,让编译器可以在“虚拟”目录下解析相对模块导入。( 好像它们被合并在了一起)

多个目录下的工程源文件在编译时会进行合并放在某个输出目录下,这可以看做一些源目录创建了一个“虚拟”目录。

比如如下工程结构:

 src	// 控制 UI 的用户代码
 └── views
     └── view1.ts (imports './template1')
     └── view2.ts

 generated	// UI 模版
 └── templates
         └── views
             └── template1.ts (imports './view2')

在运行时,视图可以假设与模版在同一相对 ./template导入的目录下。每当编译器在某一rootDirs的子目录下发现了相对模块导入,它就会尝试从每一个rootDirs中导入。

tsconfig.json 配置 roots 列表如下。

{
  "compilerOptions": {
    "rootDirs": [
      "src/views",
      "generated/templates/views"
    ]
  }
}
  • rootDirs的灵活性不仅仅局限于其指定了要在逻辑上合并的物理目录列表,提供的数组可以包含任意数量的任何名字的目录,不论它们是否存在。

这允许编译器以类型安全的方式处理复杂捆绑(bundles)和运行时的特性,比如条件引入和工程特定的加载器插件。

设想一个国际化的场景,构建工具自动插入特定的路径记号来生成针对不同区域的捆绑,将#{locale}做为相对模块路径./#{locale}/messages的一部分。在这个假定的设置下,工具会枚举支持的区域,将抽像的路径映射成./zh/messages./de/messages等。

假设每个模块都会导出一个字符串的数组。比如./zh/messages可能包含:

export default [
    "您好吗",
    "很高兴认识你"
];

利用rootDirs我们可以让编译器了解这个映射关系,从而也允许编译器能够安全地解析./#{locale}/messages,就算这个目录永远都不存在。比如,使用下面的tsconfig.json

{
  "compilerOptions": {
    "rootDirs": [
      "src/zh",
      "src/de",
      "src/#{locale}"
    ]
  }
}

编译器现在可以将import messages from './#{locale}/messages'解析为import messages from './zh/messages'用做工具支持的目的,并允许在开发时不必了解区域信息。

  • 跟踪模块解析。编译器在解析模块时可能访问当前文件夹外的文件,导致很难诊断模块为什么没有被解析,或解析到了错误的位置。 可以通过 --traceResolution启用编译器的模块解析跟踪。

    • 示例 app.ts 导入 import * as ts from 'typescript'
    // 目录如下
    │   tsconfig.json
    ├───node_modules
    │   └───typescript
    │       └───lib
    │               typescript.d.ts
    └───src
            app.ts
    
    • 使用--traceResolution调用编译器。

      tsc --traceResolution
      
    • 输出结果如下:

      ======== Resolving module 'typescript' from 'src/app.ts'. ========
      Module resolution kind is not specified, using 'NodeJs'.
      Loading module 'typescript' from 'node_modules' folder.
      File 'src/node_modules/typescript.ts' does not exist.
      File 'src/node_modules/typescript.tsx' does not exist.
      File 'src/node_modules/typescript.d.ts' does not exist.
      File 'src/node_modules/typescript/package.json' does not exist.
      File 'node_modules/typescript.ts' does not exist.
      File 'node_modules/typescript.tsx' does not exist.
      File 'node_modules/typescript.d.ts' does not exist.
      Found 'package.json' at 'node_modules/typescript/package.json'.
      'package.json' has 'types' field './lib/typescript.d.ts' that references 'node_modules/typescript/lib/typescript.d.ts'.
      File 'node_modules/typescript/lib/typescript.d.ts' exist - use it as a module resolution result.
      ======== Module name 'typescript' was successfully resolved to 'node_modules/typescript/lib/typescript.d.ts'. ========
      

注意几个关键信息:

  • 导入的名字及位置

======== Resolving module 'typescript' from 'src/app.ts'. ========

  • 编译器使用的策略

Module resolution kind is not specified, using 'NodeJs'.

  • 从npm加载types

'package.json' has 'types' field './lib/typescript.d.ts' that references 'node_modules/typescript/lib/typescript.d.ts'.

  • 最终结果

======== Module name 'typescript' was successfully resolved to 'node_modules/typescript/lib/typescript.d.ts'. ========

  • --noResolve编译选项从命令行中指定模块进行解析。-

正常来讲编译器会在开始编译之前解析模块导入,每当它成功地解析了对一个文件 import,这个文件被会加到一个文件列表里,以供编译器稍后处理。

// app.ts
import * as A from "moduleA" // OK, moduleA passed on the command-line
import * as B from "moduleB" // Error TS2307: Cannot find module 'moduleB'.
tsc app.ts moduleA.ts --noResolve

--noResolve编译app.ts,指定编译 moduleA 模块:

  • 可能正确找到moduleA,因为它在命令行上指定了。
  • 找不到moduleB,因为没有在命令行上传递。

exclude 列表失效问题

  • 如果不指定任何 “exclude”“files”tsconfig.json将文件夹所有文件包括子目录放入编译列表中。
  • 利用 “exclude”排除某些文件,利用 "files",指定所有要编译的文件列表。
  • 如果编译器识别出一个文件是模块导入目标,就会加到编译列表里,不管它是否被 exclude 排除了,都将被 tsconfig.json 自动加入。

因此,要从编译列表中排除一个文件,除了在 tsconfig.json 配置排除它的同时,还要排除所有对它进行import导入或/// <reference path="..." />引用标签。

跳转(下)