在這個章節,我們將會直接使用TypeScript程式語言來建立出猜數字程式的專案,並逐步將它完成!並在撰寫程式的過程中,來練習TypeScript程式語言基礎的程式語法,以及針對Node.js和網頁瀏覽器這兩個不一樣的執行環境來撰寫程式。這支小程式在執行之後,將會先從1到100的整數中,隨機抽取一個數字作為答案,並且允許使用者輸入要猜的數字,如果答錯了,程式會回答使用者輸入的數字究竟是大於答案,還是小於答案,並持續讓使用者繼續猜下去;如果答對了,程式會出現使用者贏了的訊息,並且結束程式。



建立新的TypeScript專案

使用以下指令在家目錄中的typescript-projects目錄中建立出guessing-game目錄,並將目前工作目錄移動到guessing-game目錄:

mkdir -p ~/typescript-projects/guessing-game && cd ~/typescript-projects/guessing-game

開始撰寫程式碼

先用以下指令新增index.ts檔案:

touch index.ts

typescript-guess-number

用文字編輯器開啟index.ts,加入以下程式,目的是要讓程式在螢幕上秀出Guess the number!這幾個字。

function showMessage(text: string): void {
    if (typeof window === 'object') {
        alert(text);
    } else {
        console.log(text);
    }
}

showMessage('Guess the number!');

以上的showMessage函數主體中,我們利用由ifelse關鍵字組成的條件判斷語法。if關鍵字後必須要接上一對小括號(),括號中間必須要放入一個表達式。當這個表達式的值並非undefinednullfalse(型別名稱為boolean)、0(型別名稱為number)或NaN(型別名稱為number)時,這個小括號()後由大括號{}組成的程式區塊(block)就會被執行,而不會去執行else關鍵字後的程式區塊。反之,當if關鍵字後的小括號()中的表達式若為上述的值,就會去執行else關鍵字後的程式區塊。

在這邊,我們利用typeof關鍵字來取得window這個變數的型別。typeof關鍵字後可以接上一個表達式,它會判斷這個表達式的回傳值,並且會傳回一個字串,其可能的值如下:

  • undefined:表示表達式回傳值為undefined,或表達式為一個未宣告的變數。
  • object:表示表達式回傳值的型別為object,或表達式回傳值為null
  • boolean:表示表達式回傳值的型別為bool
  • number:表示表達式回傳值的型別為number
  • bigint:表示表達式回傳值的型別為bigint
  • string:表示表達式回傳值的型別為string
  • symbol:表示表達式回傳值的型別為symbol
  • function:表示表達式回傳值的型別為Function

這邊還有一個觀念要先提及,那就是JavaScript中沒有被宣告的變數,在非使用typeof關鍵字的情況下是無法直接被使用的,執行時會有錯誤發生。而若在TypeScript中使用未宣告的變數,更是無論有沒有用typeof關鍵字都會在編譯階段時就被檢查出來。但是在以上程式中,我們並沒有去宣告window變數就直接用了,這是因為window以及其下預設的屬性,和JavaScript內建的物件(如MathDateRegExp等)與函數(如parseIntisNaN等)屬於例外的狀況,可以直接被用在TypeScript程式碼中並成功通過編譯。

網頁瀏覽器都會有window這個物件,所以我們可以藉由判斷window變數的值的型別是否是object(利用===運算子來判斷),來偵測執行這個JavaScript程式的環境是否為網頁瀏覽器。若是,就直接使用window物件的alert方法來讓網頁瀏覽器跳出文字訊息方塊;若否,我們就假設環境是Node.js,並用window物件下的console物件提供的log方法來在終端機畫面上印出文字訊息。

等等!為什麼我們可以假設明明沒有定義window物件的Node.js也去用window物件下的屬性?雖然我們可以在網頁瀏覽器和Node.js下直接使用console物件,但前者的console物件在window這個物件下,而後者的console物件在global這個物件下。在使用windowglobal這兩個物件下的屬性時,windowglobal這兩個物件名稱都可以省略不寫,因為網頁瀏覽器預設就會把沒有宣告出來的名稱當作是window這個物件下的屬性,而Node.js則會當作是global這個物件下的屬性。

所以,以下這行TypeScript/JavaScript程式,在Node.js中可以執行:

global.console.log('Hello world!');

但是若將以上這行程式碼交給tsc來編譯的話,就會編譯失敗,因為global在TypeScript程式中並沒有被宣告!

所以我們將以上這行程式寫成:

console.log('Hello world!');

如此一來,儘管tsc以為這個console是在window之下的屬性,而不會去要求console要宣告才能編譯,但是當把這行程式碼交給Node.js來執行時,在Node.js實際看來,它就是global這物件下的console屬性。所以這樣的寫法除了可以通過tsc的編譯之外,還能夠正常地在網頁瀏覽器和Node.js的環境中使用。當然,TypeScript也不是不能支援Node.js環境內建的物件,這部份會在之後的章節做介紹。

接下來介紹產生一個1到100隨機整數的方式。

function showMessage(text: string): void {
    if (typeof window === 'object') {
        alert(text);
    } else {
        console.log(text);
    }
}

showMessage('Guess the number!');

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

來看一下我們新增的第11行敘述。let這個關鍵字可以宣告(建立)變數,例如:

let foo = bar;

這行程式可以宣告出一個變數foo,並且讓它存放bar這個值(value)。在變數名稱的後面,可以再使用一個冒號:來連接一個型別名稱,以限制這個變數所能儲存的值的型別。如果不使用冒號,則會使用在宣告時設定的初始值的型別作為這個變數的型別。

例如:

let n: number = 1;
let s: string = 'Hi';
let a = 123;

一個let關鍵字可以宣告多個變數,用逗號,隔開即可。例如:

let n: number = 1, s: string = 'Hi', a = 123;

接下來我們來看一下Math.floor(Math.random() * 100)到底是什麼意思吧!JavaScript內建了Math物件,這個物件提供了許多與數學計算有關的方法。其中,floor可以用來將傳入的數值捨棄掉小數點後的位數後再回傳出來。random則是可以隨機回傳大於等於0或是小於1的數值。

為了要取得一個1到100隨機整數,我們要先把Math.random()的回傳值再乘上100,使其範圍擴充為大於等於0或是小於100的數值。接著再將這個數值加一,使其範圍變成大於等於1或是小於101的數值。最後再取這個數值的整數部份,也就會得到大於等於1,且小於等於100的數值啦!

我們可以將這個隨機產生出來的數值再利用前面介紹過的「模板定數」語法和showMessage這個函數,輸出到螢幕上。程式如下:

function showMessage(text: string): void {
    if (typeof window === 'object') {
        alert(text);
    } else {
        console.log(text);
    }
}

showMessage('Guess the number!');

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

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

再來要稍微修正一下我們目前程式有的問題。由於這個程式會呼叫很兩次以上的showMessage函數,所以showMessage函數主體內的typeof window === 'object'表達式也會被計算很多次,這個是很沒有意義的。利用剛才學到的let關鍵字,我們可以再宣告一個變數來儲存typeof window === 'object'表達式的計算結果。程式可以修改如下:

let isBrowser: boolean = typeof window === 'object';

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

showMessage('Guess the number!');

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

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

由於isBrowsersecretNumber變數在被賦值之後就不會被改變,因此我們也可以將let關鍵字改為const關鍵字,使它們變成常數。程式再修改如下:

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

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

showMessage('Guess the number!');

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

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

接下來要來撰寫讓使用者輸入他們要猜的數字的程式,由於這部份網頁瀏覽器和Node.js的作法不一樣,因此我們在showMessage函數下方再加上一個函數,如下:

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

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

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

        const readline = require('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;
    }
}

showMessage('Guess the number!');

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

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

這個新加入的showMessageAndInputText函數有點複雜,它可以傳入一個字串來提示使用者要輸入什麼樣的資料,然後回傳使用者輸入的字串。如果JavaScript的執行環境是網頁瀏覽器,那麼只要使用window物件提供的prompt方法來讓網頁瀏覽器跳出文字輸入方塊就好了。但如果JavaScript的執行環境是Node.js,我們就需要從標準輸入(stdin)中讀取使用者輸入的資料。

Node.js內建readline這個模組,可以用來讀取Readable串流中的資料,此處就用是它來讀取標準輸入串流的資料。我們可以利用Node.js內建的require函數來載入指定的模組,這個在之後的章節會有更詳細的介紹。

程式第19行到第21行,利用readlinecreateInterface方法來建立readline模組底下的Interface這個物件的實體。並且用大括號{}語法直接建立一個物件,作為參數傳給createInterface方法。這個用大括號{}建立出來的物件,有一個input欄位,它可以用來設定Interface這個物件要讀取的Readable串流物件,而Node.js內建的process.stdin就是標準輸入的串流物件。

程式第23到第25行,利用了new關鍵字來建立Promise<string>這個物件的實體(由小於<和大於>符號括起來的部份稱為「泛型」,之後的章節會再介紹)。在建構子參數的部份需要傳入一個函數,此處使用ES6加入的Lambda語法來定義匿名函數,可以省下撰寫function關鍵字的麻煩。有關匿名函數和Lambda語法會在之後的章節做比較詳細的介紹。總之在此處,建立了一個參數為resolve的函數,在這個函數的主體中,呼叫了Interface物件的on方法,用來非同步地監聽line這個事件。當Interface物件從輸入串流讀取到一行資料時,就會隨時觸發on方法第二個參數傳入的函數,這個函數的第一個參數,就儲存著該行Interface物件讀取到的資料。

傳入Promise建構子的函數所用的resolve參數,它其實是一個函數,可以用來包裹某個值,作為回傳的「正確值」。所以在程式第24行,我們用了resolve函數來包裹line參數的值,作為這個Promise物件所回傳的「正確值」。

之所以把程式寫得那麼複雜,是因為readline模組只有提供非同步的API來讀取Readable串流中的資料,我們需要依靠ES6提供的Promise物件將非同步的API包裹起來,再利用ES2017之後才有的「async/await」語法達成同步。有關於Promise和「async/await」語法,之後的章節會再詳細介紹。

程式第27行,利用await關鍵字來等候傳入Promise物件建構子的函數的resolve函數被呼叫,並取得被resolve函數包裹的值。

程式第29行,呼叫Interface物件的close方法來關閉它,如果此處沒有關閉的話會造成一些不正常的狀態。

此時若使用tsc index.ts指令來編譯程式,會遇到幾個大問題。

首先就是TypeScript並不認識Node.js內建的require函數和process物件,也不認識ES6後才被內建在JavaScript中的Promise物件(因為TypeScript的預設編譯目標為ES3)。

若要解決require函數和process物件的問題,可以利用declare關鍵字和const,在TypeScript程式的最外層宣告requireprocess這兩個常數。如下:

declare const require;
declare const process;

...

TypeScript提供的declare關鍵字可以使編譯器知道某個不存在TypeScript程式碼中的名稱是怎麼樣被宣告的,讓這些名稱就算沒有被真正宣告出來也可以通過編譯。

至於Promise物件的問題,現階段我們可以替tsc指令加上-t ES6參數來解決。不過,此時使用tsc index.ts -t ES6指令來編譯程式,也還是會編譯失敗。

typescript-guess-number

這是因為await關鍵字必須要用在有用async關鍵字的函數中,於是我們要將程式修改如下:

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): string {
    if (isBrowser) {
        return prompt(text);
    } else {
        console.log(text);

        const readline = require('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;
    }
}

showMessage('Guess the number!');

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

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

再次使用tsc index.ts -t ES6指令來編譯程式,還是會編譯失敗。這是因為有用async關鍵字的函數,其回傳值型別必須要是Promise<T>,這邊的T可以換成任意型別名稱。根據程式邏輯,我們的showMessageAndInputText函數的回傳值型別應為Promise<string>

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

showMessage('Guess the number!');

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

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

再次使用tsc index.ts -t ES6指令來編譯程式,就可以編譯成功了!

既然showMessageAndInputText函數已經實作完成了,那就開始用它吧!由於showMessageAndInputText函數是回傳Promise<string>物件,因此我們就如同程式第30行那樣,用await關鍵字來取得Promise物件所回傳的「正確值」。

程式撰寫如下:

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

showMessage('Guess the number!');

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

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

const line: string = await showMessageAndInputText('Please input your guess.');

再次使用tsc index.ts -t ES6指令來編譯程式,程式又編譯失敗了。這是依然是因為await關鍵字必須要用在有用async關鍵字的函數中,於是我們要將程式修改如下:

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('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}`);

    const line: string = await showMessageAndInputText('Please input your guess.');
}

main();

我們實作了一個使用了async關鍵字的main函數來作為程式進入點,如此一來就可以在其中隨意地使用await關鍵字。

能夠正常讀取使用者輸入的資料後,就可以繼續實作接下來的程式了!

首先要把showMessageAndInputText函數回傳的字串嘗試轉成整數數值,如果不能轉換的話,就出現提示;如果可以轉換的話,就輸出使用者輸入的數值,並判斷使用者輸入的數值比答案大還是小,或者是一模一樣,來輸出不同的訊息。

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('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}`);

    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!');
        }
    }
}

main();

JavaScript內建的parseInt函數可以用來將傳入參數的字串轉成整數數值,如果轉換失敗的話,它會回傳NaN(not a number),這個值也是屬於number型別。而JavaScript內建的isNaN函數,可以用來判斷傳入參數的數值是否為NaN

再來,我們要想辦法讓使用者在猜錯或是輸入錯誤之後,還能夠依照提示繼續猜下去,而不是直接結束程式,因此我們會需要用到while關鍵字來建立程式迴圈。將程式改寫如下:

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('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!');
            }
        }
    }
}

main();

程式第45行和第63行,我們利用while關鍵字將讓使用者輸入文字並顯示猜測結果的程式包成了一個區塊,while關鍵字所形成的迴圈會根據while關鍵字後面小括號()內的表達式來決定要不要繼續執行,條件判斷規則和if關鍵字是一樣的。因為此處提供給while關鍵字的表達式為true,所以程式如果未在這個區塊執行到break或是return敘述,就會不斷地重複執行這個區塊內的敘述。為了能夠讓使用者在猜對數字之後結束程式,我們需要再將程式進行改寫,如下:

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('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程式語言迴圈中的break敘述是用來跳出迴圈;continue敘述是用來跳過這次迴圈的執行,直接進入下一次。若此處將程式第61行程式改為return敘述也是可以的。

至此,我們已經差不多完成了這個猜數字程式了。可以用tsc index.ts -t ES6指令編譯程式後,再用node index.js指令玩個幾次試試。

typescript-guess-number

tsc編譯時引用的函式庫(Library)

即便2016年後的主流網頁瀏覽器都有支援ES6,但如果想要降版本到ES5並且還能夠使用Promise物件也是可行的,畢竟Promise在早期(2014年)就已被各大主流網頁瀏覽器支援了(但Promise在Internet Explorer沒辦法直接用,不過現在也沒有什麼人在用IE了,無所謂)。

所以雖然tsc預設的編譯目標是ES3,我們還是可以還算安全地將其提升到ES5,並且加上一些ES6才有新增,但是在早期就有被支援的功能。

tsc指令加上--lib參數,可以設定tsc編譯時要引用的函式庫。當編譯目標設為ES5時,預設會啟用的函式庫為dom,es5,scripthost。若要再加上Promise物件的支援,可以再加上es2015.promisees2015.iterable這兩個函式庫。

例如要編譯index.ts檔案並將其輸出成ES5且支援Promise的版本,指令如下:

tsc index.ts -t ES5 -lib dom,es5,scripthost,es2015.promise,es2015.iterable

在網頁瀏覽器上執行猜數字程式

在我們的猜數字程式的專案根目錄下新增index.html檔案,加上以下內容:

<html>
    <head>
        <meta charset="utf-8"/>
    </head>
    <body>
        <script src="./index.js"></script>
    </body>
</html>

接著再用網頁瀏覽器開啟這個index.html檔案即可。

總結

在這一章中,我們成功地完成了猜數字程式,已經用過了letconstdeclareifwhiletypeofnewasyncawait等基本的關鍵字,稍微接觸了JavaScript內建的物件和函數,以及網頁瀏覽器和Node.js環境特有的物件與函數。

此外,由於多次的編譯失敗,使我們對於JavaScript的版本和環境差異也有更深入的了解。在下一章節中,我們還是要繼續開發這個猜數字程式,不過這回我們會用npm創建Node.js專案來完成。

下一章:在Node.js專案中使用TypeScript。



關於作者

Magic Len
Magic Len

各位好,我是Magic Len,是這網站的管理員。我是台灣台中大肚山上人,畢業於台中高工資訊科和台灣科技大學資訊工程系,曾在桃機航警局服役。我熱愛自然也熱愛科學,喜歡和別人分享自己的知識與經驗。如果你有興趣認識我,可以加我的Facebook(點我),並且請註明是從MagicLen來的。

留言

載入中...