在這個章節,我們將會直接使用TypeScript程式語言來建立出猜數字程式的專案,並逐步將它完成!並在撰寫程式的過程中,來練習TypeScript程式語言基礎的程式語法,以及針對Node.js和網頁瀏覽器這兩個不一樣的執行環境來撰寫程式。這支小程式在執行之後,將會先從1到100的整數中,隨機抽取一個數字作為答案,並且允許使用者輸入要猜的數字,如果答錯了,程式會回答使用者輸入的數字究竟是大於答案,還是小於答案,並持續讓使用者繼續猜下去;如果答對了,程式會出現使用者贏了的訊息,並且結束程式。
建立新的TypeScript專案
我們依然先不要使用npm
,用最單純的方式完成這個專案。首先,建立一個用來存放TypeScript專案的目錄guessing-game
。
開始撰寫程式碼
接著在專案目錄中建立一個純文字檔案,檔名取為index.ts
。
然後用文字編輯器開啟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
函數主體中,我們利用由if
和else
關鍵字組成的條件判斷語法。if
關鍵字後必須要接上一對小括號()
,括號中間必須要放入一個表達式。當這個表達式的值並非undefined
、null
、false
(型別名稱為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內建的物件(如Math
、Date
、RegExp
等)與函數(如parseInt
、isNaN
等)屬於例外的狀況,可以直接被用在TypeScript程式碼中並成功通過編譯。
網頁瀏覽器都會有window
這個物件,所以我們可以藉由判斷window
變數的值的型別是否是object
(利用===
運算子來判斷),來偵測執行這個JavaScript程式的環境是否為網頁瀏覽器。若是,就直接使用window
物件的alert
方法來讓網頁瀏覽器跳出文字訊息方塊;若否,我們就假設環境是Node.js,並用window
物件下的console
物件提供的log
方法來在終端機畫面上印出文字訊息。
等等!為什麼我們可以假設明明沒有定義window
物件的Node.js也去用window
物件下的屬性?雖然我們可以在網頁瀏覽器和Node.js下直接使用console
物件,但前者的console
物件在window
這個物件下,而後者的console
物件在global
這個物件下。在使用window
和global
這兩個物件下的屬性時,window
和global
這兩個物件名稱都可以省略不寫,因為網頁瀏覽器預設就會把沒有宣告出來的名稱當作是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) + 1)
到底是什麼意思吧!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}`);
由於isBrowser
和secretNumber
變數在被賦值之後就不會被改變,因此我們也可以將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("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;
}
}
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
函數來載入指定的模組,要載入Node.js內建的模組,最好在模組名稱再加上node:
前綴,才不會和外面的模組混淆。
程式第19行,利用readline
的createInterface
方法來建立readline
模組底下的Interface
這個物件的實體。並且用大括號{}
語法直接建立一個物件,作為參數傳給createInterface
方法。這個用大括號{}
建立出來的物件,有一個input
欄位,它可以用來設定Interface
這個物件要讀取的Readable
串流物件,而Node.js內建的process.stdin
就是標準輸入的串流物件。
程式第21到第23行,利用了new
關鍵字來建立Promise<string>
這個物件的實體(由小於<
和大於>
符號括起來的部份稱為「泛型」,之後的章節會再介紹)。在建構子參數的部份需要傳入一個函數,此處使用ES6加入的Lambda語法來定義匿名函數,可以省下撰寫function
關鍵字的麻煩。有關匿名函數和Lambda語法會在之後的章節做比較詳細的介紹。總之在此處,建立了一個參數為resolve
的函數,在這個函數的主體中,呼叫了Interface
物件的on
方法,用來非同步地監聽line
這個事件。當Interface
物件從輸入串流讀取到一行資料時,就會隨時觸發on
方法第二個參數傳入的函數,這個函數的第一個參數,就儲存著該行Interface
物件讀取到的資料。
傳入Promise
建構子的函數所用的resolve
參數,它其實是一個函數,可以用來包裹某個值,作為回傳的「正確值」。所以在程式第22行,我們用了resolve
函數來包裹line
參數的值,作為這個Promise
物件所回傳的「正確值」。
之所以把程式寫得那麼複雜,是因為readline
模組只有提供非同步的API來讀取Readable
串流中的資料,我們需要依靠ES6提供的Promise
物件將非同步的API包裹起來,再利用ES2017之後才有的「async/await」語法達成同步。有關於Promise
和「async/await」語法,之後的章節會再詳細介紹。
程式第25行,利用await
關鍵字來等候傳入Promise
物件建構子的函數的resolve
函數被呼叫,並取得被resolve
函數包裹的值。
程式第27行,呼叫Interface
物件的close
方法來關閉它,如果此處沒有關閉的話會造成一些不正常的狀態。
此時若使用tsc index.ts
指令來編譯程式,會遇到幾個大問題。
首先就是TypeScript並不認識Node.js內建的require
函數和process
物件,也不認識ES6後才被內建在JavaScript中的Promise
物件(因為TypeScript的預設編譯目標為ES3
)。
若要解決require
函數和process
物件的問題,可以利用declare
關鍵字和const
,在TypeScript程式的最外層宣告require
和process
這兩個常數。如下:
declare const require;
declare const process;
...
TypeScript提供的declare
關鍵字可以使編譯器知道某個不存在TypeScript程式碼中的名稱是怎麼樣被宣告的,讓這些名稱就算沒有被真正宣告出來也可以通過編譯。
至於Promise
物件的問題,現階段我們可以替tsc
指令加上-t ES6
參數來解決。不過,此時使用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): 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;
}
}
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("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;
}
}
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>
物件,因此我們就如同程式第28行那樣,用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("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;
}
}
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("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}`);
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("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}`);
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("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!");
}
}
}
}
main();
程式第43行和第61行,我們利用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("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程式語言迴圈中的break
敘述是用來跳出迴圈;continue
敘述是用來跳過這次迴圈的執行,直接進入下一次。若此處將程式第59行程式改為return
敘述也是可以的。
至此,我們已經差不多完成了這個猜數字程式了。可以用tsc index.ts -t ES6
指令編譯程式後,再用node index.js
指令玩個幾次試試。
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.promise
和es2015.iterable
這兩個函式庫。
例如要編譯index.ts
檔案並將其輸出成ES5且支援Promise
的版本,指令如下:
實務上,若我們的TypeScript程式只會運行在Node.js環境時,編譯目標可以設定為ES2018
,現行的LTS版本的Node.js都能支援。而如果要讓它運行在網頁瀏覽器上,我們通常還是會透過Webpack+Babel等整合轉譯的JS工具來輸出JS檔案,在這個情況下將TypeScript的編譯目標設定為ESNEXT
(最新版本的JavaScript/ECMAScript),再用整合工具去進行降版轉譯會是比較好的作法。
在網頁瀏覽器上執行猜數字程式
在我們的猜數字程式的專案根目錄下新增index.html
檔案,加上以下內容:
<html>
<head>
<meta charset="utf-8"/>
</head>
<body>
<script src="./index.js"></script>
</body>
</html>
接著再用網頁瀏覽器開啟這個index.html
檔案即可。
總結
在這一章中,我們成功地完成了猜數字程式,已經用過了let
、const
、declare
、if
、while
、typeof
、new
、async
、await
等基本的關鍵字,稍微接觸了JavaScript內建的物件和函數,以及網頁瀏覽器和Node.js環境特有的物件與函數。
此外,由於多次的編譯失敗,使我們對於JavaScript的版本和環境差異也有更深入的了解。在下一章節中,我們還是要繼續開發這個猜數字程式,不過這回我們會用npm
創建Node.js專案來完成。