作者: 高翔
轉發連結:
前言在 2020 年的今天,TS 已經越來越火,不管是服務端(Node.js),還是前端框架(Angular、Vue3),都有越來越多的專案使用 TS 開發,作為前端程式設計師,TS 已經成為一項必不可少的技能,本文旨在介紹 TS 中的一些高階技巧,提高大家對這門語言更深層次的認知。
Typescript 簡介- ECMAScript 的超集 (stage 3)
- 編譯器的型別檢查
- 不引入額外開銷(零依賴,不擴充套件 js 語法,不侵入執行時)
- 編譯出通用的、易讀的 js 程式碼
Typescript = Type + ECMAScript + Babel-Lite
Typescript 設計目標:
為什麼使用 Typescript- 增加了程式碼的可讀性和可維護性
- 減少執行時錯誤,寫出的程式碼更加安全,減少 BUG
- 享受到程式碼提示帶來的好處
- 重構神器
- boolean
- number
- string
- array
- tuple
- enum
- void
- null & undefined
- any & unknown
- never
- any: 任意型別
- unknown: 未知的型別
任何型別都能分配給 unknown,但 unknown 不能分配給其他基本型別,而 any 啥都能分配和被分配。
let foo: unknownfoo = true // okfoo = 123 //okfoo.toFixed(2) // errorlet foo1: string = foo // error
let bar: anybar = true // okbar = 123 //okfoo.toFixed(2) // oklet bar1:string = bar // ok
可以看到,用了 any 就相當於完全丟失了型別檢查,所以大家儘量少用 any,對於未知型別可以用 unknown。
unknown 的正確用法我們可以透過不同的方式將 unknown 型別縮小為更具體的類型範圍:
function getLen(value: unknown): number { if (typeof value === 'string') { // 因為型別保護的原因,此處 value 被判斷為 string 型別 return value.length } return 0}
這個過程叫型別收窄(type narrowing)。
never 一般表示哪些使用者無法達到的型別。在最新的 typescript 3.7 中,下面程式碼會報錯:
// never 使用者控制流分析function neverReach (): never { throw new Error('an error')}const x = 2neverReach()x.toFixed(2) // x is unreachable
never 還可以用於聯合型別的 么元:
type T0 = string | number | never // T0 is string | number
函式型別幾種函式型別的返回值型別寫法function fn(): number { return 1}const fn = function (): number { return 1}const fn = (): number => { return 1}const obj = { fn (): number { return 1 }}
在 () 後面新增返回值型別即可。
ts 中也有函式型別,用來描述一個函式:
type FnType = (x: number, y: number) => number
完整的函式寫法let myAdd: (x: number, y: number) => number = function(x: number, y: number): number { return x + y}// 使用 FnType 型別let myAdd: FnType = function(x: number, y: number): number { return x + y}// ts 自動推導引數型別let myAdd: FnType = function(x, y) { return x + y}
函式過載?js因為是動態型別,本身不需要支援過載,ts為了保證型別安全,支援了函式簽名的型別過載。即:
多個過載簽名和一個實現簽名
// 過載簽名(函式型別定義)function toString(x: string): string;function toString(x: number): string;// 實現簽名(函式體具體實現)function toString(x: string | number) { return String(x)}let a = toString('hello') // oklet b = toString(2) // oklet c = toString(true) // error
如果定義了過載簽名,則實現簽名對外不可見
function toString(x: string): string;function toString(x: number): string { return String(x)}len(2) // error
實現簽名必須相容過載簽名
function toString(x: string): string;function toString(x: number): string; // error// 函式實現function toString(x: string) { return String(x)}
過載簽名的型別不會合並
// 過載簽名(函式型別定義)function toString(x: string): string;function toString(x: number): string;// 實現簽名(函式體具體實現)function toString(x: string | number) { return String(x)}function stringOrNumber(x): string | number { return x ? '' : 0}// input 是 string 和 number 的聯合型別// 即 string | numberconst input = stringOrNumber(1)toString('hello') // oktoString(2) // oktoString(input) // error
型別推斷ts 中的型別推斷是非常強大,而且其內部實現也是非常複雜的。
基本型別推斷:
// ts 推匯出 x 是 number 型別let x = 10
物件型別推斷:
// ts 推斷出 myObj 的型別:myObj: { x: number; y: string; z: boolean; }const myObj = { x: 1, y: '2', z: true}
函式型別推斷:
// ts 推匯出函式返回值是 number 型別function len (str: string) { return str.length}
上下文型別推斷:
// ts 推匯出 event 是 ProgressEvent 型別const xhr = new XMLHttpRequest()xhr.onload = function (event) {}
所以有時候對於一些簡單的型別可以不用手動宣告其型別,讓 ts 自己去推斷。
typescript 的子型別是基於 結構子型別 的,只要結構可以相容,就是子型別。(Duck Type)
class Point { x: number}function getPointX(point: Point) { return point.x}class Point2 { x: number}let point2 = new Point2()getPointX(point2) // OK
java、c++ 等傳統靜態型別語言是基於 名義子型別 的,必須顯示宣告子型別關係(繼承),才可以相容。
物件的型別子型別中必須包含源型別所有的屬性和方法:
function getPointX(point: { x: number }) { return point.x}const point = { x: 1, y: '2'}getPointX(point) // OK
注意: 如果直接傳入一個物件字面量是會報錯的:
function getPointX(point: { x: number }) { return point.x}getPointX({ x: 1, y: '2' }) // error
這是 ts 中的另一個特性,叫做: excess property check ,當傳入的引數是一個物件字面量時,會進行額外屬性檢查。
函式的型別介紹函式的型別前先介紹一下逆變與協變的概念,逆變與協變並不是 TS 中獨有的概念,在其他靜態語言中也有相關理念。
在介紹之前,先假設一個問題,約定如下標記:
- A ≼ B 表示 A 是 B 的子型別,A 包含 B 的所有屬性和方法。
- A => B 表示以 A 為引數,B 為返回值的方法。(param: A) => B
如果我們現在有三個型別 Animal 、 Dog 、 WangCai(旺財) ,那麼肯定存在下面的關係:
WangCai ≼ Dog ≼ Animal // 即旺財屬於狗屬於動物
問題:以下哪種型別是 Dog => Dog 的子類呢?
- WangCai => WangCai
- WangCai => Animal
- Animal => Animal
- Animal => WangCai
從程式碼來看解答
class Animal { sleep: Function}class Dog extends Animal { // 吠 bark: Function}class WangCai extends Dog { dance: Function}function getDogName (cb: (dog: Dog) => Dog) { const dog = cb(new Dog()) dog.bark()}// 對於入參來說,WangCai 是 Dog 的子類,Dog 類上沒有 dance 方法, 產生異常。// 對於出參來說,WangCai 類繼承了 Dog 類,肯定會有 bark 方法getDogName((wangcai: WangCai) => { wangcai.dance() return new WangCai()})// 對於入參來說,WangCai 是 Dog 的子類,Dog 類上沒有 dance 方法, 產生異常。// 對於出參來說,Animal 類上沒有 bark 方法, 產生異常。getDogName((wangcai: WangCai) => { wangcai.dance() return new Animal()})// 對於入參來說,Animal 類是 Dog 的父類,Dog 類肯定有 sleep 方法。// 對於出參來說,WangCai 類繼承了 Dog 類,肯定會有 bark 方法getDogName((animal: Animal) => { animal.sleep() return new WangCai()})// 對於入參來說,Animal 類是 Dog 的父類,Dog 類肯定有 sleep 方法。// 對於出參來說,Animal 類上沒有 bark 方法, 產生異常。getDogName((animal: Animal) => { animal.sleep() return new Animal()})
可以看到只有 Animal => WangCai 才是 Dog => Dog 的子型別,可以得到一個結論,對於函式型別來說,函式引數的型別相容是反向的,我們稱之為 逆變 ,返回值的型別相容是正向的,稱之為 協變 。
逆變與協變的例子只說明瞭函式引數只有一個時的情況,如果函式引數有多個時該如何區分?
其實函式的引數可以轉化為 Tuple 的型別相容性:
type Tuple1 = [string, number]type Tuple2 = [string, number, boolean]let tuple1: Tuple1 = ['1', 1]let tuple2: Tuple2 = ['1', 1, true]let t1: Tuple1 = tuple2 // oklet t2: Tuple2 = tuple1 // error
可以看到 Tuple2 => Tuple1 ,即長度大的是長度小的子型別,再由於函式引數的逆變特性,所以函式引數少的可以賦值給引數多的(引數從前往後需一一對應),從陣列的 forEach 方法就可以看出來:
[1, 2].forEach((item, index) => { console.log(item)}) // ok[1, 2].forEach((item, index, arr, other) => { console.log(other)}) // error
高階型別聯合型別與交叉型別聯合型別(union type)表示多種型別的 “或” 關係
function genLen(x: string | any[]) { return x.length}genLen('') // okgenLen([]) // okgenLen(1) // error
交叉型別表示多種型別的 “與” 關係
interface Person { name: string age: number}interface Animal { name: string color: string}const x: Person & Animal = { name: 'x', age: 1, color: 'red}
使用聯合型別表示列舉
type Position = 'UP' | 'DOWN' | 'LEFT' | 'RIGHT'const position: Position = 'UP'
可以避免使用 enum 進入了執行時。
ts 初學者很容易寫出下面的程式碼:
function isString (value) { return Object.prototype.toString.call(value) === '[object String]'}function fn (x: string | number) { if (isString(x)) { return x.length // error 型別“string | number”上不存在屬性“length”。 } else { // ..... }}
如何讓 ts 推斷出來上下文的型別呢?
1. 使用 ts 的 is 關鍵詞
function isString (value: unknown): value is string { return Object.prototype.toString.call(value) === '[object String]'}function fn (x: string | number) { if (isString(x)) { return x.length } else { // ..... }}
2. typeof 關鍵詞
在 ts 中,程式碼實現中的 typeof 關鍵詞能夠幫助 ts 判斷出變數的基本型別:
function fn (x: string | number) { if (typeof x === 'string') { // x is string return x.length } else { // x is number // ..... }}
3. instanceof 關鍵詞
在 ts 中,instanceof 關鍵詞能夠幫助 ts 判斷出建構函式的型別:
function fn1 (x: XMLHttpRequest | string) { if (x instanceof XMLHttpRequest) { // x is XMLHttpRequest return x.getAllResponseHeaders() } else { // x is string return x.length }}
4. 針對 null 和 undefined 的型別保護
在條件判斷中,ts 會自動對 null 和 undefined 進行型別保護:
function fn2 (x?: string) { if (x) { return x.length }}
5. 針對 null 和 undefined 的型別斷言
如果我們已經知道的引數不為空,可以使用 ! 來手動標記:
function fn2 (x?: string) { return x!.length}
typeof 關鍵詞typeof 關鍵詞除了做型別保護,還可以從實現推出型別,。
注意:此時的 typeof 是一個型別關鍵詞,只可以用在型別語法中。
function fn(x: string) { return x.length}const obj = { x: 1, y: '2'}type T0 = typeof fn // (x: string) => numbertype T1 = typeof obj // {x: number; y: string }
keyof 關鍵詞keyof 也是一個 型別關鍵詞 ,可以用來取得一個物件介面的所有 key值:
interface Person { name: string age: number}type PersonAttrs = keyof Person // 'name' | 'age'
in 關鍵詞in 也是一個 型別關鍵詞, 可以對聯合型別進行遍歷,只可以用在 type 關鍵詞下面。
type Person = { [key in 'name' | 'age']: number}// { name: number; age: number; }
[ ] 運算子使用 [] 運算子可以進行索引訪問,也是一個 型別關鍵詞
interface Person { name: string age: number}type x = Person['name'] // x is string
一個小栗子寫一個型別複製的型別工具:
type Copy = { [key in keyof T]: T[key]}interface Person { name: string age: number}type Person1 = Copy
泛型泛型相當於一個型別的引數,在 ts 中,泛型可以用在 類、介面、方法、類型別名 等實體中。
小試牛刀function createList(): T[] { return [] as T[]}const numberList = createList() // number[]const stringList = createList() // string[]
有了泛型的支援,createList 方法可以傳入一個型別,返回有型別的陣列,而不是一個 any[]。
如果我們只希望 createList 函式只能生成指定的型別陣列,該如何做,可以使用 extends 關鍵詞來約束泛型的範圍和形狀。
type Lengthwise = { length: number}function createList<T extends number | Lengthwise>(): T[] { return [] as T[]}const numberList = createList() // okconst stringList = createList() // okconst arrayList = createList() // okconst boolList = createList() // error
any[] 是一個數組型別,陣列型別是有 length 屬性的,所以 ok。string 型別也是有 length 屬性的,所以 ok。但是 boolean 就不能透過這個約束了。
extends 除了做約束型別,還可以做條件控制,相當於與一個三元運算子,只不過是針對 型別 的。
表示式:T extends U ? X : Y
含義:如果 T 可以被分配給 U,則返回 X,否則返回 Y。一般條件下,如果 T 是 U 的子型別,則認為 T 可以分配給 U,例如:
type IsNumber = T extends number ? true : falsetype x = IsNumber // false
對映型別對映型別相當於一個型別的函式,可以做一些型別運算,輸入一個型別,輸出另一個型別,前文我們舉了個 Copy 的例子。
幾個內建的對映型別// 每一個屬性都變成可選type Partial = { [P in keyof T]?: T[P]}// 每一個屬性都變成只讀type Readonly = { readonly [P in keyof T]: T[P]}// 選擇物件中的某些屬性type Pick<T, K extends keyof T> = { [P in K]: T[P];}// ......
typescript 2.8 在 lib.d.ts 中內建了幾個對映型別:
- Partial
-- 將 T 中的所有屬性變成可選。 - Readonly
-- 將 T 中的所有屬性變成只讀。 - Pick<T, U> -- 選擇 T 中可以賦值給U的型別。
- Exclude<T, U> -- 從T中剔除可以賦值給U的型別。
- Extract<T, U> -- 提取T中可以賦值給U的型別。
- NonNullable
-- 從T中剔除null和undefined。 - ReturnType
-- 獲取函式返回值型別。 - InstanceType
-- 獲取建構函式型別的例項型別。
所以我們平時寫 TS 時可以直接使用這些型別工具:
interface ApiRes { code: string; flag: string; message: string; data: object; success: boolean; error: boolean;}type IApiRes = Pick// {// code: string;// flag: string;// message: string;// data: object;// }
extends 條件分發對於 T extends U ? X : Y 來說,還存在一個特性,當 T 是一個聯合型別時,會進行條件分發。
type Union = string | numbertype isNumber = T extends number ? 'isNumber' : 'notNumber'type UnionType = isNumber // 'notNumber' | 'isNumber'
實際上,extends 運算會變成如下形式:
(string extends number ? 'isNumber' : 'notNumber') | (number extends number ? 'isNumber' : 'notNumber')
Extract 就是基於此特性,再配合 never 么元的特性實現的:
type Exclude<T, K> = T extends K ? never : Ttype T1 = Exclude<string | number | boolean, string | boolean> // number
infer 關鍵詞infer 可以對運算過程中的型別進行儲存,內建的ReturnType 就是基於此特性實現的:
type ReturnType = T extends (...args: any) => infer R ? R : nevertype Fn = (str: string) => numbertype FnReturn = ReturnType // number
模組全域性模組 vs. 檔案模組預設情況下,我們所寫的程式碼是位於全域性模組下的:
const foo = 2
此時,如果我們建立了另一個檔案,並寫下如下程式碼,ts 認為是正常的:
const bar = foo // ok
如果要打破這種限制,只要檔案中有 import 或者 export 表示式即可:
export const bar = foo // error
模組解析策略Tpescript 有兩種模組的解析策略:Node 和 Classic。當 tsconfig.json 中 module 設定成 AMD、System、ES2015 時,預設為 classic ,否則為 Node ,也可以使用 moduleResolution 手動指定模組解析策略。
兩種模組解析策略的區別在於,對於下面模組引入來說:
import moduleB from 'moduleB'
Classic 模式的路徑定址:
Node 模式的路徑定址:
宣告檔案什麼是宣告檔案宣告檔案已 .d.ts 結尾,用來描述程式碼結構,一般用來為 js 庫提供型別定義。
平時開發的時候有沒有這種經歷:當用npm安裝了某些包並使用的時候,會出現這個包的語法提示,下面是 vue 的提示:
這個語法提示就是宣告檔案的功勞了,先來看一個簡單的宣告檔案長啥樣,這是jsonp這個庫的宣告檔案:
type CancelFn = () => void;type RequestCallback = (error: Error | null, data: any) => void;interface Options { param?: string; prefix?: string; name?: string; timeout?: number;}declare function jsonp(url: string, options?: Options, cb?: RequestCallback): CancelFn;declare function jsonp(url: string, callback?: RequestCallback): CancelFn;export = jsonp;
有了這份宣告檔案,編輯器在使用這個庫的時候就可以根據這份宣告檔案來做出相應的語法提示。
編輯器是怎麼找到這個宣告檔案?
- 如果這個包的根目錄下有一個index.d.ts,那麼這就是這個庫的宣告檔案了。
- 如果這個包的package.json中有types或者typings欄位,那麼該欄位指向的就是這個包的宣告檔案。
上述兩種都是將宣告檔案寫在包裡面的情況,如果某個庫很長時間不維護了,或者作者消失了該怎麼辦,沒關係,typescript官方提供了一個宣告檔案倉庫,嘗試使用@types字首來安裝某個庫的宣告檔案:
npm i @types/lodash
當引入lodash的時候,編輯器也會嘗試查詢node_modules/@types/lodash 來為你提供lodash的語法提示。
還有一種就是自己寫宣告檔案,編輯器會收集專案本地的宣告檔案,如果某個包沒有宣告檔案,你又想要語法提示,就可以自己在本地寫個宣告檔案:
// types/lodash.d.tsdeclare module "lodash" { export function chunk(array: any[], size?: number): any[]; export function get(source: any, path: string, defaultValue?: any): any;}
如果原始碼是用ts寫的,在編譯成js的時候,只要加上-d 引數,就能生成對應的宣告檔案。
tsc -d
宣告檔案該怎麼寫可以參考
還要注意的是,如果某個庫有宣告檔案了,編輯器就不會再關心這個庫具體的程式碼了,它只會根據宣告檔案來做提示。
擴充套件原生物件可能寫過 ts 的小夥伴有這樣的疑惑,我該如何在 window 物件上自定義屬性呢?
window.myprop = 1 // error
預設的,window 上是不存在 myprop 這個屬性的,所以不可以直接賦值,當然,可以使用方括號賦值語句,但是 get 操作時也必須用 [] ,並且沒有型別提示。
window['myprop'] = 1 // OKwindow.myprop // 型別“Window & typeof globalThis”上不存在屬性“myprop”window['myprop'] // ok,但是沒有提示,沒有型別
此時可以使用宣告檔案擴充套件其他物件,在專案中隨便建一個xxx.d.ts:
// index.d.tsinterface Window { myprop: number}// index.tswindow.myprop = 2 // ok
也可以在模組內部擴充套件全域性物件:
import A from 'moduleA'window.myprop = 2declare global { interface Window { myprop: number }}
擴充套件其他模組如果使用過 ts 寫過 vue 的同學,一定都碰到過這個問題,如何擴充套件 vue.prototype 上的屬性或者方法?
import Vue from 'vue'Vue.prototype.myprops = 1const vm = new Vue({ el: '#app'})// 型別“CombinedVueInstance<Vue, object, object, object, Record<never, any>>”// 上不存在屬性“myprops”console.log(vm.myprops)
vue 給出的方案,在專案中的 xxx.d.ts 中擴充套件 vue 例項上的屬性:
import Vue from 'vue'declare module 'vue/types/vue' { interface Vue { myprop: number }}
ts 提供了 declare module 'xxx' 的語法來擴充套件其他模組,這非常有利於一些外掛化的庫和包,例如 vue-router 擴充套件 vue 。
// vue-router/types/vue.d.tsimport Vue from 'vue'import VueRouter, { Route, RawLocation, NavigationGuard } from './index'declare module 'vue/types/vue' { interface Vue { $router: VueRouter $route: Route }}declare module 'vue/types/options' { interface ComponentOptions<V extends Vue> { router?: VueRouter beforeRouteEnter?: NavigationGuard beforeRouteLeave?: NavigationGuard beforeRouteUpdate?: NavigationGuard }}
如何處理非 js 檔案,例如 .vue 檔案引入?處理 vue 檔案
對於所有以 .vue 結尾的檔案,可以預設匯出 Vue 型別,這是符合 vue單檔案元件 的規則的。
declare module '*.vue' { import Vue from 'vue' export default Vue}
處理 css in js
對於所有的 .css,可以預設匯出一個 any 型別的值,這樣可以解決報錯問題,但是丟失了型別檢查。
declare module '*.css' { const content: any export default content}
import * as React from 'react'import * as styles from './index.css'const Error = () => ( <p className={styles.title}>Ooooops!
This page doesn't exist anymore.
)export default Error其實不管是全域性擴充套件還是模組擴充套件,其實都是基於 TS 宣告合併 的特性,簡單來說,TS 會將它收集到的一些同名的介面、類、類型別名按照一定的規則進行合併。
ts 內建了一個 compiler (tsc),可以讓我們把 ts 檔案編譯成 js 檔案,配合眾多的編譯選項,有時候不需要 babel 我們就可以完成大多數工作。
常用的編譯選項tsc 在編譯 ts 程式碼的時候,會根據 tsconfig.json 配置檔案的選項採取不同的編譯策略。下面是三個常用的配置項:
- target - 生成的程式碼的JS語言的版本,比如ES3、ES5、ES2015等。
- module - 生成的程式碼所需要支援的模組系統,比如 es2015、commonjs、umd等。
- lib - 告訴TS目標環境中有哪些特性,比如 WebWorker、ES2015、DOM等。
和 babel 一樣,ts 在編譯的時候只會轉化新 語法,不會轉化新的 API, 所以有些場景下需要自行處理 polyfill 的問題。
更改編譯後的目錄tsconfig 中的 outDir 欄位可以配置編譯後的檔案目錄,有利於 dist 的統一管理。
{ "compilerOptions": { "module": "umd", "outDir": "./dist" }}
編譯後的目錄結構:
myproject├── dist│ ├── index.js│ └── lib│ └── moduleA.js├── index.ts├── lib│ └── moduleA.ts└── tsconfig.json
編譯後輸出到一個js檔案中對於 amd 和 system 模組,可以配置 tsconfig.json 中的 outFile 欄位,輸出為一個 js 檔案。如果需要輸出成其他模組,例如 umd ,又希望打包成一個單獨的檔案,需要怎麼做?可以使用 rollup 或者 webpack :
// rollup.config.jsconst typescript = require('rollup-plugin-typescript2')module.exports = { input: './index.ts', output: { name: 'MyBundle', file: './dist/bundle.js', format: 'umd' }, plugins: [ typescript() ]}
一些常用的 ts 周邊庫- @typescript-eslint/eslint-plugin、@typescript-eslint/parser - lint 套件
- DefinitelyTyped - @types 倉庫
- ts-loader、rollup-plugin-typescript2 - rollup、webpack 外掛
- typedoc - ts 專案自動生成 API 文件
- typeorm - 一個 ts 支援度非常高的、易用的資料庫 orm 庫
- nest.js、egg.js - 支援 ts 的服務端框架
- ts-node - node 端直接執行 ts 檔案
- utility-types - 一些實用的 ts 型別工具
- type-coverage - 靜態型別覆蓋率檢測
大家在日常開發的時候,可能會經常用到webpack的路徑別名,比如: import xxx from '@/path/to/name',如果編輯器不做任何配置的話,這樣寫會很尷尬,編譯器不會給你任何路徑提示,更不會給你語法提示。這裡有個小技巧,基於 tsconfig.json 的 baseUrl和paths這兩個欄位,配置好這兩個欄位後,.ts檔案裡不但有了路徑提示,還會跟蹤到該路徑進行語法提示。
這裡有個小彩蛋,可以把 tsconfig.json 重新命名成jsconfig.json,.js檔案裡也能享受到路徑別名提示和語法提示了。
使用 webstorm 的同學如果也想使用的話,只要開啟設定,搜尋webpack,然後設定一下webpack配置檔案的路徑就好了。
作者: 高翔
轉發連結: