当前位置: 首页 > news >正文

《深入理解 TypeScript:函数类型与泛型全解析》(万字长文)

有人说:我们应该四十岁离开人世,这样就可以保证自己最好的一面留下来。

我以前也是这样想的,但是最近有人给我的感觉是:四十岁以后会有更好的风景。所以我想活到四十岁后看看!


函数类型表达式

函数类型表达式在语法上类似箭头函数。

function greeter(fn: (a: string) => void) {
  fn("Hello, World");
}
 
function printToConsole(s: string) {
  console.log(s);
}
 
greeter(printToConsole);

语法 (a: string) => void 意味着 "有一个参数的函数,名为 a ,类型为字符串,没有返回值"。就像 函数声明一样,如果没有指定参数类型,它就隐含为 any 类型。

在这里我想复习一下js中的箭头函数的特殊性:最主要的就是箭头函数没有自己的this,而是从上下文中捕获,而且箭头函数可以不写返回,这些都是我在面试中被问到的。

当然,我们可以用一个类型别名来命名一个函数类型。

type GreetFunction = (a: string) => void;
function greeter(fn: GreetFunction) {
  // ...
}

 调用签名

在JavaScript中,除了可调用之外,函数还可以有属性。然而,函数类型表达式的语法不允许声明属性。 如果我们想用属性来描述可调用的东西,我们可以在一个对象类型中写一个调用签名。

用人话讲就是,如果我们想要给函数添加属性,就可以使用调用签名的形式。

type DescribableFunction = {
  description: string;
 (someArg: number): boolean;
}
function doSomething(fn: DescribableFunction) {
  console.log(fn.description + " returned " + fn(6));
}

// DescribableFunction既可以作为函数,又可以有属性description

function fn1() {
  return true
}
fn1.description = 'balabala...'
doSomething(fn1)
/输出:balabale return true

注意,与函数类型表达式相比,语法略有不同:在参数列表和返回类型之间使用 : 而不是 => 。

构造签名

 JavaScript函数也可以用 new 操作符来调用。TypeScript将这些称为构造函数,因为它们通常会创建一 个新的对象。你可以通过在调用签名前面添加 new 关键字来写一个构造签名。

class Ctor {
  s: string
  constructor(s: string) {
    this.s = s
 }
}
type SomeConstructor = {
  new (s: string): Ctor
}
function fn(ctor: SomeConstructor) {
  return new ctor("hello")
}
const f = fn(Ctor)
console.log(f.s)

//输出 :hello

有些对象,如 JavaScript 的 Date 对象,可以在有 new 或没有 new 的情况下被调用。你可以在同一类型中任意地结合调用和构造签名。

interface CallOrConstruct {
  new (s: string): Date;
 (n?: number): number;
}
function fn(date: CallOrConstruct) {
  let d = new date('2021-11-20')
  let n = date(100)

泛型函数

类型推断

我们再写一个函数的时候,我们输入的类型和输出的类型有关,或者两个输入的类型以某种方式相关。

function firstElement(arr: any[]) {
  return arr[0];
}

这个函数返回的类型是any,但我们调用这个函数的时候,给的却是具体的元素,所以我们希望能返回数组元素的类型。这时候我们就需要使用到泛型。

 在TypeScript中,当我们想描述两个值之间的对应关系时,会使用泛型。我们通过在函数签名中声明一 个类型参数来做到这一点:

function firstElement<Type>(arr: Type[]): Type | undefined {
  return arr[0];
}

通过给这个函数添加一个类型参数 Type ,并在两个地方使用它,我们已经在函数的输入(数组)和输出(返回值)之间建立了一个联系。现在当我们调用它时,一个更具体的类型就出来了:

// s 是 'string' 类型
const s = firstElement(["a", "b", "c"]);
// n 是 'number' 类型
const n = firstElement([1, 2, 3]);
// u 是 undefined 类型
const u = firstElement([]);

请注意,在这个例子中,我们没有必要指定类型。类型是由TypeScript推断出来的--自动选择。

我们也可以使用多个类型参数。例如,一个独立版本的map看起来是这样的。

function map<Input, Output>(arr: Input[], func: (arg: Input) => Output): 
Output[] {
  return arr.map(func);
}
 
// 参数'n'是'字符串'类型。
// 'parsed'是'number[]'类型。
const parsed = map(["1", "2", "3"], (n) => parseInt(n));

请注意,在这个例子中,TypeScript可以推断出输入类型参数的类型(从给定的字符串数组),以及基 于函数表达式的返回值(数字)的输出类型参数。

限制条件

我们已经写了一些通用函数,可以对任何类型的值进行操作。有时我们想把两个值联系起来,但只能对 某个值的子集进行操作。在这种情况下,我们可以使用一个约束条件来限制一个类型参数可以接受的类型。

就是我们现在想对一个值进行约束了。

让我们写一个函数,返回两个值中较长的值。要做到这一点,我们需要一个长度属性,是一个数字。我 们通过写一个扩展子句将类型参数限制在这个类型上。

function longest<Type extends { length: number }>(a: Type, b: Type) {
  if (a.length >= b.length) {
    return a;
 } else {
    return b;
 }
}
 
// longerArray 的类型是 'number[]'
const longerArray = longest([1, 2], [1, 2, 3]);
// longerString 是 'alice'|'bob' 的类型。
const longerString = longest("alice", "bob");
// 错误! 数字没有'长度'属性
const notOK = longest(10, 100);

在这个例子中,有一些有趣的事情需要注意。我们允许TypeScript推断 longest 的返回类型。返回类 型推断也适用于通用函数。

因为我们将 Type 约束为 { length: number } ,所以我们被允许访问 a 和 b 参数的 .length 属 性。如果没有类型约束,我们就不能访问这些属性,因为这些值可能是一些没有长度属性的其他类型。

longerArray 和 longerString 的类型是根据参数推断出来的。记住,泛型就是把两个或多个具有相 同类型的值联系起来。

最后,正如我们所希望的,对 longest(10, 100) 的调用被拒绝了,因为数字类型没有一个 .length 属性。 

使用受限值

这里有一个使用通用约束条件时的常见错误。

function minimumLength<Type extends { length: number }>(
  obj: Type,
  minimum: number
): Type {
  if (obj.length >= minimum) {
    return obj
 } else {
    return { length: minimum }
 }
}

看起来这个函数没有问题--Type被限制为{ length: number },而且这个函数要么返回Type,要么返回一 个与该限制相匹配的值。问题是,该函数承诺返回与传入的对象相同的类型,而不仅仅是与约束条件相 匹配的一些对象。如果这段代码是合法的,你可以写出肯定无法工作的代码。

// 'arr' 获得值: { length: 6 }
const arr = minimumLength([1, 2, 3], 6);
//在此崩溃,因为数组有一个'切片'方法,但没有返回对象!
console.log(arr.slice(0));

指定类型参数

TypeScript 通常可以推断出通用调用中的预期类型参数,但并非总是如此。例如,假设你写了一个函数 来合并两个数组:

function combine<Type>(arr1: Type[], arr2: Type[]): Type[] {
  return arr1.concat(arr2);
}

通常情况下,用不匹配的数组调用这个函数是一个错误:
 

const arr = combine([1, 2, 3], ["hello"]);

然而,如果你打算这样做,你可以手动指定类型:  

const arr = combine<string | number>([1, 2, 3], ["hello"]);

 编写优秀通用函数的准则

类型参数下推

规则:在可能的情况下,使用类型参数本身,而不是对其进行约束

下面是两种看似相似的函数写法。

function firstElement1<Type>(arr: Type[]) {
  return arr[0];
}
 
function firstElement2<Type extends any[]>(arr: Type) {
  return arr[0];
}
 
// a: number (推荐)
const a = firstElement1([1, 2, 3]);
// b: any (不推荐)
const b = firstElement2([1, 2, 3]);

乍一看,这些可能是相同的,但 firstElement1 是写这个函数的一个更好的方法。它的推断返回类型 是Type,但 firstElement2 的推断返回类型是 any ,因为TypeScript必须使用约束类型来解析 arr[0] 表达式,而不是在调用期间 "等待 "解析该元素

使用更少的类型参数

规则:总是尽可能少地使用类型参数

下面是另一对类似的函数

function filter1<Type>(arr: Type[], func: (arg: Type) => boolean): Type[] {
  return arr.filter(func);
}
 
function filter2<Type, Func extends (arg: Type) => boolean>(
  arr: Type[],
  func: Func
): Type[] {
  return arr.filter(func);
}

我们已经创建了一个类型参数 Func ,它并不涉及两个值。这总是一个值得标记的坏习惯,因为它意味 着想要指定类型参数的调用者必须无缘无故地手动指定一个额外的类型参数。 Func 除了使函数更难阅 读和推理外,什么也没做。

类型参数应出现两次

规则:如果一个类型的参数只出现在一个地方,请重新考虑你是否真的需要它

有时我们会忘记,一个函数可能不需要是通用的:

function greet<Str extends string>(s: Str) {
  console.log("Hello, " + s);
}
 
greet("world");

 我们完全可以写一个更简单的版本:

function greet(s: string) {
  console.log("Hello, " + s);
}

记住,类型参数是用来关联多个值的类型的。如果一个类型参数在函数签名中只使用一次,那么它就没 有任何关系。

可选参数

JavaScript中的函数经常需要一个可变数量的参数。例如, number 的 toFixed 方法需要一个可选的数字计数。

function f(n: number) {
  console.log(n.toFixed()); // 0 个参数
  console.log(n.toFixed(3)); // 1 个参数
}

我们可以在TypeScript中通过将参数用 ? 标记:

function f(x?: number) {
  // ...
}
f(); // 正确
f(10); // 正确

虽然参数被指定为 number 类型,但 x 参数实际上将具有 number | undefined 类型,因为在 JavaScript中未指定的参数会得到 undefined 的值。

你也可以提供一个参数默认值。

function f(x = 10) {
  // ...
}

现在在 f 的主体中, x 将具有 number 类型,因为任何 undefined 的参数将被替换为 10 。请注 意,当一个参数是可选的,调用者总是可以传递未定义的参数,因为这只是模拟一个 "丢失 "的参数:

declare function f(x?: number): void;
// 以下调用都是正确的
f();
f(10);
f(undefined);

回调中的可选参数

当为回调写一个函数类型时,永远不要写一个可选参数,除非你打算在不传递该参数的情况下调用 函数。

一旦你了解了可选参数和函数类型表达式,在编写调用回调的函数时就很容易犯以下错误:

function myForEach(arr: any[], callback: (arg: any, index?: number) => void) {
  for (let i = 0; i < arr.length; i++) {
    callback(arr[i], i);
 }
}

我们在写 index? 作为一个可选参数时,通常是想让这些调用都是合法的:

myForEach([1, 2, 3], (a) => console.log(a));
myForEach([1, 2, 3], (a, i) => console.log(a, i));

这实际上意味着回调可能会被调用,只有一个参数。换句话说,该函数定义说,实现可能是这样的:

function myForEach(arr: any[], callback: (arg: any, index?: number) => void) {
  for (let i = 0; i < arr.length; i++) {
    // 我现在不想提供索引
    callback(arr[i]);
 }
}

反过来,TypeScript会强制执行这个意思,并发出实际上不可能的错误:

myForEach([1, 2, 3], (a, i) => {
  console.log(i.toFixed())
})

 在JavaScript中,如果你调用一个形参多于实参的函数,额外的参数会被简单地忽略。TypeScript的行为 也是如此。参数较少的函数(相同的类型)总是可以取代参数较多的函数的位置。

函数重载

一些 JavaScript 函数可以在不同的参数数量和类型中被调用。例如,你可能会写一个函数来产生一个 Date,它需要一个时间戳(一个参数)或一个月/日/年规格(三个参数)。

说点我的理解吧,就是给一个函数多种传参形式,就是一个函数可以有很多入口。每个入口都可以调用这个函数。

在TypeScript中,我们可以通过编写重载签名来指定一个可以以不同方式调用的函数。要做到这一点, 要写一些数量的函数签名(通常是两个或更多),然后是函数的主体:

function makeDate(timestamp: number): Date;
function makeDate(m: number, d: number, y: number): Date;
function makeDate(mOrTimestamp: number, d?: number, y?: number): Date {
  if (d !== undefined && y !== undefined) {
    return new Date(y, mOrTimestamp, d);
 } else {
    return new Date(mOrTimestamp);
 }
}
const d1 = makeDate(12345678);
const d2 = makeDate(5, 5, 5);
const d3 = makeDate(1, 3);

 在这个例子中,我们写了两个重载:一个接受一个参数,另一个接受三个参数。这前两个签名被称为重 载签名。

然后,我们写了一个具有兼容签名的函数实现。函数有一个实现签名,但这个签名不能被直接调用。即 使我们写了一个在所需参数之后有两个可选参数的函数,它也不能以两个参数被调用!

 重载签名和实现签名

这是一个常见的混乱来源。通常我们会写这样的代码,却不明白为什么会出现错误:

function fn(x: string): void;
function fn() {
  // ...
}
// 期望能够以零参数调用
fn();

同样,用于编写函数体的签名不能从外面 "看到"。

 实现的签名从外面是看不到的。在编写重载函数时,你应该总是在函数的实现上面有两个或多个签名。

 实现签名也必须与重载签名兼容。例如,这些函数有错误,因为实现签名没有以正确的方式匹配重载:

function fn(x: boolean): void;
// 参数类型不正确
function fn(x: string): void;
function fn(x: boolean) {}

function fn(x: string): string;
// 返回类型不正确
function fn(x: number): boolean;
function fn(x: string | number) {
  return "oops";
}

说点人话,就是你重载了多少函数签名,你就要在实现签名中实现多少,不然就会出现匹配问题,意思就是你既然给人家表白了(重载签名 )你就要对人家负责啊!

编写好的重载 

和泛型一样,在使用函数重载时,有一些准则是你应该遵循的。遵循这些原则将使你的函数更容易调 用,更容易理解,更容易实现。

让我们考虑一个返回字符串或数组长度的函数:

function len(s: string): number;
function len(arr: any[]): number;
function len(x: any) {
  return x.length;
}

这个函数是好的;我们可以用字符串或数组来调用它。然而,我们不能用一个可能是字符串或数组的值 来调用它,因为TypeScript只能将一个函数调用解析为一个重载:

len(""); // OK
len([0]); // OK
len(Math.random() > 0.5 ? "hello" : [0]);

因为两个重载都有相同的参数数量和相同的返回类型,我们可以改写一个非重载版本的函数:

function len(x: any[] | string) {
  return x.length;
}
len(""); // OK
len([0]); // OK
len(Math.random() > 0.5 ? "hello" : [0]); // OK

这就好得多了! 调用者可以用任何一种值来调用它,而且作为额外的奖励,我们不需要找出一个正确的实现签名。

在可能的情况下,总是倾向于使用联合类型的参数而不是重载参数

函数内This 的声明

 TypeScript会通过代码流分析来推断函数中的 this 应该是什么,比如下面的例子:

const user = {
  id: 123,
 
  admin: false,
  becomeAdmin: function () {
    this.admin = true;
 },
};

TypeScript理解函数 user.becomeAdmin 有一个对应的 this ,它是外部对象 user 。这个对于很多情 况来说已经足够了,但是有很多情况下你需要更多的控制 this 代表什么对象。JavaScript规范规定, 你不能有一个叫 this 的参数,所以TypeScript使用这个语法空间,让你在函数体中声明 this 的类型。

interface User {
  admin: boolean
}
interface DB {
  filterUsers(filter: (this: User) => boolean): User[];
}
const db:DB = {
  filterUsers: (filter: (this: User) => boolean) => {
    let user1 = {
      admin: true
   }
    let user2 = {
      admin: false
   }
    return [user1, user2]
 }
}
const admins = db.filterUsers(function (this: User) {
  return this.admin;
})

这种模式在回调式API中很常见,另一个对象通常控制你的函数何时被调用。注意,你需要使用函数而不 是箭头函数来获得这种行为。

interface User {
  admin: boolean
}
interface DB {
  filterUsers(filter: (this: User) => boolean): User[];
}
const db:DB = {
  filterUsers: (filter: (this: User) => boolean) => {
    let user1 = {
      admin: true
   }
    let user2 = {
      admin: false
   }
    return [user1, user2]
 }
}
// 不能为箭头函数
const admins = db.filterUsers(() => this.admin);

 需要了解的其他类型

有一些额外的类型你会想要认识,它们在处理函数类型时经常出现。像所有的类型一样,你可以在任何 地方使用它们,但这些类型在函数的上下文中特别相关。

void

void 表示没有返回值的函数的返回值。当一个函数没有任何返回语句,或者没有从这些返回语句中返 回任何明确的值时,它都是推断出来的类型。

// 推断出的返回类型是void
function noop() {
  return;
}

在JavaScript中,一个不返回任何值的函数将隐含地返回 undefinded 的值。然而,在TypeScript中, void 和 undefined 是不一样的。在本章末尾有进一步的细节。

object

特殊类型 object 指的是任何不是基元的值( string 、 number 、 bigint 、 boolean 、 symbol 、 null 或 undefined )。这与空对象类型 { } 不同,也与全局类型 Object 不同。你很可能永远不会使 用 Object 。

请注意,在JavaScript中,函数值是对象。它们有属性,在它们的原型链中有 Object.prototype ,是 Object 的实例,你可以对它们调用 Object.key ,等等。由于这个原因,函数类型在TypeScript中被 认为是 object 。

unknown

unknown 类型代表任何值。这与 any 类型类似,但更安全,因为对未知 unknown 值做任何事情都是 不合法的。

function f1(a: any) {
  a.b(); // 正确
}
function f2(a: unknown) {
  a.b();
}

 

这在描述函数类型时很有用,因为你可以描述接受任何值的函数,而不需要在函数体中有 any 值。

 反之,你可以描述一个返回未知类型的值的函数:

function safeParse(s: string): unknown {
  return JSON.parse(s);
}
 
// 需要小心对待'obj'!
const obj = safeParse(someRandomString);

never

有些函数永远不会返回一个值:

function fail(msg: string): never {
  throw new Error(msg);
}

never 类型表示永远不会被观察到的值。在一个返回类型中,这意味着函数抛出一个异常或终止程序的 执行

never 也出现在TypeScript确定一个 union 中没有任何东西的时候。 

function fn(x: string | number) {
  if (typeof x === "string") {
    // 做一些事
 } else if (typeof x === "number") {
    // 再做一些事
 } else {
    x; // 'never'!
 }
}

Function

全局性的 Function 类型描述了诸如 bind 、 call 、 apply 和其他存在于JavaScript中所有函数值的属 性。它还有一个特殊的属性,即 Function 类型的值总是可以被调用;这些调用返回 any 。

function doSomething(f: Function) {
  return f(1, 2, 3);
}

这是一个无类型的函数调用,一般来说最好避免,因为 any 返回类型都不安全。 如果你需要接受一个任意的函数,但不打算调用它,一般来说, () => void 的类型比较安全。

参数展开运算符

 形参展开(Rest Parameters)

除了使用可选参数或重载来制作可以接受各种固定参数数量的函数之外,我们还可以使用休止参数来定义接受无限制数量的参数的函数。

rest 参数出现在所有其他参数之后,并使用 ... 的语法:

function multiply(n: number, ...m: number[]) {
  return m.map((x) => n * x);
}
// 'a' 获得的值 [10, 20, 30, 40]
const a = multiply(10, 1, 2, 3, 4);

在TypeScript中,这些参数的类型注解是隐含的 any[] ,而不是 any ,任何给出的类型注解必须是 Array 或 T[] 的形式,或一个元组类型(我们将在后面学习)。

实参展开(Rest Arguments)

反之,我们可以使用 spread 语法从数组中提供可变数量的参数。例如,数组的 push 方法需要任意数 量的参数。

const arr1 = [1, 2, 3];
const arr2 = [4, 5, 6];
arr1.push(...arr2);

请注意,一般来说,TypeScript并不假定数组是不可变的。这可能会导致一些令人惊讶的行为。

// 推断的类型是 number[] -- "一个有零或多个数字的数组"。
// 不专指两个数字
const args = [8, 5];
const angle = Math.atan2(...args);
function sum({ a, b, c }: { a: number; b: number; c: number }) {
  console.log(a + b + c);
}

这种情况的最佳解决方案取决于你的代码,但一般来说, const context 是最直接的解决方案。  

// 推断为2个长度的元组
const args = [8, 5] as const;
// 正确
const angle = Math.atan2(...args);

 参数解构

你可以使用参数重构来方便地将作为参数提供的对象,解压到函数主体的一个或多个局部变量中。在 JavaScript中,它看起来像这样:

function sum({ a, b, c }) {
  console.log(a + b + c);
}
sum({ a: 10, b: 3, c: 9 });

对象的类型注解在解构的语法之后:

function sum({ a, b, c }: { a: number; b: number; c: number }) {
  console.log(a + b + c);
}

这看起来有点啰嗦,但你也可以在这里使用一个命名的类型:

// 与之前的例子相同
type ABC = { a: number; b: number; c: number };
function sum({ a, b, c }: ABC) {
  console.log(a + b + c);
}

函数的可分配性

返回 void 类型

函数的 void 返回类型可以产生一些不寻常的,但却是预期的行为。 返回类型为 void 的上下文类型并不强迫函数不返回东西。另一种说法是,一个具有 void 返回类型的 上下文函数类型( type vf = () => void ),在实现时,可以返回任何其他的值,但它会被忽略。 因此,以下 ()=> void 类型的实现是有效的:

type voidFunc = () => void
 
const f1: voidFunc = () => {
  return true
}
 
const f2: voidFunc = () => true
 
const f3: voidFunc = function () {
  return true
}

而当这些函数之一的返回值被分配给另一个变量时,它将保留 void 的类型:

const v1 = f1();
const v2 = f2();
const v3 = f3();

 这种行为的存在使得下面的代码是有效的,即使 Array.prototype.push 返回一个数字,而 Array.prototype.forEach 方法期望一个返回类型为 void 的函数:

const src = [1, 2, 3];
const dst = [0];
 
src.forEach((el) => dst.push(el));

还有一个需要注意的特殊情况,当一个字面的函数定义有一个 void 的返回类型时,该函数必须不返回 任何东西。

function f2(): void {
  return true;
}
 
const f3 = function (): void {
  return true;
};


 “如果我的坚强任性,

会不小心伤害了你。

你能不能温柔提醒,

我虽然心太急更害怕错过你” 

相关文章:

  • 【MyDB】5-索引管理之 1-索引管理思路概览
  • Centos7配置本地yum源
  • 大白话读懂java对象创建的过程
  • 织梦DedeCMS数据库表说明大全
  • django入门教程之request和reponse【二】
  • Windows 图形显示驱动开发-WDDM 3.0功能- 硬件翻转队列(六)
  • 联想拯救者触摸板会每次开机都自动关闭、联想笔记本触摸板关闭、笔记本电脑触摸板自动关闭的解决方法
  • 演员马晓琳正式加入创星演员出道计划,开启演艺事业新篇章
  • 基于YOLOv8与ByteTrack的车辆行人多目标检测与追踪系统
  • @maptalks/gl-layers中的VectorTileLayer的setStyle属性的全部line配置
  • 群体智能优化算法-模拟退火优化算法(Simulated Annealing, SA,含Matlab源代码)
  • 前端Tailwind CSS面试题及参考答案
  • 实时时钟芯片HYM1381的使用(51单片机)
  • 在K8S中挂载 Secret 到 Pod
  • 【C#知识点详解】ExcelDataReader介绍
  • day3 微机运算基础
  • 【LINUX操作系统】 动静态库的链接原理
  • 指令系统2(Load/Store 指令)
  • 打靶笔记:利用站点Install功能连接Kali本地数据库
  • 传输层协议 — TCP协议与套接字
  • 美国失去最后的AAA主权评级,继标普、惠誉后再遭穆迪降级
  • “GoFun出行”订单时隔7年扣费后续:平台将退费,双方已和解
  • 试点首发进口消费品检验便利化措施,上海海关与上海商务委发文
  • 沃尔玛上财季净利下滑12%:关税带来成本压力,新财季价格涨幅将高于去年
  • 英国6月初将公布对华关系的审计报告,外交部:望英方树立正确政策导向
  • 美官方将使用华为芯片视作违反美出口管制行为,外交部回应