函数

状态: 初稿

介绍

函数是 JavaScript 应用最基本的构件.
JavaScript 开发者用函数建立抽象层, 模拟类, 实施信息隐藏, 实行模块管理等.
尽管 TypeScript 原生支持类, 名字空间, 模块, 函数依然要承担起描述怎么做事的职责.
TypeScript 也为标准 JavaScript 函数增加了许多易于使用的特性.

函数

如同 JavaScript, 你可以创建命名函数和匿名函数.
你可以根据需要从中选择 — 是在创建一套 API, 还是创建作为参数的一次性函数…

以一个例子快速回顾这两种函数风格:

1
2
3
4
5
6
7
// Named function
function add(x, y) {
return x + y;
}

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

函数可以引用定义在函数体外部的变量.
我们称那种情形为函数捕获了这个变量.
理解这个体系的原理(及应用该技术利弊)超出了本文讨论的范围, 但我们认为作为 JavaScript 或 TypeScript 学习者, 至少要掌握它们的运行方式.

1
2
3
4
5
let z = 100;

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

函数与类型注解

为函数定类型

让我们为先前定义的函数加上类型注解:

1
2
3
4
5
function add(x: number, y: number): number {
return x + y;
}

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

我们可以注明函数每个参数和返回值的类型.
多数情况, 你可以省去返回值类型不写, TypeScript 有能力结合参数列表和返回表达式推断返回值的类型.

写出函数类型

我们已经了解如何定下函数参数和返回值类型, 现在可以写出函数自身的类型.

1
2
let myAdd: (x: number, y: number) => number =
function(x: number, y: number): number { return x + y; };

函数类型由两部分构成: 参数列表声明和返回值类型声明.
两部分都是写出一个完整函数类型所需的.
类型中的参数列表声明即函数的参数列表, 为每个参数定下名称和类型.
这里的名字是为了提高可读性, 并无实际作用.
可将上例改写成:

1
2
let myAdd: (baseValue: number, increment: number) => number =
function(x: number, y: number): number { return x + y; };

无论参数名是否匹配, 只要参数类型跟函数两两一致, 函数类型就被认为是有效的(译注: 还应当考虑返回值类型).

第二部分是函数返回值类型声明.
同样为了可读性, 我们用胖箭头 => 连接参数列表声明和返回值类型声明.
如前所述, 函数返回值是一个函数类型的关键组成部分, 如果你的函数没有返回值, 就要以 void 指出.

注意, 只有参数列表和返回值类型对一个函数类型起决定作用.
函数捕获的变量不反映在函数类型中.
实际上, 被捕获的变量是一个函数的隐藏状态, 用户不可感知.

推断类型

通过这些例子, 你会注意到赋值语句只要等号有一端类型是确定的, TypeScript 就能弄清楚另一端的类型:

1
2
3
4
5
6
// myAdd has the full function type
let myAdd = function(x: number, y: number): number { return x + y; };

// The parameters 'x' and 'y' have the type number
let myAdd: (baseValue: number, increment: number) => number =
function(x, y) { return x + y; };

这叫做上下文类型推断, 一种类型推断形式.
帮助减轻你维护类型信息的负担.

可选参数, 参数默认值

TypeScript 对照参数列表, 认为每个参数都是函数所需的.
在调用函数时, 编译器检查用户是否为每个参数提供了值, 即使是 null, undefined.
TypeScript 还认为除此之外, 函数不需要其他参数.
简单来说, 函数实参数量与形参数量应严格匹配.

1
2
3
4
5
6
7
function buildName(firstName: string, lastName: string) {
return firstName + " " + lastName;
}

let result1 = buildName("Bob"); // error, too few parameters
let result2 = buildName("Bob", "Adams", "Sr."); // error, too many parameters
let result3 = buildName("Bob", "Adams"); // ah, just right

对 JavaScript 而言, 每个参数都是可选的, 只要用户觉得合理, 就可以留空.
留空参数默认值是 undefined.
要在 TypeScript 中利用该特性, 你需要参数名后面加上问号?, 以表明它是”可选”的.
举个例子, 我们把上例最后一个参数 lastName 指定为可选:

1
2
3
4
5
6
7
8
9
10
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
let result3 = buildName("Bob", "Adams"); // ah, just right

可选参数只能出现在所有必选参数之后.
上例, 如果我们想让第一个参数 firstName “可选”, 而不是第二个参数 lastName, 需要改变两个参数的顺序, 把 firstName 置于最后.

我们可以为参数指定默认值, 如果用户留空一个参数, 或传递 undefined 值, 函数参数都会得到默认值.
该参数称作默认初始化的参数.
再次修改上例, 把 "Smith" 作为参数 lastName 的默认值.

1
2
3
4
5
6
7
8
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
let result4 = buildName("Bob", "Adams"); // ah, just right

排在所有必选参数身后的默认初始化参数按可选参数对待, 与可选参数一样, 调用时允许留空.
这也说明在函数类型中, 不区分可选参数和尾部默认参数, 所以:

1
2
3
function buildName(firstName: string, lastName?: string) {
// ...
}

1
2
3
function buildName(firstName: string, lastName = "Smith") {
// ...
}

的函数类型都是 (firstName: string, lastName?: string) => string.
函数类型不保存默认值, 它只体现默认参数”可选”的那一面.

与一般可选参数不同, 默认初始化的参数可以出现在必选参数之前.
对于这样的默认参数, 用户需要显式传入 undefined 使默认值生效.
我们只为 firstName 参数添加默认值:

1
2
3
4
5
6
7
8
function buildName(firstName = "Will", lastName: string) {
return firstName + " " + lastName;
}

let result1 = buildName("Bob"); // error, too few parameters
let result2 = buildName("Bob", "Adams", "Sr."); // error, too many parameters
let result3 = buildName("Bob", "Adams"); // okay and returns "Bob Adams"
let result4 = buildName(undefined, "Adams"); // okay and returns "Will Adams"

变长参数

“必选”, “可选”, “默认”都是在讨论单个参数.
有时, 一个函数最终需要的参数数量是不确定的, 或者你就是想以一个组接受多个参数.
在 JavaScript 中, 你可以直接访问每个函数都有的 arguments 变量.

借助 TypeScript, 你可以把这些参数收集到一个变量中:

1
2
3
4
5
6
function buildName(firstName: string, ...restOfName: string[]) {
return firstName + " " + restOfName.join(" ");
}

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

变长参数相当于不限数量的可选参数.
要填充一个变长参数, 实参的数量可以是 0 个; 也可以是任意多个.
TypeScript 为变长参数(以省略号 ... 指示)创建一个数组, 存储所有元素, 以便你在函数中使用.

书写函数类型时, 我们也用省略号表示变长参数:

1
2
3
4
5
function buildName(firstName: string, ...restOfName: string[]) {
return firstName + " " + restOfName.join(" ");
}

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

this 指针

在 JavaScript 中学习如何使用 this 可以说是一条充满仪式感的道路.
要掌握它的超集 TypeScript, 我们同样需要学习 this 的用法, 最好还能指出哪些 this 用法是不当的.
保险起见, TypeScript 也有技术找出 this 的不当使用.
如果你想了解 JavaScript 中 this 的工作原理, 可以阅读 Yehuda Katz 的 Understanding JavaScript Function Invocation and “this”.
鉴于这篇文章已经详细阐述 this 的内部原理, 我们在这里仅做必要说明.

this 和箭头函数

在 JavaScript 中, this 是一个函数被调用时创建的变量.
这造就了它强大而灵活的特性, 而代价是你必须时刻留意函数是在何种上下文中被调用的.
这是极大的负担, 特别是你把一个函数当成返回值返回或当成一个参数传递时.

我们看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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 返回的那个函数里面的 this 指向 window, 而不是 deck 对象.
根本原因是 15 行, 我们独立地调用 cardPicker().
一个顶层非方法调用的 this 总是指向 window.
(注: 在严格模式下, this 指向 undefined).

我们可以确保在作为返回值的函数在返回前已经与正确的 this 绑定, 以修复该问题.
这样, 无论这个函数之后怎么被调用, 它看到的都是最初的 desk 对象.
要实现该想法, 我们修改函数表达式, 运用 ECMAScript 6 箭头语法.
箭头函数 捕获 函数创建处而不是调用处的 this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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);

如果你打开 --noImplictThis 编译器选项, TypeScript 还能做得更好.
它会指出 this.suits[pickedSuit] 中的 thisany 类型.

this 作为参数

没错, this.suits[pickedSuit] 中的 this 的确是 any.
因为 this 来自一个对象字面量内部的函数表达式.
要消除 any, 可以显式增加 this 参数.
this 参数是个先于参数列表其他所有参数的伪参数.

1
2
3
function f(this: void) {
// make sure `this` is unusable in this standalone function
}

先为我们的例子添加一些接口, Card, Deck. 使类型更清晰, 也有利于重用.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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 希望自己在 Deck 对象上调用.
同时也说明 this 的类型变成了 Deck, --noImplicitThis 不再报错.

回调与 this

你传递一个函数给库, 库在正确地时机调用它, 这个调用就叫回调, 回调中的 this 同样容易出错.
假设库以调用普通函数的方式调用回调函数, this 会是 undefined.
this 再加上一些技巧可以避免回调中的 this 错误.
首先, 库作者需要正确书写回调函数类型, 为它增加 this 参数:

1
2
3
interface UIElement {
addClickListener(onclick: (this: void, e: Event) => void): void;
}

this: void 表示 addClickLisener 希望 onclick 是一个不依赖 this 的函数.
其次, 为你的回调函数加上 this 参数.

1
2
3
4
5
6
7
8
9
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!

上例, 你显式注明 onClickBad 必须在 Handler 对象上调用.
addClickListener 要求它参数(回调函数)有 this: void.
我们改变 this 的类型:

1
2
3
4
5
6
7
8
9
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);

这里 onClickGoodthis 类型是 void, 可以合法传递给 addClickListener.
当然, 既然 thisvoid, 你就不能再访问 this.info.
如果你想同时做到两点(1. 能调用addClickListener, 2. 能使用 this):

1
2
3
4
class Handler {
info: string;
onClickGood = (e: Event) => { this.info = e.message }
}

我们再次请出箭头函数, 它捕获外层 this, 不受 this: void 限制.
其局限性是 TypeScript 为每个 Handler 对象创建一个箭头函数.
作为对比, 普通方法是附加到 Handler 类的原型上的, 只会被创建一次.
所有 Handler 对象共享这些方法.

重载

我们知道, JavaScript 是一种动态语言.
一个函数根据函数参数的类型, 返回不同类型的结果, 是很稀松平常的事情.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
let suits = ["hearts", "spades", "clubs", "diamonds"];

function pickCard(x): 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 根据参数的类型返回两种不同类型的值.
如果用户传入的参数代表一副扑克, 这个函数会挑选一张卡片.
如果用户挑选卡片, 我们告诉他们挑选的是哪一张.
那么, 怎么用类型系统来描述?

答案是为一个函数提供多个函数签名, 组成一个重载列表.
编译器将查询重载列表解析每个函数调用.
下面, 我们为 pickCard 创建一个重载列表, 以描述这个函数怎么根据不同的参数返回不同的返回值.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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 {
// 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 函数调用都经过了类型检查.

编译器循序 JavaScript 底层一种类似流程选择合适重载检查调用正确性.
它从第一个重载开始, 遍历重载列表, 看当前参数列表是否匹配.
如果它找到一个匹配, 选取它作为最佳重载.
为配合这一机制, 通常我们按照最常用-最不常用原则手动排序重载列表.

注意函数实现 function pickCard(x): any 不是重载列表的一部分, 这个函数只有两个有效重载: 一个接受 object, 一个接收 number.
以其他任何形式调用 pickCard 都会引发错误.

如果这篇文章对您有用,可以考虑打赏:)