TypeScript 之基礎入門

冴羽發表於2021-12-06

前言

TypeScript 的官方文件早已更新,但我能找到的中文文件都還停留在比較老的版本。所以對其中新增以及修訂較多的一些章節進行了翻譯整理。

本篇整理自 TypeScript Handbook 中 「The Basics」 章節。

本文並不嚴格按照原文翻譯,對部分內容也做了解釋補充。

正文

JavaScript 的每個值執行不同的操作時會有不同的行為。這聽起來有點抽象,所以讓我們舉個例子,假設我們有一個名為 message 的變數,試想我們可以做哪些操作:

// Accessing the property 'toLowerCase'
// on 'message' and then calling it
message.toLowerCase();
// Calling 'message'
message();

第一行程式碼是獲取屬性 toLowerCase ,然後呼叫它。第二行程式碼則是直接呼叫 message

但其實我們連 message 的值都不知道呢,自然也不知道這段程式碼的執行結果。每一個操作行為都先取決於我們有什麼樣的值。

  • message 是可呼叫的嗎?
  • message 有一個名為 toLowerCase 的屬性嗎?
  • 如果有,toLowerCase 是可以被呼叫的嗎?
  • 如果這些值都可以被呼叫,它們會返回什麼?

當我們寫 JavaScript 的時候,這些問題的答案我們需要謹記在心,同時還要期望處理好所有的細節。

讓我們假設 message 是這樣定義的:

const message = "Hello World!";

你完全可以猜到這段程式碼的結果,如果我們嘗試執行 message.toLowerCase() ,我們可以得到這段字元的小寫形式。

那第二段程式碼呢?如果你對 JavaScript 比較熟悉,你肯定知道會報如下錯誤:

TypeError: message is not a function

如果我們能避免這樣的報錯就好了。

當我們執行程式碼的時候,JavaScript 會在執行時先算出值的型別(type),然後再決定幹什麼。所謂值的型別,也包括了這個值有什麼行為和能力。當然 TypeError 也會暗示性的告訴我們一點,比如在這個例子裡,它告訴我們字串 Hello World 不能作為函式被呼叫。

對於一些值,比如基本值 stringnumber,我們可以使用 typeof 運算子確認他們的型別。但是對於其他的比如函式,就沒有對應的方法可以確認他們的型別了,舉個例子,思考這個函式:

function fn(x) {
  return x.flip();
}

我們通過閱讀程式碼可以知道,函式只有被傳入一個擁有可呼叫的 flip 屬性的物件,才會正常執行。但是 JavaScript 在程式碼執行時,並不會把這個資訊體現出來。在 JavaScript 中,唯一可以知道 fn 在被傳入特殊的值時會發生什麼,就是呼叫它,然後看會發生什麼。這種行為讓你很難在程式碼執行前就預測程式碼執行結果,這也意味著當你寫程式碼的時候,你會更難知道你的程式碼會發生什麼。

從這個角度來看,型別就是描述什麼樣的值可以被傳遞給 fn,什麼樣的值則會導致崩潰。JavaScript 僅僅提供了動態型別(dynamic typing),這需要你先執行程式碼然後再看會發生什麼。

替代方案就是使用靜態型別系統(static type system),在程式碼執行之前就預測需要什麼樣的程式碼。

靜態型別檢查(Static type-checking)

讓我們再回想下這個將 string 作為函式進行呼叫而產生的 TypeError ,大部分的人並不喜歡在執行程式碼的時候得到報錯。這些會被認為是 bug。當我們寫新程式碼的時候,我們也盡力避免產生新的 bug。

如果我們新增一點程式碼,儲存檔案,然後重新執行程式碼,就能立刻看到錯誤,我們可以很快的定位到問題,但也並不總是這樣,比如如果我們沒有做充分的測試,我們就遇不到可能出錯的情況。或者如果我們足夠幸運看到了這個錯誤,我們也許不得不做一個大的重構,然後新增很多不同的程式碼,才能找出問題所在。

理想情況下,我們應該有一個工具可以幫助我們,在程式碼執行之前就找到錯誤。這就是靜態型別檢查器比如 TypeScript 做的事情。靜態型別系統(Static types systems)描述了值應有的結構和行為。一個像 TypeScript 的型別檢查器會利用這個資訊,並且在可能會出錯的時候告訴我們:

const message = "hello!";
 
message();

// This expression is not callable.
// Type 'String' has no call signatures.

在這個例子中,TypeScript 會在執行之前就會丟擲錯誤資訊。

非異常失敗(Non-exception 失敗)

至今為止,我們已經討論的都是執行時的錯誤,所謂執行時錯誤,就是 JavaScript 會在執行時告訴我們它認為的一些沒有意義的事情。這些事情之所以會出現,是因為 ECMAScript 規範已經明確的宣告瞭這些異常時的行為。

舉個例子,規範規定,當呼叫一個非可呼叫的東西時應該丟擲一個錯誤。也許聽起來像是理所當然的,由此你可能認為,如果獲取一個物件不存在的屬性也應該丟擲一個錯誤,但是 JavaScript 並不會這樣,它不報錯,還返回值 undefined

const user = {
  name: "Daniel",
  age: 26,
};
user.location; // returns undefined

一個靜態型別需要標記出哪些程式碼是一個錯誤,哪怕實際生效的 JavaScript 並不會立刻報錯。在 TypeScript 中,下面的程式碼會產生一個 location 不存在的報錯:

const user = {
  name: "Daniel",
  age: 26,
};
 
user.location;
// Property 'location' does not exist on type '{ name: string; age: number; }'.

儘管有時候這意味著你需要在表達的時候上做一些取捨,但目的還是找出我們專案中一些合理的錯誤。TypeScript 現在已經可以捕獲很多合理的錯誤。

舉個例子,比如拼寫錯誤:

const announcement = "Hello World!";
 
// How quickly can you spot the typos?
announcement.toLocaleLowercase();
announcement.toLocalLowerCase();
 
// We probably meant to write this...
announcement.toLocaleLowerCase();

函式未被呼叫:

function flipCoin() {
  // Meant to be Math.random()
  return Math.random < 0.5;
// Operator '<' cannot be applied to types '() => number' and 'number'.
}

基本的邏輯錯誤:

const value = Math.random() < 0.5 ? "a" : "b";
if (value !== "a") {
  // ...
} else if (value === "b") {
  // This condition will always return 'false' since the types '"a"' and '"b"' have no overlap.
  // Oops, unreachable
}

型別工具(Types for Tooling)

TypeScript 不僅在我們犯錯的時候,可以找出錯誤,還可以防止我們犯錯。

型別檢查器因為有型別資訊,可以檢查比如說是否正確獲取了一個變數的屬性。也正是因為有這個資訊,它也可以在你輸入的時候,列出你可能想要使用的屬性。

這意味著 TypeScript 對你編寫程式碼也很有幫助,核心的型別檢查器不僅可以提供錯誤資訊,還可以提供程式碼補全功能。這就是 TypeScript 在工具方面的作用。

TypeScript 的功能很強大,除了在你輸入的時候提供補全和錯誤資訊。還可以支援“快速修復”功能,即自動的修復錯誤,重構成組織清晰的程式碼。同時也支援導航功能,比如跳轉到變數定義的地方,或者找到一個給定的變數所有的引用。

所有這些功能都建立在型別檢查器上,並且跨平臺支援。有可能你最喜歡的編輯器已經支援了 TypeScript。

tsc TypeScript 編譯器(tsc,the TypeScript compiler)

至今我們只是討論了型別檢查器,但是還一直沒有用過。現在讓我們瞭解下我們的新朋友 tsc —— TypeScript 編譯器。首先,我們可以通過 npm 安裝它:

npm install -g typescript
這會把 TypeScript 編譯器安裝在全域性,如果你想把 tsc 安裝在一個本地的 node_modules 中,你也可以使用 npx 或者類似的工具。

讓我們建立一個空資料夾,然後寫下我們第一個 TypeScript 程式: hello.ts

// Greets the world.
console.log("Hello world!");

注意這裡並沒有什麼多餘的修飾,這個 hello world 專案就跟你用 JavaScript 寫是一樣的。現在你可以執行 tsc 命令,執行型別檢查:

tsc hello.ts

現在我們已經執行了 tsc,但是你會發現什麼也沒有發生。確實如此,因為這裡並沒有什麼型別錯誤,所以命令列裡也不會有任何輸出。

但如果我們再次檢查一次,我們就會發現,我們得到了一個新的檔案。檢視一下當前目錄,我們會發現 hello.ts 同級目錄下還有一個 hello.js,這就是 hello.ts 檔案編譯輸出的檔案, tsc 會把 ts 檔案編譯成一個純 JavaScript 檔案。讓我們檢視一下編譯輸出的檔案:

// Greets the world.
console.log("Hello world!");

在這個例子中,因為 TypeScript 並沒有什麼要編譯處理的內容,所以看起來跟我們寫的是一樣的。編譯器會盡可能輸出乾淨的程式碼,就像是正常開發者寫的那樣,當然這並不是容易的事情,但 TypeScript 會堅持這樣做,比如保持縮排,注意跨行程式碼,保留註釋等。

如果我們執意要產生一個型別檢查錯誤呢?我們可以這樣寫 hello.ts:

// This is an industrial-grade general-purpose greeter function:
function greet(person, date) {
  console.log(`Hello ${person}, today is ${date}!`);
}
 
greet("Brendan");

此時我們再執行下 tsc hello.ts 。這次我們會在命令列裡得到一個錯誤:

Expected 2 arguments, but got 1.

TypeScript 告訴我們少傳了一個引數給 greet 函式。

雖然我們編寫的是標準的 JavaScript,但 TypeScript 依然可以幫助我們找到程式碼中的錯誤,cool~。

報錯時仍產出檔案(Emitting with Errors)

在剛才的例子中,有一個細節你可能沒有注意到,那就是如果我們開啟編譯輸出的檔案,我們會發現檔案依然發生了改動。這是不是有點奇怪?tsc 明明已經報錯了,為什麼還要再編譯檔案?這就要講到 TypeScript 一個核心的觀點:大部分時候,你要比 TypeScript 更清楚你的程式碼。

舉個例子,假如你正在把你的程式碼遷移成 TypeScript,這會產生很多型別檢查錯誤,而你不得不為型別檢查器處理掉所有的錯誤,這時候你就要想了,明明之前的程式碼可以正常工作,TypeScript 為什麼要阻止程式碼正常執行呢?

所以 TypeScript 並不會阻礙你。當然了,你如果想要 TypeScript 更嚴厲一些,你可以使用 noEmitOnError 編譯選項,試著改下你的 hello.ts 檔案,然後執行 tsc:

tsc --noEmitOnError hello.ts

你會發現 hello.ts 並不會得到更新。

顯示型別(Explicit Types)

直到現在,我們還沒有告訴 TypeScript,persondate 是什麼型別,讓我們編輯一下程式碼,告訴 TypeScript,person 是一個 string 型別,date 是一個 Date 物件。同時我們使用 datetoDateString() 方法。

function greet(person: string, date: Date) {
  console.log(`Hello ${person}, today is ${date.toDateString()}!`);
}

我們所做的就是給 persondate 新增了型別註解(type annotations),描述 greet 函式可以支援傳入什麼樣的值。你可以如此理解這個簽名 (signature)greet 支援傳入一個 string 型別的 person 和一個 Date 型別的 date

新增型別註解後,TypeScript 就可以提示我們,比如說當 greet 被錯誤呼叫時:

function greet(person: string, date: Date) {
  console.log(`Hello ${person}, today is ${date.toDateString()}!`);
}
 
greet("Maddison", Date());
// Argument of type 'string' is not assignable to parameter of type 'Date'.

TypeScript 提示第二個引數有錯誤,這是為什麼呢?

這是因為,在 JavaScript 中呼叫 Date() 會返回一個 string 。使用 new Date() 才會產生 Date 型別的值。

我們快速修復下這個問題:

function greet(person: string, date: Date) {
  console.log(`Hello ${person}, today is ${date.toDateString()}!`);
}
 
greet("Maddison", new Date());

記住,我們並不需要總是寫型別註解,大部分時候,TypeScript 可以自動推斷出型別:

let msg = "hello there!";
// let msg: string

儘管我們並沒有告訴 TypeScript, msgstring 型別的值,但它依然推斷出了型別。這是一個特性,如果型別系統可以正確的推斷出型別,最好就不要手動新增型別註解了。

型別抹除(Erased Types)

上一個例子裡的程式碼,TypeScript 會編譯成什麼樣呢?我們來看一下:

"use strict";
function greet(person, date) {
    console.log("Hello " + person + ", today is " + date.toDateString() + "!");
}
greet("Maddison", new Date());

注意兩件事情:

  1. 我們的 persondate 引數不再有型別註解
  2. 模板字串,即用 ` 包裹的字串被轉換為使用 + 號連線

讓我們先看下第一點。型別註解並不是 JavaScript 的一部分。所以並沒有任何瀏覽器或者執行環境可以直接執行 TypeScript 程式碼。這就是為什麼 TypeScript 需要一個編譯器,它需要將 TypeScript 程式碼轉換為 JavaScript 程式碼,然後你才可以執行它。所以大部分 TypeScript 獨有的程式碼會被抹除,在這個例子中,像我們的型別註解就全部被抹除了。

謹記:型別註解並不會更改程式執行時的行為

降級(Downleveling)

我們再來關注下第二點,原先的程式碼是:

`Hello ${person}, today is ${date.toDateString()}!`;

被編譯成了:

"Hello " + person + ", today is " + date.toDateString() + "!";

為什麼要這樣做呢?

這是因為模板字串是 ECMAScript2015(也被叫做 ECMAScript 6 ,ES2015, ES6 等)裡的功能,TypeScript 可將新版本的程式碼編譯為老版本的程式碼,比如 ECMAScript3 或者 ECMAScript5 。這個將高版本的 ECMAScript 語法轉為低版本的過程就叫做降級(downleveling)

TypeScript 預設轉換為 ES3,一個 ECMAScript 非常老的版本。我們也可以使用 target 選項轉換為比較新的一些版本,比如執行 --target es2015 會轉換為 ECMAScript 2015, 這意味著轉換後的程式碼可以在任何支援 ECMAScript 2015 的地方執行。

執行 tsc --target es2015 hello.ts ,讓我們看下編譯成 ES2015 後的程式碼:

function greet(person, date) {
  console.log(`Hello ${person}, today is ${date.toDateString()}!`);
}
greet("Maddison", new Date());
儘管預設的目標是 ES3 版本,但是大多數的瀏覽器都已經支援 ES2015 了,因此大部分開發者可以安全的指定為 ES2015 或者更新的版本,除非你非要相容某個問題瀏覽器。

嚴格模式(Strictness)

不同的使用者使用 TypeScript 會關注不同的事情。一些使用者會尋找較為寬鬆的體驗,既可以幫助檢查他們程式中的部分程式碼,也可以享受 TypeScript 的工具功能。這就是 TypeScript 預設的開發體驗,型別是可選的,推斷會相容大部分的型別,對有可能是 null/ undefined 值也不做強制檢查。就像 tsc 在編譯報錯時依然會輸出檔案,這些預設選項並不會阻礙你的開發。如果你正在遷移 JavaScript 程式碼,最一開始就可以使用這種方式。

與之形成鮮明對比的是,還有很多使用者希望 TypeScript 儘可能多地檢查程式碼,這就是為什麼這門語言會提供嚴格模式設定。但不同於切換開關的形式(要麼檢查要麼不檢查),TypeScript 提供的形式更像是一個刻度盤,你越是轉動它,TypeScript 就會檢查越多的內容。這需要一點額外的工作,但是是值得的,它可以帶來更全面的檢查和更準確的工具功能。如果可能的話,新專案應該始終開啟這些嚴格設定。

TypeScript 有幾個嚴格模式設定的開關。除非特殊說明,文件裡的例子都是在嚴格模式下寫的。CLI 裡的 strict 配置項,或者 tsconfig.json 中的 "strict": true 可以同時開啟,也可以分開設定。在這些設定裡,你最需要了解的是 noImplicitAnystrictNullChecks

noImplicitAny

在某些時候,TypeScript 並不會為我們推斷型別,這時候就會回退到最寬泛的型別:any 。這倒不是最糟糕的事情,畢竟回退到 any就跟我們寫 JavaScript 沒啥一樣了。

但是,經常使用 any 有違揹我們使用 TypeScript 的目的。你程式使用的型別越多,你在驗證和工具上得到的幫助就會越多,這也意味著寫程式碼的時候會遇到更少的 bug。啟用 noImplicitAny 配置項後,當型別被隱式推斷為 any 時,會丟擲一個錯誤。

strictNullChecks

預設情況下,像 nullundefined 這樣的值可以賦值給其他的型別。這可以讓我們更方面的寫一些程式碼。但是忘記處理 nullundefined 也導致了不少的 bug,甚至有些人會稱呼它為價值百萬的錯誤strictNullChecks 選項會讓我們更明確的處理 nullundefined,也會讓我們免於憂慮是否忘記處理 nullundefined

TypeScript 系列

  1. TypeScript 之 型別收窄
  2. TypeScript 之 函式
  3. TypeScript 之 物件型別
  4. TypeScript 之 泛型
  5. TypeScript 之 Keyof 操作符
  6. TypeScript 之 Typeof 操作符
  7. TypeScript 之 索引訪問型別
  8. TypeScript 之 條件型別

微信:「mqyqingfeng」,加我進冴羽唯一的讀者群。

如果有錯誤或者不嚴謹的地方,請務必給予指正,十分感謝。如果喜歡或者有所啟發,歡迎 star,對作者也是一種鼓勵。

相關文章