深入 TypeScript 中的子類型、逆變、協(xié)變,進(jìn)階 Vue3 源碼前必須搞懂的
本文轉(zhuǎn)載自微信公眾號(hào)「 前端從進(jìn)階到入院」,作者 ssh前端 。轉(zhuǎn)載本文請(qǐng)聯(lián)系 前端從進(jìn)階到入院公眾號(hào)。
前言
TypeScript 中有很多地方涉及到子類型 subtype、父類型 supertype 的概念,如果搞不清這些概念,那么很可能被報(bào)錯(cuò)搞得無從下手,或者在寫一些復(fù)雜類型的時(shí)候看到別人可以這么寫,但是不知道為什么他可以生效。(就是我自己沒錯(cuò)了)
子類型
比如考慮如下接口:
- interface Animal {
- age: number
- }
- interface Dog extends Animal {
- bark(): void
- }
在這個(gè)例子中,Animal 是 Dog 的父類,Dog是Animal的子類型,子類型的屬性比父類型更多,更具體。
- 在類型系統(tǒng)中,屬性更多的類型是子類型。
- 在集合論中,屬性更少的集合是子集。
也就是說,子類型是父類型的超集,而父類型是子類型的子集,這是直覺上容易搞混的一點(diǎn)。
記住一個(gè)特征,子類型比父類型更加具體,這點(diǎn)很關(guān)鍵。
可賦值性 assignable
assignable 是類型系統(tǒng)中很重要的一個(gè)概念,當(dāng)你把一個(gè)變量賦值給另一個(gè)變量時(shí),就要檢查這兩個(gè)變量的類型之間是否可以相互賦值。
- let animal: Animal
- let dog: Dog
- animal = dog // ✅ok
- dog = animal // ❌error! animal 實(shí)例上缺少屬性 'bark'
從這個(gè)例子里可以看出,animal 是一個(gè)「更寬泛」的類型,它的屬性比較少,所以更「具體」的子類型是可以賦值給它的,因?yàn)槟闶侵?animal 上只有 age 這個(gè)屬性的,你只會(huì)去使用這個(gè)屬性,dog 上擁有 animal 所擁有的一切類型,賦值給 animal 是不會(huì)出現(xiàn)類型安全問題的。
反之,如果 dog = animal,那么后續(xù)使用者會(huì)期望 dog 上擁有 bark 屬性,當(dāng)他調(diào)用了 dog.bark() 就會(huì)引發(fā)運(yùn)行時(shí)的崩潰。
從可賦值性角度來說,子類型是可以賦值給父類型的,也就是 父類型變量 = 子類型變量 是安全的,因?yàn)樽宇愋蜕虾w了父類型所擁有的的一切屬性。
當(dāng)我初學(xué)的時(shí)候,我會(huì)覺得 T extends {} 這樣的語句很奇怪,為什么可以 extends 一個(gè)空類型并且在傳遞任意類型時(shí)都成立呢?當(dāng)搞明白上面的知識(shí)點(diǎn),這個(gè)問題也自然迎刃而解了。
在函數(shù)中的運(yùn)用
假設(shè)我們有這樣的一個(gè)函數(shù):
- function f(val: { a: number; b: number })
有這樣兩個(gè)變量:
- let val1 = { a: 1 }
- let val2 = { a: 1, b: 2, c: 3 }
調(diào)用 f(val1) 是會(huì)報(bào)錯(cuò)的,比較顯而易見的來看是因?yàn)槿鄙賹傩?b,而函數(shù) f 中很可能去訪問 b 屬性并且做一些操作,比如 b.substr(),這就會(huì)導(dǎo)致崩潰。
換成上面的知識(shí)點(diǎn)來看,val1 對(duì)應(yīng)的類型是{ a: number },它是 { a: number, b: number } 的父類型,調(diào)用 f(val1) 其實(shí)就相當(dāng)于把函數(shù)定義中的形參 val 賦值成了 val1, 把父類型的變量賦值給子類型的變量,這是危險(xiǎn)的。
反之,調(diào)用 f(val2) 沒有任何問題,因?yàn)?val2 的類型是 val類型的子類型,它擁有更多的屬性,函數(shù)有可能使用的一切屬性它都有。
假設(shè)我現(xiàn)在要開發(fā)一個(gè) redux,在聲明 dispatch 類型的時(shí)候,我就可以這樣去做:
- interface Action {
- type: string
- }
- declare function dispatch<T extends Action>(action: T)
這樣,就約束了傳入的參數(shù)一定是 Action 的子類型。也就是說,必須有 type,其他的屬性有沒有,您隨意。
在聯(lián)合類型中的運(yùn)用
學(xué)習(xí)了以上知識(shí)點(diǎn),再看聯(lián)合類型的可賦值性,乍一看會(huì)比較反直覺, 'a' | 'b' | 'c' 是 'a' | 'b' 的子類型嗎?它看起來屬性更多誒?其實(shí)正相反,'a' | 'b' | 'c' 是 'a' | 'b' 的父類型。因?yàn)榍罢弑群笳吒笇挿骸梗笳弑惹罢吒妇唧w」。
- type Parent = 'a' | 'b' | 'c'
- type Son = 'a' | 'b'
- let parent: Parent
- let son: Son
- parent = son // ✅ok
- son = parent // ❌error! parent 有可能是 'c'
這里 son 是可以安全的賦值給 parent 的,因?yàn)?son 的所有可能性都被 parent 涵蓋了。
而反之則不行,parent 太寬泛了,它有可能是 'c',這是 Son 類型 hold 不住的。
這個(gè)例子看完以后,你應(yīng)該可以理解為什么 'a' | 'b' extends 'a' | 'b' | 'c' 為 true 了,在書寫 conditional types的時(shí)候更加靈活的運(yùn)用吧。
逆變和協(xié)變
先來段維基百科的定義[1]:
協(xié)變與逆變(Covariance and contravariance )是在計(jì)算機(jī)科學(xué)中,描述具有父/子型別關(guān)系的多個(gè)型別通過型別構(gòu)造器、構(gòu)造出的多個(gè)復(fù)雜型別之間是否有父/子型別關(guān)系的用語。
描述的比較晦澀難懂,但是用我們上面的動(dòng)物類型的例子來解釋一波,現(xiàn)在我們還是有 Animal 和 Dog 兩個(gè)父子類型。
協(xié)變(Covariance)
那么想象一下,現(xiàn)在我們分別有這兩個(gè)子類型的數(shù)組,他們之間的父子關(guān)系應(yīng)該是怎么樣的呢?沒錯(cuò),Animal[] 依然是 Dog[] 的父類型,對(duì)于這樣的一段代碼,把子類型賦值給父類型依然是安全的:
- let animals: Animal[]
- let dogs: Dog[]
- animals = dogs
- animals[0].age // ✅ok
轉(zhuǎn)變成數(shù)組之后,對(duì)于父類型的變量,我們依然只會(huì)去 Dog 類型中一定有的那些屬性。
那么,對(duì)于 type MakeArray
逆變(Contravariance)
有這樣兩個(gè)函數(shù):
- let visitAnimal = (animal: Animal) => void;
- let visitDog = (dog: Dog) => void;
animal = dog 是類型安全的,那么 visitAnimal = visitDog 好像也是可行的?其實(shí)不然,想象一下這兩個(gè)函數(shù)的實(shí)現(xiàn):
- let visitAnimal = (animal: Animal) => {
- animal.age
- }
- let visitDog = (dog: Dog) => {
- dog.age
- dog.bark()
- }
由于 visitDog 的參數(shù)期望的是一個(gè)更具體的帶有 bark 屬性的子類型,所以如果 visitAnimal = visitDog 后,我們可能會(huì)用一個(gè)不帶 bark 屬性的普通的 animal 類型來傳給 visitDog。
- visitAnimal = visitDog
- let animal = { age: 5 }
- visitAnimal(animal) // ❌
這會(huì)造成運(yùn)行時(shí)錯(cuò)誤,animal.bark 根本不存在,去調(diào)用這個(gè)方法會(huì)引發(fā)崩潰。
但是反過來,visitDog = visitAnimal 卻是完全可行的。因?yàn)楹罄m(xù)調(diào)用方會(huì)傳入一個(gè)比 animal 屬性更具體的 dog,函數(shù)體內(nèi)部的一切訪問都是安全的。
在對(duì) Animal 和 Dog 類型分別調(diào)用如下的類型構(gòu)造器之后:
- type MakeFunction<T> = (arg: T) => void
父子類型關(guān)系逆轉(zhuǎn)了,這就是 逆變(Contravariance)。
在 TS 中
當(dāng)然,在 TypeScript 中,由于靈活性等權(quán)衡,對(duì)于函數(shù)參數(shù)默認(rèn)的處理是 雙向協(xié)變 的。也就是既可以 visitAnimal = visitDog,也可以 visitDog = visitAnimal。在開啟了 tsconfig 中的 strictFunctionType 后才會(huì)嚴(yán)格按照 逆變 來約束賦值關(guān)系。