TypeScript 初識

ping4god發表於2016-07-15

文章部落格地址:http://pinggod.com/2016/Typescript/

TypeScript 是 JavaScript 的超集,為 JavaScript 的生態增加了型別機制,並最終將程式碼編譯為純粹的 JavaScript 程式碼。型別機制很重要嗎?最近的一些專案經歷讓我覺得這真的很重要。當你陷在一箇中大型專案中時(Web 應用日趨成為常態),沒有型別約束、型別推斷,總有種牽一髮而動全身的危機和束縛。Immutable.js 和 Angular 2 都在使用 TypeScript 做開發,它們都是體量頗大的專案,所以我決定嘗試一下 Typescript。此外我們還可以嘗試 Facebook 的 Flow,比較一下兩者的優劣。Typescript 對 ES6 也有良好的支援,目前組內專案使用 Babel 編譯 ES6,這也就自然而然的把 TypeScirpt 和 Flow / babel-plugin-tcomb 放在了對立面,也許下一篇文章就是介紹 Flow 和 babel-plugin-tcomb。

What and Why

如果你想對 TypeScript 有更深入的認識,那麼推薦你閱讀 Stack Overflow 上的問答 What is TypeScript and why would I use it in place of JavaScript? ,這一節也是對這篇問答的一個簡述。

雖然 JavaScript 是 ECMAScript 規範的標準實現,但並不是所有的瀏覽器都支援最新的 ECAMScript 規範,這也就限制了開發者使用最新的 JavaScript / ECMAScript 特性。TypeScript 同樣支援最新的 ECMAScript 標準,並能將程式碼根據需求轉換為 ES 3 / 5 / 6,這也就意味著,開發者隨時可以使用最新的 ECMAScript 特性,比如 module / class / spread operator 等,而無需考慮相容性的問題。ECMAScript 所支援的型別機制非常豐富,包括:interface、enum、hybird type 等等。

與 TypeScript 相似的工具語言還有很多,它們主要分為兩個陣營,一個是類似 Babel 的陣營,以 JavaScript 的方式編寫程式碼,致力於為開發者提供最新的 ECMAScript 特性並將程式碼編譯為相容性的程式碼;另一個則是 Coffeescript、Clojure、Dart 等的陣營,它們的語法與 JavaScript 迥然不同,但最終會編譯為 JavaScript。TypeScript 在這兩者之間取得了一種平衡,它既為 JavaScript 增加了新特性,也保持了對 JavaScript 程式碼的相容,開發者幾乎可以直接將 .js 檔案重新命名為 .ts 檔案,就可以使用 TypeScript 的開發環境,這種做法一方面可以減少開發者的遷移成本,一方面也可以讓開發者快速上手 TypeScript。

JavaScript 是一門解釋型語言,變數的資料型別具有動態性,只有執行時才能確定變數的型別,這種後知後覺的認錯方法會讓開發者成為除錯大師,但無益於程式設計能力的提升,還會降低開發效率。TypeScript 的型別機制可以有效杜絕由變數型別引起的誤用問題,而且開發者可以控制對型別的監控程度,是嚴格限制變數型別還是寬鬆限制變數型別,都取決於開發者的開發需求。新增型別機制之後,副作用主要有兩個:增大了開發人員的學習曲線,增加了設定型別的開發時間。總體而言,這些付出相對於程式碼的健壯性和可維護性,都是值得的。

目前主流的 IDE 都為 TypeScript 的開發提供了良好的支援,比如 Visual Studio / VS Code、Atom、Sublime 和 WebStorm。TypeScript 與 IDE 的融合,便於開發者實時獲取型別資訊。舉例來說,通過程式碼補全功能可以獲取程式碼庫中其他函式的資訊;程式碼編譯完成後,相關資訊或錯誤資訊會直接反饋在 IDE 中……

在即將釋出的 TypeScript 2.0 版本中,將會有許多優秀的特性,比如對 null 和 undefined 的檢查。cannot read property `x` of undefinedundefined is not a function 在 JavaScript 中是非常常見的錯誤。在 TypeScript 2.0 中,通過使用 non-nullable 型別可以避免此類錯誤:let x : number = undefined 會讓編譯器提示錯誤,因為 undefined 並不是一個 number,通過 let x : number | undefined = undefinedlet x : number? = undefined 可以讓 x 是一個 nullable(undefined 或 null) 的值。如果一個變數的型別是 nullable,那麼 TypeScript 編譯器就可以通過控制流和型別分析來判定對變數的使用是否安全:

let x : number?;
if (x !== undefined)
    // this line will compile, because x is checked.
    x += 1;

// this line will fail compilation, because x might be undefined.    
x += 1;

TypeScript 編譯器既可以將 source map 資訊置於生成的 .js 檔案中,也可以建立獨立的 .map 檔案,便於開發者在程式碼執行階段設定斷點、審查變數。此外,TypeScript 還可以使用 decorator 攔截程式碼,為不同的模組系統生成模組載入程式碼,解析 JSX 等。

Usage

這一節介紹 TypeScirpt 的一些基礎特性,算是拋磚引玉,希望引起大家嘗試和使用 TypeScript 的興趣。首先,從最簡單的型別標註開始:

// 原始值
const isDone: boolean = false;
const amount: number = 6;
const address: string = `beijing`;
const greeting: string = `Hello World`;

// 陣列
const list: number[] = [1, 2, 3];
const list: Array<number> = [1, 2, 3];

// 元組
const name: [string, string] = [`Sean`, `Sun`];

// 列舉
enum Color {
    Red,
    Green,
    Blue
};
const c: Color = Color.Green;

// 任意值:可以呼叫任意方法
let anyTypes: any = 4;
anyTypes = `any`;
anyTypes = false

// 空值
function doSomething (): void {
    return undefined;
}

// 型別斷言
let someValue: any = "this is a string";
let strLength: number = (someValue as string).length;

TypeScript 中的 Interface 可以看做是一個集合,這個集合是對物件、類等內部結構的約定:

// 定義介面 Coords
// 該介面包含 number 型別的 x,string 型別的 y
// 其中 y 是可選型別,即是否包含該屬性無所謂
interface Coords {
    x: number;
    y?: string;
};

// 定義函式 where
// 該函式接受一個 Coords 型別的引數 l
function where (l: Coords) {
    // doSomething
}

const a = { x: 100 };
const b = { x: 100, y1: `abc` };

// a 擁有 number 型別的 x,可以傳遞給 where
where(a);
// b 擁有 number 型別的 x 和 string 型別的 y1,可以傳遞給 where
where(b);

// 下面這種呼叫方式將會報錯,雖然它和 where(b) 看起來是一致的
// 區別在於這裡傳遞的是一個物件字面量
// 物件字面量會被特殊對待並經過額外的屬性檢查
// 如果物件字面量中存在目標型別中未宣告的屬性,則丟擲錯誤
where({ x: 100, y1: `abc` });

// 最好的解決方式是為介面新增索引簽名
// 新增如下所示的索引簽名後,物件字面量可以有任意數量的屬性
// 只要屬性不是 x 和 y,其他屬性可以是 any 型別
interface Coords {
    x: number;
    y?: string;
    [propName: string]: any
};

上面的程式碼演示了介面對物件的約束,此外,介面還常用於約束函式的行為:

// CheckType 包含一個呼叫簽名
// 該呼叫簽名宣告瞭 getType 函式需要接收一個 any 型別的引數,並最終返回一個 string 型別的結果
interface CheckType {
    (data: any): string;
};

const getType: CheckType = (data: any) : string => {
    return Object.prototype.toString.call(data);
}

getType(`abc`);
// => `[object String]`

與老牌強型別語言 C#、Java 相同的是,Interface 也可以用於約束類的行為:

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);

class

除了 ES6 增加的 Class 用法,TypeScript 還增加了 C++、Java 中常見的 public / protected / private 限定符,限定變數或函式的使用範圍。TypeScript 使用的是結構性型別系統,只要兩種型別的成員型別相同,則認為這兩種型別是相容和一致的,但比較包含 private 和 protected 成員的型別時,只有他們是來自同一處的統一型別成員時才會被認為是相容的:

class Animal {
    private name: string;
    constructor(theName: string) { this.name = theName; }
}

class Rhino extends Animal {
    constructor() { super("Rhino"); }
}

class Employee {
    private name: string;
    constructor(theName: string) { this.name = theName; }
}

let animal = new Animal("Goat");
let rhino = new Rhino();
let employee = new Employee("Bob");

animal = rhino;
// Error: Animal and Employee are not compatible
animal = employee;

抽象類是供其他類繼承的基類,與介面不同的是,抽象類可以包含成員方法的實現細節,但抽不可以包含抽象方法的實現細節:

abstract class Animal {
    // 抽象方法
    abstract makeSound(): void;
    // 成員方法
    move(): void {
        console.log(`roaming the earch...`);
    }
}

function

新增型別機制的 TypeScript 在函式上最可以秀的一塊就是函式過載了:

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 {
    // 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 };
    }
}

let myDeck = [{ suit: "diamonds", card: 2 }, { suit: "spades", card: 10 }, { suit: "hearts", card: 4 }];
let pickedCard1 = myDeck[pickCard(myDeck)];
let pickedCard2 = pickCard(15);

console.log("card: " + pickedCard1.card + " of " + pickedCard1.suit);
console.log("card: " + pickedCard2.card + " of " + pickedCard2.suit);

編譯器首先會嘗試匹配第一個函式過載的宣告,如果型別匹配成功就執行,否則繼續匹配其他的過載宣告,因此引數的針對性越強的函式過載,越要靠前宣告。

genrics

function identity<T>(arg: T[]): T[] {
    console.log(arg.length);
    return arg;
}

let myIdentity: {<T>(arg: T[]): T[]} = identity;

上面的程式碼展示了泛型的基本用法,這裡的 <T> 稱為泛型變數,通過這個宣告,我們可以確定傳入的引數型別和返回的資料型別是一致的,一旦確定了傳入的引數型別,也就確定了返回的資料型別。myIdentity 使用了帶有呼叫簽名的物件字面量定義泛型函式,實際上可以結合介面,寫出更簡潔的泛型介面:

interface IdentityFn {
     <T>(arg: T[]): T[];
};

let myIdentity: IdentityFn = identity;

如果同一個泛型變數在介面中被反覆使用,那麼可以在定義介面名的同時宣告泛型變數:

interface IdentityFn<T> {
    (arg: T[]): T[];
};

function identity<T>(arg: T[]): T[] {
    console.log(arg.length);
    return arg;
}

let myIdentity: IdentityFn<string> = identity;

在泛型介面之外,還可以使用泛型類,兩者的形式非常類似:

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

泛型也可以直接繼承介面約束自己的行為:

interface Lengthwise {
    length: number;
}

function loggingIdentity<T extends Lengthwise>(arg: T): T {
    console.log(arg.length);
    return arg;
}

type inference

TypeScript 主要有兩種型別推斷方式:Best Common Type 和 Contextual Type。我們先介紹 Best Common Type:

let x = [0, 1, null];

對於上面程式碼中的變數 x,如果要推斷出它的型別,就必須充分考慮 [0, 1, null] 的型別,所以這裡進行型別推斷的順序是從表示式的葉子到根的,也就是先推斷變數 x 的值都包含什麼型別,然後總結出 x 的型別,是一種從下往上的推斷過程。

TypeScript 的型別推論也可以按照從上往下的順序進行,這被稱為 Contextual Type

window.onmousedown = function(mouseEvent) {
    // Error: Property `button` does not exist ontype `MouseEvent`
    console.log(mouseEvent.buton);  
};

在上面的示例中,TypeScript 型別推斷機制會通過 window.onmousedown 函式的型別來推斷右側函式表示式的型別,繼而推斷出 mouseEvent 的型別,這種從上到下的推斷順序就是 Contextual Type 的特徵。

這裡只對 TypeScript 的特性做簡單的介紹,更詳細的資料請參考以下資料:

React and Webpack

在 TypeScript 中開發 React 時有以下幾點注意事項:

  • 對 React 檔案使用 .tsx 的副檔名

  • 在 tsconfig.json 中使用 compilerOptions.jsx: `react`

  • 使用 typings 型別定義

interface Props {
    foo: string;
}

class MyComponent extends React.Component<Props, {}> {
    render() {
        return <span>{this.props.foo}</span>
    }
}

<MyComponent foo="bar" />; // 正確

TypeScript 的官方文件中對 React 的開發做了一個簡單的演示,主要包含以下幾個部分:

  • 使用 tsconfig.json 作為 TypeScript 的編譯配置檔案

  • 使用 webpack 作為構建工具,需要安裝 webpack、ts-loader 和 source-map-loader

  • 使用 typings 作為程式碼提示工具

具體的搭建流程可以參考文件 React & Webpack,此外,我個人寫過一個 TypeScript & Webpack & React 開發的最小化模板可供各位參考,與之等同的 Babel & Webpack & React 版本

如果檢視模板之後對 import * as React from `react` 的方式有所疑惑,請檢視 TypeScript 的負責人 Anders Hejlsberg 在 issue#2242 中的詳細解析。

參考資料