文章目录
- 3.1.2 箭头函数——更流行的方式 Arrow functions - the modern way
- 1. 返回值 Returning values
- 2. this 值的处理 Handling the this value
- 3. arguments 的处理 Working with arguments
- 4. 单参数还是多参数? One argument or many?
写在前面
故天将降大任于是人也,必先苦其心志,劳其筋骨,饿其体肤,空乏其身,行拂乱其所为,所以动心忍性,曾益其所不能。
——《孟子·告子章句下·第十五节》
3.1.2 箭头函数——更流行的方式 Arrow functions - the modern way
尽管箭头函数工作原理与其他函数几乎殊无二致,但与普通函数相比还是存在一些关键差异(详见 箭头函数 MDN 文档)。箭头函数可以不带 return
语句隐式地返回某个值、同时也没有绑定 this
(即函数的上下文)的操作、且不存在 arguments
对象;它们不能被用作构造函数(constructors),也没有 prototype
属性(property),并且由于不允许使用 yield
关键字,也无法用作生成器函数(generators)。
本节我们将讨论以下几个与 JavaScript
函数相关的话题:
- 如何返回不同的函数值;
- 如何处理
this
值带来的问题; - 参数个数不固定时的处理;
- 一个重要概念:科里化(
currying
)(后续章节将多次用到)。
1. 返回值 Returning values
根据 Lambda
演算的编码风格,函数仅由一个结果构成。简化起见,新增的箭头函数也提供了相应的语法支持。当写作 (x, y, z) =>
并后跟一个表达式时,就隐式包含了一个 return
语句。例如下面的两个函数就与前面演示的 sum3()
函数功能相同:
const f1 = (x: number, y: number, z: number): number =>
x + y + z;
const f2 = (x: number, y: number, z: number): number => {
return x + y + z;
};
如若返回的是一个对象,则必须添加小括号,否则 JavaScript
会误以为后面跟的是代码。为了避免您认为这是个小概率事件,请参阅本章最后 思考题 中的 问题3.1。这是一个非常常见的情况!
关于代码风格的说明
在定义一个单参数函数时,参数两边的小括号可以忽略。为保持风格一致,笔者更倾向于始终保留小括号。然而,本书使用的格式化工具
Prettier
(第一章《入门函数式编程的若干问题》提到过)最初倾向于默认不保留;但在 2.0 版本中,配置项arrow-parens
的默认值已由先前的avoid
(尽量不使用小括号)改为了always
(始终保留小括号)。
2. this 值的处理 Handling the this value
JavaScript
的一个经典问题是 this
值的处理,该取值往往并不按您的“套路”出牌。最终 ES2015
通过箭头函数成功解决了 this
的指向问题。来看下面这个例子:当超时函数被调用时,this
会指向全局变量(window
)而非新的对象,因此控制台输出的是 undefined
:
function ShowItself1(identity: string) {
this.identity = identity;
setTimeout(function () {
console.log(this.identity);
}, 1000);
}
var x = new ShowItself1("Functional");
// 一秒后显示 undefined,而非 Functional
解决这个问题,传统 JavaScript
有两个经典方案,此外还有一个新增的箭头函数的方案:
- 传统方案一:利用闭包的特性,定义一个局部变量(通常命名为
that
或self
),这样该变量就能获取到this
的原始值,而非undefined
; - 传统方案二:使用
bind()
函数,将超时函数的this
绑定到正确的值上(上一节介绍《λ表达式与函数》时也有类似应用); - 箭头函数版:这是更新潮的写法,无需其他改动就能获取到正确的
this
值(直接指向对象)。
三种方案的代码实现如下:第一个 timeout
函数使用了闭包,第二个用到了函数绑定,第三个则用到了箭头函数:
// 接上段代码...
function ShowItself2(identity: string) {
this.identity = identity;
const that = this;
setTimeout(function () {
console.log(that.identity);
}, 1000);
setTimeout(
function () {
console.log(this.identity);
}.bind(this),
2000
);
setTimeout(() => {
console.log(this.identity);
}, 3000);
}
const x2 = new ShowItself2("JavaScript");
// 一秒后显示 "JavaScript"
// 再过一秒同样显示 "JavaScript"
// 又过一秒还是显示 "JavaScript"
运行上述代码,控制台将在一秒后出现 JavaScript
;然后又过了一秒,再次看到 JavaScript
;再过 1 秒,控制台会第三次看到 JavaScript
:
【图 1 三种解决方案在控制台中的实际执行情况】
三种方法都能正确运行,具体选哪一个视个人喜好决定。
3. arguments 的处理 Working with arguments
前两章介绍过展开运算符(...
)的一些用法。然而,它最常见的使用场景却是与 arguments
对象的处理密切相关(后续第六章会详述)。先来重温上一章的 once()
函数:
// once.ts
const once = <FNType extends (...args: any[]) => any>(
fn: FNType
) => {
let done = false;
return ((...args: Parameters<FNType>) => {
if (!done) {
done = true;
return fn(...args);
}
}) as FNType;
};
为什么在写了 return (...args) =>
这句后,第 9 行又来一个 func(...args)
呢?这与当下主流观点在处理 函数参数个数不固定 时的具体方式有关,包括没有参数的情况在内。那么,老版本的 JavaScript
是怎么处理这个问题的呢?答案是 arguments
对象(注意它 不是 数组,详见 MDN 官方文档),通过它来访问到实际传入的参数。
而 arguments
恰巧是一个 类数组对象(array-like object),并不是真正的数组——它唯一拥有的数组属性,便是 length
;除此之外,arguments
无法调用 map()
、forEach()
等任何数组方法。要将 arguments
转换为真正的数组,则必须使用 slice()
方法,并通过 apply()
方法来调用另一个函数,如下所示:
function somethingElse() {
// get arguments and do something
}
function useArguments() {
...
var myArray = Array.prototype.slice.call(arguments);
somethingElse.apply(null, myArray);
...
}
而使用新版 JavaScript 语法,则无需考虑 arguments
、slice
以及 apply
:
function useArguments2(...args) {
...
somethingElse(...args);
...
}
查看上述代码您需要牢记以下三点:
- 写下
listArguments2(...args)
表明新函数将接收若干个参数(也可能不带参数); - 无需任何手动处理就能获得一个参数数组;
args
是一个真正的数组; - 写成
somethingElse(...args)
比写成apply()
更加清晰明了。
顺便提一下,当前版本的 JavaScript
依旧支持 arguments
的使用,若要用它来创建数组,有两种替代方案可以实现,不必使用 Array.prototype.slice.call
:
- 使用
from()
方法,具体写作:myArray = Array.from(arguments)
; - 直接写作:
myArray = [... arguments]
,这也是扩展运算符的另一种用法。
在后续介绍高阶函数、需要用函数来处理其它函数时,如果遇到参数数量不固定的情况,上述写法会变得非常普遍。
JavaScript
为这类问题提供了简洁高效的写法,因此必须尽快熟悉。这笔投资相当划算!
4. 单参数还是多参数? One argument or many?
编写一个返回值为函数的函数也是可行的,后续第六章还会见到更多这样的情况。例如,按照 λ 算子的演算要求,当中用到的函数没有参数为多个的情况,只接受一个参数;这时就可以通过一项称为 柯里化(currying) 的处理技术来解决这个问题(这么做是何用意?这里先卖个关子,暂且按下不表)。
拓展:双重嘉奖
科里化(
Currying
)得名于这一概念的提出者 Haskell Curry。值得一提的是,另一门函数式编程语言也被冠名为Haskell
—— 这也算是对其杰出贡献的双重认可(double recognition),可谓梅开二度!
举个例子,之前演示过的三数求和的函数就可以写成下列形式:
// sum3.ts
const altSum3 = (x: number) => (y: number) => (z: number)
=>
x + y + z;
这里的函数为什么重命名了呢?简单来说,它已经与之前定义的函数 sum3()
不一样了:sum3()
的类型为 (x: number, y: number, z: number) => number
;而 altSum3()
的类型则是 (x: number) => (y: number) => (z: number) => number
,二者截然不同(了解更多信息,可参阅本章最后的思考题 3.3)。尽管如此,后者也能得到与之前完全相同的结果。来看看它的具体用法。例如,要对数字 1、2、3 求和:
altSum3(1)(2)(3); // 6
思考
继续往下读之前,不妨思考一下:如果执行的是
altSum3(1, 2, 3)
,会得到什么样的结果?提示:结果并非是数字!完整答案参见下文。
该函数是怎么运行的呢?不妨将其拆分为多次调用,这也是上面那句表达式在 JavaScript
解释器上的实际计算方式:
const fn1 = altSum3(1);
const fn2 = fn1(2);
const fn3 = fn2(3);
开动您的函数式思维大脑!根据定义,调用 altSum3(1)
的结果,应该是一个 函数。该函数利用了闭包,可以等效解析为如下形式:
const fn1 = y => z => 1 + y + z;
此时的 altSum3()
函数只单独接受一个参数,而非三个参数;其运行结果,fn1
,也是一个只接受单个参数的新函数。再运行 fn1(2)
,结果同样是一个函数,同样也只接受一个参数,它等效于:
const fn2 = z => 1 + 2 + z;
再运行 fn2(3)
,才得到最终结果。如前所述,该函数执行的运算与之前看到的版本是一样的,但实现方式上却有着天壤之别。
您可能会觉得柯里化只是一种取巧的操作罢了:谁会只调用单参数的函数呢?在本书后续第八章《函数的连接》和第十二章《构建更好的容器》讲到如何将函数连接在一起时,您就能明白这么做的根本原因了,届时将多个参数从上一步传递到下一步的操作是无效的。