TypeScript 官方手冊翻譯計劃【一】:基礎

Chor發表於2021-11-21
  • 說明:目前網上沒有 TypeScript 最新官方文件的中文翻譯,所以有了這麼一個翻譯計劃。因為我也是 TypeScript 的初學者,所以無法保證翻譯百分之百準確,若有錯誤,歡迎評論區指出;
  • 翻譯內容:暫定翻譯內容為 TypeScript Handbook,後續有空會補充翻譯文件的其它部分;
  • 專案地址TypeScript-Doc-Zh,如果對你有幫助,可以點一個 star ~

本章節官方文件地址:The Basics

基礎

歡迎來到手冊的第一章節。如果這是你第一次接觸到 TypeScript,你可能需要先閱讀一下入門指南

JavaScript 中的每個值會隨著我們執行不同的操作表現出一系列的行為。這聽起來很抽象,看下面的例子,考慮一下針對變數 message 可能執行的操作:

// 訪問 message 的 toLowerCase 方法並呼叫它
message.toLowerCase()
// 呼叫 message 函式
message()

第一行程式碼訪問了 messagetoLowerCase 方法並呼叫它;第二行程式碼則直接呼叫了 message 函式。

不過讓我們假設一下,我們並不知道 message 的值 —— 這是很常見的一種情況,僅從上面的程式碼中我們無法確切得知最終的結果。每個操作的結果完全取決於 message 的初始值。

  • message 是否可以呼叫?
  • 它有 toLowaerCase 屬性嗎?
  • 如果有這個屬性,那它可以呼叫嗎?
  • 如果 message 以及它的屬性都是可以呼叫的,那麼分別返回什麼?

在編寫 JavaScript 程式碼的時候,這些問題的答案經常需要我們自己記在腦子裡,而且我們必須得祈禱自己處理好了所有細節。

假設 message 是這樣定義的:

const message = 'Hello World!'

你可能很容易猜到,如果執行 message.toLowerCase(),我們將會得到一個首字母小寫的字串。

如果執行第二行程式碼呢?熟悉 JavaScript 的你肯定猜到了,這會丟擲一個異常:

TypeError: message is not a function

如果可以避免這樣的錯誤就好了。

當我們執行程式碼的時候,JavaScript 執行時會計算出值的型別 —— 這種型別有什麼行為和功能,從而決定採取什麼措施。這就是上面的程式碼會丟擲 TypeError 的原因 —— 它表明字串 "Hello World!" 無法作為函式被呼叫。

對於諸如 string 或者 number 這樣的原始型別,我們可以通過 typeof 操作符在執行時算出它們的型別。但對於像函式這樣的型別,並沒有對應的執行時機制去計算型別。舉個例子,看下面的函式:

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

從程式碼可以看出,僅當存在一個帶有 flip 屬性的物件時,這個函式才可以正常執行,但 JavaScript 無法在程式碼執行時以一種我們可以檢查的方式傳遞這個資訊。要讓純 JavaScript 告訴我們 fn 在給定特定引數的時候會做什麼事,唯一的方法就是實際呼叫 fn 函式。這樣的特徵使得我們很難在程式碼執行前進行相關的預測,也意味著我們在編寫程式碼的時候,很難搞清楚程式碼會做什麼事。

從這個角度看,所謂的型別其實就是描述了什麼值可以安全傳遞給 fn,什麼值會引起報錯。JavaScript 只提供了動態型別 —— 執行程式碼,然後才能知道會發生什麼事。

那麼不妨我們改用一種方案,使用一個靜態的型別系統,在程式碼實際執行前預測程式碼的行為。

靜態型別檢查

還記得之前我們將字串作為函式呼叫時,丟擲的 TypeError 錯誤嗎?大多數開發者在執行程式碼時不希望看到任何錯誤 —— 畢竟這些都是 bug!當我們編寫新程式碼的時候,我們也會盡量避免引入新的 bug。

如果我們只是新增了一點程式碼,儲存檔案,重新執行程式碼,然後馬上看到報錯,那麼我們或許可以快速定位到問題 —— 但這種情況畢竟只是少數。我們可能沒有全面、徹底地進行測試,以至於沒有發現一些潛在錯誤!或者,如果我們幸運地發現了這個錯誤,我們可能最終會進行大規模的重構,並新增許多不同的程式碼。

理想的方案應該是,我們有一個工具可以在程式碼執行前找出 bug。而這正是像 TypeScript 這樣的靜態型別檢查器所做的事情。靜態型別系統描述了程式執行時值的結構和行為。像 TypeScript 這樣的靜態型別檢查器會利用型別系統提供的資訊,並在“事態發展不對勁”的時候告知我們。

const message = 'hello!';
message()
// This expression is not callable.
//    Type 'String' has no call signatures.

還是之前的程式碼,但這次使用的是 TypeScript,它會在編譯的時候就丟擲錯誤。

非異常失敗

目前為止,我們討論的都是執行時錯誤 —— JavaScript 執行時告訴我們,它覺得某個地方有異常。這些異常之所以能夠丟擲,是因為 ECMAScript 規範 明確規定了針對異常應該表現的行為。

舉個例子,規範指出,試圖呼叫無法呼叫的東西應該丟擲一個錯誤。也許你會覺得這是“理所當然的”,並且你會覺得,訪問物件上不存在的屬性時,也會丟擲一個錯誤。但恰恰相反,JavaScript 的表現和我們的預想不同,它返回的是 undefined

const user = {
    name: 'Daniel',
    age: 26,
};
user.location;       // 返回 undefined

最終,我們需要一個靜態型別系統來告訴我們,哪些程式碼在這個系統中被標記為錯誤的程式碼 —— 即使它是不會馬上引起錯誤的“有效” JavaScript 程式碼。在 TypeScript 中,下面的程式碼會丟擲一個錯誤,指出 location 沒有定義:

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

雖然有時候這意味著你需要在表達的內容上進行權衡,但我們的目的是為了找到程式中更多合法的 bug。而 TypeScript 也的確可以捕獲到很多合法的 bug:

舉個例子,拼寫錯誤:

const announcement = "Hello World!";
 
// 你需要花多久才能注意到拼寫錯誤?
announcement.toLocaleLowercase();
announcement.toLocalLowerCase();
 
// 實際上正確的拼寫是這樣的
announcement.toLocaleLowerCase();

未呼叫的函式:

function flipCoin(){
    // 其實應該使用 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") {
// 永遠無法到達這個分支
}

型別工具

TypeScript 可以在我們的程式碼出現錯誤時捕獲 bug。這很好,但更關鍵的是,它能夠在一開始就防止我們的程式碼出現錯誤。

型別檢查器可以通過獲取的資訊檢查我們是否正在訪問變數或者其它屬性上的正確屬性。同時,它也能憑藉這些資訊提示我們可能想要訪問的屬性。

這意味著 TypeScript 也能用於編輯程式碼。我們在編輯器中輸入的時候,核心的型別檢查器能夠提供報錯資訊和程式碼補全。人們經常會談到 TypeScript 在工具層面的作用,這就是一個典型的例子。

import express from "express";
const app = express();
 
app.get("/", function (req, res) {
  // 在拼寫 send 方法的時候,這裡會有程式碼補全的提示
  // res.sen...         
});
 
app.listen(3000);

TypeScript 在工具層面的作用非常強大,遠不止拼寫時進行程式碼補全和錯誤資訊提示。支援 TypeScript 的編輯器可以通過“快速修復”功能自動修復錯誤,重構產生易組織的程式碼。同時,它還具備有效的導航功能,能夠讓我們跳轉到某個變數定義的地方,或者找到對於給定變數的所有引用。所有這些功能都建立在型別檢查器上,並且是跨平臺的,因此你最喜歡的編輯器很可能也支援了 TypeScript

TypeScript 編譯器 —— tsc

我們一直在討論型別檢查器,但目前為止還沒上手使用過。是時候和我們的新朋友 —— TypeScript 編譯器 tsc 打交道了。首先,通過 npm 進行安裝。

npm install -g typescript
這將全域性安裝 TypeScript 的編譯器 tsc。如果你更傾向於安裝在本地的 node_modules 資料夾中,那你可能需要藉助 npx 或者類似的工具才能便捷地執行 tsc 指令。

現在,我們新建一個空資料夾,嘗試編寫第一個 TypeScript 程式 hello.ts 吧。

// 和世界打個招呼
console.log('Hello world!');

注意這行程式碼沒有任何多餘的修飾,它看起來就和使用 JavaScript 編寫的 “hello world” 程式一模一樣。現在,讓我們執行 typescript 安裝包自帶的 tsc 指令進行型別檢查吧。

tsc hello.ts

看!

等等,“看”什麼呢?我們執行了 tsc 指令,但好像也沒發生什麼事!是的,畢竟這行程式碼沒有型別錯誤,所以控制檯中當然看不到報錯資訊的輸出。

不過再檢查一下 —— 你會發現輸出了一個新的檔案。在當前目錄下,除了 hello.ts 檔案外還有一個 hello.js 檔案,而後者是 tsc 通過編譯得到的純 JavaScript 檔案。檢查 hello.js 檔案的內容,我們可以看到 TypeScript 編譯器處理完 .ts 檔案後產出的內容:

// 和世界打個招呼
console.log('Hello world!');

在這個例子中,TypeScript 幾乎沒有需要轉譯的內容,所以轉譯前後的程式碼看起來一模一樣。編譯器總是試圖產出清晰可讀的程式碼,這些程式碼看起來就像正常的開發者編寫的一樣。雖然這不是一件容易的事情,但 TypeScript 始終保持縮排,關注跨行的程式碼,並且會嘗試保留註釋。

如果我們刻意引入了一個會在型別檢查階段丟擲的錯誤呢?嘗試改寫 hello.ts 的程式碼如下:

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!

報錯時仍產出檔案

有一件事你可能沒有注意到,在上面的例子中,我們的 hello.js 檔案再次發生了改動。開啟這個檔案,你會發現內容和輸入的檔案內容是一樣的。這可能有點出乎意料,明明 tsc 剛才報錯了啊,為什麼還是可以編譯產出檔案呢?但這種結果其實和 TypeScript 的核心原則有關:大多數時候,開發者比 TypeScript 更瞭解程式碼。

再次重申,對程式碼進行型別檢查,會限制可以執行的程式的種類,因此型別檢查器會進行權衡,以確定哪些程式碼是可以被接受的。大多數時候這樣沒什麼問題,但有的時候,這些檢查會對我們造成阻礙。舉個例子,想象你現在正把 JavaScript 程式碼遷移到 TypeScript 程式碼,併產生了很多型別檢查錯誤。最後,你不得不花費時間解決型別檢查器丟擲的錯誤,但問題在於,原始的 JavaScript 程式碼本身就是可以執行的!為什麼把它們轉換為 TypeScript 程式碼之後,反而就不能執行了呢?

所以在設計上,TypeScript 並不會對你造成阻礙。當然,隨著時間的推移,你可能希望對錯誤採取更具防禦性的措施,同時也讓 TypeScript 採取更加嚴格的行為。在這種情況下,你可以開啟 noEmitOnError 編譯選項。嘗試修改你的 hello.ts 檔案,並使用引數去執行 tsc 指令:

tsc --noEmitOnError hello.ts

現在你會發現,hello.js 沒有再發生改動了。

顯式型別

目前為止,我們還沒有告訴 TypeScript persondate 是什麼。修改一下程式碼,宣告 personstring 型別,dataDate 物件。我們也會通過 date 去呼叫 toDateString 方法。

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

我們所做的事情,是給 persondate 新增型別註解,描述 greet 呼叫的時候應該接受什麼型別的引數。你可以將這個簽名解讀為“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 方法返回的是字串,而通過 new 去呼叫,則可以如預期那樣返回一個 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 msg 是一個 string 型別的變數,它也能夠自己進行推斷。這是一個特性,在型別系統能夠正確地進行型別推斷的時候,最好不要手動新增型別註解了。

注意:在編輯器中,將滑鼠放到變數上面,會有關於變數型別的提示

抹除型別

我們看一下 greet 經過編譯後產出的 JavaScript 程式碼是什麼樣的:

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

可以注意到有兩個變化:

  1. persondate 引數的型別註解不見了
  2. 模板字串變成了通過 + 拼接的字串

稍後再解釋第二點,我們先來看第一個變化。型別註解並不屬於 JavaScript 或者 ECMAScript 的內容,所以沒有任何瀏覽器或者執行時能夠直接執行不經處理的 TypeScript 程式碼。這也是為什麼 TypeScript 首先需要一個編譯器 —— 它需要經過編譯,才能去除或者轉換 TypeScript 獨有的程式碼,從而讓這些程式碼可以在瀏覽器上執行。大多數 TypeScript 獨有的程式碼都會被抹除,在這個例子中,可以看到型別註解的程式碼完全被抹除了。

記住: 型別註解永遠不會改變程式在執行時的行為

降級

另一個變化就是我們的模板字串從:

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

變成了:

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

為什麼會這樣子呢?

模板字串是 ECMAScript 2015(或者 ECMAScript6、ES2015、ES6 等)引入的新特性。TypeScript 可以將高版本 ECMAScript 的程式碼重寫為類似 ECMAScript3 或者 ECMAScript5 (也就是 ES3 或者 ES5)這樣較低版本的程式碼。類似這樣將更新或者“更高”版本的 ECMAScript 向下降級為更舊或者“更低”版本的程式碼,就是所謂的“降級”。

預設情況下,TypeScript 會轉化為 ES3 程式碼,這是一個非常舊的 ECMAScript 版本。我們可以使用 target 選項將程式碼往較新的 ECMAScript 版本轉換。通過使用 --target es2015 引數,我們可以得到 ECMAScript2015 版本的目的碼,這意味著這些程式碼能夠在支援 ECMAScript2015 的環境中執行。因此,執行 tsc --target es2015 hello.ts 之後,我們會得到如下程式碼:

function greet(person, date) {
  console.log(`Hello ${person}, today is ${date.toDateString()}!`);
}
greet("Maddison", new Date());
雖然預設的目的碼採用的是 ES3 語法,但現在瀏覽器大多數都已經支援 ES2015 了。所以,開發者可以安全地指定目的碼採用 ES2015 或者是更高的 ES 版本,除非你需要著重相容某些古老的瀏覽器。

嚴格性

不同的使用者會由於不同的理由去選擇使用 TypeScript 的型別檢查器。一些使用者尋求的是一種更加鬆散、可選的開發體驗,他們希望型別檢查僅作用於部分程式碼,同時還可享受 TypeScript 提供的功能。這也是 TypeScript 預設提供的開發體驗,型別是可選的,推斷會使用最鬆散的型別,對於潛在的 null/undefined 型別的值也不會進行檢查。就像 tsc 在編譯報錯的情況下仍然能夠正常產出檔案一樣,這些預設的配置會確保不對你的開發過程造成阻礙。如果你正在遷移現有的 JavaScript 程式碼,那麼這樣的配置可能剛好適合。

另一方面,大多數的使用者更希望 TypeScript 可以快速地、儘可能多地檢查程式碼,這也是這門語言提供了嚴格性設定的原因。這些嚴格性設定將靜態的型別檢查從一種切換開關的模式(對於你的程式碼,要麼全部進行檢查,要麼完全不檢查)轉換為接近於刻度盤那樣的模式。你越是轉動它,TypeScript 就會為你檢查越多東西。這可能需要額外的工作,但從長遠來看,這是值得的,它可以帶來更徹底的檢查以及更精細的工具。如果可能,新專案應該始終啟用這些嚴格性配置。

TypeScript 有幾個和型別檢查相關的嚴格性設定,它們可以隨時開啟或關閉,如若沒有特殊說明,我們文件中的例子都是在開啟所有嚴格性設定的情況下執行的。CLI 中的 strict 配置項,或者 tsconfig.json 中的 "strict: true" 配置項,可以一次性開啟全部嚴格性設定。當然,我們也可以單獨開啟或者關閉某個設定。在所有這些設定中,尤其需要關注的是 noImplicitAnystrictNullChecks

noImplicitAny

回想一下,在前面的某些例子中,TypeScript 沒有為我們進行型別推斷,這時候變數會採用最寬泛的型別:any。這並不是一件最糟糕的事情 —— 畢竟,使用 any 型別基本就和純 JavaScript 一樣了。

但是,使用 any 通常會和使用 TypeScript 的目的相違背。你的程式使用越多的型別,那麼在驗證和工具上你的收益就越多,這意味著在編碼的時候你會遇到越少的 bug。啟用 noImplicitAny 配置項,在遇到被隱式推斷為 any 型別的變數時就會丟擲一個錯誤。

strictNullChecks

預設情況下,nullundefined 可以被賦值給其它任意型別。這會讓你的編碼更加容易,但世界上無數多的 bug 正是由於忘記處理 nullundefined 導致的 —— 有時候它甚至會帶來數十億美元的損失strictNullChecks 配置項讓處理nullundefined 的過程更加明顯,會讓我們時刻留意自己是否忘記處理 nullundefined

相關文章