TypeScript 系列之函数

2020-10-30 11:44:10 浏览数 (1)

摘要

函数是 JavaScript 中的一等公民,在 TypeScript 中也一样。函数可以用来抽象逻辑、模拟类、隐藏实现以及实现模块。虽然在 TypeScript 中已经有了类、命名空间以及模块,但是函数在描述如何做某件事上仍然有很重要的作用。TypeScript 相比于 JavaScript 也添加了一些额外的功能,让函数用起来更顺手。

函数

和 JavaScript 一样,函数有两种,第一种是具名函数,第二种匿名函数。选择使用哪一种取决于该函数要应用的场景。快速回顾一下这两种函数:

代码语言:javascript复制
// Named function
function add(x, y) {
  return x   y;
}

// Anonymous function
let myAdd = function (x, y) {
  return x   y;
};

函数可以访问函数体外部的变量。当在函数体内访问函数体外部的变量时,我们称之为该函数捕获了该变量。这其实就是 JavaScript 中的闭包,但是闭包是如何工作的,以及使用闭包的好处和坏处并不在本文的讨论范围之内,不过闭包仍然是非常重要的,不论在 JavaScript 还是 TypeScript 中都非常有用。

如果想深入了解 JavaScript 中闭包的工作原理,可以查阅《你不知道的 JavaScript(上)》中的第一部分的第五章。

代码语言:javascript复制
let z = 100;

function addToZ(x, y) {
  return x   y   z;
}

函数类型

给函数添加类型

以上面的两个函数为例,我们可以为这两个函数添加类型信息:

代码语言:javascript复制
function add(x: number, y: number): number {
  return x   y;
}

let myAdd = function (x: number, y: number): number {
  return x   y;
};

我们既可以给参数添加类型,也可以给返回值添加类型。TypeScript 可以根据函数的返回语句推断返回值类型,因此有时候你可以选择不写返回值类型。

函数类型

我们可以把函数的类型也单独写出来,参照上面的 myAdd 函数:

代码语言:javascript复制
let myAdd: (x: number, y: number) => number = function (
  x: number,
  y: number
): number {
  return x   y;
};

函数类型是与函数的参数、返回值一一对应的。函数类型也有两个部分,参数列表和返回值。当你要写一个函数类型的时候,这两者都是必须的。参数列表和函数实现几乎一样,都包括参数的名字和参数的类型,而返回值只包括类型。两者之间用一个胖箭头(=>)分隔。类型中的参数名字不必和函数实现中的参数名字相同,类型中的参数名字只是用来增加类型的可读性的。你可以在函数实现中给参数不一样的名字,比如:

代码语言:javascript复制
let myAdd: (base: number, offset: number) => number = function (
  x: number,
  y: number
): number {
  return x   y;
};

返回值类型是必须的,当一个函数类型不要求有任何返回值的时候,返回值类型应该用 void 类型。 需要注意的是,函数类型只包含参数类型和返回值类型,而不包含闭包中变量的类型。闭包中的变量应该被视为“隐藏状态”的一部分,并不是 API 的一部分。

类型推断

你也许注意到了,有时候函数中可以省略返回值类型,而 TypeScript 编译器仍然可以给函数的返回值添加正确的类型:

代码语言:javascript复制
// The parameters 'x' and 'y' have the type number
let myAdd = function (x: number, y: number): number {
  return x   y;
};

// myAdd has the full function type
let myAdd2: (baseValue: number, increment: number) => number = function (x, y) {
  return x   y;
};

这被称为“上下文类型”,类型推断的一种。这可以帮助减少在维护代码类型完备时的代码量和工作量。

可选参数和参数默认值

在 TypeScript 中,所有参数都被假定是必须的。当然这并不意味着参数不能被赋值 null 或者 undefined,但是,当函数被调用的时候,编译器会检查函数的每个参数是否都已经提供了该参数可以接受的类型的值。而且编译器还会假定,有且只有一个参数会传递给参数列表中的任意一个参数,换句话说就是调用函数的参数个数和函数能接受的参数个数要一致:

代码语言:javascript复制
function buildName(firstName: string, lastName: string) {
  return firstName   " "   lastName;
}

let result1 = buildName("Bob"); // error, too few parameters
// Expected 2 arguments, but got 1.
let result2 = buildName("Bob", "Adams", "Sr."); // error, too many parameters
// Expected 2 arguments, but got 3.
let result3 = buildName("Bob", "Adams"); // ah, just right

在 JavaScript 中,函数的所有参数都是可选的,在调用函数的时候,可以选择传递任意数量的参数。当参数被省略的时候,该参数的值就是 undefined。在 TypeScript 中,我们可以在参数名后面添加一个 ? 来表明该参数是可选参数。比如,我们想让函数的最后一个参数是可选的:

代码语言:javascript复制
function buildName(firstName: string, lastName?: string) {
  if (lastName) return firstName   " "   lastName;
  else return firstName;
}

let result1 = buildName("Bob"); // works correctly now
let result2 = buildName("Bob", "Adams", "Sr."); // error, too many parameters
// Expected 1-2 arguments, but got 3.
let result3 = buildName("Bob", "Adams"); // ah, just right

可选参数必须在必选参数后面。如果你想让上面的 firstName 变成可选参数的话,你需要将 firstNamelastName 的顺序进行互换。 TypeScript 当然也支持参数默认值,当调用函数时没有提供参数或者以 undefined 调用时,该参数会被置为参数默认值。还是以上面这个函数为例,我们将 lastName 的默认值设置为 Smith:

代码语言:javascript复制
function buildName(firstName: string, lastName = "Smith") {
  return firstName   " "   lastName;
}

let result1 = buildName("Bob"); // works correctly now, returns "Bob Smith"
let result2 = buildName("Bob", undefined); // still works, also returns "Bob Smith"
let result3 = buildName("Bob", "Adams", "Sr."); // error, too many parameters
// Expected 1-2 arguments, but got 3.
let result4 = buildName("Bob", "Adams"); // ah, just right

在必选参数后面有默认值的参数会被当作可选参数对待,和可选参数一样,有默认值的参数可以在调用的时候省略。这也意味着,可选参数和参数默认值的函数拥有同样的函数类型,即函数

代码语言:javascript复制
function buildName(firstName: string, lastName?: string) {
  // ...
}

和函数

代码语言:javascript复制
function buildName(firstName: string, lastName = "Smith") {
  // ...
}

都拥有同样的函数类型:

代码语言:javascript复制
(firstName: string, lastName?: string) => string

函数参数的默认值从类型中消失了,取而代之的是该默认值的类型和 undefined 的联合类型。 和可选参数不一样,有默认值的参数不必出现在必选参数后面。如果有默认值的参数出现在必选参数之前,调用该函数的时候需要显式地传递 undefined 才能让参数默认值生效。比如,如果 firstName 有默认值时,想让 firstName 使用默认值,需要显式传入 undefined

代码语言:javascript复制
function buildName(firstName = "Will", lastName: string) {
  return firstName   " "   lastName;
}

let result1 = buildName("Bob"); // error, too few parameters
// Expected 2 arguments, but got 1.
let result2 = buildName("Bob", "Adams", "Sr."); // error, too many parameters
// Expected 2 arguments, but got 3.
let result3 = buildName("Bob", "Adams"); // okay and returns "Bob Adams"
let result4 = buildName(undefined, "Adams"); // okay and returns "Will Adams"

剩余参数

必选参数、可选参数和默认值参数都有一个共同特点,那就是他们都只是针对一个参数而言的。有时候,你可能需要将多个参数视为一个参数组来进行一些操作,或者你根本不知道会有多少个参数。在 JavaScript 中,你可以通过访问函数内部的 arguments 变量来获取全部参数。而在 ECMAScript 6 以及 TypeScript 中,你可以通过一个变量来收集这些参数:

代码语言:javascript复制
function buildName(firstName: string, ...restOfName: string[]) {
  return firstName   " "   restOfName.join(" ");
}

// employeeName will be "Joseph Samuel Lucas MacKinzie"
let employeeName = buildName("Joseph", "Samuel", "Lucas", "MacKinzie");

剩余参数被当成不限个数的可选参数。当函数使用剩余参数的时候,你可以传入任意数量的参数,不传入也是可以的。编译器会自动创建一个数组赋值给 ... 后面的变量名,你可以在函数体中使用该变量。... 也会出现在函数的类型中:

代码语言:javascript复制
function buildName(firstName: string, ...restOfName: string[]) {
  return firstName   " "   restOfName.join(" ");
}

let buildNameFun: (fname: string, ...rest: string[]) => string = buildName;

this

学会如何使用 this 是 JavaScript 开发者的一个重大里程碑。TypeScript 是 JavaScript 的超集,TypeScript 开发者一样也要学习如何使用 this,以及能够发现 this 的不正确的用法。TypeScript 提供了一些技术来帮助捕获不正确的 this 用法,如果你想学习如何理解和使用 this 的话,可以查看http://yehudakatz.com/2011/08/11/understanding-javascript-function-invocation-and-this/(英),或者阅读《你不知道的 JavaScript(上)》第一部分的附录 C。本文不再赘述。

this 和箭头函数

在 JavaScript 中,this 是一个在调用函数时设定的变量。this 是一个非常有用且灵活的技术,但任何技术都有代价,理解 this 如何工作是 JavaScript 初学者的一大难题。this 的工作机制是众所周知的难以理解,尤其是把函数作为参数或者返回值的时候。来看一个例子:

代码语言:javascript复制
let deck = {
  suits: ["hearts", "spades", "clubs", "diamonds"],
  cards: Array(52),
  createCardPicker: function () {
    return function () {
      let pickedCard = Math.floor(Math.random() * 52);
      let pickedSuit = Math.floor(pickedCard / 13);

      return { suit: this.suits[pickedSuit], card: pickedCard % 13 };
    };
  },
};

let cardPicker = deck.createCardPicker();
let pickedCard = cardPicker();

alert("card: "   pickedCard.card   " of "   pickedCard.suit);

注意:createCardPicker 是一个返回函数的函数。如果我们尝试运行上面这个例子的话,引擎会报错,而不是弹出一个对话框。这是由于 createCardPicker 所创建的函数内部使用的 thiswindow 而不是 deck 对象。直接在全局作用域调用一个非对象的方法时,函数的 this 指向的就是全局对象(浏览器是 window,NodeJS 是 global),如果是在严格模式下,则 thisundefined。 我们可以通过一些技巧来确保返回的函数在执行时 this 是我们预期之内的,不论后期该函数是如何被调用的,this 总是指向 deck 对象。要实现这个效果很简单,只需要把返回的函数从普通函数改成 ECMAScript 6 中引入的箭头函数就行。箭头函数的 this 会保持住该箭头函数被创建时的 this 对象,而不会随着该函数执行位置的改变而改变:

代码语言:javascript复制
let deck = {
  suits: ["hearts", "spades", "clubs", "diamonds"],
  cards: Array(52),
  createCardPicker: function () {
    // NOTE: the line below is now an arrow function, allowing us to capture 'this' right here
    return () => {
      let pickedCard = Math.floor(Math.random() * 52);
      let pickedSuit = Math.floor(pickedCard / 13);

      return { suit: this.suits[pickedSuit], card: pickedCard % 13 };
    };
  },
};

let cardPicker = deck.createCardPicker();
let pickedCard = cardPicker();

alert("card: "   pickedCard.card   " of "   pickedCard.suit);

如果你在 tsconfig.json 中开启了 noImplicitThis 时,TypeScript 编译器还会指出 this.suits[pickedSuit]any 类型。

this 参数

然而不幸的是,this.suits[pickedSuit] 仍然是 any 类型。这是因为 this 来自一个对象字面量的内部,想修复这个问题,我们可以提供一个显式的 this 参数。this 参数是一个“假”参数,它并不是函数参数,this 参数应该放在所有其他参数之前:

代码语言:javascript复制
function f(this: void) {
  // make sure `this` is unusable in this standalone function
}
给上面的例子加一些类型,让类型更加清晰且易于重用:
interface Card {
  suit: string;
  card: number;
}

interface Deck {
  suits: string[];
  cards: number[];
  createCardPicker(this: Deck): () => Card;
}

let deck: Deck = {
  suits: ["hearts", "spades", "clubs", "diamonds"],
  cards: Array(52),
  // NOTE: The function now explicitly specifies that its callee must be of type Deck
  createCardPicker: function (this: Deck) {
    return () => {
      let pickedCard = Math.floor(Math.random() * 52);
      let pickedSuit = Math.floor(pickedCard / 13);

      return { suit: this.suits[pickedSuit], card: pickedCard % 13 };
    };
  },
};

let cardPicker = deck.createCardPicker();
let pickedCard = cardPicker();

alert("card: "   pickedCard.card   " of "   pickedCard.suit);

现在 TypeScript 知道函数 createCardPicker 执行时 this 指向的对象拥有 Deck 类型。这意味着 this 指向的对象拥有 Deck 类型,而非 any 类型,因此 noImplicitThis 也不再会报错。

回调函数中的 this 参数

当你传递给第三方库回调函数的时候仍然会有 this 的问题。因为第三方库调用回调函数的时候,是按照普通函数来调用的(而非在一个对象上的方法或者通过 call / apply),这时候 thisundefined。用 this 参数可以防止回调函数中的此类错误。首先,第三方库的作者需要声明以下类型:

代码语言:javascript复制
interface UIElement {
  addClickListener(onclick: (this: void, e: Event) => void): void;
}

this: void 表示 addClickListener 预期 onclick 被调用时不需要用到 this。然后,给你的函数添加 this 参数:

代码语言:javascript复制
class Handler {
  info: string;
  onClickBad(this: Handler, e: Event) {
    // oops, used `this` here. using this callback would crash at runtime
    this.info = e.message;
  }
}

let h = new Handler();
uiElement.addClickListener(h.onClickBad); // error!

Argument of type '(this: Handler, e: Event) => void' is not assignable to parameter of type '(this: void, e: Event) => void'.
  The 'this' types of each signature are incompatible.
    Type 'void' is not assignable to type 'Handler'.

当你添加了 this 参数的时候,就指明了 onClickBad 执行的时候,this 必须指向 Handler 的实例。TypeScript 发现 addClickListener 需要回调函数的 thisvoid 类型,因此报错。为了修复这个错误,需要修改 this 的类型:

代码语言:javascript复制
class Handler {
  info: string;
  onClickGood(this: void, e: Event) {
    // can't use `this` here because it's of type void!
    console.log("clicked!");
  }
}

let h = new Handler();
uiElement.addClickListener(h.onClickGood);

因为 onClickGood 指明了 thisvoid 类型,因此可以传递给 addClickListener。当然,这也意味着你不能在函数体内使用 this.info。如果你既不想 TypeScript 报错,又想使用 this.info,你就必须使用箭头函数:

代码语言:javascript复制
class Handler {
  info: string;
  onClickGood = (e: Event) => {
    this.info = e.message;
  };
}

这个例子不会报错是因为箭头函数会保持住创建时的 this 指向,无论第三方库如何调用,都不会改变 this 的指向,因此你可以传递给期望 this: void 的函数。这个例子中,每新建一个 Handler 对象,都会新生成一个 this 与之绑定的箭头函数。而前一种用法,方法只会在创建 Handler 的原型的时候创建一次并附加到 Handler 原型上。原型上的方法在所有 Handler 的实例中是共享的。

函数重载

JavaScript 是一个动态语言。一个函数根据传入的参数的不同返回不同类型的返回值是非常常见的。

代码语言:javascript复制
let suits = ["hearts", "spades", "clubs", "diamonds"];

function pickCard(x: any): any {
  // Check to see if we're working with an object/array
  // if so, they gave us the deck and we'll pick the card
  if (typeof x == "object") {
    let pickedCard = Math.floor(Math.random() * x.length);
    return pickedCard;
  }
  // Otherwise just let them pick the card
  else if (typeof x == "number") {
    let pickedSuit = Math.floor(x / 13);
    return { suit: suits[pickedSuit], card: x % 13 };
  }
}

let myDeck = [
  { suit: "diamonds", card: 2 },
  { suit: "spades", card: 10 },
  { suit: "hearts", card: 4 },
];

let pickedCard1 = myDeck[pickCard(myDeck)];
alert("card: "   pickedCard1.card   " of "   pickedCard1.suit);

let pickedCard2 = pickCard(15);
alert("card: "   pickedCard2.card   " of "   pickedCard2.suit);

pickCard 函数会根据传入的参数不同而返回完全不同的返回值。如果传入的是一个代表牌组的对象,该函数会从该牌组中选择一张(返回值是 number 类型)。如果传入的是一个数字,该函数就返回该数字所代表的牌。我们该如何来描述这个函数的类型? 答案是为一个函数提供多个重载函数类型。编译器会按顺序挨个尝试函数重载列表中的所有函数类型。以 pickCard 为例子,我们给它添加一个重载函数列表,每个重载描述一种情况下该函数的参数类型和返回值类型:

代码语言:javascript复制
let suits = ["hearts", "spades", "clubs", "diamonds"];

function pickCard(x: { suit: string; card: number }[]): number;
function pickCard(x: number): { suit: string; card: number };
function pickCard(x: any): any {
  // Check to see if we're working with an object/array
  // if so, they gave us the deck and we'll pick the card
  if (typeof x == "object") {
    let pickedCard = Math.floor(Math.random() * x.length);
    return pickedCard;
  }
  // Otherwise just let them pick the card
  else if (typeof x == "number") {
    let pickedSuit = Math.floor(x / 13);
    return { suit: suits[pickedSuit], card: x % 13 };
  }
}

let myDeck = [
  { suit: "diamonds", card: 2 },
  { suit: "spades", card: 10 },
  { suit: "hearts", card: 4 },
];

let pickedCard1 = myDeck[pickCard(myDeck)];
alert("card: "   pickedCard1.card   " of "   pickedCard1.suit);

let pickedCard2 = pickCard(15);
alert("card: "   pickedCard2.card   " of "   pickedCard2.suit);

函数重载让我们可以针对不同类型的参数做不同的类型检查。编译器在检查函数重载的时候,会按照重载顺序,挨个向下尝试用已有的参数匹配每个重载,如果能匹配到,则使用匹配到的重载进行类型检查,跳过剩余的重载。因此,函数重载一般把最具体的放在最上面,最不具体的放在最下面。 注意最后的 function pickCard(x): any 并不是函数重载的一部分,因此该函数只有两个重载,第一个重载接受一个对象,第二个重载则接受一个数字。用其他任何参数调用 pickCard 都会报错。

(完)

- END -

0 人点赞