8月20日,TypeScript 4.0 正式釋出了( Announcing TypeScript 4.0 ),雖然沒有重大的變更和特性,可以看做是 3.9 版本正常疊代,不過 Daniel 也在公告中說了:對于初學者而言,現在是最好的上手時機。
In fact, if you’re new to the language, now is the best time to start using it.
确實 TS 在經過了幾年的發展後,使用 TS 的團隊也越來越多,更重要的是 TS 的生态越來越完備,非常多的庫、架構等都支援了類型系統甚至直接用 TS 重寫,現在開始使用 TS 就能夠直接享受整個技術生态帶來的開發效率提升。回歸到業務,我們團隊最近也确實在開始用 TS 來進行類庫的開發,是以結合官方文檔和社群文檔并從新人學習的角度梳理了一份包含大部分 TS 核心概念的學習手冊。本文主要是做減法,梳理出核心的點,能夠先用起來,然後按工作需要找一些點逐個進行深入學習。
背景
因為日常工作會使用頁面搭建系統來生成很多前端頁面,是以會開發很多的樓層子產品配合搭建系統使用,最近子產品是基于 Rax 開發的,後續會支援越來越多的投放管道:web、weex、淘寶小程式、支付寶小程式等等,為了相容約來越多的管道,很多功能被抽象成了一個個小的類庫,在類庫中去相容各個管道,進而讓子產品中的業務代碼保持盡量的隻有清晰的業務邏輯。
但是随着支援管道的增多,不可避免的導緻類庫在不同的管道支援的特性不一緻,比如 web 中,類庫A 支援三個參數甲、乙、丙,而在小程式中類庫A 僅支援兩個參數甲、乙,是以丙這個參數要設計成可選參數,在類似的場景變多之後,這些類庫的文檔說明成了很重要的工作,同時在 IDE 中寫代碼時如果有類型系統能自動告訴開發人員這個函數支援哪些參數就更好了,是以我們準備對類庫進行 TS 的重寫來提高我們的生産效率,也為後續 TS 在團隊内的落地打一個好基礎。
什麼是 TypeScript
- 簡單的說 TypeScript 是 JavaScript 一個超集,能夠編譯成 JavaScript 代碼
- 其核心能力是在代碼編寫過程中提供了類型支援,以及在編譯過程中進行類型校驗
先說一下 JS 的現狀:
1、在 JS 中的變量本身是沒有類型,變量可以接受任意不同類型的值,同時可以通路任意屬性,屬性不存在無非是傳回 undefined
2、JS 也是有類型的,但是 JS 的類型是和值綁定的,是值的類型,用 typeof 判斷變量類型其實是判斷目前值的類型
// JavaScript
var a = 123
typeof a // "number"
a = 'sdf'
typeof a // "string"
a = { name: 'Tom' }
a = function () {
return true
}
a.xxx // undefined
TS 做的事情就是給變量加上類型限制
1、限制在變量指派的時候必須提供類型比對的值
2、限制變量隻能通路所綁定的類型中存在的屬性和方法
舉個簡單的例子,如下是一段能夠正常執行的 JS 代碼:
let a = 100
if (a.length !== undefined) {
console.log(a.length)
} else {
console.log('no length')
}
直接用 TS 來重寫上面的代碼,把變量 a 的類型設定為 number
在 TS 中給變量設定類型的文法是 【 : Type 】 類型注解
let a: number = 100
if (a.length !== undefined) { // error TS2339: Property 'length' does not exist on type 'number'.
console.log(a.length)
} else {
console.log('no length')
}
但是如果直接對這個 TS 代碼進行編譯會報錯,因為當變量被限制了類型之後,就無法通路該類型中不存在的屬性或方法。
那再來寫一段能正常執行的 TS
let a: string = 'hello'
console.log(a.length)
編譯成 JS 後的代碼為
var a = 'hello'
console.log(a.length)
可以發現 : string 這個類型限制編譯之後是不存在的,隻在編譯時進行類型校驗。
當 TS 源碼最終被編譯成 JS 後,是不會産生任何類型代碼的,是以在運作時自然也不存在類型校驗。
也就是說,假設一個項目,用 TS 來寫,哼哧哼哧加上各種類型檢驗,項目測試通過部署到線上之後
最後運作在用戶端的代碼和我直接用 JS 來寫的代碼是一樣的,寫了很多額外的類型代碼,竟然是為了保證能順利編譯成原來的代碼
▐ TypeScript 的作用
那 TS 的作用究竟是什麼呢,主要是以下三點:
- 将類型系統看作為文檔,在代碼結構相對複雜的場景中比較适用,本質上就是良好的注釋。
- 配合 IDE,有更好的代碼自動補全功能。
- 配合 IDE,在代碼編寫的過程中就能進行一些代碼校驗。例如在一些 if 内部的類型錯誤,JS 需要執行到了對應代碼才能發現錯誤,而 TS 在寫代碼的過程中就能發現部分錯誤,代碼傳遞品質相對高一些,不過對于邏輯錯誤,TS 當然也是無法識别的。
TypeScript 類型梳理
分兩類來介紹 TS 的類型系統:
- JS 中現有的值類型在 TS 中對應如何去限制變量
- TS 中拓展的類型,這些類型同樣隻在編譯時存在,編譯之後運作時所賦的值其實也是 JS 現有的值類型
下文中會穿插一些類似 [ xx ] 這樣的标題,這是在列舉介紹 TS 類型的過程中插入介紹的 TS 概念
▐ JS 中現有的值類型如何綁定到變量
- 使用文法:類型注解【 : Type 】
布爾值
let isDone: boolean = false
數值
let age: number = 18
字元串
let name: string = 'jiangmo'
空值
function alertName(): void { // 用 : void 來表示函數沒有傳回值
alert('My name is Tom')
}
Null 和 Undefined
let u: undefined = undefined
let n: null = null
// 注意:和所有靜态類型的語言一樣,TS 中不同類型的變量也無法互相指派
age = isDone // error TS2322: Type 'false' is not assignable to type 'number'.
// 但是因為 undefined 和 null 是所有類型的子類型,是以可以指派給任意類型的變量
age = n // ok
[ 類型推論 ]
- 如果沒有明确的指定類型,那麼 TypeScript 會依照類型推論的規則推斷出一個類型
例如:定義變量的時候同時進行指派,那麼 TS 會自動推斷出變量類型,無需類型注解
let age = 18
// 等價于
let age: number = 18
// 是以上面代碼中的類型聲明其實都可以省略
// 但是如果定義的時候沒有指派,不管之後有沒有指派,則這個變量完全不會被類型檢查(被推斷成了 any 類型)
let x
x = 'seven'
x = 7
// 是以這個時候應該顯示的聲明類型
let x: number
x = 7
繼續列舉類型
數組的類型
- 文法是 【 Type[] 】
let nameList: string[] = ['Tom', 'Jerry']
let ageList: number[] = [5, 6, 20]
對象的類型
- 接口 (interface) 用于描述對象的類型
interface Person { // 自定義的類型名稱,一般首字母大寫
name: string
age: number
}
let tom: Person = {
name: 'Tom',
age: 25,
}
函數的類型
- 以函數表達式為例 ( 函數聲明定義的函數也是用類似的 參數注解 文法來進行類型限制 )
// JavaScript
const sum = function (x, y) {
return x + y
}
TS 中有多種文法來定義函數類型
- 直接限制出入參類型
const sum = function (x: number, y: number): number {
return x + y
}
- 單獨給 sum 變量設定類型
const sum: (x: number, y: number) => number = function (x, y) {
return x + y
}
這裡如果把函數類型直接提取出來用并起一個自定義的類型名,代碼會更美觀,也易複用。
利用 類型别名 可以給 TS 類型重命名
[ 類型别名 ]
- 類型别名的文法是 【 type 自定義的類型名稱 = Type 】
type MySum = (x: number, y: number) => number
const sum: MySum = function (x, y) {
return x + y
}
回到函數類型
1、用接口定義函數的類型
interface MySum {
(a: number, b: number): number
}
const sum: MySum = function (x, y) {
return x + y
}
函數類型介紹完了,最後額外補充一下函數類型怎麼 定義剩餘參數的類型 以及 如何設定預設參數。
const sum = function (x: number = 1, y: number = 2, ...args: number[]): number {
return x + y
}
類的類型
- 和函數類型的文法相似,直接在 ES6 文法中用【 : Type 】類型注解 和 參數注解 文法給類的屬性和方法設定類型
class Animal {
name: string // 這一行表示聲明執行個體屬性 name
constructor(name: string) {
this.name = name
}
sayHi(): string {
return `My name is ${this.name}`
}
}
let a: Animal = new Animal('Jack') // : Animal 限制了變量 a 必須是 Animal 類的執行個體
console.log(a.sayHi()) // My name is Jack
順便值得一提的是,除了類型支援以外,TS 也拓展了 class 的文法特性
新增了三種通路修飾符 public、 private、 protected 和隻讀屬性關鍵字 readonly 以及 abstract 抽象類
這裡就不展開了,有需要的再去查閱一下官方文檔即可
内置對象和内置方法
JavaScript 中有很多内置對象和工具函數,TS 自帶其對應的類型定義
- 很多内置對象可以直接在 TypeScript 中當做定義好了的類型來使用
let e: Error = new Error('Error occurred')
let d: Date = new Date()
let r: RegExp = /[a-z]/
let body: HTMLElement = document.body
- 一些内置的方法,TS 也補充了類型定義,配合 IDE 在編寫代碼的時候也能得到 TS 的參數提示。
Math.pow(2, '3') // error TS2345: Argument of type '"3"' is not assignable to parameter of type 'number'.
▐ TS 中拓展的類型
任意值 any
與其說 any 是 JS 中不存在的類型,不如說原本 JS 中的變量隻有一個類型就是 any
任意值 any 的特點:
any 類型的變量可以指派給任何别的類型,這一點和 null 與 undefined 相同任何類型都可以指派給 any 類型的變量
在任意值上通路任何屬性都是允許的
let a: any = 123
a = '123' // ok
let n: number[] = a // ok
a.foo && a.foo() // ok
是以 any 是萬金油,也是和 TS 進行類型限制的目的是相違背的,要盡量避免使用 any。
聯合類型
- 類型中的或操作,在列出的類型裡滿足其中一個即可
let x: string | number = 1
x = '1'
不過聯合類型有一個額外限制:
當 TypeScript 不确定一個聯合類型的變量到底是哪個類型的時候,我們隻能通路此聯合類型的所有類型裡共有的屬性或方法。
let x: string | number = 1
x = '1'
x.length // 這裡能通路到 length ,因為 TS 能确定此時 x 是 string 類型
// 下面這個例子就會報錯
function getLength(something: string | number): number {
return something.length // error TS2339: Property 'length' does not exist on type 'string | number'.
}
兩種解決思路
- 讓 TS 能夠自行推斷出具體類型
function getLength(something: string | number): number {
if (typeof something === 'string') { // TS 能識别 typeof 語句
return something.length // 是以在這個 if 分支裡, something 的類型被推斷為 string
} else {
return 0
}
}
利用 類型斷言,手動強制修改現有類型
function getLength(something: string | number): number {
return (something as string).length // 不過這樣做實際上代碼是有問題的,是以用斷言的時候要小心
}
[ 類型斷言 ]
- 用來手動指定一個值的類型,文法 【 value as Type 】
用類型斷言修改類型時的限制:
1、聯合類型可以被斷言為其中一個類型
2、父類可以被斷言為子類
3、任何類型都可以被斷言為 any
4、any 可以被斷言為任何類型
總結成一條規律就是:要使得 A 能夠被斷言為 B,隻需要 A 相容 B 或 B 相容 A 即可。
✎ 雙重斷言
利用利用上述 3 和 4 兩條規則,可以強制把一個值改為任意其他類型
**let a = 3
(a as any) as string).split // ok**
如果說斷言有風險,那雙重斷言就是在反複橫跳了
字元串字面量類型
- 用來限制取值隻能是某幾個字元串中的一個
type EventNames = 'click' | 'scroll' | 'mousemove'
function handleEvent(ele: Element, event: EventNames) {
// do something
}
注意,隻有一個字元串也是字元串字面量類型
type MyType = 'hello'
雖然一般不會手動設定這樣的類型,不過類型推論經常會推斷出這種類型。
比如某次編譯報錯提示為:Argument of type '"foo"' is not assignable to parameter of type 'number'.
提示中的 type '"foo"' 一般就是根據字元串 'foo' 推斷出來的字元串字面量類型。
元組
類似 Python 中的元組,可以看做是固定長度和元素類型的數組
let man: [string, number] = ['Tom', 25]
// 不過 TS 中的元組支援越界
// 當添加越界的元素時,它的類型會被限制為元組中每個類型的聯合類型
man.push('male')
枚舉
- 用于取值被限定在一定範圍内的場景,可以替代 JS 中用字面量來定義一個對象作為字典的場景
enum Directions {
Up,
Down,
Left,
Right,
}
let d: Directions = Directions.Left
這裡看到 Directions.Left 直接把類型當做一個值來用了。
不是說類型是用于【 : Type 】類型注解 文法來限制變量,編譯之後類型代碼都會被删除嗎?
為了解釋這個問題,我們先來來看看單純的類型代碼會被編譯成什麼。
- 首先以一個聯合類型舉例
type MyType = string | number | boolean
編譯結果:
// 不會産生任何 JS 代碼
- 再來看看枚舉類型會被編譯成什麼
enum Directions {
Up,
Down,
Left,
Right,
}
console.log(Directions)
var Directions
;(function (Directions) {
Directions[(Directions['Up'] = 0)] = 'Up'
Directions[(Directions['Down'] = 1)] = 'Down'
Directions[(Directions['Left'] = 2)] = 'Left'
Directions[(Directions['Right'] = 3)] = 'Right'
})(Directions || (Directions = {}))
console.log(Directions)
/*
運作時 log 出來的 Directions 變量如下
{
'0': 'Up',
'1': 'Down',
'2': 'Left',
'3': 'Right',
Up: 0,
Down: 1,
Left: 2,
Right: 3
}
*/
這怎麼了解呢?
let d: Directions = Directions.Left
其實這一行代碼中,前一個 Directions 表示類型,後一個 Directions 表示值。
即 Directions 是一個值和類型的“複合體”,在不同的文法中具象化為值或者類型。
其實有辦法可以把類型部分從 Directions 中抽離出來。
enum Directions {
Up,
Down,
Left,
Right,
}
type MyDirections = Directions
console.log(MyDirections) // error TS2693: 'MyDirections' only refers to a type, but is being used as a value here.
此時 MyDirections 就是一個純粹的類型,不能當做一個值來使用。
其實之前介紹的函數類型、類類型等聲明中,也存在這樣的值與類型的“複合體”
const sum = function (x: number, y: number = 5): number {
return x + y
}
console.log(sum) // [Function: sum]
type MySum = typeof sum // 注意,剝離出來的函數類型是不會帶有預設參數的,因為預設參數其實是函數的特性,和類型系統無關
const f: MySum = (a, b) => 3 // ok
console.log(MySum) // error TS2693: 'MySum' only refers to a type, but is being used as a value here.
然後再回到枚舉。
✎ 字元串枚舉
用字元串字面量初始化枚舉成員,在實際使用過程中很常見
enum Directions {
Up = 'UP',
Down = 'DOWN',
Left = 'LEFT',
Right = 'RIGHT',
}
console.log(Directions.Up === 'UP') // true
✎ 常數枚舉
- 用 const enum 定義的枚舉類型
- 和普通枚舉的差別就是對應的值也會在編譯階段被删除,隻會留下枚舉成員的值
const enum Directions {
Up = 'UP',
Down = 'DOWN',
Left = 'LEFT',
Right = 'RIGHT',
}
let d = Directions.Left
// 如果取消注釋下面這行代碼,編譯會報錯
// console.log(Directions) // error TS2475: 'const' enums can only be used in property or index access expressions or the right hand side of an import declaration or export assignment or type query.
var d = 'LEFT' /* Left */
泛型
- 其實泛型并不是一種具體的類型,而是在定義函數、接口或類的類型時的的拓展特性。
- 泛型是類型系統裡的 “函數” ,通過傳入具體的 類型參數 來得到一個具體的類型,進而達到複用類型代碼的目的
假設一個場景,某個函數的入參類型為 number | string ,并且出參類型和入參相同
先嘗試用聯合類型來限制出入參
type MyFunc = (x: number | string) => number | string
但是 MyFunc 無法表示出參類型和入參相同,即入參是 number 的時候出參也是 number。
在這個場景下,可以利用泛型來定義出多個類似的函數類型。
泛型函數
function GenericFunc<T>(arg: T): T {
return arg
}
// 這裡的 GenericFunc<number> 是表示的是一個函數值,同時将類型參數 T 指派為 number
let n = GenericFunc<number>(1) // n 可以通過類型推論得出類型為 :number
// 進一步,利用 泛型限制 ,限制出入參為 number | string
type MyType = number | string
function GenericFunc<T extends MyType>(arg: T): T { // extends MyType 表示類型參數 T 符合 MyType 類型定義的形狀
return arg
}
let s = GenericFunc<string>('qq')
let b = GenericFunc<boolean>(false) // error TS2344: Type 'boolean' does not satisfy the constraint 'string | number'.
泛型接口
- 用 泛型接口 來定義函數類型
interface GenericFn<T> {
(arg: T): T
}
// 定義一個泛型函數作為函數實作
function identity<T>(arg: T): T {
return arg
}
// 使用泛型時傳入一個類型來使 類型參數 變成具體的類型
// <number> 表示 T 此時就是 number 類型,GenericFn<number> 類似是 “函數調用” 并傳回了一個具體的類型 (這裡是一個函數類型)
const myNumberFn: GenericFn<number> = identity
const myStringFn: GenericFn<string> = identity
let n = myNumberFn(1) // n 可以通過類型推論得出類型為 :number
let s = myStringFn('string') // s 可以通過類型推論得出類型為 :string
對比上述的 泛型函數 和 泛型接口,有一個差別:
- 給泛型函數傳參之後得到的是一個函數值,而不是類型
// GenericFunc 是上面定義的泛型函數
type G = GenericFunc<string> // error TS2749: 'GenericFunc' refers to a value, but is being used as a type here.
而泛型接口傳參之後得到的是一個類型,而不是函數值
// GenericFn 是上面定義的泛型接口
type G = GenericFn<number> // ok
GenericFn<number>() // error TS2693: 'GenericFn' only refers to a type, but is being used as a value here.
泛型類
- 用 泛型類 來定義類的類型
class GenericClass<T> {
zeroValue: T
constructor(a: T) {
this.zeroValue = a
}
}
let instance = new GenericClass<number>(1)
// 等價于
let instance: GenericClass<number> = new GenericClass(1)
// 因為有類型推論,是以可以簡寫成
let instance = new GenericClass(1)
✎ 内置的數組泛型
- TS 中内置類一個數組泛型 Array,傳入類型參數後會傳回對應的數組類型
// 數組的類型之前是用 【 Type[] 】 文法來表示的
let list: number[] = [1, 2, 3]
// 現在也可以這麼表示
let list: Array<number> = [1, 2, 3]
[ 聲明合并 ]
上面那個場景,某個函數的入參類型為 number | string ,并且出參類型和入參相同,其實不用泛型也可以用函數重載來實作
✎ 函數的合并
- 即函數聲明的合并,即函數重載
- TS 中的重載并不是真正意義上的重載,隻是在根據不同的實參類型,從上而下挑選出一個具體的函數類型來使用
function func(x: number): number
function func(x: string): string
function func(x: any): any {
// 這裡定義的函數類型 (x: any): any 會被覆寫失效
return x.length
}
let n = func(1) // n 可以通過類型推論得出類型為 :number
let s = func('1') // s 可以通過類型推論得出類型為 :string
// 需要注意的是,如上重載之後隻剩下兩種函數類型,調用時的入參要麼是 number 要麼是 string,無法傳入其他類型的值
let b = func(true) // error
/*
- error TS2769: No overload matches this call.
Overload 1 of 2, '(x: number): number', gave the following error.
Argument of type 'true' is not assignable to parameter of type 'number'.
Overload 2 of 2, '(x: string): string', gave the following error.
Argument of type 'true' is not assignable to parameter of type 'string'.
*/
✎ 接口的合并
- 接口中方法的合并和函數的合并相同,但是 屬性的合并要求類型必須唯一
interface Alarm {
price: number
alert(s: string): string
}
interface Alarm {
weight: number
alert(s: string, n: number): string
}
// 相當于
interface Alarm {
price: number
weight: number
alert(s: string): string
alert(s: string, n: number): string
}
聲明檔案
- 以 .d.ts 結尾的檔案
- 聲明檔案裡面 100% 全部都是純類型的聲明,不會編譯出任何 JS 代碼
一般來說,TS 會解析項目中所有的 *.ts 以及 .d.ts 結尾的檔案,進而擷取其中這些類型聲明。
使用場景:作為一個第三方類庫的開發者
假如你的庫是用 TypeScript 寫的,但是最終你的庫分發出去的時候要編譯成 JS。
否則這個類庫就隻能給 TS 項目來使用了,因為沒有使用 TS 的項目沒法直接引用 .ts 檔案。
但是編譯成 JS 有一個問題就是類型代碼都被删除了之後,對于 TS 項目的使用方來說就沒法繼承 TS 的三大優勢 (文檔、自動補全、類型校驗)
是以需要在類庫的編譯産出檔案中保留一些 .d.ts 類型聲明檔案,和編譯出來的 JS 檔案中導出的函數、類等進行一一比對。
這樣 JS 檔案和類型檔案分離之後,你的類庫就可以同時被 JS 項目和 TS 項目引用了。
假如你的庫是并沒有使用 TypeScript 來編寫,那就需要額外有人給這個庫寫單獨的聲明檔案。
比如 jQuery 并不是用 TS 來寫的,但是你可以安裝單獨的 TS 類型包來實作補全這個包的類型系統。
npm install @types/jquery --save-dev
**
感謝&全文引用:**
TypeScript 入門教程
TypeScript 官方手冊 TypeScript Handbook關注「淘系技術」微信公衆号,一個有溫度有内容的技術社群~