二、函数

Abstract

正如你在前一章所学的,JavaScript 中几乎所有的东西都是对象,包括函数。然而,函数不仅仅是容纳属性的袋子;它们是用这种语言完成工作的方式。通常,只有当开发人员写的东西在他们面前爆炸时,他们才会意识到函数的细节。我在这一章的目标是向你展示 JavaScript 函数的复杂性,这将有望使你不必从代码库中取出语法碎片。

正如你在前一章所学的,JavaScript 中几乎所有的东西都是对象,包括函数。然而,函数不仅仅是容纳属性的袋子;它们是用这种语言完成工作的方式。通常,只有当开发人员写的东西在他们面前爆炸时,他们才会意识到函数的细节。我在这一章的目标是向你展示 JavaScript 函数的复杂性,这将有望使你不必从代码库中取出语法碎片。

在我开始之前,有一点需要注意:JavaScript 的好坏取决于它的解释器。尽管这里讨论的概念在语言规范中已经很好地涵盖了,但这并不意味着所有的主机环境都将以相同的方式工作。换句话说,你的里程可能会有所不同。本节将讨论 JavaScript 函数的常见误解以及它们引入的无声错误。但是,没有详细介绍调试函数。幸运的是,JavaScript 社区中的其他人已经记录了函数中的错误,特别是在 Juriy Zaytsev 的优秀文章“命名函数表达式去神秘化”中。 1

JavaScript 中的块

在你理解 JavaScript 中的函数之前,你必须理解块。JavaScript 块只不过是组合在一起的语句。块以左花括号“{”开始,以右花括号“}”结束。简单地说,块允许括号内的语句一起执行。块构成了 JavaScript 中最基本的控制结构。以下是 JavaScript 中块如何工作的几个例子:

// Immediately invoked function expression

;!function () {

var triumph = false

cake = false

satisfaction = 0

isLie

note;

// Block used as part of a function expression

var isLie = function (val) {

return val === false;

}

// Block used as part of a conditional statement

if (isLie(cake)) {

triumph = true;

makeNote('huge success');

satisfaction += 10;

}

// Block used as part of a function declaration

function makeNote(message) {

note = message;

}

}();

正如您之前看到的,函数本质上是开发人员可以按需调用的命名块。这很容易证明:

// The inline conditional block statement is executed only once per cycle.

if (isLie(cake)) {

...

}

function makeNote(message) {

...

}

// The function declaration is executed as many times as it is called.

makeNote("Moderate Success");

makeNote("Huge Success");

函数参数

控制流语句(if、for、while 等)等函数。)可以通过向函数体传递参数来初始化。在 JavaScript 中,变量或者是复杂类型(例如,对象、数组),或者是基本类型(例如,字符串、整数)。当复杂对象作为参数提供时,它通过引用传递给函数体。JavaScript 不是发送变量的副本,而是发送一个指针指向它在内存堆中的位置。相反,当把一个基本类型传递给一个函数时,JavaScript 通过值传递。这种差异可能会导致微妙的错误,因为从概念上讲,函数通常被视为一个黑盒,并假设它们只能通过返回变量来影响封闭范围。通过引用传递,参数对象被修改,即使它可能没有被函数返回。这里演示了按引用传递和按值传递:

var object = {

'foo': 'bar'

}

num = 1;

// Passed by reference

;!function(obj) {

obj.foo = 'baz';

}(object);

// => Object {foo: "baz"}

console.log(object);

// Passed by value;

;!function(num) {

num = 2;

}(num);

// => 1

console.log(num);

获胜的论点

对于设计不需要预先确定数量的参数作为方法签名的函数来说,arguments对象是一个有用的工具。arguments对象背后的思想是,它就像一个通配符,允许您像数组一样通过迭代这个特殊的对象来访问任意数量的参数。这里有一个例子:

var sum = function () {

var len = arguments.length

total = 0;

for (var x = 0; x < len; x++) {

total += arguments[x];

}

return total;

};

// => 6

console.log(sum(1, 2, 3));

然而,arguments 对象最令人沮丧的一个方面是,它有足够多的类似数组的行为来绊倒开发人员。如果重写函数以使用更多数组方法,脚本将失败:

var sum = function () {

var total = 0;

while (arguments.length > 0) {

total += arguments.pop();

}

return total;

};

// Uncaught TypeError: Object #<Object> has no method 'pop'

sum(1, 2, 3);

幸运的是,ESCMAScript 6 改进了函数接受参数的方式,使得原始的arguments对象几乎不再有用。让我们来看几个支持参数的新特性。

defaultParameters (ECMAScript 6)

许多语言允许您为方法签名中的参数选择默认值。最后,在 ECMAScript 6 (ES 6)中,JavaScript 将是这些语言中的一种。

var join = function (foo = 'foo', baz = (foo === 'foo') ? join(foo + "!") : 'baz') {

return foo + ":" + baz;

}

// => hi:there

console.log(join("hi", "there"));

// Use the default parameter when not supplied

// => hi:baz

console.log(join("hi"));

// Use the default parameter when undefined is supplied

// => foo:there

console.log(join(undefined, "there"));

// Use an expression which has access to the current set of arguments

// => foo:foo!:baz

console.log(join('foo'));

其他(ECMAScript 6)

有时,设计接受任意数量参数的函数是有用的,甚至是必要的。然而,由于argument对象的不稳定性,这可能很棘手。

var dispatcher = {

join: function (before, after) {

return before + ':' + after

}

sum: function () {

var args = Array.prototype.slice.call(arguments);

return args.reduce(function (previousValue, currentValue, index, array) {

return previousValue + currentValue;

});

}

};

var proxy = {

relay: function (method) {

var args;

args = Array.prototype.splice.call(arguments, 1);

return dispatcher[method].apply(dispatcher, args);

}

};

// => bar:baz

console.log(proxy.relay('join', 'bar', 'baz'));

// => 28

console.log(proxy.relay('sum', 1, 2, 3, 4, 5, 6, 7));

在前面的例子中,我们的代理对象需要一个参数,这个参数是 dispatcher 上调用的方法。它不知道它所调用的函数还需要多少其他参数。如您所知,参数对象不是数组,因此没有有用的方法,如splicemapreduce。为了将剩余的任意数量的参数发送给调度程序,您必须用一个数组来处理它们。

rest参数消除了函数间令人讨厌的秘密握手。下面是使用rest参数重写的先前方法:

var dispatcher = {

join: function (before, after) {

return before + ':' + after

}

sum: function (...rest) {

return rest.reduce(function (previousValue, currentValue, index, array) {

return previousValue + currentValue;

});

}

};

var proxy = {

relay: function (method, ...goodies) {

return dispatcher[method].apply(dispatcher, goodies);

}

};

// => bar:baz

console.log(proxy.relay('join', 'bar', 'baz'));

// => 28

console.log(proxy.relay('sum', 1, 2, 3, 4, 5, 6, 7));

函数类型

现在您已经对块和参数有了更好的理解,让我们更深入地研究函数声明和函数表达式,这是 JavaScript 中使用的两种类型的函数。对于普通读者来说,这两者非常相似:

// Function Declaration

function isLie(cake){

return cake === true;

}

// Function Expression

var isLie = function(cake){

return cake === true;

}

两者之间唯一真正的区别是何时被评估。解释器可以在解析函数声明时访问它。另一方面,函数表达式是赋值表达式的一部分,它阻止 JavaScript 在程序完成赋值之前对其求值。这种差异可能看起来很小,但意义是巨大的;考虑下面的例子:

// => Hi, I'm a function declaration!

declaration();

function declaration() {

console.log("Hi, I'm a function declaration!");

}

// => Uncaught TypeError: undefined is not a function

expression();

var expression = function () {

console.log("Hi, I'm a function expression!");

}

正如您在前面的例子中看到的,函数表达式在被调用时抛出了一个异常,但是函数声明执行得很好。这个异常触及了声明函数和表达式函数之间差异的核心。JavaScript 知道声明函数,可以在程序执行前解析它。因此,如果程序在定义函数之前就调用它,这并不重要,因为 JavaScript 已经在幕后将函数提升到了当前范围的顶部。在将函数表达式赋值给变量之前,不会对其求值;因此,它在被调用时仍然是未定义的。这就是为什么好的代码风格是在当前作用域的顶部定义所有变量。如果您当时这样做了,您的脚本将在视觉上与 JavaScript 在解析时所做的相匹配。

要带走的概念是,在解析期间,JavaScript 将所有函数声明移动到当前作用域的顶部。这就是为什么声明性函数出现在脚本体的什么地方并不重要。要进一步探究声明和表达式之间的区别,请考虑以下几点:

function sayHi() {

console.log("hi");

}

var hi = function sayHi() {

console.log("hello");

}

// => "hello"

hi();

// => 'hi'

sayHi();

如果您不经意地阅读这段代码,您可能会认为声明函数会被破坏,因为它的函数表达式有一个相同的名称。但是,因为第二个函数是赋值表达式的一部分,所以它有自己的作用域,JavaScript 将它们视为独立的实体。让事情更加混乱的是,看看这个例子:

var sayHo

// => function

console.log(typeof (sayHey))

// => undefined

console.log(typeof (sayHo))

if (true) {

function sayHey() {

console.log("hey");

}

sayHo = function sayHo() {

console.log("ho");

}

} else {

function sayHey() {

console.log("no");

}

sayHo = function sayHo() {

console.log("no");

}

}

// => no

sayHey();

// => ho

sayHo();

在前面的例子中,您看到了如果一个函数是表达式,而另一个是声明,那么同名的函数会被认为是不同的。在这个例子中,我试图根据程序如何执行来有条件地定义函数。阅读该脚本的控制流,您会期望 sayHey 返回“Hey ”,因为条件语句评估为 true。相反,它返回“否”,意味着第二个版本的sayHey函数击败了第一个。更令人困惑的是 sayHo 函数的行为方式正好相反!同样,差异归结于解析时间和运行时间。

您已经了解到,当 JavaScript 解析脚本时,它会收集所有的函数声明并将它们提升到当前作用域的顶部。当这种情况发生时,它将第一个版本的 sayHey 与第二个版本的 say hey 撞在一起,因为它们存在于相同的作用域中。这解释了为什么它返回“否”。您还知道,在赋值过程完成之前,解析器会忽略函数表达式。赋值发生在运行时,也就是计算条件语句的时候。这解释了为什么 sayHo 函数可以有条件地定义。这里要记住的关键是函数声明不能有条件地定义。如果需要条件定义,请使用函数表达式。此外,函数声明不应该在控制流语句中进行,因为解释器处理它的方式不同。

函数范围

与许多其他语言的作用域是块不同,JavaScript 的作用域是函数。在 Ruby(1.9 . x 版)中,可以这样写:

x = 20

10.times do |x|

# => 0..9

puts x

end

# => 20

puts x

这表明每个块都有自己的作用域。相反,您可以用 JavaScript 编写类似的代码:

var x = 20;

// Functions have their own scope

;!function() {

var x = "foo";

// => "foo"

console.log(x);

}();

// => 20

console.log(x);

for (x = 0; x < 10; x++) {

// => 0..9

console.log(x);

}

// => 10

console.log(x);

在 JavaScript 中,x在 for 循环中是可用的,因为作为控制语句,它属于封闭范围。这对于许多习惯于阻塞级别范围的开发人员来说并不直观。JavaScript 至少部分通过使用闭包来处理块级范围的需求,我将在后面讨论。

箭头倾向(ECMAScript 6)

从 ES 5 开始,JavaScript 只支持函数级范围。这意味着this总是引用函数体内的作用域。对于习惯于阻塞级别范围的开发人员来说,函数级别范围的这种质量一直是一个尴尬的事实。许多开发人员通过使用自由变量或使用绑定函数来规避这种行为。

// Option 1: Use a local free variable to bypass the need to reference this.

var VendingMachine = function () {

this.stock = ["Sgt. Pepper", "Choke", "Spite"];

var that = this;

return {

dispense: function () {

if (that.stock.length > 0) {

return that.stock.pop();

}

}

};

};

var popMachine = new VendingMachine();

// => 'Spite'

console.log(popMachine.dispense());

// Option 2: Use a bound function to reference this.

var VendingMachine = function () {

this.stock = ["Sgt. Pepper", "Choke", "Spite"];

var dispense = function () {

if (this.stock.length > 0) {

return this.stock.pop();

}

};

return {

dispense: dispense.bind(this)

};

};

var popMachine = new VendingMachine();

// => 'Spite'

console.log(popMachine.dispense());

幸运的是,ES 6 的主要新特性之一是通过使用所谓的胖箭头来消除词汇 this 的歧义。粗箭头是使用“=>”而不是“function(){}``”来编写函数的一种新的更短的方法,使用过 CoffeeScript 的人会对它很熟悉。与任何变化一样,一些开发人员哀叹他们认为函数工作方式不必要的复杂。然而,当用于正确的问题时,粗箭头确实有其优势。下面是如何使用粗箭头重写VendingMachine`函数:

// Option 3: Use a fat arrow to supply the lexical this.

var VendingMachine = function () {

this.stock = ["Sgt. Pepper", "Choke", "Spite"];

return {

dispense: () => {

if (this.stock.length > 0) {

return this.stock.pop();

}

}

};

};

var popMachine = new VendingMachine();

// => 'Spite'

console.log(popMachine.dispense());

除了语法更短之外,粗箭头还使得阅读代码更清晰,因为this参数与代码的其余部分是可视化链接的。粗箭头还使开发人员能够编写更短、更简洁(但可读)的代码(例如,map 和 reduce 需要迭代器函数,当使用旧的函数范式编写时,这些函数看起来不必要的复杂)。现在,使用粗箭头,您可以在一行中编写简单的函数,这允许省略隐含的需求,如 return 语句。这是CoffeeScripters熟悉的另一个约定,因为 CoffeeScript 函数中的最后一个语句总是返回值。

// function classic

var sum = [1, 2, 3, 4, 5].reduce(function (last, curr) {

return last + curr;

});

// => 15

console.log(sum);

// now with 100% more fat arrow.

var sum = [1, 2, 3, 4, 5].reduce((last, curr) => last + curr);

// => 15

console.log(sum);

函数符

JavaScript 中的函数是将整个语言结合在一起的粘合剂,掌握函数对征服整个语言大有帮助。记住这一点,您现在可以研究 JavaScript 中函数的几种高级用法,它们不仅可以提高代码的质量,还可以提高阅读的清晰度。

表达式闭包

表达式闭包是编写简单函数的捷径。如果表达式闭包看起来很熟悉,那是因为它们非常类似于 lambda 表达式在其他语言(如 Lisp)中的工作方式。

// => 10

[1, 2, 3, 4].reduceRight(function(curr, val) curr + val);

使用 ES 6 中新的胖箭头语法,可以节省更多的字符。

// => 10

[1,2,3,4].reduceRight((curr, val) => curr + val);

Note

目前,表达式闭包在大多数浏览器中支持有限。基于 Mozilla 的浏览器是唯一完全实现这种语法的浏览器。

立即调用的函数表达式

立即调用的函数表达式(IIFE)是一种你会看到各种库和框架重复使用的模式。最基本的形式是,它可以用几种方式来写:

;(function(){

...

})();

;!function(){

...

}();

;-function(){

...

}();

;+function(){

...

}();

;∼function(){

...

}();

// Not Recommended

;void function(){

...

}();

// Not Recommended

;delete function(){

...

}();

生命的美妙之处在于,它使用一元表达式来强制函数声明,这通常需要显式地调用到可以自我执行的函数表达式中。在内部,JavaScript 在函数声明上运行一元运算。该操作的结果是函数表达式,它会被后面的括号()立即调用。除了优雅的代码之外,IIFE 还提供了以下内容:

  • 它提供了防止命名冲突的闭包。
  • 它提供了优雅的块范围。
  • 它防止了全局名称空间的污染。
  • 它促使开发人员从模块化代码的角度进行思考。

Note

值得一提的另一点是在语句前使用分号。添加它提供了一点防御编程,防止其他可能没有尾随分号的畸形模块。如果这只是一个函数声明,那么它将被吸收到前面的模块中,当多个脚本作为部署过程的一部分连接在一起时,这种情况经常发生。强烈建议您遵循这一约定,以保护自己免受生产中的神秘 bug。

递归函数

递归函数就是能够调用自身的函数。你可以把它们想象成受控循环。这种自我执行的能力被证明是一种优秀的工具,可以使代码更加简洁,同时降低复杂性。然而,递归函数并非没有潜在的危险;如果使用不当,递归函数会变成吞噬资源的内存黑洞,直到脚本失败。让我们来探索使用递归函数的正确方法,同时避免无限递归。

考虑以下示例:

var tree = {

name: 'Users'

children: [{

name: 'heavysixer'

children: [{

name: 'Applications'

children: []

}, {

name: 'Downloads'

children: []

}, {

name: 'Library'

children: [{

name: 'Accounts'

children: []

}, {

name: 'Arduino'

children: []

}]

}]

}, {

name: 'root'

children: []

}]

};

var walker = function walk(branch, newDepth) {

var depth = newDepth || 0;

var len = branch.children.length;

console.log(depth + ':' + branch.name);

while (len > 0) {

len--;

walker(branch.children[len], depth + 1);

}

};

/*

=> 0:Users

=> 1:root

=> 1:heavysixer

=> 2:Library

=> 3:Arduino

=> 3:Accounts

=> 2:Downloads

=> 2:Applications

*/

walker(tree);

在这个例子中,walker 函数接受一个表示目录树的 JSON 对象;迭代每个节点;并分别输出所有目录名及其深度的列表。您可以编写一系列嵌套的for循环,并得到相同的输出。然而,要做到这一点,你必须首先计算树的绝对深度,以了解所需的循环次数。当然,这个过程是完全错误的,因为它使代码变得非常脆弱。使用递归函数,您可以以灵活的方式实现相同的效果,因为您可以测试子节点的存在,然后再递归调用该函数,这次提供当前分支作为根节点。

您可能想知道是否可以通过在 arguments 对象中使用callee引用来进一步简化递归函数:

// reference the callee object from the arguments object

arguments.callee(branch.children[len], depth + 1);

不幸的是,使用arguments.callee在严格模式下不起作用;它抛出一个错误:"Uncaught TypeError: 'caller', 'callee', and 'arguments' properties may not be accessed on strict mode functions or the arguments objects for calls to them."

Note

有关递归的更多信息,请参阅函数一章的函数部分。

高阶函数

当人们将 JavaScript 描述为具有“一流的函数”时,这意味着 JavaScript 允许将函数作为参数提供给其他函数。一级函数是函数式编程范式的标志,JavaScript 也间接支持它。函数式编程将函数作为一个抽象单元来使用和执行。这与面向对象编程(OOP)不同,面向对象编程使用对象作为抽象的手段来操作和存储数据的变化状态。

一级函数的一个很大的特点是,它们允许使用 JavaScript 创建高阶函数,即接受函数作为参数或返回函数作为返回值的函数。高阶函数有许多优点,但主要用途之一是将常见功能抽象到一个地方。所以,JavaScript 中很多高阶函数的使用都是为了所谓的效用函数也就不足为奇了。例如,Jeremy Ashkenas 的项目 underscore.js 称自己是“一个 JavaScript 的实用函数库,它提供了许多您在 Prototype.js(或 Ruby)中期望的函数式编程支持,但没有扩展任何内置的 JavaScript 对象。 2

毫不奇怪,下划线. js 很好地利用了高阶函数。我在这里包含了两个这样的函数:

// The cornerstone, aneachimplementation, akaforEach.

// Handles objects with the built-inforEach, arrays, and raw objects.

// Delegates to **ECMAScript 5**'s nativeforEachif available.

var each = _.each = _.forEach = function(obj, iterator, context) {

if (obj == null) return;

if (nativeForEach && obj.forEach === nativeForEach) {

obj.forEach(iterator, context);

} else if (obj.length === +obj.length) {

for (var i = 0, l = obj.length; i < l; i++) {

if (iterator.call(context, obj[i], i, obj) === breaker) return;

}

} else {

for (var key in obj) {

if (_.has(obj, key)) {

if (iterator.call(context, obj[key], key, obj) === breaker) return;

}

}

}

};

// Return the results of applying the iterator to each element.

// Delegates to **ECMAScript 5**'s nativemapif available.

_.map = _.collect = function(obj, iterator, context) {

var results = [];

if (obj == null) return results;

if (nativeMap && obj.map === nativeMap) return obj.map(iterator, context);

each(obj, function(value, index, list) {

results.push(iterator.call(context, value, index, list));

});

return results;

};

您可以使用_.map() like this:

// => [2,3,6]

var doubled = _.map([1, 2, 3], function(num){ return num * this.multiplier; }, {multiplier : 2});

当您打开_.map()高阶函数时,您会看到几个使其如此强大的特性:

  • 因为数据、迭代器和上下文是作为参数传递给 map 命令的,所以使用该函数变得非常有表现力。这是因为意图的透明性是由参数提供的。
  • 该函数变得与实现无关,允许它在方法的本机实现不可用时充当多填充,否则遵从内置版本。
  • 事实上,您可以将迭代器和执行上下文作为参数传入,这意味着 map 函数保持了令人满意的通用性。这使得该方法可以在各种各样的环境中使用,从而减少了代码重复发生的机会。

调试函数

在我结束这个主题之前,让我们简单地讨论一下调试函数。在 JavaScript 命名中,函数表达式是完全可选的。那么为什么要这么做呢?答案是帮助调试过程。命名函数表达式可以在新定义的范围内访问其名称,但不能在封闭范围内访问。没有名字,他们的匿名性质会让他们在调试时感觉有点像机器中的幽灵。

var namedFunction = function named() {

// => function

console.log(typeof(named));

}

namedFunction();

// => undefined

console.log(typeof(named));

无名函数表达式在堆栈跟踪中显示为“(anonymous function)”或类似的东西。当试图展开一个调用堆栈可能长达数英里的异常时,命名函数表达式会使您更加清晰:

/*

* It is much harder to debug anonymous function expressions

* Uncaught boom

* - (anonymous function)

* - window.onload

*/

;!function(){

throw("boom");

}();

/*

* Naming your function expressions give you a place to start looking when debugging.

* Uncaught boom

* - goBoom

* - window.onload

*/

;!function goBoom() {

throw("boom")

}();

摘要

在 JavaScript 中使用函数时,需要记住几个关键概念:

  • 除了少数例外(如let操作符),JavaScript 具有函数级作用域,这与其他许多主要作用域在块级的语言不同。
  • 函数有两种形式:函数声明和函数表达式。函数声明在运行时被提升,这允许你从本地块中的任何地方调用它们;如果在定义函数表达式之前调用它们,函数表达式会引发错误。
  • 参数对象就像一个数组,足以让你陷入困境。
  • ES 6 增加了一种将默认参数指定为函数签名一部分的方法。
  • ES 6 引入了rest操作符,这为您提供了一种处理函数中任意数量参数的简单方法。
  • 粗箭头函数可以作为在函数体内指定this值的简洁方式。
  • 有许多很棒的概念范例,比如 IIFEs,可以用来使您的函数更强大、更易于管理。
  • 尽可能使用命名函数,因为它们使堆栈跟踪更具可读性。

Footnotes 1

T2http://kangax.github.com/nfe/

2

T2http://underscorejs.org/