作者:王春雨 前言 隨著前端工程化的快速發展, TypeScript 變得越來越受歡迎,它已經成為前端開發人員必備技能。 TypeScript 最初是由微軟開發並開源的一種編程語言,自2012年10月發佈首個公開版本以來,它已得到了人們的廣泛認可。TypeScript 發展至今,已經成為很多大型項目 ...
作者:王春雨
前言
隨著前端工程化的快速發展, 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 的特點還有很多比如下麵這些:
- 免費開源,使用 Apache 授權協議;
- 基於ECMAScript 標準進行拓展,是 JavaScript 的超集;
- 添加了可選靜態類型、類和模塊;
- 可以編譯為可讀的、符合ECMAScript 規範的 JavaScript;
- 成為一款跨平臺的工具,支持所有的瀏覽器、主機和操作系統;
- 保證可以與 JavaScript 代碼一起使用,無須修改(這一點保證了 JavaScript 項目可以向 TypeScript 平滑遷移);
- 文件擴展名是 ts/tsx;
- 編譯時檢查,不污染運行時;
總的來說我們沒有理由不使用 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。為一個簡單的對象類型使用類型別名,只需要給它一個語義化的名字即可。另外,想給聯合類型和交叉類型提供一個語義化的別名時,使用類型別名更加合適而不是用介面。類型別名與介面的區別如下:
- 類型別名能夠表示非對象類型,介面則只能表示對象類型,因此我們想要表示原始類型、聯合類型和交叉類型時只能使用類型別名;
- 類型別名不支持繼承,介面可以繼承其它介面、類等對象類型,類型別名可以藉助交叉類型來實現繼承的效果;
- 介面名總是會顯示在編譯器的診斷信息和代碼編輯器的智能提示信息中,而類型別名的名字只在特定情況下顯示;
- 介面具有聲明合併的行為,而類型別名不會進行聲明合併;
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 入門級為讀者做一個良好的開端。感謝閱讀!!