之前和大家提过,想要写一个多参重载场景中能够将函数实现分离的通用方法。经过一些天在类型体操中的折腾,今天,它来了!
如果你也遇到类似问题或场景,开发和维护陷入混乱和挣扎的话,不妨一起来看看。
前言
在编写一些通用库/通用方法时,为了支持多种使用方式,就需要有多个参数,参数的数量和类型不同,所对应的处理方法也不同。
假如现在有这样的需求:
是不是已经开始头皮发麻了呢,不过别慌,我们一步步来看。很明显这是一个函数重载的场景,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 --
string
- number --
number
- boolean --
boolean
- null --
null
- undefined --
undefined
- symbol --
symbol
- bigint --
bigint
- function --
Function
- array --
any[]
- date --
Date
- map --
Map
- set --
Set
- weakmap --
WeakMap
- weakset --
WeakSet
- regexp --
RegExp
- promise --
Promise
- error --
Error
- object --
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
的指向。 - 在添加实现函数时,不能使用箭头函数,而是使用普通函数。这是 JS 基础知识,这里就不做赘述。
这样就可以实现重载的同时,拥有正确的 this
类型推导。
写在最后
灵感源自于 渡一教育-袁老师 的一期短视频(感兴趣的朋友抖音搜索观看),在其基础上添加了完善的 TS 支持,并且改进了部分实现,拓展了更多功能。
其实这个库打包后的运行时 JS 代码一共也没有多少,重点在于完善的 TS 支持,提升开发体验和可维护性。
当然可能还有问题、bug、或是其他我没有考虑到的地方,欢迎交流呀。👉🏻评论区 ✈️ issue
附上源码地址: github 地址 gitee 地址
感觉怎么样,来试一试吧