TypeScript 前端工程最佳實踐

2022-12-29 12:01:21

作者:王春雨

前言

隨著前端工程化的快速發展, TypeScript 變得越來越受歡迎,它已經成為前端開發人員必備技能。 TypeScript 最初是由微軟開發並開源的一種程式語言,自2012年10月釋出首個公開版本以來,它已得到了人們的廣泛認可。TypeScript 發展至今,已經成為很多大型專案的標配,其提供的靜態型別系統,大大增強了程式碼的可讀性、可維護性和程式碼質量。同時,它提供最新的JavaScript特性,能讓我們構建更加健壯的元件,新版本不斷迭代更新,編寫前端程式碼也越來越香。

typescript 下載量變化趨勢(來自於 npm trends)

1 為什麼使用 TypeScript

微軟提出 TypeScript 主要是為了實現兩個目標:為 JavaScript 提供可選的型別系統,相容當前及未來的 JavaScript 特性。首先型別系統能夠提高程式碼的質量和可維護性,國內外大型團隊經過不斷實踐後得出一些結論:

  • 型別有利於程式碼的重構,它有利於編譯器在編譯時而不是執行時發現錯誤;
  • 型別是出色的檔案形式之一,良好的函數宣告勝過冗長的程式碼註釋,通過宣告即可知道具體的實現;

像其他語言都有型別的存在,如果強加於 JavaScript 之上,型別可能會有一些不必要的複雜性,而 TypeScript 在兩者之間做了折中處理儘可能地降低了入門門檻,它使 JavaScript 即 TypeScript ,為 JavaScript 提供了編譯時的型別安全。TypeScript 型別完全是可選的,原來的 .js 檔案可以直接被重新命名為 .ts ,ts 檔案可以被編譯成標準的 JavaScript 程式碼,並保證編譯後的程式碼全部相容,它也被成為 JavaScript 的 「超集」。沒有型別的 JavaScript 語法雖然簡單靈活,使用的變數是弱型別,但是比較難以掌握,TypeScript 提供的靜態型別檢查,很好的彌補了 JavaScript 的不足。

TypeScript 型別可以是隱式的也可以是顯式的,它會盡可能安全地推斷型別,以便在程式碼開發過程中以極小的成本為你提供型別安全,也可以使用顯式的宣告型別註解讓編譯器編譯出我們想要的內容,更重要的是為下一個必須閱讀程式碼的開發人員理解程式碼邏輯。

型別錯誤也不會阻止JavaScript 的正常執行,為了方便把 JavaScript 程式碼遷移到 TypeScript,即使存在編譯錯誤,TypeScript 也會被編譯出完整的 JavaScript 程式碼,這與其他語言的編譯器工作方式有很大不同,這也正是 TypeScript 被青睞的另一個原因。

TypeScript 的特點還有很多比如下面這些:

  1. 免費開源,使用 Apache 授權協定;
  2. 基於ECMAScript 標準進行拓展,是 JavaScript 的超集;
  3. 新增了可選靜態型別、類和模組;
  4. 可以編譯為可讀的、符合ECMAScript 規範的 JavaScript;
  5. 成為一款跨平臺的工具,支援所有的瀏覽器、主機和作業系統;
  6. 保證可以與 JavaScript 程式碼一起使用,無須修改(這一點保證了 JavaScript 專案可以向 TypeScript 平滑遷移);
  7. 副檔名是 ts/tsx;
  8. 編譯時檢查,不汙染執行時;

總的來說我們沒有理由不使用 TypeScript, 因為 JavaScript 就是 TypeScript,TypeScript 可以讓 JavaScript 更美好。

2 開始使用 TypeScript

2.1 安裝 TypeScript 依賴環境

TypeScript 開發環境搭建非常簡單,大部分前端工程都整合了 TypeScript 只需安裝依賴增加設定即可。所有前端專案都離不開 NodeJS 和 npm 工具,npm 命令安裝 TypeScript,通常TypeScript 自帶的 tsc 並不能直接執行TypeScript 程式碼,因此我們還會安裝 TypeScript 的執行時 ts-node:

npm install --save-dev typescript ts-node

2.1.1 整合 Babel

前端工程大都離不開 Babel ,我們需要將 TypScript 和 Babel 結合使用,TypeScript 編譯器負責對程式碼進行靜態型別檢查,Babel 負責將TypeScript 程式碼轉譯為可以執行的 JavaScript 程式碼:

Babel 與 TypeScript 結合的關鍵依賴 @babel/preset-typescript,它提供了從 TypeScript 程式碼中移除型別相關程式碼(如,型別註解,介面,型別檔案等),並在 babel.config.js 檔案新增設定選項:

npm install -D @babel/preset-typescript

// babel.config.js
{
"presets": [
// ...
"@babel/preset-typescript"
]
}

2.1.2 整合 ESlint

程式碼檢查是專案的重要組成部分,TypeScript 自身的約束相對簡單隻可以發現一些程式碼錯誤並不會幫助我們統一程式碼風格,當專案越來越龐大,開發人員越來越多時,程式碼風格的約束還是必不可少的。我們可以藉助 ESLint對程式碼風格進行約束,為了讓 eslint 來解析 TypeScript 程式碼我們需要安裝解析器 @typescript-eslint/parser 和 外掛 @typescript-eslint/eslint-plugin:

npm install --save-dev @typescript-eslint/parser @typescript-eslint/eslint-plugin

注意: @typescript-eslint/parser 和 @typescript-eslint/eslint-plugin 必須使用相同的版本
在 .eslintrc.js 組態檔中新增選項:

 "parser": "@typescript-eslint/parser",
      "plugins": ["@typescript-eslint"],


// 可以直接啟用推薦的規則
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended"
]
// 也可以選擇自定義規則
"rules": {
"@typescript-eslint/no-use-before-define": "error",
// ...
}

自定義規則選項具體解讀:

2.2 設定 TypeScript

TypeScript 本身提供了只使用引數在命令列編譯 TypeScript 檔案,但是在實際專案開發時我們都會使用 tsconfig.json ,如果專案中沒有此檔案,可以手動建立也可以使用命令列建立(tsc —init)。使用 TypeScript 初期僅需要一份預設的 tsconfig.json 即可,它包含了一下基本的編譯選項相關資訊,當我們需要客製化編譯選項時就需要去了解每一項具體的含義,編譯選項解讀如下:

2.嚴格的型別檢查選項:

  • strict: 是否啟用嚴格型別檢查選項,可選 ture | false
  • allowUnreachableCode: 是否允許不可達的程式碼出現,可選 ture | false
  • allowUnusedLabels: 是否報告未使用的標籤錯誤,可選 ture | false
  • noImplicitAny: 當在表示式和宣告上有隱式的 any 時是否報錯,可選 ture | false
  • strictNullChecks: 是否啟用嚴格的 null 檢查,可選 ture | false
  • noImplicitThis: 當 this 表示式的值為 any 時,生成一個錯誤,可選 ture | false
  • alwaysStrict: 是否以嚴格模式檢查每個模組,並在每個檔案里加入 use strict,可選 ture | false
  • noImplicitReturns: 當函數有的分支沒有返回值時是否會報錯,可選 ture | false
  • noFallthroughCasesInSwitch: 表示是否報告 switch 語句的 case 分支落空(fallthrough)錯誤;

3.模組解析選項:

  • moduleResolution: 模組解析策略預設為 node 比較通用的一種方式基
  • commonjs 模組標準,另一種是 classic 適用於其他 module 標準,如 amd、 umd、 esnext 等等
  • baseUrl: 「./「 用於解析非相對模組名稱的根目錄
  • paths: 模組名到基於 baseUrl 的路徑對映的列表,格式 {}
  • rootDirs: 根資料夾列表,其做好內容表示專案執行時的結果內容,格式 []
  • typeRoots: 包含型別宣告的檔案列表,格式 [「./types」] ,相對於組態檔的路徑解析;
  • allowSyntheticDefaultImports: 是否允許從沒有設定預設匯出的模組中預設匯入

4.Source Map 選項:

  • sourceRoot: ./ 指定偵錯程式應該找到 TypeScript 檔案而不是原始檔的位置
  • mapRoot: ./ 指定偵錯程式應該找到對映檔案而不是生成檔案的位置
  • inlineSourceMap: 是否生成單個 sourceMap 檔案,不是將 sourceMap 生成不同的檔案
  • inlineSources: 是否將程式碼與 sourceMap 生成到一個檔案中,要求同時設定 inlineSourceMap 和 sourceMap 屬性

5.其它選項:

  • experimentalDecorators: 是否啟用裝飾器
  • emitDecoratorMetadata: 是否為裝飾器提供後設資料的支援

6.還可以使用include 和 exclude 選項來指定編譯器需要和不需要編譯的檔案,一般增加必要的 exclude 檔案會提升編譯效能:

 "exclude": [
    "node_modules",
    "dist"
...
  ],

2.3 TypeScript 型別註解

熟悉了 TypeScript 的相關設定,再來看一看 TypeScript 提供的基本型別,下圖是與 ES6 型別的對比:


圖中藍色的為基本型別,紅色為 TypeScript 支援的特殊型別

TypeScript 的型別註解相當於其它語言的型別宣告,可以使用 let 和 const 宣告一個變數,語法如下:

// let 或 const 變數名:資料型別 = 初始值;
//例如:
let varName: string = 'hello typescript'

函數宣告,推薦使用函數表示式,也可以使用箭頭函數顯得更簡潔一下:

let 或 const 函數表示式名 = function(引數1:型別,引數2:型別):型別{
// 執行程式碼
// return xx;
}
// 例如
let sum = function(num1: number, num2: number): number {
return num1 + num2;
}

2.4 TypeScript 特殊型別介紹

typescript 基本型別的用法和其它後端語言類似在這裡不進行詳細介紹,TypeScript 還提供了一些其它語言沒有的特殊型別在使用過程中有很多需要注意的地方。

2.4.1 any 任意值

any 在 TypeScript 型別系統中佔有特殊的地位。它為我們提供了一個型別系統的「後門」,TypeScript 會把型別檢查關閉,它能夠相容所有的型別,因此所有型別都能被賦值給它。但我們必須減少對它的依賴,因為需要確保型別安全,除非必須使用它才能解決問題,當使用 any 時,基本上是在告訴 TypeScript 編譯器不用進行任何型別檢查。
任意值型別和 Object 有相似的作用,但是 Object 型別的變數只允許給它賦值不同型別的值,但是卻不能在它上面呼叫方法,即便真有這些方法:

2.4.2 void、null 和 undefined

空值(void)、null 和 undefined 這幾個值類似,在使用的過程中很容易混淆,以下依次進行說明:

  • 空值 void 表示不返回任何值,一般用於函數定義返回型別時使用,用 void 關鍵字表示沒有任何返回值的函數,void 型別的變數只能賦值為 null 和 undefined,不能賦值給其他型別上(除了 any 型別以外);
  • null 表示不存在的物件值,一般只當作值來用,而不是當作型別使用;
  • undefined 表示變數已經宣告但是尚未初始化的變數的值,undefined 通常也是當作值來使用;
    null 和 undefined 是所有型別的子型別,我們可以把 null 和 undefined 賦值給任何型別的變數。如果開啟了 strictNullChecks 設定,那麼 null 和 undefined 只能賦值給 void 和它們自身,這能避免很多常見的問題。

2.4.3 列舉

TypeScript 語言支援列舉型別,它是對JavaScript 標準資料型別的一個補充。列舉取值被限定在一定範圍內的場景,在實際開發中有很多場景都適合用列舉來表示,列舉型別可以為一組資料賦予更加友好的名稱,從而提升程式碼的可讀性,使用 enum 關鍵字來定義:

enum SendType {
SEND_NORMAL,
SEND_BATCH,
SEND_FRESH,
...
}
console.log(SendType.SEND_NORMAL === 0) // true
console.log(SendType.SEND_BATCH === 1) // true
console.log(SendType.SEND_FRESH === 2) // true

一般列舉的宣告都採用首字母大寫或者全部大寫的方式,預設列舉值是從 0 開始編號。也可以手動編號為數值型或者字串型別:

// 數值列舉
enum SendType {
SEND_NORMAL = 1,
SEND_BATCH = 2,
SEND_FRESH,  // 按以上規則自動賦值為 3
...
}
const sendtypeVal =  SendType.SEND_BATCH; 

// 編譯後輸出程式碼
var SendType;
(function (SendType) {
    SendType[SendType["SEND_NORMAL"] = 1] = "SEND_NORMAL";
    SendType[SendType["SEND_BATCH"] = 2] = "SEND_BATCH";
    SendType[SendType["SEND_FRESH"] = 3] = "SEND_FRESH"; // 按以上規則自動賦值為 3
})(SendType || (SendType = {}));

var sendtypeVal =  SendType.SEND_BATCH; 

// 字串列舉
enum PRODUCT_CODE {
  P1 = 'ed-m-0001', // 特惠送
  P2 = 'ed-m-0002', // 特快送
  P4 = 'ed-m-0003', // 同城即日
  P5 = 'ed-m-0006', // 特瞬送城際
}

這樣寫法編譯後的常數程式碼比較冗長,而且在執行時 sendtypeVal 的取值不變,將會查詢變數 SendType 和 SendType.SEND_BATCH。我們還有一個可以使程式碼更簡潔且能獲得效能提升的小技巧那就是使用常數列舉(const enum)。

// 使用常數列舉編譯前
const enum SendType {
    SEND_NORMAL = 1,
    SEND_BATCH = 2,
    SEND_FRESH  // 按以上規則自動賦值為 3
}

const sendtypeVal =  SendType.SEND_BATCH;

// 編譯後
var sendtypeVal = 2 /* SendType.SEND_BATCH */;

2.4.4 never 型別

大多數情況我們並不需要手動定義 never 型別,只有在寫一些非常複雜的型別和型別工具方法,或者為一個庫定義型別等情況下才需要用到它,never 型別一般出現在函數丟擲異常或存在無法正常結束的情況下。

2.4.5 元組型別

元組型別的宣告和陣列比較類似,只是元組中的各個元素型別可以不同。簡單範例如下:

// 元祖範例
let row: [number, string, number] = [1, 'hello', 88];

2.4.6 介面 interface

介面是 TypeScript 的一個核心概念,它能將多個型別宣告組合成一個型別註解:

interface CountDown {
readonly uuid: string // 唯讀屬性
  time: number
  autoStart: boolean
  format: string
value: string | number // 聯合型別,支援字串和數值型
[key: string]: number // 字串的鍵,數值型的值
}
interface CountDown {
  finish?: () => void // 可選型別
  millisecond?: boolean // 可選方法
}
// 介面可以重複宣告,多次宣告可以合併為一個介面

介面可以繼承其它型別物件,相當於將繼承的物件型別複製到當前介面:

interface Style {
color: string
}
interface: Shape {
name: string
}
interface: Circle extends Style, Shape {
radius: number

// 還會包含繼承的屬性
// color: string
// name: string
}
const circle: Circle = { // 包含 3 個屬性
radius: 1,
color: 'red',
name: 'circle'
}

如果子介面與父介面之間存在同名的型別成員,那麼子介面中的型別成員具有更高優先順序。

2.4.7 型別別名 type

TypeScript 提供了為型別註解設定別名的便捷方法——型別別名,型別別名就是可以給一個型別起一個新名字。在 TypeScript 中使用關鍵字 type 來描述型別變數:

type StrOrNum = string | number
// 用法和其它基本型別一樣
let sample: StrOrNum
sample = 123
sample = '123'
sample = true // 錯誤

與介面區別,我們可以為任意型別註解設定別名,這在聯合型別和交叉型別中比較實用,下面是一些常用方法

type Text = string | { text: string } // 聯合型別

type Coordinates = [number, number] // 元組型別
type Callback = (data: string) => void // 函數型別

type Shape = { name: string } // 物件型別
type Circle = Shape & { radius: number} // 交叉型別,包含了 name 和 radius 屬性

如果需要使用型別註解的層次結構,請使用介面,它能使用implements 和 extends。為一個簡單的物件型別使用型別別名,只需要給它一個語意化的名字即可。另外,想給聯合型別和交叉型別提供一個語意化的別名時,使用型別別名更加合適而不是用介面。型別別名與介面的區別如下:

  1. 型別別名能夠表示非物件型別,介面則只能表示物件型別,因此我們想要表示原始型別、聯合型別和交叉型別時只能使用型別別名;
  2. 型別別名不支援繼承,介面可以繼承其它介面、類等物件型別,型別別名可以藉助交叉型別來實現繼承的效果;
  3. 介面名總是會顯示在編譯器的診斷資訊和程式碼編輯器的智慧提示資訊中,而型別別名的名字只在特定情況下顯示;
  4. 介面具有宣告合併的行為,而型別別名不會進行宣告合併;

2.4.8 名稱空間 namespace

隨著專案越來越複雜,我們需要一種手段來組織程式碼,以便於在記錄它們型別的同時還不用擔心與其它物件產生命名衝突。因此我們把一些程式碼放到一個名稱空間內,而不是把它們放到全域性名稱空間下。現實生活中,一個學校裡經常會出現同名同姓的同學,如果在不同班裡,就可以用班級名+姓名來區分。其實名稱空間與班級名的作用一樣,可以防止同名的函數和變數相互影響。
TypeScript 中名稱空間使用 namespace 關鍵字來定義,基本語法格式:

namespace 名稱空間名 {
const 私有變數; 
export interface 介面名;
export class 類名;
}
// 如果需要在名稱空間外部呼叫需要新增 export 關鍵字
名稱空間名.介面名;
名稱空間名.類名;
名稱空間名.私有變數; // 錯誤,私有變數不允許存取

在構建比較複雜的應用時,往往需要將程式碼分離到不同的檔案中,以便進行維護,同一個名稱空間可以出現在多個檔案中。儘管是不同的檔案,但是它們依然是同一個名稱空間,使用時就如同它們在一個檔案中定義的一樣。

// 多檔案名稱空間
// Validation.ts
namespace Validation {
export interface StringValidator {
isAcceptable(s: string): boolean;
}
}

// NumberValidator.ts
namespace Validation { // 相同名稱空間
export interface NumberValidator {
isAcceptable(num: number): boolean;
}
}

2.4.9 泛型

TypeScript 設計泛型的關鍵動機是在成員之間提供有意義的型別約束,這些成員可以是類的範例成員、類的方法、函數的引數、函數的返回值。使用泛型,可以將相同的程式碼用於不同的型別(語法:一般在類名、方法名的後面加上<泛型> ),一個佇列的簡單實現與泛型的範例:

class Queue {
private data = []
push = item => this.data.push(item)
pop = () => this.data.shift()
}

const queue = new Queue()
// 在沒有約束的情況下,開發人員很可能進入誤區,導致執行時錯誤(或潛在問題)
queue.push(0) // 最初是數值型別
queue.push('1') // 有人新增了字串型別

// 使用過程中,走入了誤區
console.log(queue.pop().toPrecision(1));
console.log(queue.pop().toPrecision(1)); // 執行時錯誤

一個解決辦法可以解決以上問題:

class QueueOfNumber {
private data: number[] = []
push = (item: number) => this.data.push(item)
pop = (): number => this.data.shift()
}
const queue = new Queue()

queue.push(0) 
queue.push('1') // 錯誤,不能放入一個 字串型別 的資料

這麼做如果需要一個字串的佇列,怎麼辦?需要重寫一遍類似的程式碼?這時就可以用到泛型,可以讓放入的型別和取出的型別一樣:

class Queue<T> {
private data: T[] = []
push = (item: T) => this.data.push(item)
pop = (): T | undefined => this.data.shift()
}
// 數值型別
const queue = new Queue<number>()
queue.push(0) 
queue.push(1) 
// 或者 字串型別
const queue = new Queue<string>()
queue.push('0')
queue.push('1')

我們可以隨意指定泛型的引數型別,一般使用簡單的泛型時,常用 T、U、V 表示。如果在我們的引數裡,擁有不止一個泛型,就應該使用更加語意化的名稱,如 TKey 和 TValue。依照慣例,以 T 作為泛型的字首,在其它語言已經是約定俗成的方式了。

2.4.10 型別斷言

TypeScript 程式中的每一個表示式都具有某種型別,編譯器可以通過型別註解或型別推導來確定表示式型別,但有時,開發者比編譯器更清楚某個表示式的型別,因此就需要用到型別斷言,型別斷言(Type Assertion) 可以用來手動指定一個值的型別,告訴編譯器應該是什麼型別,具體語法如下:

  • expr(<目標型別>值、物件或者表示式);
  • expr as T (值或者物件 as 型別);
  • expr as const 或 expr 可以將某型別強制轉換成不可變型別;
  • expr!(!型別斷言):非空型別斷言運運算元 「!」 是 TypeScript 特有的型別運運算元;
type AddressVO = { address: string }
(<AddressVO>sendAddress).address // <T> 型別斷言
(sendAddress as AddressVO).address // as 型別斷言

let val = true as const // 等於 const val = true
function getParams(router: { params: Array<string> } | undefined) {
if(!router) return ''

return router!.params // 告訴編譯器 router 是非空的
}

3 深入 TypeScript 泛型程式設計

泛型程式設計是一種程式設計風格或者程式設計正規化,它允許在程式中定義形式型別引數,然後在泛型範例化時使用實際型別引數來替換形式型別引數。剛開始進行 TypeScript 開發時,我們很容易重複的編寫程式碼,通過泛型,我們能夠定義更加通用的資料結構和型別。許多程式語言都很流行物件導向程式設計,可以建立公共介面的類並隱藏實現細節,讓類之間進行互動,可以有效管理複雜度對複雜領域分而治之。但是對於前端來說泛型程式設計可以更好的解耦、元件化和可複用。接下來使用泛型處理一種常見的需求:通過範例建立獨立的、可重用的元件。

3.1 解耦關注點

我們需要一個 getNumbers 函數返回一個數位陣列,允許在返回陣列之前對每一項數位應用一個變換處理常式,該函數接收一個數位返回一個新數位。如果呼叫者不需要任何處理,可以將只返回其結果的函數作為預設值。

type TransformFunction = (value: number) => number

function doNothing(value: number): number ( // doNothing() 只返回原資料,不進行任何處理
  return value
)

function getNumbers(transform: TransformFunction = doNothing): number[] {
    /** */
}

又出現另一種業務場景,有一個 Widget 物件陣列,可以從 WidgetWidget 物件建立一個 AssembledWidget 物件。assembleWidgets() 函數處理一個 Widget 物件陣列,並返回一個 AssembledWidget 物件陣列。因為我們不想做不必要的封裝,所以 assembleWidgets() 將一個 pluck() 函數作為實參,給定一個 Widget 物件陣列時,pluck() 返回該陣列的一個子集。允許呼叫者告訴函數需要哪些欄位,從而忽略其餘欄位。

type PluckFunction = (widgets: Widget) => Widget[]

function pluckAll(widgets:  Widget[]):  Widget[] ( 
// pluckAll() 返回全部,不進行任何處理
  return widgets
)

// 如果使用者沒有提供 pluck() 函數,則返回 pluckAll 作為實參的預設值
function assembleWidgets(pluck: PluckFunction = pluckAll): AssembledWidget[] {
    /** */
}

仔細觀察可以兩處程式碼都有相似之處,doNothing() 和 pluckAll() 它們都接收一個引數,並不做處理就返回。它們的區別只是接收和返回的值型別不同:doNothing 使用數位,pluckAll 使用 Widget 物件數位,兩個函數都是恆等函數。在代數中恆等函數指的是 f(x) = x。在實際開發中這種恆等函數會有很多,出現在各處,我們需要編寫一個可重用的恆等函數來簡化程式碼,使用 any 型別是不安全的它會繞過正常的型別檢查,這時我們就可以使用泛型恆等函數:

function identity<T>(value: T):  T ( // 有一個型別引數 T 的泛型恆等函數
  return value
)
// 可以使用 identity 代替 doNothing 和 pluckAll

採用這種實現方式,可以將恆等邏輯與實際業務邏輯問題進行更好的解耦,恆等邏輯可以完全獨立出來。這個恆等函數的型別引數是 T,當為 T 指定了實際型別時,就建立了具體的函數。

泛型型別:是指引數化一個或多個型別的泛型函數、類、介面等。泛型型別允許我們編寫能夠支援不同型別的通用程式碼,從而實現高度的程式碼重用。使用泛型讓程式碼的元件化程度更高,我們可以把這些泛型元件用作基本模組,通過組合它們實現期望的行為,同時在元件之間只保留下最小限度的依賴。

3.2 泛型資料結構

假如我們要實現一個數值二元樹和字串連結串列。把二元樹實現為一個或多個結點,每個結點儲存一個數值,並參照其左側和右側的子結點,這些參照指向結點,如果沒有子結點,可以指向 undefined。

class NumberBinaryTreeNode {
  value: number
  left: NumberBinaryTreeNode | undefined
  right: NumberBinaryTreeNode | undefined

  constructor(value: number) {
    this.value = value
  }
}

類似地,我們實現連結串列為一個或多個結點,每個結點儲存一個 string 和對下一個結點的參照,如果沒有下一個結點,參照就指向 undefined。

class StringLinkedListNode {
  value: string
  next: StringLinkedListNode | undefined

  constructor(value: string) {
    this.value = value
  }
}

如果工程的其它部分需要一個字串二元樹或者數值列表我們可以簡單的複製程式碼,然後替換幾個地方,複製從來不是一個好選擇,如果原來的程式碼有Bug,很可能會忘記在複製的版本中修復 Bug。我們可以使用泛型來避免複製程式碼。
我們可以實現一個泛型的 NumberTreeNode,使其可用於任何型別:

class BinaryTreeNode<T> {
  value: T
  left: BinaryTreeNode<T> | undefined
  right: BinaryTreeNode<T> | undefined

  constructor(value: T) {
    this.value = value
  }
}

實際我們不應該等待有字串二元樹的新需求才建立泛型二元樹:原始的 NumberBinaryTreeNode 實現在二元樹資料結構和型別 number 之間產生了不必要的耦合。同樣,我們也可以把字串連結串列替換成泛型的 LinkedListNode:

class LinkedListNode<T> {
  value: string
  next: LinkedListNode | undefined

  constructor(value: string) {
    this.value = value
  }
}

我們要知道,有很成熟的庫已經提供了所需的大部分資料結構(如列表、佇列、棧、集合、字典等)。介紹實現,只是為了更好的理解泛型,在真實專案中最好不要自己編寫程式碼,可以從庫中選擇泛型資料結構,去閱讀庫中泛型資料結構的程式碼更有助於提升我們的編碼能力。一個可以迭代的泛型連結串列完整實現供參考如下:

type IteratorResult<T> = {
  done: boolean
  value: T
}

interface Iterator<T> {
  next(): IteratorResult<T>
}

interface IterableIterator<T> extends Iterator<T> {
    [Symbol.iterator](): IterableIterator<T>;
}

function* linkedListIterator<T>(head: LinkedListNode): IterableIterator<T> {
  let current: LinkedListNode<T> | undefined = head
  while (current) {
    yield current.value // 在遍歷連結串列過程中,交出每個值
    current = current.next
  }
}

class LinkedListNode<T> implements Iterable<T> {
  value: T
  next: LinkedListNode<T> | undefined

  constructor(value: T) {
    this.value = value
  }

  // Symbol.iterator 是 TypeScript 特有語法,預示著當前物件可以使用 for ... of 遍歷
  [Symbol.iterator](): Iterator<T> { 
    return linkedListIterator(this)
  }
}

我們使用了生成器在遍歷資料結構的過程中會交出值,所以使用它能夠簡化遍歷程式碼。生成器返回一個 IterableIterator,所以我們可以直接在 for … of 迴圈中使用。
以上對泛型程式設計的介紹只是鳳毛菱角,其實泛型程式設計支援極為強大的抽象和程式碼可重用性,使用正確的抽象時,我們可以寫出簡潔、高效能、容易閱讀且優雅的程式碼。

4 TypeScript 註釋指令

4.1 常用註釋指令

TypeScript 編譯器可以通過編譯選項設定對所有 .ts 和 .tsx 檔案進行型別檢查。但是在實際開發中有些程式碼可能無法避免檢查錯誤,因此 TypeScript 提供了一些註釋指令來忽略或者檢查某個JavaScript 檔案或者程式碼片段:

  • // @ts-nocheck: 為某個檔案新增這個註釋,就相當於告訴編譯器不對該檔案進行型別檢查。即使存在錯誤,編譯器也不會報錯;
  • // @ts-check: 與上個註釋相反,可以在某個特定的檔案新增這個註釋指令,告訴編譯器對該檔案進行型別檢查;
  • // @ts-ignore: 註釋指令的作用是忽略對某一行程式碼進行型別檢查,編譯器進行型別檢查時會跳過指令相鄰的下一行程式碼;4.2 JSDoc 與型別JSDoc 是一款知名的為 JavaScript 程式碼新增檔案註釋的工具,JSDoc 利用 JavaScript 語言中的多行註釋結合特殊的「JSDoc 標籤」來為程式碼新增豐富的描述資訊。
    TypeScript 編譯器可以自動推斷出大部分程式碼的型別資訊,也能從 JSDoc 中提取型別資訊,以下是TypeScript 編譯器支援的部分 JSDoc 標籤:
  • @typedef 標籤能夠建立自定義型別;
  • @type 標籤能夠定義變數型別;
  • @param 標籤用於定義函數引數型別;
  • @return 和 @returns 標籤作用相同,都用於定義函數返回值型別;
  • @extends 標籤定義繼承的基礎類別;
  • @public @protected @private 標籤分別定義類的公共成員、受保護成員和私有成員;
  • @readonly 標籤定義唯讀成員;

4.3 三斜線指令

三斜線指令是一系列指令的統稱,它是從 TypeScript 早期版本就開始支援的編譯指令。目前,已經不推薦繼續使用三斜線指令了,因為可以使用模組來取代它的大部分功能。簡單瞭解一下即可,它以三條斜線開始,幷包含一個XML標籤,有幾種不同的語法:

5 TypeScript 內建工具型別

TypeScript 提供了很多內建的工具型別根據不同的應用場景選擇合適的工具可以減輕很多工作,減少冗餘程式碼提升程式碼質量,下面列舉了一些常用的工具:

  • Partial:構造一個新型別,並將型別 T 的所有屬性變為可選屬性;
  • Required:構造一個新型別,並將型別 T 的所有屬性變為必選屬性;
  • Readonly: 構造一個新型別,並將型別 T 的所有屬性變為唯讀屬性;
  • Pick: 已有物件型別中選取給定的屬性名,返回一個新的物件型別;
  • Omit: 從已有物件型別中剔除給定的屬性名,返回一個新的物件型別;
    範例程式碼:
interface A {
  x: number
  y: number
  z?: string
}
type T0 = Partial<A>
// 等價於 
type T0 = {
    x?: number | undefined;
    y?: number | undefined;
    z?: string | undefined;
}

type T1 = Required<A>
// 等價於
type T1 = {
    x: number;
    y: number;
    z: string;
}

type T2 = Readonly<A>
// 等價於
type T2 = {
    readonly x: number;
    readonly y: number;
    readonly z?: string | undefined;
}

type T3 = Pick<A, 'x'>
// 等價於
type T3 = {
    x: number;
}

type T4 = Omit<A, 'x'>
// 等價於
type T4 = {
    y: number;
    z?: string | undefined;
}

6 TypeScript 提效工具

6.1 TypeScript 演練場

TypeScript 開發團隊提供了一款非常實用的線上程式碼編輯工具——TypeScript 演練場
地址:https://www.typescriptlang.org/zh/play

  • 左側編寫 TS 程式碼,右側自動生成編譯後的程式碼;
  • 可以自主選擇 TypeScript 編譯版本;
  • 版本列表最後一項是一個特殊版本 「Nightly」 即 「每日構建版本」,想嘗試最新功能可以試試;
  • 支援 TypeScript 大部分設定項和編譯選項,可以模擬本地環境,檢視程式碼片段的輸出結果;

6.2 JSDoc Generator 外掛

如果使用的是 vscode 編輯器直接搜尋( JSDoc Generator 外掛)外掛地址:https://marketplace.visualstudio.com/items?itemName=crystal-spider.jsdoc-generator 安裝成功後,使用 Ctrl + Shift + P 開啟命令面板,可以進行如下操作可以自動生成帶有 TypeScript 宣告型別的檔案註釋:

  • 選擇 Generate JSDoc 為當前遊標處程式碼生成檔案註釋;

  • 選擇Generate JSDoc for the current file 為當前檔案生成檔案註釋;

    6.3 程式碼格式化工具
    VSCode 僅提供了基本的格式化功能,如果需要客製化更加詳細的格式化規則可以安裝專用的外掛來實現。我們使用 Prettier 功能非常強大(推薦使用),它是目前最流行的格式化工具: https://prettier.io/,同時也提供了一個線上編輯器:https://prettier.io/playground/6.4 模組匯入自動歸類和排序在多人共同作業開發時程式碼越來越複雜,一個檔案需要匯入很多模組,每個人都會加加著加著就有點亂了,絕對路徑的、相對路徑的,自定義模組、公用模組順序和類別都是混亂的,模組匯入過多還會出現重複的。引入 TypeScript 之後檢查更加嚴格,匯入的不規範會有錯誤提示,如果只靠手動優化工作量大且容易出錯。VSCode 編輯器提供了按字母順序自動排序和歸類匯入語句的功能,直接按下快捷鍵「Shift + Alt + O」即可優化。也可以通過右鍵選單「Source Action」 下的 「Organize Imports」 選項來進行優化匯入語句。6.5 啟用 CodeLens

CodeLens 是一項特別好用的功能,它能夠在程式碼的位置顯示一些可操作項,例如:

  • 顯示函數、類、方法和介面等被參照的次數以及被哪些程式碼參照;
  • 顯示介面被實現的次數以及誰實現了該介面;

VSCode 已經內建了 CodeLens 功能,只需要在設定面板開啟,找到TypeScript 對應的 Code Lens 兩個相關選項並勾選上:

開啟後的效果,出現參照次數,點選 references 位置可以檢視哪裡參照了:

6.6 介面自動生成 TypeScript 型別

對於前端業務開發來說,最頻繁的工作之一就是和介面打交道,前端和介面之間經常出現出入參不一致的情況,後端的介面定義也需要在前端定義相同的型別,大量的型別定義如果都靠手寫不僅工作量大而且容易出錯。因此,我們需要能夠自動生成這些介面型別定義的 TypeScript 程式碼。VSCode 外掛市場就有這樣一款外掛——Paste JSON as Code 。
外掛地址:https://marketplace.visualstudio.com/items?itemName=quicktype.quicktype
安裝這個 VSCode 外掛可以將介面返回的資料,自動轉換成型別定義介面檔案。
1.剪貼簿轉換成型別定義:首先將 JSON 串複製到剪貼簿, Ctrl + Shift + P 找到命令:Paste JSON to Types -> 輸入介面名稱

{"a":1,"b":"2","c":3} // 複製這段 JSON 程式碼

// Generated by https://quicktype.io
export interface Obj {
  a: number;
  b: string;
  c: number;
}

2.JSON 檔案轉換型別定義(這個更常用一些):開啟 JSON 檔案使用Ctrl + Shift + P 找到命令: Open quicktype for JSON。下圖為 package.json 檔案生成型別定義的範例:

對應大量且冗長的介面欄位一鍵生成是不是很方便呢!希望這些工具能給每一位研發帶來幫助提升研發效率。

7 總結

TypeScript 是一個比較複雜的型別系統,本文只是對其基本用法進行了簡要說明和工作中用到的知識點,適合剛開始使用 TypeScript 或者準備使用的研發人員,對於更深層次的架構設計和技術原理並未提及,如果感興趣的可以線下交流。用好 TypeScript 可以編寫出更好、更安全的程式碼希望對讀到本文的有所幫助並能在實際工作中運用。希望本文作為 TypeScript 入門級為讀者做一個良好的開端。感謝閱讀!!