在前面的章節中,我們都是直接用tsc指令加上參數來編譯TypeScript程式,並且利用declare關鍵字來宣告Node.js才有的物件和函數。在這個章節,我們會建立Node.js專案,運用TypeScript的tsconfig.json檔案來設定編譯參數,並加裝套件讓TypeScript能直接支援Node.js內建的物件和函數的型別檢查。



為了專注學習用Node.js專案開發TypeScript的方式,我們要繼續使用上一章撰寫過的程式碼來繼續實作猜數字程式。

建立Node.js專案

首先,建立一個用來存放TypeScript專案的目錄node-guessing-game

然後在該目錄下執行以下指令來建立Node.js專案:

npm init -y

這個指令會建立package.json檔案,為JavaScript的套件設定檔,格式為一個JSON物件。

typescript-nodejs

為了方便我們編譯TypeScript程式和執行JavaScript程式,將package.jsonscripts欄位修改如下:

{
    ...

    "scripts": {
        "build": "tsc index.ts",
        "build:watch": "npm run build -- --watch"
    }
  
    ...
}

如此一來,我們就可以在程式專案的根目錄下,執行npm run build指令來編譯index.ts。也可以執行npm run build:watch來持續監看TypeScript的原始碼有沒有變動,如果有的話就自動編譯。

npm run build -- --watch指令中的--參數,表示要將寫在其之後的參數提供給npm run build指令對應到的指令,而非提供給npm本身。而tsc指令加上--watch參數,就會去監看輸入的檔案是否有變動,有的話就會自動編譯。

將TypeScript安裝到Node.js專案

考慮到我們的Node.js專案可能會由別人來幫忙維護,而別人的開發環境中可能不會安裝TypeScript,又或者他們用的TypeScript版本和我們用的相去甚遠,而造成TypeScript程式無法通過編譯。我們應該要將TypeScript安裝至我們的Node.js專案。除上述原因之外,有些TypeScript相關的套件也需要專案目錄下有TypeScript存在才能正確運作。

現在我們已經有了package.json設定檔,所以可以很輕易地替我們的專案加入依賴套件。在package.json設定檔中,可以加上dependencies欄位,來設定這個專案要依賴於哪些套件。設定好之後,只要在程式專案根目錄執行npm install指令,就可以把這些依賴套件從npm的網路空間上下載回來,放置在程式專案根目錄的node_modules目錄中。不過我們通常不需要手動編輯dependencies欄位,直接使用以下格式的npm指令就可以快速地將指定套件加進dependencies欄位,也會立刻將其下載到node_modules目錄中。

npm install 套件名稱1 [套件名稱2 [套件名稱3 [套件名稱n]]]

install這個子命令可以簡寫為i

我們在第一章安裝tsc時用的npm i -g typescript指令,用了一個-g參數,這個是讓套件安裝到全域的node_modules目錄,而不是當前的Node.js專案中。通常指令工具類的JavaScript套件會加上-g參數來安裝,這樣就可以直接在終端機下使用。而若不用-g參數來安裝指令工具類的JavaScript套件,則要透過在package.jsonscripts欄位中撰寫指令才可以直接(直接使用指令工具的名稱而不撰寫其路徑)呼叫。

執行以下指令可以在當前Node.js專案中安裝TypeScript。

npm i -D typescript

注意以上指令使用-D參數,-D參數即--save-dev參數,會將套件儲存在devDependencies欄位下,而不是dependencies欄位。

devDependencies欄位下的套件,只有在目前這個專案使用npm install指令時才會被安裝。而dependencies欄位下的套件,別的套件若有依賴於目前這個套件,則在別的套件專案下使用npm install指令時,也會去安裝目前這個套件的package.json設定檔中的dependencies欄位下的套件。

由於TypeScript只會在我們開發目前這個套件時才會用到,用來將TypeScript的程式編譯為JavaScript程式,因此將其加入devDependencies欄位下即可,不需要加進dependencies欄位。

撰寫TypeScript程式

我們可以直接將上一章撰寫過的猜數字程式碼複製到目前這個專案中來用。記得原始碼檔案要存放在程式專案根目錄下,檔名為index.ts,這樣才能夠被npm run build指令編譯。

declare const require;
declare const process;

const isBrowser: boolean = typeof window === "object";

function showMessage(text: string): void {
    if (isBrowser) {
        alert(text);
    } else {
        console.log(text);
    }
}

async function showMessageAndInputText(text: string): Promise<string> {
    if (isBrowser) {
        return prompt(text);
    } else {
        console.log(text);

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

        const rl = readline.createInterface({ input: process.stdin });

        const lineResolver: Promise<string> = new Promise<string>(resolve => {
            rl.on("line", line => resolve(line));
        });

        const line: string = await lineResolver;

        rl.close();

        return line;
    }
}

async function main(): Promise<void> {
    showMessage("Guess the number!");

    const secretNumber: number = Math.floor(Math.random() * 100 + 1);

    showMessage(`The secret number is: ${secretNumber}`);

    while (true) {
        const line: string = await showMessageAndInputText("Please input your guess.");

        const guess: number = parseInt(line);

        if (isNaN(guess)) {
            showMessage("Please type a number!");
        } else {
            showMessage(`You guessed: ${guess}`);

            if (guess < secretNumber) {
                showMessage("Too small!");
            } else if (guess > secretNumber) {
                showMessage("Too big!");
            } else {
                showMessage("You win!");
                break;
            }
        }
    }
}

main();

不過很顯然地,在撰寫完上面的TypeScript程式後,執行npm run build指令,是會編譯失敗的。

typescript-nodejs

您的第一個反應可能會去修改package.json中的scripts欄位,將tsc指令的部份加上-t甚至是--lib參數,來讓tsc能夠正確編譯我們的TypeScript程式。是可以這樣做沒錯,但還有一個更好的作法,那就是新增一個TypeScript的設定檔。

TypeScript的設定檔

tsc指令預設會去讀取當前工作目錄下的tsconfig.json檔案作為設定檔。所以我們可以在Node.js專案的根目錄中,新增一個tsconfig.json檔案讓它來讀取。這個設定檔也是JSON物件格式,在把它建立出來後,可以先加上以下內容:

{
    "compilerOptions": {
    }
}

compilerOptions欄位就是用來設定我們在使用tsc指令時要加上的額外參數。例如要加上-t ES6參數的話,tsconfig.json的寫法如下:

{
    "compilerOptions": {
        "target": "ES6"
    }
}

理論上,我們的猜數字程式只要有了這個設定檔就可以被成功編譯了,但此時實際執行npm run build指令,也還是會編譯失敗。這是因為,當使用tsc指令時,若有指定檔案,就不會去讀取tsconfig.json設定檔了。所以說,我們要將package.jsontsc指令的index.ts拿掉。

{
    ...

    "scripts": {
        "build": "tsc",
        "build:watch": "npm run build -- --watch"
    }
  
    ...
}

此時執行npm run build指令,index.ts就能夠被成功編譯。

typescript-nodejs

不過tsc是怎麼知道要去編譯index.ts檔案的?其實它並不完全知道我們就是要編譯index.ts檔案,而是在預設情況下,它會把工作目底下包含子目錄中的.ts.tsx檔案都拿來編譯,並在.ts.tsx檔案的所在目錄之下,輸出.js檔案。

如果要指定tsc編譯的檔案,就要在tsconfig.json設定檔中新增files欄位。這個files欄位的值是一個字串陣列,每個字串表示一個glob檔案路徑。glob檔案路徑簡單來說就是一般的檔案路徑,但能以星號*表示同層級下的任意字元,也能以雙星號**表示任意層級的目錄。

舉例來說,因為我們的這個專案只需要編譯index.ts檔案,所以可以將tsconfig.json設定檔改寫如下:

{
    "compilerOptions": {
        "target": "ES6"
    },
    "files": [
        "index.ts"
    ]
}

如果想要將JavaScript版本降到ES5,但啟用ES6的Promise物件的話,可以將tsconfig.json設定檔改寫如下:

{
    "compilerOptions": {
        "target": "ES5",
        "lib": [
            "dom",
            "es5",
            "scripthost",
            "es2015.promise",
            "es2015.iterable"
        ]
    },
    "files": [
        "index.ts"
    ]
}

讓TypeScript支援Node.js內建的物件和函數

如果我們的TypeScript程式有用到Node.js內建的物件和函數的話,上一章提供的方法是利用declare關鍵字來將它們「假宣告」出來。雖然這樣也可以用,但是我們無法準確地去設定這些被宣告出來的物件和函數的型別,也就沒有辦法在編譯階段時進行有效的型別檢查。所以最好還是讓TypeScript能夠直接支援Node.js。

TypeScript可以藉由安裝額外套件的方式來擴充其支援的物件和函數,那麼要裝什麼樣的套件才能夠讓TypeScript能夠支援Node.js呢?

在TypeScript生態圈有個很重要的官方開源專案──DefinitelyTyped。這個專案整理了其它JavaScript套件暴露(export)的項目,將它們都加上TypeScript的型別定義,這些定義都會寫在副檔名為.d.ts的檔案中。我們可以透過在npmjs.com搜尋JavaScript的套件名稱,如果有看到該套件有DT標示,就代表這個套件在DefinitelyTyped上有型別定義,於是我們可以使用npm i @types/<套件名稱>來安裝該套件的型別定義到我們的TypeScript程式專案中。

以Node.js本身的常數、函數、物件在DefinitelyTyped中也有型別定義,可以執行以下指令來安裝:

npm i @types/node

注意這邊是把@types/node套件存在package.json設定檔的dependencies欄位之中,但如果我們確定我們製作的TypeScript程式不會(不適合)被當作函式庫來引入,或者說對外並沒有與Node.js所提供的函數、物件等有所關聯的話,將@types/node套件存入devDependencies欄位之中會是比較好的作法。所以事實上,以這個猜數字遊戲的專案來說,應該要使用npm i -D @types/node指令來安裝@types/node套件才是比較適當的作法。

不管如何,將@types/node套件安裝好之後,不需要特別設定tsc,TypeScript預設就會從node_modules目錄中,引用@types目錄底下的所有.d.ts檔案。此時在我們的程式專案根目錄中執行npm run build指令,會發現程式編譯失敗了。

typescript-nodejs

這是因為我們重複宣告了requireprocess,所以要把index.ts檔案的最上面的兩行declare敘述移除掉,才可以成功通過編譯。

TypeScript的編輯器/IDE(Integrated Development Environment)

有了tsconfig.json設定檔和DefinitelyTyped提供的.d.ts檔案後,筆者強烈建議使用Visual Studio Code等編輯器或是IDE來開發TypeScript程式,因為它們可以根據tsconfig.json設定檔的內容和.d.ts檔案來即時顯示TypeScript程式碼有什麼問題。

typescript-nodejs

除此之外我們還可以安裝eslint來協助找出TypeScript程式碼的問題並統一樣式。不過這部份並不在本系列文章的討論範圍之內。

嚴格模式

既然用了IDE來撰寫TypeScript,就可以儘量讓程式碼寫得更嚴謹一些。我們可以修改tsconfig.json設定檔,讓tsc能用嚴格的方式來編譯TypeScript程式。修改方式如下:

{
    "compilerOptions": {
        ...

        "strict": true

        ...
    }

    ...
}

啟用tsc--strict參數後,在定義函數參數時都必須要明確指定型別,無法直接像以前那樣直接被當成任意型別(any)了。

所以我們的猜數字程式要稍微修改一下。

const isBrowser: boolean = typeof window === "object";

function showMessage(text: string): void {
    if (isBrowser) {
        alert(text);
    } else {
        console.log(text);
    }
}

async function showMessageAndInputText(text: string): Promise<string> {
    if (isBrowser) {
        return prompt(text);
    } else {
        console.log(text);

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

        const rl = readline.createInterface({ input: process.stdin });

        const lineResolver: Promise<string> = new Promise<string>(resolve => {
            rl.on("line", (line: string) => resolve(line));
        });

        const line: string = await lineResolver;

        rl.close();

        return line;
    }
}

async function main(): Promise<void> {
    showMessage("Guess the number!");

    const secretNumber: number = Math.floor(Math.random() * 100 + 1);

    showMessage(`The secret number is: ${secretNumber}`);

    while (true) {
        const line: string = await showMessageAndInputText("Please input your guess.");

        const guess: number = parseInt(line);

        if (isNaN(guess)) {
            showMessage("Please type a number!");
        } else {
            showMessage(`You guessed: ${guess}`);

            if (guess < secretNumber) {
                showMessage("Too small!");
            } else if (guess > secretNumber) {
                showMessage("Too big!");
            } else {
                showMessage("You win!");
                break;
            }
        }
    }
}

main();

除了函數參數的型別要明確定義外,原先undefinednull可以是所有型別的值,但在嚴格模式下,它們就必須屬於另外的型別。例如:

function a(): string {
    return null;
}

以上程式在TypeScript的嚴格模式下就要寫成:

function a(): string | null {
    return null;
}

利用|字元來隔開其它可能的型別。null的型別為nullundefined的型別為undefined

在我們的猜數字程式中有用到window物件的prompt方法,這個方法的回傳值型別為string | null。所以我們的程式要修改成以下這樣才能通過編譯:

const isBrowser: boolean = typeof window === "object";

function showMessage(text: string): void {
    if (isBrowser) {
        alert(text);
    } else {
        console.log(text);
    }
}

async function showMessageAndInputText(text: string): Promise<string | null> {
    if (isBrowser) {
        return prompt(text);
    } else {
        console.log(text);

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

        const rl = readline.createInterface({ input: process.stdin });

        const lineResolver: Promise<string> = new Promise<string>(resolve => {
            rl.on("line", (line: string) => resolve(line));
        });

        const line: string = await lineResolver;

        rl.close();

        return line;
    }
}

async function main(): Promise<void> {
    showMessage("Guess the number!");

    const secretNumber: number = Math.floor(Math.random() * 100 + 1);

    showMessage(`The secret number is: ${secretNumber}`);

    while (true) {
        const line: string | null = await showMessageAndInputText("Please input your guess.");

        if (line === null) {
            continue;
        }

        const guess: number = parseInt(line);

        if (isNaN(guess)) {
            showMessage("Please type a number!");
        } else {
            showMessage(`You guessed: ${guess}`);

            if (guess < secretNumber) {
                showMessage("Too small!");
            } else if (guess > secretNumber) {
                showMessage("Too big!");
            } else {
                showMessage("You win!");
                break;
            }
        }
    }
}

main();

注意程式第41行的地方,line變數的型別為string | null,但是parseInt函數第一個參數的型別必須是string。那麼為什麼以上程式還可以通過編譯呢?這就是TypeScript厲害的地方啦!它可以去檢查line變數在執行的過程中,有沒有透過流程控制來限縮line變數的型別範圍。由於程式第43行到第45行,使用了if語法來判斷line變數的值是否在數值和型別上相等於(即===運算子)null,如果是的話就利用continue敘述直接進行下一次迴圈的執行。因此在程式第46行之後,line變數的型別只有可能是string。而因為TypeScript在編譯時也能夠得出這樣的結論,所以程式第47行是可以通過編譯的!

還有一些在啟用嚴格模式時會有差異的地方,只不過我們這邊的猜數字程式沒有用到,會在之後的章節遇到時再介紹一下。

總結

在這一章節中,我們建立了Node.js專案來撰寫TypeScript程式,學會了如何用npm來安裝套件,並且透過安裝DefinitelyTyped提供的套件來擴充TypeScript,使其能支援Node.js和其它的JavaScript套件。此外,我們還用了嚴格模式來撰寫TypeScript程式。在接下來的章節中,將會對變數、資料型別、函數、程式流程進行更深入的探討。

下一章:TypeScript程式語言的基礎概念