之前和大家提過,想要寫一個多參重載場景中能夠將函數實現分離的通用方法。經過一些天在類型體操中的折騰,今天,它來了!
如果你也遇到類似問題或場景,開發和維護陷入混亂和掙扎的話,不妨一起來看看。
在編寫一些通用庫/通用方法時,為了支持多種使用方式,就需要有多個參數,參數的數量和類型不同,所對應的處理方法也不同。
假如現在有這樣的需求:
是不是已經開始頭皮發麻了呢,不過別慌,我們一步步來看。很明顯這是一個函數重載的場景,TS 原生支持函數重載,于是我們會這樣寫:
吼!這個參數的類型,要暈了,于是甚至可能這樣寫:
直接擺爛,參數交給 any[]
,變成 anyscript。幾乎放棄了函數實現裡的類型推導和檢查。這還只是函數簽名而已,函數體怎麼寫,可能會是這樣:
一眼望不到頭的 if else
,這可怎麼辦?有一種經典的解決方案叫參數歸一化:
但其實也只是把 if else
換了個位置,雖然相比之下簡潔方便了很多,但還是不夠優雅。
這個時候突然想要推出一個新功能,還要添加新的函數簽名,吼🙃!又要埋進寫好的代碼中摸索。😱
TS 函數重載不夠強大的根本原因在於它只存在於編譯時,要實現真正的重載肯定需要嵌入運行時代碼。如果能夠以極少量代碼實現運行時的函數匹配,並在開發過程中保留完整的類型檢查和提示,將函數實現真正分離,那開發體驗肯定會大大提高。
想一想,如果我們能這樣寫代碼:
整潔的觀感,一目了然的函數簽名定義,各個函數實現完全分離,不再是滿屏的 if else
,並且保持完善的 TS 類型檢查和代碼提示。將來如果增加一些新的函數簽名,也不需要修改已有代碼,遵循開閉原則。
是不是感覺優雅了很多呢!話不多說,一起來看看吧。
優雅永不過時!!!
npm install overload-func
調用 createOverloadedFunction
方法,需要一個類型參數,傳入一個數組,每一項都是一個函數類型。
import { createOverloadedFunction } from 'overload-func';
const func = createOverloadedFunction<[
(a: string) => string,
(a: number, b: number) => boolean
]>();
調用 addImple
方法,最後一個參數為函數實現,之前的各個參數都是字符串,對應不同的參數類型。
func.addImple('string', (a) => {
return a;
});
func.addImple('number', 'number', (a, b) => {
return a > b;
});
TS 會根據傳入的參數類型,自動推導匹配對應的函數類型。
如果匹配不到相應的函數類型,或者定義的實現函數參數或返回值類型不匹配,TS 就會報錯,擁有完善的類型檢查和提示。
小技巧:調用
addImple
方法時,先寫好最後一個函數參數占位,再寫前面的參數類型,就可以隨時獲得代碼補全提示,
更多內置類型詳見 內置類型
和 TS 原生的函數重載一樣,調用時只需要傳入正確的參數類型即可。
const r1 = func('hello'); // string
const r2 = func(1, 2); // boolean
會自動匹配到對應的函數實現,並返回結果,並且 TS 也會提示出正確的返回類型。
內置的類型支持:(字符串 -- 對應類型)
string
number
boolean
null
undefined
symbol
bigint
Function
any[]
Date
Map
Set
WeakMap
WeakSet
RegExp
Promise
Error
object
目前支持這些類型,包含所有基本類型,以及一些常用的內置類型。能夠滿足大部分的場景。
需要注意的是,object
類型不能和其他內置類型匹配,例如 any[]
、Map
等,這些類型本該滿足 extends object
的條件,但是為了更好的作區分,內部判斷時不為 object
類型的其他內置類型,是不會被認為匹配 object
類型的。例如這樣:
const fun = createOverloadedFunction<[
(a: string[]) => string
]>();
fun.addImple('object', (a) => a.join('')); // error
fun.addImple('array', (a) => a.join(''));
你不能拿 object
參數去匹配 string[]
,雖然這在 TS 中看起來是正常的,但在這裡你需要用 array
來匹配數組類型。
源碼中使用了一個 LooseEqual
類型工具來匹配函數參數類型
export type LooseEqual<X, Y> = Equal<Y, object> extends true
? X extends BaseType
? false
: X extends Y
? true
: false
: X extends Y ? true : false;
其中 BaseType
為object
以外的其他內置類型。object
類型會單獨處理,不會和其他內置類型匹配。
目前不支持在函數簽名中使用可選參數。
例如:(a: number, b?: string) => boolean
,如果使用這樣的可選參數,使用中可能會出錯的。因為類似於 func(1)
這樣的調用,沒法正確匹配到函數實現。暫時還沒有想到好的解決方案。
我們可以通過下面的方式處理需要可選參數的場景。
const fn = createOverloadedFunction<[
(a: number) => boolean,
(a: number, b: string) => boolean,
]>();
不過話說回來,可選參數的場景,在函數實現中就存在判斷參數類型的邏輯。這好像和我們使用這個庫編寫重載代碼的初衷相悖吧😂。當然大家有什麼好的想法,歡迎交流指教。
TS 是結構化類型系統,所以我們在推導類型、定義使用重載、處理使用中遇到的問題時,一定要從結構化類型的角度出發來考慮問題。
看下面的例子(使用了後面會介紹到的 擴展類型)
class Person {
constructor(public name: string, public age: number) {}
}
const extendType = createExtendType({
person: Person,
});
const fn = createOverloadedFunction<[
(a: { name: string, age: number }) => number,
(a: Person) => boolean
], typeof extendType>({
extendType: extendType
});
fn.addImple('object', (a) => a.age);
fn.addImple('person', (a) => a.age > 18); // error
在上面的例子中,兩個實現匹配到的都是第一個函數簽名(運行起來雖然會得到想要的結果,但 TS 會報錯)。因為 TS 是結構化類型,Person
類型和 { name: string, age: number }
是兼容的。
如果確實需要上面的功能,就需要兩個對象擁有明確區別的屬性。我們可以為 Person
添加一個 gender
屬性,為 { name: string, age: number }
添加一個 id
屬性。這樣一來,就能正確匹配到各自的函數簽名。
createOverloadedFunction
方法支持一些配置選項,可以更靈活地定制函數重載。
默認情況下,一個重載只允許添加一個實現。如果需要允許多個實現,可以設置 allowMultiple
配置選項,設置為 true
時,可以為一個重載添加多個實現。
const func = createOverloadedFunction<[
(a: string) => string,
(a: number, b: number) => boolean
]>({ allowMultiple: true });
func.addImple('string', (a) => {
console.log('first implementation');
return a;
});
func.addImple('string', (a) => {
console.log('second implementation');
return a.toUpperCase();
});
const r1 = func('hello'); // HELLO
此時,調用函數並傳入一個 string
類型參數,會依次調用兩個實現函數。但是要注意,返回值為最後一個實現函數的返回值。
extendType
參數允許擴展類型支持,可以為 addImple
方法擴展可選類型參數。
通過創建類來定義類型,傳入對象,鍵名將作為 addImple
方法的可選類型參數,類作為鍵值。這裡推薦使用 createExtendType
方法創建擴展類型(可以得到更好的類型檢查)。
extendType
參數createOverloadedFunction
的第二個類型參數class Teacher {
salary: number;
constructor(public name: string) {}
}
class Student {
score: number;
constructor(public name: string) {}
}
const extendType = createExtendType({
teacher: Teacher,
student: Student,
});
const test = createOverloadedFunction<[
(t: Teacher) => string,
(s: Student) => number,
], typeof extendType>({
extendType: extendType
});
test.addImple('teacher', (t) => t.name);
test.addImple('student', (s) => s.name.length);
const res1 = test(new Teacher('John'));
const res2 = test(new Student('Alice'));
console.log(res1, res2); // John 5
正如之前 結構化類型 中提到的問題,TS 是結構化類型系統。所以上面的例子中,為了區分 Teacher
和 Student
,它們必須擁有能夠區分彼此的不同屬性。
當通過 extendType
擴展類型時,addImple
方法的可選類型參數就會增加 teacher
和 student
,同時也會有相應的代碼提示。
要在類中使用函數,重要的一點就是正確處理 this
的指向,並且在 TS 類型中正確推導它。如果你 TS 寫得還不錯,那這和上面例子的使用並沒有大的區別。
const test = createOverloadedFunction<[
(this: Test, n: number) => boolean,
(this: Test, n: string, s: string) => string,
]>();
test.addImple('number', function(n) {
return n > this.count;
});
test.addImple('string', 'string', function(n, m) {
return n + m;
});
class Test {
count = 10;
test = test;
}
const t = new Test();
console.log(t.test(8));
console.log(t.test('pknk', 'lll'));
this
類型來指定 this
的指向。這樣就可以實現重載的同時,擁有正確的 this
類型推導。
靈感源自於 渡一教育-袁老師 的一期短視頻(感興趣的朋友抖音搜索觀看),在其基礎上添加了完善的 TS 支持,並且改進了部分實現,拓展了更多功能。
其實這個庫打包後的運行時 JS 代碼一共也沒有多少,重點在於完善的 TS 支持,提升開發體驗和可維護性。
當然可能還有問題、bug、或是其他我沒有考慮到的地方,歡迎交流呀。👉🏻評論區 ✈️ issue