本篇中,将学习到以多种不同的方式来定义JavaScript中的函数,你会学习到函数表达式和函数声明,并且还会看到局部作用域和变量声明提升的工作原理。
性质
JavaScript中的一切都是对象,函数也不例外。因此函数也是对象,并且具有所有对象的特性,其表现如下:
- 函数可以在运行时动态创建,还可以在程序执行过程中创建。
- 函数可以分配给变量,可以将他们的引用复制到其他变量,可以被扩展。此外,除少数特殊情况外,函数还可以被删除。
- 可以作为参数传递给其他函数,并且还可以由其他函数返回。
- 函数可以有自己的属性和方法。
作用域
JavaScript中的作用域并不像其他编程语言那样复杂,在JavaScript中仅存在函数作用域。在函数内以var关键字定义的任何变量都是局部变量,对于函数外部是不可见的。
不同于其他编程语言通过大括号{}划分作用域,如果在if语句或在for以及while循环中使用var关键字定义一个变量,这并不意味着该变量对于if或for来说是局部变量。相反,它仅对于包装该函数来说是局部变量,并且如果没有包装函数,它将会成为一个全局变量。例如:
function foo() { for (var i = 0; i < 5; i++) { } console.log(i); // 输出5 }
函数表达式与函数声明
正如C++中有函数指针和函数,C#中有委托和方法一样,JavaScript中所对应的是函数表达式和函数声明。
函数表达式
正如本篇开始时提到的,函数也是对象,因此可以把一个函数赋值给一个变量,这就是函数表达式。例如:
var add = function add(a, b) { return a + b; }; var sum = add(1, 2); // 3
注意最后的大括号后有一个分号,说明这是一条赋值语句。同时也要注意函数的调用,即add(1, 2),此处的add代表的是var关键字后的add,而非function后的add。
大多数浏览器会为函数创建一个名为name的属性(函数也是对象,因此函数也可以有属性),属性值为function关键字后的函数名,因此可以将function后的add替换成其他名称,而调用该函数时依旧要使用var关键字声明的变量名,例如:
var add = function plus(a, b) { return a + b; }; var sum = add(1, 2); // 3
如果function关键字后不声明方法名,将会得到一个未命名函数表达式,或称为匿名函数,例如:
var add = function (a, b) { return a + b; }; var sum = add(1, 2); // 3
函数表达式实际上就是传递函数对象的指针,通过该指针执行该函数,函数名反而变得可有可无,请读者和函数指针或者委托做类比。
函数声明
函数声明和其他编程语言中定义一个函数类似,通过函数名来执行该函数。
函数声明只能出现在“程序代码”中,这表示它仅能在其他函数体内部或全局空间中。它们的定义不能分配给变量或属性,也不能以参数形式出现在函数调用中。例如:
function add(a, b) { return a + b; } var sum = add(1, 2); // 3
注意函数定义的大括号最后没有分号。
函数的提升
由于函数表达式的核心是变量,而函数声明的核心是函数,因此二者在声明提升上具有不同的行为。
变量提升
对于所有的变量,无论在函数体的何处进行声明,都会在后台被提升到函数的顶部。
函数提升
与变量提升类似,唯一不同的地方在于被提升的不仅是函数声明,函数的定义也被提升至函数顶部。请参考如下代码,观察变量提升和函数提升的不同之处:
// 全局函数 function foo() { console.log("global foo"); } function bar() { console.log("global bar"); } function hoistMe() { console.log(typeof foo); // 输出function console.log(typeof bar); // 输出undefined foo(); // 输出local foo bar(); // TypeError: undefined is not a function // 函数声明 // foo和实现都被提升 function foo() { console.log("local foo"); } // 函数表达式 // 仅bar被提升,实现未被提升 var bar = function () { console.log("local bar"); } } hoistMe();
可以发现,函数创建了作用域,因此hoistMe中的foo和bar覆盖了同名全局函数。
同时,我们也可以发现,foo作为函数声明,其具体实现也被提升至函数顶部,因此调用foo输出了local foo;相反,bar作为函数表达式,本质上是一个变量,仅bar的声明被提升到函数顶部,具体实现并未被提升,因此bar覆盖了全局同名函数,但并未被赋值。
回调模式
函数都是对象,这表示它们也可以作为参数传递给其他函数,这种模式也称为回调模式。例如:
function fromOneToTen(callback) { var result = 1; for (var i = 1; i < 11; i++) { result = callback(result, i); } return result; } function add(a, b) { return a + b; } function multipy(a, b) { return a * b; } console.log(fromOneToTen(add)); // 输出56 console.log(fromOneToTen(multipy)); // 输出3628800
示例中主函数fromOneToTen从1迭代到10,迭代方式通过回调函数传入,分别为叠加和叠乘。
回调的作用域
既然函数也是对象,那么函数也存在于某个作用域下。想象一下如果回调函数内使用了this对象,this应该指向哪个对象?我们可以做如下实验:
var myapp = { color: "green", paint: function () { console.log(this.color); } }; var master = { color: "red" }; myapp.paint(); // 输出green master.callback = myapp.paint; master.callback(); // 输出red
可见,this所指代的对象严格遵从于作用域的限制,只要充分理解了作用域的范围,就不难理解回调的作用域。
自定义上下文
我们也可手动指定函数执行的上下文。下例展示了两种方式,均通过函数的call函数传入所执行的上下文环境,第二种方式是第一种的变种,仅需传入上下文对象和函数名:
var myapp = { color: "green", paint: function () { console.log(this.color); } }; var master = { color: "red" }; myapp.paint(); // 输出green master.invokeCallback = function (sender, callback) { callback.call(sender); }; master.invokeCallback(myapp, myapp.paint); // 输出green master.invokeCallback2 = function (sender, func) { sender[func].call(sender); }; master.invokeCallback2(myapp, "paint"); // 输出green
返回函数
本篇已经多次提到函数是对象,因此一个函数也可以返回另一个函数,例如:
var setup = function () { var count = 0; return function () { return count++; } } var next = setup(); console.log(next()); // 输出0 console.log(next()); // 输出1 console.log(next()); // 输出2
返回函数的好处在于,返回的函数仅暴露出原函数的一个子集,示例中的count变量对外是不可见的。setup包装了返回函数,它创建了一个闭包,可以使用这个闭包存储一些私有数据,而这些数据仅可被该返回函数访问,但对外部代码却无法访问。
即时函数
即时函数模式是一种可以支持在定义函数后立即执行该函数的语法。例如:
(function () { console.log("Hello Immediate Function!"); }()); // 输出 Hello Immediate Function!
这种模式本质上只是一个函数表达式,该函数会在创建后立即执行。该模式由以下几部分组成:
- 可以使用函数表达式定义一个函数(函数声明则无法达到这个效果);
- 在末尾添加一组括号,这将导致该函数立即执行;
- 将整个函数包装在括号中(只有不将该函数分配给变量才需要这样做)。
这种模式非常有用,如果某个函数仅会被执行一次,同时又需要一些临时变量,那么我们没有必要将函数赋值给某个变量(函数表达式)或给函数定义函数名(函数声明)来调用。考虑下面这样一个例子,该函数输出当天的日期,函数内用到了一些局部变量:
(function () { var days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], today = new Date(), msg = "Today is " + days[today.getDay()] + ", " + today.getDate(); console.log(msg); }()); // 输出 Today is Thu, 2
如果该段代码没有包装到即时函数中,那么days、today、msg变量将会成为全局变量。也就是说即时函数为代码的执行提供了一个沙箱。
即时函数的参数
也可以将参数传递到即时函数中,例如:
(function (god) { console.log("Hello " + god); }("Tale Xu")); // 输出 Hello Tale Xu
即时函数的返回值
正如任何其他函数一样,即时函数可以返回值,并且这些返回值也可以分配给变量,例如:
var date = (function () { today = new Date(); return today.getDate(); }()); console.log(date); // 输出 2
即时函数和返回函数结合可以用于给即时函数添加局部变量:
var sayHello = (function () { var god = "Tale Xu"; return function () { console.log("Hello " + god); }; }()); console.log(typeof sayHello.god); // 输出 undefined sayHello(); // 输出 Hello Tale Xu
即时对象初始化
如果即时函数涉及到非常复杂的逻辑,仅凭即时函数和局部变量无法很好地组织代码时,我们可以将即时函数和对象字面量相结合,该模式称为即时对象初始化。例如:
({ max 600, maxheight: 400, gimmeMax: function () { return this.maxwidth + "*" + this.maxheight; }, init: function () { console.log(this.gimmeMax()); } }).init(); // 输出 600*400
就语法而言,对待这种模式就像在使用对象字面量创建一个普通的对象。这种模式的优点与即时函数的优点是相同的:可以在执行一次性的初始化任务时保护全局命名空间。与仅仅将一堆代码包装到匿名函数的方法比,这种模式看起来涉及更多的语法特征,但是如果初始化任务更复杂,它会使整个初始化过程显得更有结构化。比如,私有帮助函数是非常难清晰可辨的,因为它们是临时对象的属性,而在即时函数模式中,它们就很可能只是分散在各处的函数而已。
需要注意的是,这种模式主要适用于一次性的任务,而且在init()完毕后也没有对该对象的访问。但是如果想在init()完毕后保存对该对象的一个引用,可以通过在init()尾部添加"return this;"语句实现该功能。
函数属性——备忘模式
函数是对象,因此函数具有属性,可以在任何时候将自定义属性添加到你的函数中。自定义属性的一个应用是缓存函数结果,在下一次调用该函数时就不用重做计算,这个模式在一些算法中对于提升性能尤为有用。缓存函数结果也被称为备忘模式。
在下面的例子中,函数myFunc创建了一个属性cache,该属性可以通过myFunc.cache像通常那样进行访问。cache属性是一个对象(即哈希对象),其中使用传递给函数的参数param作为键,而计算结果作为值。计算结果可以是需要的任意复杂数据结构:
var myFunc = function (param) { if (!myFunc.cache[param]) { var result = {}; // ...开销很大的操作... myFunc.cache[param] = result; } return myFunc.cache[param]; }; myFunc.cache = {};
Curry
本篇剩下的部分主要讨论有关Curry化和部分函数应用的主题。但是在深入讨论该主题之前,让我们首先看看函数应用准确的含义。
函数应用
我们很熟悉如何执行某个函数,即function(param),事实上还存在另一种执行方式,即function.apply(param)。下面我们通过一个示例说明:
var sayHello = function (god) { return "Hello" + (god ? ", " + god : "") + "!"; }; console.log(sayHello()); // 输出 Hello! console.log(sayHello("Tale Xu")); // 输出 Hello, Tale Xu! console.log(sayHello.apply(null, ["Tale Xu"])); // 输出 Hello, Tale Xu!
正如从上面的例子中所看到的,调用函数和应用函数可以得到完全相同的结果。apply()带有两个参数:第一个参数为将要绑定到该函数内部this的一个对象(或称为上下文对象),而第二个参数是一个数组或多个参数变量,这些参数将变成可用于该函数内部的类似数组arguments对象。如果第一个参数为null,那么this将指向全局对象,此时得到的结果就恰好如同当调用一个非指定对象时的方法。
需要注意的是,除了apply()外,还有一个类似的call()方法,这个方法我们之前用到过。实际上call只是建立在apply上的语法糖而已。当函数仅带有一个参数时,可以根据实际情况避免创建只有一个元素的数组。例如:
var sayHello = function (god) { return "Hello" + (god ? ", " + god : "") + "!"; }; console.log(sayHello.apply(null, ["Tale Xu"])); // 输出 Hello, Tale Xu! console.log(sayHello.call(null, "Tale Xu")); // 输出 Hello, Tale Xu!
部分应用
在其他编程语言中,我们可以为函数的参数设置一个默认值,在执行该函数时可以不传入具备默认值的参数。在javaScript中,我们可以实现类似的功能,并且由于javaScript中的函数也是对象,所以实现起来更加灵活。这种模式也称为部分应用,即我们仅应用了第一个参数。当执行部分应用时,并不会得到结果,相反,会获得另一个函数。使函数理解并处理部分应用的过程就称为Curry过程(Curring)。
Curry化
Curry和印度的咖喱没有任何关系,它来源于数学家Haskell Curry的名字。Curry化是一个转换过程,即我们执行函数转换的过程。
那么如何Curry化一个函数?让我们看下边的例子:
function add(x, y) { if (typeof y === "undefined") { return function (y) { return x + y; } } return x + y; } console.log(typeof add(5)); // 输出 function console.log(add(3)(4)); // 输出 7 var add2000 = add(2000); console.log(add2000(14)); // 输出 2014
在上面的代码段中,如果调用add时只传入了一个参数,那么返回的结果是一个新的函数,这个新函数将调用add时传入的参数作为add第一个参数的值。
上述例子只能Curry化特定函数,下面介绍一种更通用的方法:
function schonfinkelize(fn) { var slice = Array.prototype.slice, stored_args = slice.call(arguments, 1); return function () { var new_args = slice.call(arguments), args = stored_args.concat(new_args); return fn.apply(null, args); }; } function add(x, y) { return x + y; } var newadd = schonfinkelize(add, 5); console.log(newadd(4)); // 输出 9 // 一些灵活用法 console.log(schonfinkelize(add, 5)(4)); // 输出 9 function add2(a, b, c, d, e) { return a + b + c + d + e; } console.log(schonfinkelize(add2, 1, 2, 3)(5, 5)); // 输出 16 var addOne = schonfinkelize(add2, 1); console.log(addOne(10, 10, 10, 10)); // 输出 41 var addSix = schonfinkelize(addOne, 2, 3); console.log(addSix(5, 5)); // 输出 16
示例中,schonfinkerlize为通用的Curry化函数,传入参数为要被Curry化的函数以及一些参数,返回值为Curry化后的函数。实际上该函数创建了一个闭包,缓存原函数和参数。
何时使用Curry化
当发现正在调用同一个函数,并且传递的参数绝大多数都是相同的,那么该函数可能是用于Curry化的一个很好的候选参数。可以通过将一个函数集合部分应用到函数中,从而动态创建一个新函数。这个新函数将会保存重复的参数,并且还会使用预填充原始函数所期望的完整参数列表。
小节
在javaScript中,有关函数的知识以及函数的正确用法是至关重要的。本篇讨论了有关函数的背景和术语。学习了JavaScript中函数的两个重要特性,即:
- 函数是第一类对象(first-class object),可以作为带有属性和方法的值以及参数进行传递。
- 函数提供了局部作用域,而其他大括号并不能提供这种局部作用域。此外还需要记住的是,声明的局部变量可被提升到局部作用域的顶部。
创建函数的语法包括:
- 命名函数表达式。
- 函数表达式(与上面的相同,但缺少一个名字),通常也称为匿名函数。
- 函数声明,与其他编程语言中的函数语法类似。
在涵盖了函数的背景和语法之后,将学习到一些有用的模式,可以将它们分为以下几类:
- API模式,它们可以帮助您为函数提供更好且更整洁的接口。这些模式包括以下几个:
- 回调模式:将函数作为参数进行传递。
- 配置对象: 有助于保持受到控制的函数的参数数量。
- 返回函数:当一个函数的返回值是另一个函数时。
- Curry化:当新函数是基于现有函数,并加上部分参数列表创建时。
- 初始化模式,它们可以帮助您在不污染全局命名空间的情况下,使用临时变量以一种更加整洁、结构化的方式执行初始化以及设置任务。这些模式包括:
- 即时函数:只要定义之后就立即执行。
- 即时对象初始化:匿名对象组织了初始化任务,提供了可被立即调用的方法。
- 初始化时分支:帮助分支代码在初始化代码执行过程中仅检测一次,这与以后在程序生命周期内多次检测相反。
- 性能模式,可以帮助加速代码运行,这些模式包括:
- 备忘模式:使用函数属性以便使得计算过的值无须再次计算。
- 自定义模式:以新的主体重写自身,以使得在第二次或以后调用时仅需执行更少的工作。