在這個章節將會介紹許多其它程式語言也都有的基礎概念,包含變數、資料型別、函數、註解以及條件和迴圈的流程控制。



變數和常數

let變數

在前幾個章節中,我們已經會用let關鍵字來宣告變數了,這邊再加強一下觀念。

首先要提到的是let變數的使用範圍(scope)。let變數的scope是在其被宣告出來的同一個程式區塊之後。例如:

{
    let a = 5;
    console.log(a);
}

console.log(a);

以上的第六行程式會編譯失敗,因為a變數是用let關鍵字來宣告的,它的scope並無法到達其被宣告的程式區塊外。

學過程式語言的人大多會覺得這個很正常,沒有什麼好提的。等等介紹var關鍵字的時候您就會知道為什麼要特別提到let變數的scope了。

再來要提的是,let變數的遮蔽(shadowing),只能發生在子區塊中,不能在同一個區塊。例如:

let a = 1;

{
    let a = 5;
    console.log(a);
}

console.log(a);

程式第四行,數值為1a變數被新宣告且數值為5a變數給遮蔽掉了。然後前面有提到過,let關鍵字宣告出來的變數的scope只在同一個程式區塊之後,因此會輸出:

5
1

而不是:

5
5

再舉一個例子:

let a = 1;

let a = 5;
    
console.log(a);

以上程式,第一行和第三行都會編譯失敗,因為a變數在同一個程式區塊中被宣告過了。

let變數的遮蔽可以使用不同型別,例如以下程式是合法的:

let a = 1;

{
    let a = '5';
    console.log(a);
}

console.log(a);

最後要提到的是,被宣告出來的let變數,其預設值為undefined。例如:

let a;
    
console.log(a);

a = 5;

console.log(a);

以上程式會輸出:

undefined
5

注意哦!以上程式,因為我們在a變數被指派值之前就先去讀取它,因此變數a的型別會變成number | undefined。還記得我們在上一章節提到的嚴格模式嗎?在嚴格模式下,以下程式是會編譯失敗的:

let a: number;

console.log(a);

a = 5;

console.log(a);

必須要改成以下這樣才能成功編譯:

let a: number | undefined;

console.log(a);

a = 5;

console.log(a);

const常數

在前幾個章節中,我們也已經會用const關鍵字來宣告常數了,const常數的用法、scope和遮蔽特性都和let變數完全一樣。差別只在const常數在被宣告出來之後就必須要被指派值,而且一旦被賦值之後就無法再被更改。

例如:

const a;

console.log(a);

以上程式會編譯失敗。因為常數a在宣告出來後並沒有被指派值就被讀取了。

再例如:

const a = 1;

a = 2;

console.log(a);

以上程式會編譯失敗,因為第3行我們去修改常數a所儲存的值。

雖然const關鍵字是用來定義常數,但不可變的部份只限於常數所儲存的值,而非值的本身。什麼意思呢?好比下面這個例子,const常數儲存一個物件,但這個物件的欄位依然是可變的。有關於JavaScript的物件,在之後的章節會詳細介紹。

const a = {
    f: 1
};

a.f = 2;

console.log(a.f);

以上程式會輸出:

2

var變數

事實上,剛才介紹的letconst關鍵字,雖然我們用得很理所當然,但它們都是ES6之後才有的語法,在ES5和ES5之前只能夠使用var關鍵字來宣告變數。

TypeScript中,用var關鍵字來宣告變數的語法和用let關鍵字是一樣的。最大的差別在於var變數的scope是在其被宣告出來的同一個函數主體之後。例如:

{
    var a = 5;
    console.log(a);
}

console.log(a);

以上程式會編譯成功,並且輸出:

5
5

再例如:

function f() {
    var a = 5;
    console.log(a);
}

console.log(a);

以上的第六行程式會編譯失敗,因為a變數是用var關鍵字來宣告的,它的scope並無法到達其被宣告的函數主體外。

這下知道為什麼剛才在介紹let關鍵字時還要特地提及它的scope了吧!

事實上,在非嚴格模式下,以下這樣的程式也是可以通過編譯並被正常執行的:

{
    console.log(a);
    var a = 5;
}

console.log(a);

以上程式會輸出:

undefined
5

雖然變數a是在執行完console.log(a)敘述後才被宣告,但變數a還是可以被提前使用,因為var關鍵字宣告出來的變數,它的scope其實是在其被宣告出來的同一個函數主體中,無分程式敘述的順序,宣告時設定的初始值則必須要等到程式執行到該敘述才會被真正指派。不過一般不會將程式寫成這樣,因為太不直覺了。

還沒完呢!var變數的遮蔽會比let變數還來得稍微複雜一點。在同一個函數主體下,我們可以用var關鍵字來宣告名稱相同的變數,例如:

var a = 1;
var a = 2;

console.log(a);

以上程式會輸出:

2

再例如:

var a = 1;

{
    var a = 5;
    console.log(a);
}

console.log(a);

以上程式會輸出:

5
5

其實在同一個函數主體下,var變數並不會發生遮蔽,它其實比較像重新指派新的值給原本的變數。像是上面的兩個例子,其實可以等同於:

var a = 1;
a = 2;

console.log(a);
var a = 1;

{
    a = 5;
    console.log(a);
}

console.log(a);

也就是說,以下程式是會編譯失敗的:

var a = 1;

{
    var a = "5";
    console.log(a);
}

console.log(a);

因為很顯然地,第四行的字串5不能交給型別為numbera變數儲存。

不過,雖然在同一個函數主體下,我們不能用var變數來遮蔽var變數,但是可以用let變數或是const常數來遮蔽var變數。例如:

var a = 1;

{
    let a = "5";
    console.log(a);
}

console.log(a);

以上程式會輸出:

5
1

以下才是var變數發生遮蔽的例子:

var a = 1;

function f() {
    var a = "5";
    console.log(a);
}

f();

console.log(a);

以上程式會輸出:

5
1

被宣告出來的var變數,其預設值為undefined,這點和let變數是一樣的。例如:

var a: number | undefined;
    
console.log(a);

a = 5;

console.log(a);

以上程式會輸出:

undefined
5

在宣告時未定義初始值的變數型別

之前的章節有提到過,在宣告變數或是常數時,若不使用冒號:來定義型別的話,則會使用宣告時設定的初始值的型別作為這個變數的型別。例如:

let a = 1;
a = "5";

以上程式會編譯失敗,因為第一行被宣告出來的a變數型別為number,所以第二行的字串5不能交給a變數來儲存。

那如果將程式改成以下這樣呢?

let a;

a = 1;
a = "5";

以上程式可以通過編譯,但是變數a的型別是什麼?

透過撰寫以下程式來嘗試編譯:

let a: number | string;

a = 1;
a = "5";

我們可以發現這個a的型別,其實就是number | string。依此類推,可以寫出以下能夠通過編譯的程式:

let a: number | string | boolean | object;

a = 1;
a = "5";
a = true;
a = {};

再把我們剛才學到的變數預設值為undefined的觀念融合進去,以下程式可以通過嚴格模式的編譯:

let a: number | string | boolean | object | undefined;

let b = a;

a = 1;
a = "5";
a = true;
a = {};

資料型別

TypeScript是「靜態型別」(Static Typing)的程式語言,在編譯階段就要完全決定好變數的型別。在許多情況下,tsc會替我們推論出變數的型別,使我們不需要明確定義出來。例如:

let a;

a = 1;
a = "5";

let b = false;

以上程式,變數a的型別會被編譯器推論為number | string,變數b的型別會被編譯器推論為boolean

而函數的參數型別,若不明確定義的話,預設會被編譯器推論為任意型別any。例如:

function f(x) {
   
}

以上程式等同於:

function f(x: any) {
   
}

不過,在嚴格模式下,函數的參數並不會被自動推論為any,這點在前面的章節已有提到。

至於函數的回傳值型別,若沒有定義的話,編譯器就會根據這個函數內所有回傳的值的型別,組合成回傳值型別。例如:

function f(x: any) {
    if (typeof x === "number") {
        return 1;
    } else {
        return "something";
    }
}

以上程式等同於:

function f(x: any): number | string {
    if (typeof x === "number") {
        return 1;
    } else {
        return "something";
    }
}

如果函數不回傳任何值,則編譯器會推論這個函數的回傳值型別為void。例如:

function f(x: any) {
   
}

以上程式等同於:

function f(x: any): void {
   
}

基本資料型別(Primitive Data Types)

JavaScript程式語言中一共有七種基本資料型別,分別是數值(number)、布林(boolean)、字串(string)、符號(symbol)、任意精度整数(bigint)、Null(null)和Undefined(undefined),底下將分別介紹這七種型別。

數值(number)

number型別採用64位元的IEEE 754標準來表示整數和浮點數數值。其中整數的安全範圍在-(253 - 1) ~ 253 - 1之間。我們可以透過Number.MIN_SAFE_INTEGERNumber.MAX_SAFE_INTEGER來得到其所能表示的整數最小值與最大值。

console.log(Number.MAX_SAFE_INTEGER);
console.log(Number.MIN_SAFE_INTEGER);

透過Number.MIN_VALUENumber.MAX_VALUE可以得到其所能表示的浮點數最小值與最大值。

console.log(Number.MAX_VALUE);
console.log(Number.MIN_VALUE);

JavaScript允許加上底線_來分割太多位數的數值。例如:

let x = 12_345;

如同其它大部份程式語言,JavaScript的整數也支援16進制、8進制、2進制的表示方式。如下:

let h = 0xff;
let o = 0o377;
let b = 0b11111111;

除了一般的數值之外,number型別的資料還有兩種特別的值。第一種為NaN,表示這個number型別的資料並不是一個數值;第二種為Infinity,表示這個number型別的資料為正無限大,若加上負號則表示為負無限大。

由於任何值包括NaN本身都無法與NaN相等,因此如果要判斷number型別的值是否為NaN的話,只能夠使用JavaScript內建的isNaN函數。例如:

let a = NaN;

if (isNaN(a)) {
    console.log("Equal!");
}

至於兩個Infinity值,不論怎麼與一般數值做加減法,都是永遠相等的。例如:

let a = Infinity - 99999;

if (a === Infinity + 99999) {
    console.log("Equal!");
}
布林(boolean)

和其它大多數的程式語言一樣,JavaScript的布林型別也只有truefalse兩種值。布林的型別名稱為boolean。這個型別我們在之前就有用到了,也沒什麼好多提的。

字串(string)

JavaScript的字串在內部是用UTF-16的編碼格式來儲存的,所以Unicode能支援的字元,都可以被用在JavaScript的字串當中。字串的型別名稱為string。這個型別我們在之前就有用到了,在此只再補充兩點。

首先,字串和字串或是其它型別的值可以直接用+運算子來串接起來。例如:

let major = 1;
let minor = 0;
let patch = 0;

let version = major + "." + minor + "." + patch;

console.log(version);

以上程式會輸出:

1.0.0

不過現在既然有「模板定數」語法能用,就多加利用吧!以上程式可以改寫如下:

let major = 1;
let minor = 0;
let patch = 0;

let version = `${major}.${minor}.${patch}`;

console.log(version);

有沒有覺得更容易閱讀了呢?

再來,我們在先前的章節用到的parseInt函數可以將字串轉成整數數值,而如果要轉成浮點數數值的話,就要用parseFloat函數,如下:

let s = "2.75";

let i = parseInt(s);
let f = parseFloat(s);

console.log(i);
console.log(f);

以上程式的執行結果如下:

2
2.75
符號(symbol)

symbol是ES6後才被加進JavaScript中的新型別。它的樣子如下:

let a: symbol = Symbol(undefined);
let b: symbol = Symbol("description");
let c: symbol = Symbol(1);

Symbol是一個JavaScript內建,用來產生並回傳symbol型別的值的函數。它有一個參數,這個參數的型別為number | string | undefined,為了讓方便開發者能區分不同的symbol型別的值。不管這個參數被代進什麼值,建立出來的兩個symbol型別的值都是不相等的。例如:

let a: symbol = Symbol(undefined);
let b: symbol = Symbol(undefined);
let c: symbol = Symbol(1);
let d: symbol = Symbol(1);

if (a === b) {
    console.log("a, b are equal!");
}

if (c === d) {
    console.log("c, d are equal!");
}

以上程式不會輸出任何資料。

所以symbol型別可以用來幹嘛?利用它的不可被重製性,我們可以用它來實作私有的屬性(property)。這個部份會於之後的章節在介紹物件時實際應用。

任意精度整数(bigint)

bigint是未來才會被正式加進JavaScript中的新型別,它可以解決JavaScript的number型別只能表示54位元整數的問題,因為bigint能夠表示的整數範圍完全沒有限制!想要在TypeScript使用bigint,編譯目標必須要改成ES2020之後的版本。

我們可以在一般的整數定數後面直接加上一個n,來將其轉成bigint的定數。例如:

let i: bigint = 633825300114114700748351602688n;

number型別能用的運算子,除了>>>(無號向右位移)外,bigint也都可以用。

Null(null)

null值的型別即為null。在預設的情況下,null是任意型別的子型別,所以任意型別都可以使用null值。例如:

function f1(): number {
    return null;
}

function f2(): boolean {
    return null;
}

function f3(): string {
    return null;
}

以上程式是可以通過編譯的。

但是在嚴格模式下,null值的型別就只能是null或是any。所以以上程式要改寫成下面這樣才能通過編譯:

function f1(): number | null {
    return null;
}

function f2(): boolean | null {
    return null;
}

function f3(): string | null {
    return null;
}
Undefined(undefined)

undefined值的型別即為undefined。在預設的情況下,undefined是任意型別的子型別,所以任意型別都可以使用undefined值。例如:

function f1(): number {
    return undefined;
}

function f2(): boolean {
    return undefined;
}

function f3(): string {
    return undefined;
}

以上程式是可以通過編譯的。

但是在嚴格模式下,undefined型別只為any或是void的子型別,所以以上程式可以藉由改寫成下面這樣來通過編譯:

function f1(): number | undefined {
    return undefined;
}

function f2(): boolean | undefined {
    return undefined;
}

function f3(): string | void {
    return undefined;
}

若是一個表達式回傳的型別可能有nullundefined或是void,可以在其尾端加上驚嘆號!,來消除nullundefined以及void。例如:

function f1(): number | undefined | null | void {
    return 1;
}

const r1: number = f1()!;

這裡要注意的是,就算上面的f1函數是直接回傳undefined,還是能通過編譯,且r1的變數值也會是undefined。驚嘆號!是在當你完全確定這個表達式是絕對會回傳有意義的東西的時候才用的。

陣列

陣列是JavaScript物件的一種,可以用來儲存多個不同型態的值,這些值在陣列中被稱為元素值(element)。JavaScript的陣列為可變長度,可以在執行階段自由增減。利用逗號,在中括號[]內分隔元素值,就能將它們組合成「陣列」。例如:

let a = [2, 4, 6, 8, 10];

以上程式,a變數的型別可以表示為number[],如下:

let a: number[] = [2, 4, 6, 8, 10];

存取陣列的方式很簡單,例如:

let a: number[] = [2, 4, 6, 8, 10];

a[2] = a[1] + a[0];

在陣列後面加上一對中括號[],在中括號的裡面放入要存取的陣列索引值(鍵值)即可。

如果查詢的索引值並不是該陣列的所擁有的話,就會回傳undefined。不過這個部份由於是在JavaScript的執行階段才能檢查得出來,TypeScript根本就管不著,因此以下程式雖然b變數被指定為number型別,卻可以在嚴格模式下被成功編譯,在執行之後輸出undefined,由此可見TypeScript也不是多麼安全的程式語言。

let a: number[] = [2, 4, 6, 8, 10];

let b: number = a[6];

console.log(b);

以下再介紹幾個其它陣列的常見用法。

要取得陣列的長度的話,可以去讀取陣列的length屬性。例如:

let a: number[] = [2, 4, 6, 8, 10];

console.log(a.length);

要增加元素的話,可以使用其提供的push方法。例如:

let a: number[] = [2, 4, 6, 8, 10];

a.push(12);

若使用typeof關鍵字來偵測陣列的話,會與偵測物件一樣只會回傳object關鍵字,並沒有辦法區分出這個物件到底是不是陣列,因此最好用JavaScript內建的Array.isArray函數來判斷傳入參數的值是否為陣列。例如:

let a: number[] = [2, 4, 6, 8, 10];

if (Array.isArray(a)) {
    console.log(a[0]);
}

物件

由於JavaScript的物件比較複雜一些,所以留到之後的章節再做介紹。

=====

JavaScript的「相等」運算子有=====兩種,前者(兩個等於)會自動將兩個運算元的值轉成相同型別,然後去判斷值是否相同,而後者(三個等於)則會判斷型別和值是否都相同,所以後者也會算得比前者還要來得快。

例如:

let a: number | string = 5;
let b: number | string = 0;

if (1) {
    b = "5";
}

console.log(a == b);
console.log(a === b);

以上程式會輸出:

true
false

因為TypeScript會檢查關係運算子兩邊的運算元型別的一致性,所以以上程式無法直接寫成:

console.log(5 == "5");
console.log(5 === "5");

如果要判斷兩個值是否「不相等」的話,運算子則是使用!=!==

函數

程式語言最重要的觀念就是函數啦!我們先前已經有使用過function關鍵字,甚至是Lambda語法來設計函數,這邊要再介紹更多相關的用法。

先來看看下面這個函數:

function save(path: string, data: string, force: boolean): boolean {
    const fs = require("node:fs");

    if (fs.existsSync(path)) {
        if (force) {
            fs.unlinkSync(path);
        } else {
            return false;
        }
    }

    fs.writeFileSync(path, data);

    return true;
}

以上這個save函數,可以透過參數傳入一個檔案路徑和字串,將字串存進指定的檔案中,並且可以設定當檔案存在時,是否要強制覆蓋(先刪除已存在的檔案)。所以,當我們在呼叫save函數時,就必須要傳入三個參數才行。

有沒有什麼辦法能讓save函數傳入前兩個參數就好,剩下來的force參數就預設為false呢?

當然可以!只要在force參數的型別後面,用=語法指派一個預設值給它就好了。如下:

function save(path: string, data: string, force: boolean = false): boolean {
    const fs = require("node:fs");

    if (fs.existsSync(path)) {
        if (force) {
            fs.unlinkSync(path);
        } else {
            return false;
        }
    }

    fs.writeFileSync(path, data);

    return true;
}

像這樣直接在函數的參數定義中指定參數的預設值的用法,是從ES6之後才有的語法。

如果不想要替force參數指定預設值,我們也可以直接在其名稱後加上問號?,來表示這個參數是可選填的。如下:

function save(path: string, data: string, force?: boolean): boolean {
    const fs = require("node:fs");

    if (fs.existsSync(path)) {
        if (force) {
            fs.unlinkSync(path);
        } else {
            return false;
        }
    }

    fs.writeFileSync(path, data);

    return true;
}

此時的force參數的型別雖然是被明確定義為boolean,但它實際上是boolean | undefined

那如果連data參數都想能夠省略不填,並預設為Hello, world!呢?

若將save函數改寫成以下這樣:

function save(path: string, data: string = "Hello, world!", force: boolean = false): boolean {
    const fs = require("node:fs");

    if (fs.existsSync(path)) {
        if (force) {
            fs.unlinkSync(path);
        } else {
            return false;
        }
    }

    fs.writeFileSync(path, data);

    return true;
}

我們希望可以執行以下敘述,將Hello, world!文字存到/path/to/file檔案中,並且覆蓋掉原先的檔案。

save("123", true);

然而,我們會發現不管怎麼樣,我們都無法跳過第二個參數,直接傳送引數到第三個參數。最少都還是得把undefined傳進第二個參數中,來當作沒有傳入第二個參數。

save("123", undefined, true);

如果您有學過Java程式語言,可能會想嘗試以下這樣的寫法:

function save(path: string, data: string, force: boolean = false): boolean {
    const fs = require("node:fs");

    if (fs.existsSync(path)) {
        if (force) {
            fs.unlinkSync(path);
        } else {
            return false;
        }
    }

    fs.writeFileSync(path, data);

    return true;
}

function save(path: string, force: boolean = false): boolean {
    const fs = require("node:fs");

    if (fs.existsSync(path)) {
        if (force) {
            fs.unlinkSync(path);
        } else {
            return false;
        }
    }

    fs.writeFileSync(path, "Hello, world!");

    return true;
}

雖然以上的寫法是錯的,且JavaScript確實沒有支援函數多載(overloading),但TypeScript是有支援函數多載的哦!這部份稍候會再提到,在此先用typeof關鍵字來解決這個問題,如下:

function save(path: string, data_force?: string | boolean, force?: boolean): boolean {
    let data;

    const t_data_force = typeof data_force;

    if (t_data_force === "undefined") {
        data = "Hello, world!";
        force = false;
    } else if (t_data_force === "boolean") {
        data = "Hello, world!";
        force = data_force;
    } else { // string
        data = data_force;
    }

    const fs = require("node:fs");

    if (fs.existsSync(path)) {
        if (force) {
            fs.unlinkSync(path);
        } else {
            return false;
        }
    }

    fs.writeFileSync(path, data);

    return true;
}

然而,以上程式第11行會編譯失敗,因為force參數允許的型別中沒有string型別,所以無法儲存data_force參數的值。還記得在前面的章節中我們有學到可以利用流程控制來限縮變數的型別範圍嗎?您可能會問,這邊的else if (t_data_force === "boolean")不是應該就有限縮data_force的型別範圍只能是boolean了嗎?這個就是TypeScript編譯器的限制了,它無法知道此處t_data_force變數存的值其實就是typeof data_force

如果我們將else if (t_data_force === "boolean")改為else if (typeof data_force === "boolean")的話,就可以通過編譯了。但是這樣做的話,顯然效能會差一點。

為了解決這個問題,我們需要使用TypeScript的「顯式轉型」(explicit casting)。在要轉型的表達式後加上as關鍵字,再加上要轉型的型別名稱,即可進行顯式轉型。這種轉型方式可以向上或是向下轉型,不可以轉成完全無關的型別。如下:

function save(path: string, data_force?: string | boolean, force?: boolean): boolean {
    let data;

    const t_data_force = typeof data_force;

    if (t_data_force === "undefined") {
        data = "Hello, world!";
        force = false;
    } else if (t_data_force === "boolean") {
        data = "Hello, world!";
        force = data_force as boolean;
    } else { // string
        data = data_force;
    }

    const fs = require("node:fs");

    if (fs.existsSync(path)) {
        if (force) {
            fs.unlinkSync(path);
        } else {
            return false;
        }
    }

    fs.writeFileSync(path, data);

    return true;
}

接著來談談TypeScript要怎麼做到多載吧!簡單來說,就是替函數撰寫多個簽名,使用分號;隔開,共用一個函數主體。例如以上程式,可以改寫成以下這樣:

function save(path: string): boolean;
function save(path: string, data: string): boolean;
function save(path: string, data: string, force: boolean): boolean;
function save(path: string, force: boolean): boolean;
function save(path: string, data_force?: string | boolean, force?: boolean): boolean {
    let data;

    const t_data_force = typeof data_force;

    if (t_data_force === "undefined") {
        data = "Hello, world!";
        force = false;
    } else if (t_data_force === "boolean") {
        data = "Hello, world!";
        force = data_force as boolean;
    } else { // string
        data = data_force;
    }

    const fs = require("node:fs");

    if (fs.existsSync(path)) {
        if (force) {
            fs.unlinkSync(path);
        } else {
            return false;
        }
    }

    fs.writeFileSync(path, data);

    return true;
}

save("/path/to/file", true, false); // compilation error

注意以上第35行程式敘述會編譯失敗,因為程式第5行的函數簽名是給主體內看的,函數外只會看第1行到第4行的簽名。另外,多載的函數簽名中,必須要明確地寫出函數的回傳值型別。

函數也可以像數值或字串一樣,直接用變數、常數或是參數來儲存,這個在前面的章節撰寫猜數字程式的時候有碰到過(即Promise建構子的函數所用的resolve參數)。而函數的型別可以用(參數) => 回傳值型別這樣的語法來表示,也可以直接用它們的父型別──Function

這邊再舉一個例子:

function add(a: number, b: number): number {
    return a + b;
}

const subtract = function (a: number, b: number): number {
    return a - b;
};

function math(a: number, b: number, f: Function): number {
    return f(a, b);
}

let answer = math(2, 1, add);

console.log(answer);

answer = math(2, 1, subtract);

console.log(answer);

以上程式會輸出:

3
1

注意這邊程式第五行到第七行的函數寫法。我們直接使用subtract常數來定義並儲存一個函數,此時的function關鍵字後就不用撰寫函數名稱了,這樣的函數就稱為「匿名函數」(Anonymous Function)。

先前稍微用過的Lambda語法,就是匿名函數的簡化寫法。它是在ES6之後才被加入的語法。省略了function關鍵字和函數名稱,直接從函數的參數開始定義,並用等於大於=>符號來連接函數的主體。

例如以上程式,可以用Lambda語法改寫為:

function math(a: number, b: number, f: Function): number {
    return f(a, b);
}

let answer = math(2, 1, (a: number, b: number) => {
    return a + b;
});

console.log(answer);

answer = math(2, 1, (a: number, b: number) => {
    return a - b;
});

console.log(answer);

事實上,=>符號能連接的不只有函數的主體,它還可以直接連接一個表達式,作為函數要回傳的值。例如:

function math(a: number, b: number, f: Function): number {
    return f(a, b);
}

let answer = math(2, 1, (a: number, b: number) => a + b);

console.log(answer);

answer = math(2, 1, (a: number, b: number) => a - b);

console.log(answer);

如何?有沒有覺得改用Lambda語法後,程式碼精簡了很多?

若將f參數的函數型別用(參數)或是(參數) => 回傳值型別這樣的語法來表示,作為引數的匿名函數的參數就不需要明確地寫出型別了!例如:

function math(a: number, b: number, f: (a: number, b: number) => number): number {
    return f(a, b);
}

let answer = math(2, 1, (a, b) => a + b);

console.log(answer);

answer = math(2, 1, (a, b) => a - b);

console.log(answer);

如果您有學過Java程式語言的話,一定會知道Java程式語言有「varargs」語法,它可以使函數的最後一個參數變成一個陣列,接收所有剩下來的引數。JavaScript在ES6之後也有這樣的語法,稱為「蔓延語法」(Spread Syntax)。只要在參數、常數或是變數名稱前加上三個點...,就是蔓延語法了。函數只有最後一個參數可以使用蔓延語法來接收所有剩下來的引數。

於是我們再將math函數升級成可以接受任意數量的數值作為運算元的版本,如下:

function math(f: (...numbers: number[]) => number, ...numbers: number[]): number {
    return f(...numbers); // logically equals to `return f(numbers[0], numbers[1], numbers[2], ..., numbers[numbers.length - 1]);`
}

let answer = math((...numbers) => numbers.reduce((a, b) => a + b), 100, 50, 10, 2);

console.log(answer);

answer = math((...numbers) => numbers.reduce((a, b) => a - b), 100, 50, 10, 2);

console.log(answer);

以上程式會輸出:

162
38

陣列的reduce方法,可以傳入一個函數,用來走訪所有元素做累進計算。這個函數的第一個參數為累進值(初始值為第一個元素),第二個參數為目前走訪到的元素值(從第二個元素開始),函數回傳值則會被當成下一次迭代的第一個參數的值,直到走訪完最後一個元素,reduce方法就會把累進值回傳出來。

這邊比較需要注意到的地方是程式第二行,我們在傳遞numbers參數的值給函數f的時候,不是直接傳入numbers,而是再用一次蔓延語法。這是因為如果我們將一個陣列作為引數直接傳給使用了蔓延語法的參數時,這個陣列並不會被自動展開成多個引數,必須要在陣列引數上使用蔓延語法,將其展開成多個引數。

錯誤示範如下:

function math(f: (...numbers: number[]) => number, ...numbers: number[]): number {
    return f(numbers);
}

let answer = math((...numbers) => numbers.reduce((a, b) => a + b), 100, 50, 10, 2);

console.log(answer);

answer = math((...numbers) => numbers.reduce((a, b) => a - b), 100, 50, 10, 2);

console.log(answer);

以上程式會編譯錯誤,因為傳進f函數的引數並不是number,而是number[]

註解

TypeScript程式語言的單行註解都是由//開頭,直到該行結束。例如:

// So we're doing something complicated here, long enough that we need
// multiple lines of comments to do it! Whew! Hopefully, this comment will
// explain what's going on.

多行註解則是由/*開頭,直到*/結束。例如:

/*
So we're doing something complicated here, long enough that we need
multiple lines of comments to do it! Whew! Hopefully, this comment will
explain what's going on.
*/

在程式碼中加入適當的註解可以讓程式變得更容易理解。

在預設的情況下,TypeScript的註解在被編譯之後,也會保留在JavaScript程式碼中。如果想要移除註解,就要在tsconfig.json設定檔中的compilerOptions欄位,加上removeComments欄位,並將其值設為true。如下:

{
  "compilerOptions": {
    ...

    "removeComments": true

    ```
  }
  ...
}

不過如果是位於原始碼檔案最上面,以/*!為開頭的註解,TypeScript就會保留它,這種通常是用來標示原始碼授權訊息的註解。

流程控制

大部分的程式語言可以藉由判斷條件來決定要執行哪些程式,以及重複執行哪些程式。

if條件判斷語法

ifelse關鍵字組成的條件判斷語法我們已經會用了,這邊要再多提一個三元條件運算的語法,可以用來精簡程式。

如果我們說if-else的語法結構是以下這樣:

if (條件表達式) {
    條件表達式的值不為undefined、null、false、0或NaN時執行的程式敘述區塊
} else {
    條件表達式的值為undefined、null、false、0或NaN時執行的程式敘述區塊
}

那麼這個三元條件運算子的語法的結構就是以下這樣:

條件表達式 ? 條件表達式的值不為undefined、null、false、0或NaN時執行的表達式 : 條件表達式的值為undefined、null、false、0或NaN時執行的表達式

舉例來說:

let y: number;

...

let x;

if (y >= 0) {
    x = y;
} else {
    x = -y;
}

以上程式可以用三元條件運算子的語法改寫為:

let y = 0;

...

let x = y >= 0 ? y : -y;

switch判斷語法

有時候我們會需要撰寫如下的程式邏輯:

let mode: number;

if (mode === 0) {
    // do something
} else if (mode === 1) {
    // do something
} else if (mode === 2) {
    // do something
} else if (mode === 3 || mode === 4) {
    if (mode === 3) {
        // do something
    }

    // do something
} else {
    // do something
}

這時候可以改用switchcasedefault關鍵字組成來實作這樣的程式邏輯,如下:

switch (mode) {
    case 0:
        // do something
        break;
    case 1:
        // do something
        break;
    case 2:
        // do something
        break;
    case 3:
        // do something
    case 4:
        // do something
        break;
    default:
        // do something
}

switch關鍵字後必須要接上一對小括號(),括號中間必須要放入一個表達式。在這對小括號的後面,要再接上一對大括號{}作為switch判斷語法的程式敘述區塊。在這個程式敘述區塊中,可以使用case關鍵字來設定當表達式為哪些值的時候,要直接跳到程式區塊的哪個位置執行程式敘述。為避免執行到不該執行的程式敘述,可以使用break敘述來直接跳出switch判斷語法的程式敘述區塊。default關鍵字則可以用來設定當表達式的值都不符合case關鍵字指定的值的話,要直接跳到程式區塊的哪個位置執行程式敘述,這通常會寫在case關鍵字之後。

在switch判斷語法的程式敘述區塊中,case關鍵字或是default關鍵字都是選用的。

迴圈

迴圈可以讓相同的程式自動執行一次以上,JavaScript提供了whiledofor關鍵字來建立用途不同的迴圈。

while迴圈

while關鍵字所建立的迴圈可以藉由判斷表達式的值來決定要不要脫離迴圈。用法如下:

while (條件表達式) {
    條件表達式的值不為undefined、null、false、0或NaN時執行的程式敘述區塊
}

舉例來說:

let number = 3;

while (number !== 0) {
    console.log(number);

    number = number - 1;
}

console.log("LIFTOFF!!!");

以上程式,當number變數的型別不等於0的時候就會一直執行while迴圈內的程式敘述。執行結果如下:

3
2
1
LIFTOFF!!!

while迴圈內的程式敘述區塊可以使用breakcontinue敘述。前者可以立即跳出迴圈,後者可以立即進入下一次迭代。

do-while迴圈

dowhile關鍵字所建立的迴圈可以藉由判斷表達式的值來決定要不要脫離迴圈。用法如下:

do {
    可重複執行的程式敘述區塊
} while (條件表達式)

do-while迴圈和while迴圈的不同點在於,do-while迴圈是先執行一次迴圈內的程式敘述之後,再去用條件表達式判斷要不要再次重複執行。所以不管條件表達式是什麼,迴圈內的程式敘述都會至少被執行一次。

do-while迴圈內的程式敘述區塊也可以使用breakcontinue敘述。

for迴圈

for關鍵字可以用來建立計次迴圈或是走訪一個結構。用法如下:

for ([let 變數名稱1[, 變數名稱2[, 變數名稱n]]]; [條件表達式]; [步進表達式]) {
    條件表達式的值不為undefined、null、false、0或NaN時執行的程式敘述區塊
}

在for迴圈的小括號()中用let關鍵字宣告的變數,可以被用在for迴圈的小括號和迴圈的程式敘述區塊中。除了let變數之外,也可以用var變數或是const常數,不過通常不會這樣用。

例如要走訪一個陣列,我們可以先利用while迴圈來實作看看。如下:

let a = [10, 20, 30, 40, 50];
let index = 0;

while (index < a.length) {
    console.log(`the value is: ${a[index]}`);

    index = index + 1;
}

程式執行結果:

the value is: 10
the value is: 20
the value is: 30
the value is: 40
the value is: 50

改成for迴圈的話則為:

let a = [10, 20, 30, 40, 50];

for (let index = 0; index < a.length; index++) {
    console.log(`the value is: ${a[index]}`);
}

以上程式中,index++也可以寫成++indexindex += 1index = index + 1,都是讓index變數本身加一的意思。但事實上,index++與其它三種表達式有微小的差異。演示如下:

let a = 1;

console.log(a);

console.log(a = a + 1);
console.log(a);

console.log(a += 1);
console.log(a);

console.log(++a);
console.log(a);

console.log(a++);
console.log(a);

以上程式的輸出結果如下:

1
2
2
3
3
4
4
4
5

我們可以發現,只有a++是回傳原先的a值,而不是a1過後的值。依此類推,減法--aa--也是有這樣的差異。

for迴圈內的程式敘述區塊也可以使用breakcontinue敘述。

for-in迴圈

我們可以在for迴圈的小括號中,使用in關鍵字來直接取得陣列或是物件結構的所有鍵值(key),包含其繼承(Inheritance)的鍵值。此時的for迴圈小括號內的語法會和剛才介紹的有些不一樣,如下:

for (const 常數名稱 in 陣列或物件) {
    可重複執行的程式敘述區塊
}

for-in迴圈的小括號中可以使用變數或是常數,建議使用常數。

例如:

let a = [10, 20, 30, 40, 50];

for (const index in a) {
    console.log(`the value is: ${a[index]}`);
}

有個很重要的地方要注意的是,for-in迴圈所取得的陣列鍵值,會是一個字串,而不是數值。且用來儲存鍵值的變數或是常數,不能明確定義出型別,只能由編譯器來推論。

for-of迴圈

在ES6之後,如果想要直接取得陣列的所有值(value),可以在for迴圈的小括號中,使用of關鍵字。用法如下:

for (const 常數名稱 of 陣列或物件) {
    可重複執行的程式敘述區塊
}

for-of迴圈的小括號中可以使用變數或是常數,建議使用常數。

例如:

let a = [10, 20, 30, 40, 50];

for (const element of a) {
    console.log(`the value is: ${element}`);
}

除了陣列之外,for-of迴圈還可以用來走訪一個字串。

例如:

let s = "Hello";

for (const element of s) {
    console.log(element);
}

以上程式會輸出:

H
e
l
l
o

除了陣列和字串外,for-of迴圈其實也可以用於特定的物件,這個會在之後的章節介紹。

巢狀迴圈

在迴圈的程式敘述區塊中也可以再使用其它的迴圈,形成「巢狀迴圈」(Nested Loop)。在這樣的程式結構下,我們可以替比較外層的迴圈前加上標籤名稱:,形成「標籤」,這樣內層的迴圈就可以用break 標籤名稱或是continue 標籤名稱這樣的敘述來控制外層的迴圈了。

例如:

let a = [10, 20, 30, 40, 50];
let b = [1, 3, 5, 7, 9];

outer: for (const index1 in a) {
    for (const index2 in b) {
        if (index2 === "2") {
            continue outer;
        }

        console.log(`the value is: ${a[index1] + b[index2]}`);
    }
}

以上程式會輸出:

the value is: 11
the value is: 13
the value is: 21
the value is: 23
the value is: 31
the value is: 33
the value is: 41
the value is: 43
the value is: 51
the value is: 53

總結

在這個章節中,我們學會了TypeScript程式語言的變數、資料型別、函數、註解以及條件和迴圈的流程控制。在下一章節中,要來介紹在本章跳過的物件。

下一章:TypeScript程式語言的物件