作者: 高翔
轉發鏈接:
前言在 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配置文件的路徑就好了。
作者: 高翔
轉發鏈接: