TypeScript 漫游记(四)|函数

51 阅读11分钟

大家好,我是半虹,这篇文章来讲 TypeScript 中的函数类型


1、函数定义

在 JavaScript 中,定义函数有两种方式,分别是函数声明函数表达式

其中函数表达式又可以细分为两种情况,分别是普通函数和箭头函数,对应例子如下:

// 函数声明
function add1(a, b) {
  return a + b;
}

// 函数表达式:普通函数
let add2 = function(a, b) {
  return a + b;
}

// 函数表达式:箭头函数
let add3 = (a, b) => {
  return a + b;
}

在  TypeScript  中,同样支持这些方式,并且允许给函数参数和返回值显式声明类型

只需要在函数字面量直接指定即可,对应例子如下:

// 函数声明
// 格式如下:function name(para1: type1, ...):return_type { ... }
function add1(a:number, b:number):number {
	return a + b;
}

// 函数表达式:普通函数
// 格式如下:let name = function(para1: type1, ...):return_type { ... }
let add2 = function(a:number, b:number):number {
  return a + b;
}

// 函数表达式:箭头函数
// 格式如下:let name = (para1: type1, ...):return_type => { ... }
let add3 = (a:number, b:number):number => {
  return a + b;
}

如果没有显式指定参数类型,那么也会做隐式推导

但是绝大多数情况由于缺少上下文信息,因此难以推断出正确的类型,此时会看作是 any

与之相反的是,返回值类型通常能根据上下文信息推断,因此可以省略不写

但是有的时候,为了函数签名更加清晰,或者防止返回值被无意修改,也能显式指定

// 省略参数的类型和返回值类型,由于此时缺少必要的上下文信息,因此都会被推断为 any
let add1 = function(x, y) {
  return x + y;
}

// 省略返回值类型,此时还能根据参数的类型和函数的操作,正确推断出返回值类型为 number
let add2 = function(x:number, y:number) {
  return x + y;
}

使用函数表达式定义函数时,可以注意到我们没有给等号左边的变量显式地声明类型

实际上这里也是隐式推导嘞,通过等号右边的值的类型自动推导等号左边的变量类型

这就跟我们之前定义值变量时是一样的,只不过之前的值是数字等简单类型,而这里的值是函数

区别是函数作为值时,可以在函数字面量中指定参数和返回值类型

// 数字变量
// 省略左值类型,由右值自动推导,这里右值是数字
// 最后左值类型推断为:number
let x1 = 1;

x1 = 123; // 赋值类型与推导类型相同,编译正常
x1 = '0'; // 赋值类型与推导类型不同,编译错误

// 函数变量
// 省略左值类型,由右值自动推导,这里右值是函数,可以在函数字面量指定参数和返回值类型
// 最后左值类型推断为:(x:number, y:number) => number
let x2 = function(x:number, y:number):number {
  return x + y;
}

x2 = function(x:number, y:number):number { return x - y; } // 赋值类型与推导类型相同,编译正常
x2 = function(x:string, y:string):string { return x + y; } // 赋值类型与推导类型不同,编译错误

实际上我们可以通过类型签名显式指定变量类型,注意,类型签名中只有类型没有值

其语法就和箭头函数一样,箭头的左侧指定参数的类型,箭头的右侧指定返回值类型

// 类型签名
// 签名意为:接收两个数字作为参数,返回一个数字
type X1 = (x:number, y:number) => number;

// 变量指定类型
let x1:X1;

// 变量赋值
// 赋值类型需要与类型签名相同
X1 = function(x:number, y:number):number { return x + y; } // 赋值类型与类型签名相同,编译正常
x1 = function(x:number, y:number):number { return x - y; } // 赋值类型与类型签名相同,编译正常
x1 = function(x:string, y:string):string { return x + y; } // 赋值类型与类型签名不同,编译错误

通过类型签名显式声明变量类型后,赋值需要与类型签名一致,并有以下的注意事项:

赋值时的参数名称可以和类型签名中的参数名称不一致,此时没有任何影响

赋值时的参数类型可以被省略,此时会根据类型签名自动推导出正确的类型

赋值时的参数数量可以比类型签名中的参数数量要更少,但是不能比它更多

// 类型签名
// 签名意为:接收两个数字作为参数,返回一个数字
type X1 = (x:number, y:number) => number;

// 变量指定类型
let x1:X1;
    
// 变量赋值

x1 = function(a:number, b:number):number { return a + b; }               // 赋值时参数名称与类型签名不同,编译正常
x1 = function(a, b) { return a + b; }                                    // 赋值时参数类型省略并自动推导,编译正常

x1 = function(a:number) { return a; }                                    // 赋值时参数数量比类型签名更少,编译正常
x1 = function(a:number, b:number, c:number):number { return a + b + c; } // 赋值时参数数量比类型签名更多,编译错误

函数调用时,实参数量与形参数量必须相等,既不能多,也不能少

let f:(x:number, y:number) => number = (x, y) => (x + y);

f(1, 2);    // 实参数量与形参数量相等,编译正常
f(1);       // 实参数量比形参数量要少,编译错误
f(1, 2, 3); // 实参数量比形参数量要多,编译错误

2、可选参数

在有些时候,实参数量可能比形参数量更少,这时就要用可选参数

可选参数表示某个参数在调用之时可以省略,此时相当于显式赋值 undefined

可选参数可以有多个,但必须位于参数最后,只需在对应参数后面加问号即可

// 定义

let log0 = function(x?:number, y:number) {  // 第一个参数可选,编译错误,可选参数只能在必选参数后
  console.log(x, y);
}

let log1 = function(x:number, y?:number) {  // 第二个参数可选,编译正常
  console.log(x, y);
}

let log2 = function(x?:number, y?:number) { // 第一个和第二个参数都可选,编译正常
  console.log(x, y);
}

// 调用

log1(1);                    // 传入一个参数,相当于 log1(1, undefined)
log1(1, 2);                 // 传入两个参数

log2();                     // 传入零个参数,相当于 log2(undefined) 或 log2(undefined, undefined)
log2(1);                    // 传入一个参数(第一个参数)相当于 log2(1, undefined)
log2(undefined, 2);         // 传入一个参数(第二个参数)此时第一个参数只能显式赋值为 undefined
log2(1, 2);                 // 传入两个参数

3、剩余参数

在有些时候,实参数量可能比形参数量更多,这时就要用剩余参数

剩余参数表示可以接收一组声明的所有参数,既可以是类型相同的数组,也可以是类型不同的元组

剩余参数只能有一个,且必须位于参数最后,只需在对应参数位置用拓展运算符展开数组或者元组

// 定义

let log0 = function(...rest:number[], x:number) { // 编译错误,剩余参数必须位于参数最后
  console.log(x, rest);
}

let log1 = function(x:number, ...rest:number[]) { // 编译正常,使用拓展运算符来展开数组
  console.log(x, rest);
}

let log2 = function(x:number, ...rest:[number, string, boolean]) { // 编译正常,使用拓展运算符来展开元组
  console.log(x, rest);
}

// 调用

log1(1);       // 剩余参数传零个值,此时 rest 为 []
log1(1, 2);    // 剩余参数传一个值,此时 rest 为 [2]
log1(1, 2, 3); // 剩余参数传两个值,此时 rest 为 [2, 3]
               // ...

log2(1, 123, '0', true); // 此时 rest 为 [123, '0', true]

4、默认参数

就像在 JavaScript 中一样,我们可以给函数参数指定默认值

默认值只能在赋值时函数字面量指定,不能使用类型签名,因为类型签名只有类型没有值

默认值可以在任意的位置,只需在对应参数后用等号赋予值

带有默认值的参数既能显式指定类型,同时也能省略不写,此时就会根据默认值自动推断

最后需要注意的是,默认值和可选参数不能用于同一个参数

// 不能在一个参数上同时用默认值和可选
let repeat0 = function(str:string, num?:number = 10) { // 编译错误
  console.log(str.repeat(num));
}

// 第二个参数设置默认值,并且省略类型,此时根据默认值能推断出是 number
let repeat1 = function(str:string, num = 10) {
  console.log(str.repeat(num));
}

// 第一个参数设置默认值,并且省略类型,此时根据默认值能推断出是 string
let repeat2 = function(str = '', num:number) {
  console.log(str.repeat(num));
}

repeat1('s');            // 默认参数没有传入值,此时就是设置的默认值
repeat1('s', undefined); // 默认参数传入 undefiend,此时也是默认值
repeat1('s', 100);       // 默认参数传入合法值,此时就是传入值

repeat2(undefined, 100); // 默认参数不在最后的位置不能省,此时可以传 undefined 使用默认值
repeat2('s', 100);       // 默认参数不在最后的位置不能省,或者可以传 合法值,此时就是传入值

5、只读参数

如果希望函数内部不能修改某个参数,可以将该参数设置为只读参数

只需在该参数前加 readonly,但是要注意嘞,只能用于数组或元组

let modify0 = function(num:readonly number) {   // 编译错误
  num = 1;
}

let modify1 = function(num:readonly number[]) { // 编译正常
  num[0] = 1; // 编译错误
}

let modify2 = function(num:readonly [number]) { // 编译正常
  num[0] = 1; // 编译错误
}

6、隐式参数

函数在调用时,有两个参数无需声明,就会自动传入,因此被称为隐式参数

分别是  argumentsthis ,其中  arguments 可以获取传入的所有参数

this 则是当前上下文对象,具体的值由调用时的上下文决定,难以确定

let a = {
  b() {
    console.log(this);
  }
}
let b = a.b;

a.b(); // 此时 this 为 a
b();   // 此时 this 为 全局对象

TypeScript 可以通过显式声明 this 的类型来确保 this 处于预期的上下文

需要注意的是, this 的声明必须位于参数最前面,并对调用传参没有影响

// 格式化日期,调用时要求 this 为 Date
let formatDate = function(this: Date, info: string) {
  return `[${info}] ${this.getFullYear()}/${this.getMonth()}/${this.getDate()}`;
}

formatDate.call(new Date, 'call'); // 编译正常
formatDate('direct');              // 编译错误

7、函数重载

所谓函数重载,就是说一个函数有多个类型签名,调用时会根据实际的参数执行不同的行为

前面我们介绍的类型签名其实是简写版本,如果想定义重载函数,可以使用完整版类型签名

// 简写版类型签名,类似于箭头函数
// 箭头的左侧表示参数及其类型,箭头的右侧表示返回类型
type X1 = (x:number, y:number) => number;

// 完整版类型签名,类似于对象字面量
// 每一个属性代表一个类型签名,属性之间用逗号或分号分隔
// 属性中的键表示参数及其类型,属性中的值表示返回类型,键值之间用冒号分隔
type X2 = {
  (x:number, y:number): number;
  // ...
}
// 声明多个签名时,顺序很重要
// 因为调用函数时,会根据顺序进行匹配,一旦匹配符合后,那么就不再进行检查
// 所以宽泛的签名应该放在后面

开始声明类型时,可以通过完整版的类型签名逐一声明多条签名

但是实现函数时,则必须要同时实现所有签名,这里要注意的是:

  1. 参数的类型声明必须能包含所有可能情况
  2. 函数的处理逻辑必须要同时处理所有情况

最后调用函数时,传入参数需要符合任一签名

// 声明类型,定义加法函数,重载逻辑如下:
// 如果传入两个数字,那么将两个数字相加并返回
// 如果传入一个数组,那么将所有数字相加并返回
type AddT = {
  (x:number, y:number): number;
  (x:number[]): number;
}

// 实现函数
let addF:AddT = function(
  x: number|number[], // 如果是签名 1,那么 x 是 number;如果是签名 2,那么 x 是 number[]
  y?: number          // 如果是签名 1,那么 y 是 number;如果是签名 2,那么 y 为 空,所以该参数为可选
):number {
  // 处理签名 1 的情况
  if (typeof x === 'number' && typeof y === 'number') {
    return x + y;
  }
  // 处理签名 2 的情况
  if (Array.isArray(x)) {
    return x.reduce(function(prev, curr) { return prev + curr; })
  }
  // 兜底
  throw new Error('wrong parameters');
}

// 调用函数
addF(1);      // 编译错误,单看函数字面量没问题,x 是 number|number[], y 是可选;但是该传参不符合任何一条签名
addF(1, 2);   // 编译正常,符合签名 1
addF([1, 2]); // 编译正常,符合签名 2

上面介绍的是函数表达式的重载,实际上函数声明也能定义重载

function addF(x:number, y:number):number; // 签名 1
function addF(x:number[]):number;         // 签名 2
function addF(                            // 实现
  x: number|number[],
  y?: number,
) {
  if (typeof x === 'number' && typeof y === 'number') {
    return x + y;
  }
  if (Array.isArray(x)) {
    return x.reduce(function(prev, curr) { return prev + curr; })
  }
  throw new Error('wrong parameters');
}


好啦,本文到此结束,感谢您的阅读!

如果你觉得这篇文章有需要修改完善的地方,欢迎在评论区留下你宝贵的意见或者建议

如果你觉得这篇文章还不错的话,欢迎点赞、收藏、关注,你的支持是对我最大的鼓励 (/ω\)