Showing posts with label typescript. Show all posts
Showing posts with label typescript. Show all posts

Wednesday, February 13, 2019

Angular with TypeScript

現在 Angular 有自己的 CLI 工具了,可以處理建立專案、產生元件、測試、檢驗等等瑣事。

ng new [PROJECT_FOLDER_NAME]
建立新專案。
ng serve
啟動測試伺服器。
ng help [COMMAND]
顯示說明。

產生的專案預設會有所有東西,包含簡單的測試,還會建立 Git repo 並自動做 import commit 出來,有選項可以關掉。

建新專案時可以順帶給定一些參數,調整後面產生的 component 等程式的預設組態,比如下面是幾個常用的:

ng new [PROJECT_NAME] --prefix=[SELECTOR_PREFIX (預設值: app)] --style=[STYLE_EXT (預設值: css)] --routing

打定主意不用內建的測試框架 (或是根本不打算測試的話 wwwwww) 的話,還可以加上 --skip-tests 來略過產生測試的程式碼。

在自動產生的程式碼中,進入點是 src/main.ts,在這邊會載入 src/app/app.module.ts 裡的定義,然後接著就會啟動 src/app/app.component.ts 裡的 AppComponent 作為 root component 顯示。

更新框架套件只要 ng update [LIBRARY_NAME] 即可,如果加上 --all 則會將因為相依性帶進來的開發套件一起更新。

Getting started: Project file review 中,有說明專案目錄下的檔案用途。

產生 Component 使用 ng generate component [COMPONENT_NAME] 指令,會在 src/app/ 下面產生指定名稱的資料夾與相關程式檔案,並更新 src/app/app.module.ts 檔案。

使用 ng generate 產生 service 或其他 module 時,要額外使用 --app= 選項,否則不會更新 src/app/app.module.ts 檔案,要手動將新產生的 Module 增加進去。

看起來目前還沒有針對 component library 的 generate 指令,如果要做 component library 可能要參考 Angular Material 的作法。

使用 ngModel 前,必須先在 app.module.ts 中 由 NgModel 修飾過的 AppModule 類別引入 FormsModule 才可以使用。事實上所有元件都必須在 NgModel 中引入,才可以使用。

在 Angular 中,使用了 RxJSObservable 實作來處理非同步的操作。

通常會用上 Observable 的多會是遠端的資源,因此還會有一個屬性儲存當前可用的實體,在 Angular 的慣例中 (convention) 會把存放 Observable 屬性名稱的尾端加上一個錢號 ($) 以作為區別儲存實際值的屬性。

Routing 的部份,慣例上要增加一個特殊的 AppRoutingModule 來處理在 view 之間切換的事情。使用下面的指令新增:

ng generate module app-routing --flat --module=app

產生在 view 之間切換的連結時,要使用 RouterLink directive 來讓 <a> 的 href 屬性可以根據給 RouterModule 的參數來產生適當的連結。

要使用 HTTP End-point 的話,已經包裝好了 HttpClient 模組可以使用。跟其他模組一樣,要在 AppModule 中匯入後就可以使用。

架構上,最基礎的 Angular 建構元素是 NgModule,用來表達 Component 等 Angular 應用程式組件。每個 Angular 應用程式至少會有一個 root module 來 bootstrap 整個應用程式,引入其他的 feature module 來完成應用程式的功能。

Component 會決定 view 的定義,提供給 Angular 框架來依照所定義的邏輯與當下取得的資料狀況來選用與調整。在每個 Angular 應用程式中,會有至少一個 root component 來作為最基底的組件。

Component 中使用 Service 所提供的與 view 無直接關係的相關功能,提供服務的組件作為 dependencyinject 到 Component 中。

Component 與 Service 都是一般的 class,但是透過 decorator 把相關的模組類型資訊等各項後設資訊加到 class 上。

  • 對於 Component 來說,所附加的主要是 template 資訊,也就是內含 directive 與 binding markup 的 HTML 內容。

    透過對類別加上 @Component() 裝飾子來定義出 Component 模組,關聯樣板的相關屬性是 selector, templateUrl, styleUrls 等,與 AngularDart 相同。

  • 對於 Service 來說,所附加的主要是讓 Angular 進行 Dependency Injection 所需要的資訊。

    將類別標示為 Service 所使用的是 @Injectable() 裝飾子,沒有特別的參數。

一般來說,應用程式的 root module 會稱為 AppModule

@NgModule() 中主要的參數:

declarations

在這個 NgModule 下開發的 Components, Directives, Pipes 要加到這個陣列裡面,在 NgModule FAQs: What classes should I add to the declarations array? 裡頭有大略講到用法。

exports

要讓其他 NgModule 使用的組件,也是陣列型式。

imports

要導入這個 NgModule 中使用的其他 NgModule,比如在樣板要使用 NgModel 的話就會需要 FormModule 模組。

providers

要使用到的服務,在這邊導入會變成全域的。也可以在 Component 層級進行導入來限制 scope 的範圍,可以參考 Hierarchical Dependency Injectors 的說明。

bootstrap

指定 root component,只有 root module 需要定義這個屬性。

可以在 Component 中利用 Life-cycle hook 定義 Angular 在建立、更新、摧毀等各階段要執行的邏輯,比如常見的 ngOnInit() 即為一例。

@Component() 中,常用的參數有:

selector

用來獲取這個元件在 DOM 中所在位置的 CSS selector 規則,當依規則找到節點後,會將元件安插在該節點上。

templateUrl

樣板的相對位址,樣板基本上就是 HTML 扣除 <script /> 等敏感標籤後加上樣板語法樣板語法主要是加在標籤的屬性部份。

providers

這個元件所需要使用的 service 類別所構成的陣列,將會由 Angular 透過 Dependency Injection 往建構子注入。

如果要在程式裡頭定義 template 的話,可以使用 ECMAScript 2015 backticks (`) 語法,功能與 Python 的 """ ... """ 語法大致相同。

所產生的 view 會由 Angular 自動的依據樣板定義的邏輯更新,更新的時機稱為 change detection cycle,多為與 view 相關的同步作業完成時,比如鍵盤事件、計時器完成、HTTP 傳輸完成等。

輸出值到 view 上會透過 template expression 來達成,在 template expression 中只可以參考到 Component 上的元素以及部份不會產生 side effect 的 JavaScript 運算子,不可以使用全域空間像是 window.location 或是 Math.max() 等元素。

可以在 template 中參考同樣板中的其他物件,首先用井號 (#) 加上 template reference variable 標記 (如: <input #heroInput>) 接著就可以在 Interpolation 中使用 (如: {{heroInput.value}})。

要特別注意,因為 template reference variable 對實體的繫結只會在初始化樣板時發生一次,而且 scope 是整個樣板,是不可以重複的,也就是不能在 *ngFor 中使用。如果要搭配 *ngFor 使用的話,可能要考慮做一個獨立的元件吧!

要對事件反應的話,是透過 template statement 搭配 event binding 來達成。在 template statement 中,大多是直接呼叫 Component 上的方法,或是簡單的 assignment 敘述。在定義 event binding 時,會多一個 $event 變數可以參考,代表 DOM event 物件。

HTML attribute 與 DOM property 不同,可以將 HTML attribute 視為初始化 DOM property 的設定,一旦 DOM 初始化完成後,就是透過 DOM property 來改變顯示或是互動的反應。在 template 中,操作的對象是 DOM property 的值。要注意,HTML attribute 與 DOM property 並不一定是 1-1 對應,此外,即使名稱相同,反應可能會不同。

Binding syntax: An overview 中,可以看到各種繫結語法的作用對象與範例。

Angular 區分 Component 與 Service 主要是為了:

  • 讓 Component 專注於朔造使用者體驗上,理想上 Component 只要提供 Data-binding 必要的屬性與方法,作為 View 與 Model (Application Logic) 中間的介接角色。
  • 不應在 Component 中處理有關資料擷取、輸入驗證、紀錄等邏輯,而應將這些邏輯抽到 Service 中,如此可以在不同的場合使用相同的 Service 以提升應用程式在不同需要下的可適應性。
Injector

整個 Angular 的 DI 機制的主要機制實作,應用程式不需要自己建立 Injector 實體,框架會在 Bootstrap 階段進行準備。

Container

Injector 會為每個所建立的 dependency 實體維護一個 Container 來儲存相關的資料,並在可能的時候儘量的再利用所建立的 dependency 實體。

Provider

Provider 是指要給 Injector 用來建立 dependency instance 的邏輯,對 Service 來說會是定義 Service 的類別。

如果在 root module 加 provider 的話,那麼會建立一個給全域使用的 instance。

如果在 Component 中加 provider 的話,每個 component instance 都會有一份獨立的 instance 給該 component instance 以及其下的 component instance 使用。

在 Angular 中有預先建置了一些表單的機制,像是雙向資料繫節 (two-way data binding), 變更追蹤 (change tracking), 表單資料驗證 (validation), 錯誤處理 (error handling) 等框架。

極端來看,最基本的取資料的方式就是用事件繫結 (event binding) 來取得使用者的輸入,比如 <input (keyup)="handle_keyup($event)"> 這樣,然後再實作處理鍵盤輸入事件的方法來從事件物件中取出使用者的動作。這麼做的缺點就是使用者看到的部份 (HTML) 跟程式的邏輯 (TypeScript) 綁的太緊密,當視覺部份有調整時,邏輯就必須跟著調整。

一個稍微好一點的作法是利用 template reference variable 來取得輸入值: <input #box (keyup)="handle_input(#box.value)">,這樣在邏輯端就不用非常知道前端事件來源的細節。

<form> 上加上 template reference variable 繫結的 ngForm directive 並在各 input control 加上名為 name 的 property 的話,就可以利用這個 reference variable 在樣板上或是利用 @View() 去存取 form 內的各個 input control 物件。

在 input control 使用 ngModel directive 除了可以用在雙向資料繫結外,也可以讓 Angular 自動基於輸入的狀態加上 class name: Track control state and validity with ngModel

要跨越 HTML element 存取 Angular 附加到 input control 的資訊的話,可以利用將 template reference variable 繫結到 ngModel 來達成:

<input [(ngModel)]="model.name" name="name" #name="ngModel">
<div [hidden]="name.valid || name.pristine">...

利用 @ViewChild 可以在 TypeScript 中存取到 HTML 裡面定義的元件參考,原生元件要用 ElementRef 型別。

另外就是 ViewChild 獲取元件參考的時機,利用 static 參數來控制,原則上就是如果要在 OnInit 取用的話,就會需要設為 true,但是該元件就不能使用 *ngIf, *ngFor 之類的樣板指令,詳情參考 Static Query Migration Guide

TypeScript 基本語言元素

Microsoft 的文件安排跟 Google 稍微有點不太一樣,在 Tutorial 中的東西非常少,絕大多數都放在 Handbook 中,是蠻有趣的策略。從 URL 上來看,一開始可能甚至沒有打算特別作 Tutorial 的部份。

除了官方文件外,另外有一個 TypeScript Deep Dive 在 Google 搜尋結果上排名也蠻前面的。

測試簡單的語法時,可以使用 TypeScript Playground。看起來分享功能是透過 URL 來做的,所以不能分享太長的程式碼片斷。

語言的規格文件有放在 Github 上面: TypeScript Language Specification


型別標注 (type annotation) 類似一些新的語言,都是放在變數名稱後面:

let username: string;
function func(user: Person) { ... }
interface Name {
    firstName: string;
    lastName: string;
}

利用 interface 來描述物件的外觀,包含有什麼屬性、方法等。一個物件是不是吻合特定的 interface 會自動的透過檢查內部結構來達到,並不特別需要使用 implements 之類的描述。換言之,跟 Go 一樣,也是 duck typing 的型別系統。

class 上,建構子是使用 constructor 關鍵字來宣告。如果建構子參數加上了 public, protected, private (存取範圍修飾子; accessibility modifier) 或是 readonly 關鍵字,那麼稱為 Parameter Properties,參數將會直接成為屬性。


class Octopus {
    readonly numberOfLegs: number = 8;
    constructor(readonly name: string) {
    }
}

基本型別:

boolean

布林值,使用 true 或是 false 表示。

number

跟 JavaScript 相同,所有數值實質上都是浮點數。

定數 (literal) 除了十進位之外,也可以使用 Hex (0xF01D), Binary (0b11010010), Oct (0o0755) 來表示。

string

如同 JavaScript 一樣,可以用雙引號或是單引號來定義字串定數。

另外可以使用 backtick (backquote; `) 加上 ${ expr } 來定義 Template string:

let sentence: string = `Hello, my name is ${ fullName }.

I'll be ${ age + 1 } years old next month.`;

Template string 可以有很多行,這部份有點類似 Python 的 """ ... """ 語法。

Array

陣列可以使用 [] 或是泛型陣列語法 (Array<elementType>) 來宣告:

  • let l: number[] = [1, 2, 3];
  • let l: Array<number> = [1, 2, 3];
Tuple

數對/數組 (Tuple) 宣告的語法是 let my_tuple: [elementType1, elementType2, ...];。轉換成 JavaScript 時,是轉換成陣列。

使用元素時,使用類似陣列的方式: my_tuple[1]

也可以參照超出原本定義的範圍的元素,型別會被當作所宣告的所有元素型別的聯集型別 (union type),不過取用前需要先賦值。

let x: [string, number] = ["Hello", 18];
x[2].toString();  // Error, 還沒宣告
x[2] = 100;  // OK, 100 is (string | number)
x[2].toString();  // OK
x[2].substr(1);  // Error, 可使用屬性與方法是 string 與 number 所提供的交集
x[2] = "Today";  // OK, "Today" is (string | number)
x[2] = true;  // Error, true is not (string | number)

另外在初始化時,必須同時賦值,至少在 2.7 是這樣,跟對應過去的 JavaScript 程式有關。

也就是說: 這樣寫可以,但是這樣寫不行

enum

建立列舉的語法是 enum Color {Red, Green, Blue},基本上數值代號是依照宣告順序從 0 開始,不過也可以設定值: enum Color {Red = 1, Green, Blue}

上面的例子,在 2.7 轉換出來的程式碼是這樣:

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

基本上就是任意值,比 Object 類別還廣。

某種層面來說,這是一個脫離 (opt-out) 行別檢查的方式。

void

原則上是用在描述函數回傳型別時使用,如果用來宣告變數的型別,該變數將只能繫結 nullundefined 上去。

null 與 undefined

在 TypeScript 中,null 與 undefined 同時也是型別。也就是說,可以宣告一個變數,型別是 null (或是 undefined),不過這樣子這個變數只能設定為 null (或 undefined)。

在預設的組態下,null 與 undefined 是所有型別的子型別。也就是說,可以把 null 或 undefined 賦值給任何型別的變數。

但如果開啟了 --strictNullChecks 檢查,那麼 null 與 undefined 將只會是 void 型別的子型別。在這個情況下,如果要讓一個變數或是參數可以接受 null 為參數,則需要使用 union type 來宣告,比如一個可為空值的字串: string | null

使用 --strictNullChecks 是被建議的,可以提早發現一些邏輯錯誤。

never

通常用在宣告一個函式或方法不會正常的結束,在函式或方法的內部邏輯是無窮迴圈或僅會拋出例外時使用。

Type assertion

類似型別轉換,不過轉出來的程式碼事實上並不會進行任何檢查,只是告知編譯器而已,不會有 overhead 在執行時期發生,程式設計師必須確定要轉換的變數確實是所指定的型別。語法有兩種:

  • let strLength: number = (<string>someValue).length;
  • let strLength: number = (someValue as string).length;

變數宣告在 TypeScript 中建議多多使用 let 關鍵字,雖然 JavaScript 的 var 也可以用,不過相當雷就是。

var let
Scoping function lexical / block
Shadowing No Yes
Capturing (Closure) function block (per-iteration)

比較重要的就是,使用 var 的場合下:

  • 變數的 scope 是函數,沒有在管 block level 的,整個命名空間的分割是透過函數作為邊界,在一個函數內重複定義的變數都是繫結在同一個變數實體上。
  • 也因為變數的 scope 是函數,所以利用 function 建立 closure 時,變數是繫結在當下的函數空間上。

使用 let 的話:

  • 變數的 scope 跟目前流行的語言一樣,是 block level 的,在 inner level 定義的變數可以遮蔽掉 outer level 定義的變數。
  • 建立 closure 時,一個 block 一層,不過 loop 比較特殊一點,每個 iteration 會產生一個新的 closure,所以每個 iteration 的計數用變數是不同的 instance。

下面這段程式:

function scope() {
 let x: number = 0;
 for(let i = 0; i < 3; i++) {
  setTimeout(function() { x = x + 1; console.log(`> ${ i }, [${ x }]`); }, 100 * (i + 1));
 }
 return function() {
  console.log(`x = [${ x }]`);
 };
}

let f = scope();
setTimeout(f, 500);

會輸出:

> 0, [1]
> 1, [2]
> 2, [3]
x = [3]

宣告常數可以使用 const 關鍵字,用起來跟用 let 基本上是一樣的,不過要注意,如果繫結的目標是一個物件實體的話,只是說所建立的變數參考是不變的,物件實體的內容還是可以被改變。要確保物件實體的內容不改變的話,必須要把屬性加上 readonly 宣告。

在 TypeScript 可以做 parallel assignment,也就是類似 Python 的 unpacking assignment。用法相當的多:

  • let input = [1, 2];
    let [first, second] = input;
  • [first, second] = [second, first];  // swap variable
  • function f([first, second]: [number, number]) {
        // ...
    }
    f([1, 2]);
  • let [first, ...rest] = [1, 2, 3, 4, 5];
    console.log(first); // outputs 1
    console.log(rest);  // outputs [ 2, 3, 4, 5 ]
  • let [first] = [1, 2, 3, 4, 5];  // first = 1, 丟棄其他的數值
  • let [, second, , fourth] = [1, 2, 3, 4];  // second = 2, fourth = 4, 丟棄其他的數值

物件也可以做 destructuring (Object destructuring),感覺就有點誇張了,可能是要用在函數參數上吧!不過這樣傳參數就變成要傳匿名物件,感覺也是怪怪的。

另外也可以做陣列或是物件的融合,稱為 spread:

let first = [1, 2];
let second = [3, 4];
let bothPlus = [0, ...first, ...second, 5];
// bothPlus 變成 [0, 1, 2, 3, 4, 5]
let defaults = { food: "spicy", price: "$$", ambiance: "noisy" };
let search = { ...defaults, food: "rich" };

經過 spread 操作後,原始陣列或物件的元素會做一個 shallow copy 到新的陣列或物件中。此外,針對物件的 spread 不會複製方法,只有屬性被複製。

定義 interface 時,可以對屬性加上問號 (?) 讓該屬性變成選用的屬性 (optional property)。

interface SquareConfig {
    color?: string;
    width?: number;
}

屬性可以加上 readonly 關鍵字,讓該屬性變成唯讀的。

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

透過 interface 還可以定義函式型別等特殊的型別:

Function type
interface SearchFunc {
    (subject_text: string, max_entry_count: number): string[];
}
Indexable type

主要用來限制用來索引的變數型別,在 JavaScript 中預設是可以用任何型別的值來作為陣列的索引。

interface StringArray {
    [index: number]: string;
}

混用 number 跟 string 作為索引型別其實也是可行的,不過要注意因為這個情況下 JavaScript 底層會把 number 轉成 string 來作為索引,因此 number 對應的元素型態必須是 string 所對應元素型態的子類別。

class Animal {
    name: string;
}
class Dog extends Animal {
    breed: string;
}

interface Okay {
    [x: number]: Dog;
    [x: string]: Animal;
}

interface NotOkay {
    [x: number]: Animal;
    [x: string]: Dog;
}

此外,也可以讓陣列成為唯讀的:

interface ReadonlyStringArray {
    readonly [index: number]: string;
}

定義 class 的時候,可以透過 implements 關鍵字宣告類別有實作特定的 interface

classinterface 中,兩者都可以利用 extends 關鍵字來進行繼承,都可以進行多重繼承。

此外,在 interface 上甚至可以繼承 class,不過在這個情況下,只有屬性與方法的介面定義會被繼承到 interface 上頭,方法的實作不會被繼承。

下面是一些莫名其妙的用法:

  • 因為 interface 定義的是 instance 上的限制,因此不可能在針對 instance 的 interface 定義針對建構子的介面定義,因為建構子對所定義的 class 來說是 static 的。

    這個問題可以透過額外定義一個內含 new 介面的 interface 來繞過,但就沒辦法很明確的陳述該類別建構子吻合某特定 interface 了,只能透過把 class 物件傳入函式來檢查。

  • 因為 JavaScript 的函數本身是 Object,然後 Object 可以隨意的增加屬性,因此可以定義出很奇怪的 interface:

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

定義 class 時,建構子使用 constructor 關鍵字來定義,可以使用 thissuper 來參考實體本身以及上層類別中的成員。

成員存取權限跟 Java 很相似,預設是 public,另外有 protectedprivate 可以使用。宣告為 protected 的成員,可以被衍生的類別成員所使用。

如果屬性不希望被更動,可以使用 readonly 修飾字,加上了這個修飾字之後,該屬性只能在建構子中被初始化。

如果要細緻的控制執的存取,可以使用 getset 來程式化屬性的存取,這部份跟 Dart 的設計類似。

class Employee {
    private _fullName: string;

    get fullName(): string {
        return this._fullName;
    }

    set fullName(newName: string) {
        if (newName) {
            this._fullName = newName;
        } else {
            this._fullName = "No name";
        }
    }
}

另外也可以用 static 來建立靜態成員,語法跟 Java 相同。也可以使用 abstractclassfunction 上,來建立抽象類別。

由於 class 定義最後會被轉換成一個產生 object 的 function 物件,因此有時候會看到在 TypeScript 文件中用 constructor function 這樣的名稱來指稱一個 class 定義。

透過型別推理,下面幾個函式變數的寫法有一樣的結果:

  • let myAdd: (baseValue: number, increment: number) => number = function(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, y) { return x + y; };

函數的參數可以是選用 (optional) 的:

function buildName(firstName: string, lastName?: string) { ... }

也可以幫參數設定預設值:

function buildName(firstName: string, lastName = "Smith") { ... }

選用的參數必須在非選用的 (required) 參數之後,不過預設值就沒有這個限制。呼叫時把參數值設定為 undefined 就會使用預設值,因此可以不受位置的限制。

如果使用了 --strictNullChecks 參數進行編譯,選用的參數在型別中會自動的被加上 | undefined 成為 union type,這點在選用的類別屬性上也是一樣的。

可以用 ... 來取得剩餘的參數,比較類似 Python 的 *args 的用法:

function buildName(firstName: string, ...restOfName: string[]) { ... }

在 JavaScript 中的 this 會參考到目前的 context 上,也就是函式物件是怎麼呼叫的。文件上的例子是一個物件中的方法傳回了一個函式物件,程式把傳回的函式物件存在全域變數中,此時呼叫這個函式物件會發現他的 this 是 window 這個全域物件實體。

在 TypeScript 中可以利用新的 arrow function 語法,這樣子所建立出來的函式物件會取定義函式物件時的 this 來作為函式物件被呼叫時要使用的 this 參考。

傳統的 JavaScript 函式定義:
function () { ... }
TypeScript/ECMAScript6 的 arrow function 函式定義:
() => { ... }

基本上,就算是在 TypeScript 中,函式或物件方法仍然是用傳統的 JavaScript function 去看待,因此要注意 this 到底是參考到什麼的問題。

可以加上 this 的型別標注,並在編譯時開啟 --noImplicitThis 編譯選項,來讓編譯器提出關於 this 型別的相關警告。

在沒有特別設定的 function 中,this 型別會預設為 any,這點即使是物件方法也一樣。加上關於 this 的物件宣告,就可以提示所預期的 this 參考應有的型別。

class Handler {
    onClick(this: Handler, e: Event) {
        // ...
    }
}

但是如果要把物件的方法當成給 DOM event 的 callback function 來使用,要注意是無法正常使用 this 的。因為 callback function 會被當成一般的函式呼叫,所以呼叫時的 this 必須是設定為 void 型別 。換句話說,在 callback function 的原型定義中,this 型別必須為 void 型別。因此,如果要把物件方法當成 callback function 使用,該方法必須特別使用 => (arrow function) 來定義:

class Handler {
    info: string;
    onClick = (e: Event) => {
        this.info;
        // ...
    }
}

要注意的是,每個使用 arrow function 定義出來的函式或方法,背後會帶一包 closure,因此會比傳統的 function 還要佔空間。

TypeScript 也有 function overload,不過看起來只是增加型別檢查的部份,實作上還是要寫一個參數與回傳值都是 any 的函式實體,並在裡面用 typeof== 來針對輸入的行別檢查並做出對應反應。

function pickCard(x: {suit: string; card: number; }[]): number;
function pickCard(x: number): {suit: string; card: number; };
function pickCard(x): any {
    if (typeof x == "object") { ... }
    else if (typeof x == "number") { ... }
}

以上面的例子來說,最後的 (x): any 實作是不會被算進去的,因此實際上的 overload 只有兩個。

此外,檢查 overload 時,會從給定清單的上面往下面找,因此要把比較明確的放在較上方宣告。

泛型的部份與 Java 很相似,呼叫時的語法大概是 func<string>(arg0, arg1, ...) 這樣,不過也支援型別自動推理 (type argument inference): func("my-string") 來簡略化程式碼。

也支援在泛型型別上增加條件:

  • function loggingIdentity<T extends Lengthwise>(arg: T): T { ... }
  • function getProperty<T, K extends keyof T>(obj: T, key: K) { ... }

如果要使用泛型在 factory 函式上,在手冊上有個有點複雜的範例可以參考。

型別推理 (type inference) 基本上發生在賦值時,策略大概有:

基本

只是純量的賦值,如: let x = 3;

最佳共同型別 (best common type)

在陣列上時,會儘量找出陣列元素中的共同型別,如果沒有辦法找出共同型別時,會用 union type 來處理。

脈絡 (contextual type)

如果是賦值到一個已知型別的屬性或是函數呼叫的參數值,會利用該型別來檢查傳入的參數型別。

TypeScript 在物件的型別相容上使用 structural subtyping 作為理論基礎,也就是說,物件實體是不是等同於或相容於某個型別,是透過實體上有哪些屬性與方法來決定。

部份無法在 compile time 確認是型別安全的操作在 TypeScript 是被允許的,因此 TypeScript 學理上是 not soundness 的語言。從例子看起來,主要是有 callback 的狀況下會有允許 unsound 的設計。

比如這樣一個函式:

function listenEvent(eventType: EventType, handler: (n: Event) => void) { ... }

下面這樣呼叫是 Unsound 的,因為 MouseEvent 比 Event 還嚴格,但是基於 assignment 的角度來檢查型別,所以這樣寫還是會過,而且這樣寫也比較好懂:

listenEvent(EventType.Mouse, (e: MouseEvent) => { ... });

如果要維持 Soundness 的話,可以這樣寫,但是這樣寫就比較不容易閱讀:

listenEvent(EventType.Mouse, (e: Event) => { ... ((<MouseEvent>e).x + "," + (<MouseEvent>e).y)); ... });

列舉 (enum) 跟數值 (number) 是可以互相相容的,不過不同的 enum 之間是不相容的。

類別 (class) 的型別相容檢查只針對 instance 上的屬性與方法檢查,建構子與 static member 不在檢查範圍內。類別實體檢查型別相容性時,會額外考慮 private 與 protected 的成員,檢查目標也要有同樣的成員,而且必須是在同一個類別定義的,這樣的特性讓子類別可以相容於上層類別,但不會與不同繼承關係下的其他類別實體相容。

Intersection type 語法是 T & U & V ... 這樣寫,代表這個融合後型別的實體必須有「所有」基礎類別的屬性與方法。對應到其他語言會是 Mixin 的特性,不過看起來 TypeScript 本身並沒有特別有 Mixin 的支援,至少範例裡面把兩個類別實作融在一起的部份是另外寫迴圈做的。

Union type 語法是 T | U | V ... 這樣寫,代表融合的型別只需要有基礎類別中「共同」的屬性與方法,常用在參數可以接受兩種以上型別時。

使用 union type 時可以搭配稱為 type guard 的 if 語法,來簡化程式碼。原本要利用 type assertion 的程式碼:

let pet: Fish | Bird = getSmallPet();

if ((<Fish>pet).swim) {
    (<Fish>pet).swim();
}
else {
    (<Bird>pet).fly();
}

可以透過特定的型別檢查判斷式,搭配 if ... else if ... else 分支,在 if 內的程式碼區塊中,變數的型別會被局部的窄化 (narrow) 為所檢查通過的型別。

型別檢查的判斷式有幾種型式:

使用者定義的檢查 (type predicate)

利用撰寫一個傳回 type predicate 的函式,這個函式的參數型別必須是要檢查的 union type,傳回一個型別為 type predicate 的布林值:

function isFish(pet: Fish | Bird): pet is Fish {
    return (<Fish>pet).swim !== undefined;
}

if (isFish(pet)) {
    pet.swim();
}
else {
    pet.fly();
}

這樣子可以自行撰寫型別檢查的邏輯,可以做類似 feature-based 的分支。

typeof

透過 typeof 關鍵字可以取得物件實體型別的字串表示,可以透過判斷型別名稱字串來做型別的檢查,但是只有非常有限的型別被支援。

支援的檢查陳述式為 typeof v === "typename<"typeof v !== "typename<" 兩種,支援的型別 (typename) 有: number, string, boolean, symbol,其他型別的字串也可以寫,但是不會有 type guard 的效果。

使用範例如下:

function padLeft(value: string, padding: string | number) {
    if (typeof padding === "number") {
        ...
    } else if (typeof padding === "string") {
        ...
    }
    ...
}
instanceof

也可以使用 instanceof 來進行 type guard:

if (pet instanceof Fish) {
    pet.swim();
}
else {
    pet.fly();
}

instanceof 的右側必須要是一個 constructor function,程式碼區塊內會把物件實體變數的型別依照下面邏輯順序判斷進行 narrow:

  1. 如果該 constructor function 的 prototype 有設定型別 (不是 any) 的話,那就使用該型別;或是
  2. 該 constructor function 所定義的所有可回傳型別的 union type。
針對 null 的處理

如果變數是 null 的 union type 的話,可以利用 v == null 來進行 type guard:

function f(sn: string | null): string {
    if (sn == null) {
        return "default";
    }
    else {
        return sn;
    }
}

看起來大概就是: 如果要做基本型別的 type guard 可以使用 typeof;如果是類別的話,則使用 instanceof 來進行。

對於 Nullable type (也就是有 null 或 undefined 一起作為 union type 的變數) 在除去 null 的時候可以利用 terser operator: x = x || "default";

當編譯器無法自動追蹤出變數已經不可能為 null 或 undefined 值時,可以在該變數識別字後加上驚嘆號 (type assertion operator): title = name!.charAt(1) ... 告訴編譯器該值已不可能為空值。

使用 type 關鍵字可以定義型別的別名 (type alias),類似 C/C++ 的 typedef 關鍵字。型別別名在編譯後會被展開,此外也不能進行 extends 或是 implements 等擴充。

可以在 type alias 中使用泛型或是參考到自己:

type Tree<T> = {
    value: T;
    left: Tree<T>;
    right: Tree<T>;
}

或是搭配 intersection type 使用:

type LinkedList<T> = T & { next: LinkedList<T> };

字串或是數值的定數 (literal) 也是型別,稱為 literal type,可以搭配 union type 或 type alias 等特性使用。

比如對於 type alias: type Easing = "ease-in" | "ease-out" | "ease-in-out"; 如果有變數宣告為 Easing 型別,那麼該變數中可以接受的值就只有所指定的字串。

另外也可以搭配 overload 使用:

function createElement(tagName: "img"): HTMLImageElement;
function createElement(tagName: "input"): HTMLInputElement;
// overloads ...
function createElement(tagName: string): Element {
    // 實作
}

Literal type 跟 enum member type 合稱為 singleton type,不過常會不精準的混用。

類別的方法可以用 this 當成傳回型別,這樣被繼承之後,舊的方法傳回來的物件參考還是新的類別型別,可以便利 chained invoke 的程式撰寫方法。

因為 JavaScript 物件可以用類似關聯陣列的方式存取,因此也有相關的 operator 可以使用,相關的 feature 稱為 index type

function getProperty<T, K extends keyof T>(o: T, name: K): T[K] {
    return o[name]; // o[name] is of type T[K]
}

let name: string = getProperty(person, 'name');
let age: number = getProperty(person, 'age');
let unknown = getProperty(person, 'unknown'); // ERR: 'unknown' is not in 'name' | 'age'

透過 keyof T 可以將給定的型別 T 中所有的 public 屬性與方法的名稱取出並構成 union type,這個操作稱為 index type query operator。要特別注意,是取出為型別 (union type) 而不是一個字串陣列。

取得類別中特定屬性的型別可以用 T[K] 運算子,會需要搭配 K extends keyof T 來使用,這個運算子稱為 indexed access operator,可以取出 T 類別中 K 所指涉的屬性或方法的型別。

感覺上在終端的程式上 index type 相關 operator 好像沒辦法使用,但是在 library 或 framework 上應該就可以用上,來跟終端程式碼的自訂型別進行整合。

還可以透過 mapped type 來將所有的 public 成員型別做一個系統性的變更:

type Readonly<T> = {
    readonly [P in keyof T]: T[P];
}

type Partial<T> = {
    [P in keyof T]?: T[P];
}

type Nullable<T> = {
    [P in keyof T]: T[P] | null;
}

上面這幾個算是常用的,有納入標準函式庫中: ECMAScript APIs

在 ECMAScript 2015 增加了 Symbol 建構函式跟 symbol 型別,看起來是用來建立匿名的識別字 (identifier): MDN: Glossary - Symbol, TypeScript Handbook: Symbols

除了一般的 for (...; ...; ...) 之外,迴圈還有 for (... of ...)for (... in ...) 兩種,分別是對 container 的元素本身,以及索引鍵值遊走。

在 Handbook 上針對元素遊走 (for-of) 有特別提到,因為是比較新的 ES6 的語法,如果 TypeScript 的 target 是設定為 ES5 或更早版本的話,只能用在 Array 型別上。不過,在 2.3 的 release node 裡頭好像又說目前可以轉換的程度有比較厲害,所以看來只能用試的了。

在 MDN 的文章對於 Iterator 的說明比較清楚: Symbol.iterator

要使用的話可以參考 TypeScript Deep Dive: Iterator 章節,有實作上的細節。

import 或是 export 的程式碼檔案,就自動會被視為 module 看待。沒有 import 或是 export 的程式碼檔案,定義的內容就會當成全域的內容,對所有程式碼與模組開放。模組內的變數或方法必須要有 export 修飾字才可以被其他模組 import引用,可以一次匯入或匯出複數個變數或方法,匯入時可以利用 as 設定新的名稱。

如果一個模組只 export 了一個參考,可以使用 export default 的方式,程式會比較清楚。這樣匯出的參考,在匯入時一律需要給定別名。

在模組內可以定義 namespace 來在內部切割命名空間,另外也可以搭配 /// <reference path="code-file.ts" /> 來匯入。

因為 JavaScript 的模組化實在太多嘗試了,所以感覺這部份 TypeScript 也有點亂,有各種相容語法。

使用 import ... from ... 時,搜尋模組有 Classic 以及 Node 兩種模式,原則上在使用 Angular 時應該是使用 Node 模式。

以在 /root/src/moduleA.ts 匯入為例:

相對路徑匯入 (relative import; 匯入路徑由 /, ./../ 開頭): import { b } from "./moduleB"
  1. /root/src/moduleB.{ts, tsx, d.ts}
  2. /root/src/moduleB/package.json (設定內的 types 屬性所指定的目標)
  3. /root/src/moduleB/index.{ts, tsx, d.ts}
非相對路徑匯入 (non-relative import; 匯入路徑不是由 /, ./../ 開頭): import { b } from "moduleB"
  1. /root/src/node_modules/moduleB.{ts, tsx, d.ts}
  2. /root/src/node_modules/moduleB/package.json (設定內的 types 屬性所指定的目標)
  3. /root/src/node_modules/moduleB/index.{ts, tsx, d.ts}
  4. /root/node_modules/moduleB.{ts, tsx, d.ts}
  5. /root/node_modules/moduleB/package.json (設定內的 types 屬性所指定的目標)
  6. /root/node_modules/moduleB/index.{ts, tsx, d.ts}
  7. /node_modules/moduleB.{ts, tsx, d.ts}
  8. /node_modules/moduleB/package.json (設定內的 types 屬性所指定的目標)
  9. /node_modules/moduleB/index.{ts, tsx, d.ts}

以程式碼所在資料夾為基礎無法找到時會往上層找,這部份是模擬 Node.js 的行為,依據 Node.js 的文件是會找到根目錄為止。

Triple-slash directives 就是指檔案最上頭的 /// <... /> 那個指引,比較會用到的應該就:

/// <reference path="..." />

主要是用來宣告相依順序,雖然有點類似 C/C++ 的 #include ... 但是實際參考到的檔案並不會在輸出中出現。

也就是說,如果參考到的檔案也希望一起編到輸出的 .js 檔的話,輸入檔案清單也要提到那些檔案,產生 JavaScript 程式碼時也會一句這邊的設定決定輸出的程式碼區塊的先後順序。

這個指引使用的路徑基本上是檔案相對路徑,應該是以模組內的使用為主。

/// <reference types="..." />

主要是用來參考自行撰寫的 .d.ts 用,會轉換為 @types/.../index.d.ts 後,用類似 module 的找尋邏輯去找。

需要搭配 tsconfig.json 中的設定使用: @types, typeRoots and types

使用 tsconfig.json 可以設定要給 compiler (tsc) 的相關組態設定,當執行 tsc 但沒給輸入的程式碼檔案時,就會從目前工作資料夾開始找 tsconfig.json 來使用,找不到時會往上層資料夾去找。另外也可以使用 -p 或 --project 參數,可以接受資料夾或是一個 JSON 檔案路徑,當給定資料夾時會去找該資料夾下的 tsconfig.json 檔。